diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 7a37aae7..bfb28720 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -7,9 +7,9 @@ on: workflow_dispatch: inputs: tag: - description: 'Release Tag (e.g. v2.5.1-c3)' + description: 'Release Tag (e.g. v2.8.0-c1)' required: false - default: 'v2.5.1-c3' + default: 'v2.8.0-c1' type: string permissions: @@ -79,7 +79,7 @@ jobs: elif [ -n "${{ inputs.tag }}" ]; then echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT else - echo "tag=v2.5.1-c3" >> $GITHUB_OUTPUT + echo "tag=v2.8.0-c1" >> $GITHUB_OUTPUT fi - name: Publish Assets to Forgejo Release diff --git a/.github/workflows/changelog_publish.yml b/.github/workflows/changelog_publish.yml new file mode 100644 index 00000000..97c403d3 --- /dev/null +++ b/.github/workflows/changelog_publish.yml @@ -0,0 +1,35 @@ +name: Publish Changelog + +on: + release: + types: [published, edited] + workflow_dispatch: + +concurrency: + group: changelog-publish + cancel-in-progress: true + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + steps: + # The launcher reads releases/latest/download/changelog.json, so the + # snapshot of the last 50 release bodies always lives on the latest release. + - name: Build changelog.json from release bodies + run: | + gh api "repos/$GH_REPO/releases?per_page=50" --jq '[.[] + | select(.draft | not) + | select(.tag_name | startswith("oneclient-")) + | { + version: (.tag_name | ltrimstr("oneclient-")), + body: ((.body // "") | sub("^Automated release for OneClient [^\n]*\\s*"; "")) + }]' > changelog.json + jq -r '.[].version' changelog.json + + - name: Upload to latest release + run: gh release upload "$(gh release view --json tagName -q .tagName)" changelog.json --clobber diff --git a/.github/workflows/changeset_check.yml b/.github/workflows/changeset_check.yml new file mode 100644 index 00000000..690cc4e4 --- /dev/null +++ b/.github/workflows/changeset_check.yml @@ -0,0 +1,23 @@ +name: Changeset Check + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +jobs: + changeset: + if: ${{ !contains(github.event.pull_request.labels.*.name, 'no changeset') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + + - name: Require a change file + env: + BASE_REF: ${{ github.base_ref }} + run: | + if ! git diff --name-only --diff-filter=A "origin/$BASE_REF...HEAD" -- '.changeset/*.md' | grep -q .; then + echo "::error::Add a change file with \`knope document-change\`, or label the PR 'no changeset' if it isn't user-facing." + exit 1 + fi diff --git a/.github/workflows/oneclient_nightly.yml b/.github/workflows/oneclient_nightly.yml index 657cbb10..9b41a1e7 100644 --- a/.github/workflows/oneclient_nightly.yml +++ b/.github/workflows/oneclient_nightly.yml @@ -85,6 +85,7 @@ jobs: libfontconfig-dev libfreetype-dev \ libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libdbus-1-dev \ + libgtk-3-dev libayatana-appindicator3-dev \ libfuse2 - name: Install cargo-packager @@ -263,21 +264,61 @@ jobs: for f in "${{ steps.outdir.outputs.dir }}"/*.dmg \ "${{ steps.outdir.outputs.dir }}"/*.deb \ "${{ steps.outdir.outputs.dir }}"/*.rpm \ - "${{ steps.outdir.outputs.dir }}"/*.AppImage \ - "${{ steps.outdir.outputs.dir }}"/*-setup.exe; do + "${{ steps.outdir.outputs.dir }}"/*.AppImage; do [ -e "$f" ] || continue cp "$f" dist/ done + # Raw executable, renamed per platform so the unzipped uploads don't collide. + bin="${{ steps.outdir.outputs.dir }}/oneclient_app" + ext="" + [ -e "$bin.exe" ] && ext=".exe" + cp "$bin$ext" "dist/oneclient_app-${{ matrix.os_key }}-${{ matrix.arch }}$ext" if [ -z "$(ls -A dist)" ]; then echo "::error::No installers found in ${{ steps.outdir.outputs.dir }}" exit 1 fi ls -la dist - - name: Upload unsigned installers - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + - name: Upload unsigned executable + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: oneclient-nightly-${{ matrix.os_key }}-${{ matrix.arch }} - path: dist/* + path: dist/oneclient_app-${{ matrix.os_key }}-${{ matrix.arch }}* + archive: false + if-no-files-found: error + retention-days: 14 + + - name: (macOS) Upload unsigned dmg + if: matrix.os_key == 'darwin' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + path: dist/*.dmg + archive: false + if-no-files-found: error + retention-days: 14 + + - name: (Linux) Upload unsigned deb + if: matrix.os_key == 'linux' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + path: dist/*.deb + archive: false + if-no-files-found: error + retention-days: 14 + + - name: (Linux) Upload unsigned rpm + if: matrix.os_key == 'linux' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + path: dist/*.rpm + archive: false + if-no-files-found: error + retention-days: 14 + + - name: (Linux) Upload unsigned AppImage + if: matrix.os_key == 'linux' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + path: dist/*.AppImage + archive: false if-no-files-found: error retention-days: 14 diff --git a/.github/workflows/oneclient_release.yml b/.github/workflows/oneclient_release.yml index fec605a1..106f1c40 100644 --- a/.github/workflows/oneclient_release.yml +++ b/.github/workflows/oneclient_release.yml @@ -3,17 +3,6 @@ name: OneClient Release Build on: workflow_dispatch: - inputs: - bump: - description: 'Bump the workspace version in Cargo.toml before releasing' - type: choice - required: false - default: none - options: - - none - - patch - - minor - - major # NOTE: CARGO_PACKAGER_SIGN_PRIVATE_KEY* are deliberately NOT declared at # workflow level. Swatinem/rust-cache folds every `CARGO*`-prefixed environment @@ -37,18 +26,25 @@ jobs: sha: ${{ steps.version.outputs.sha }} steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + # Knope finds the commit (and so the PR author) that added each change file + fetch-depth: 0 - name: Setup Rust - if: inputs.bump != 'none' uses: ./.github/actions/setup-rust with: restore-cache: false - - name: Resolve version, bump if requested, refuse to clobber a release + - name: Install Knope + uses: ./.github/actions/install-cargo-tool + with: + tool: knope + version: 0.23.0 + + - name: Consume changesets id: version env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUMP: ${{ inputs.bump }} shell: bash run: | set -euo pipefail @@ -57,63 +53,29 @@ jobs: sed -n -E '/^\[workspace\.package\]/,/^\[/ s/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' Cargo.toml | head -n1 } + # No change files means a re-run of a release whose version commit + # already landed, so carry on with the current version. + RELEASED=0 + if compgen -G ".changeset/*.md" > /dev/null; then + knope prepare-release + cargo update --workspace --offline || cargo update --workspace + RELEASED=1 + fi + VERSION="$(read_version)" if [ -z "$VERSION" ]; then echo "::error::Could not read version from [workspace.package] in Cargo.toml" exit 1 fi - echo "Current version: $VERSION" - - if [ "$BUMP" != "none" ]; then - if ! [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - echo "::error::Version '$VERSION' is not plain semver major.minor.patch; cannot bump automatically." - exit 1 - fi - MAJOR="${BASH_REMATCH[1]}"; MINOR="${BASH_REMATCH[2]}"; PATCH="${BASH_REMATCH[3]}" - case "$BUMP" in - major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; - minor) MINOR=$((MINOR + 1)); PATCH=0 ;; - patch) PATCH=$((PATCH + 1)) ;; - esac - NEW_VERSION="$MAJOR.$MINOR.$PATCH" - echo "Bumping ($BUMP): $VERSION -> $NEW_VERSION" - - # Only rewrite the `version` key inside [workspace.package]; the - # dependency pins further down the file must not be touched. - NEW_VERSION="$NEW_VERSION" python3 - <<'PY' - import os, re - new = os.environ["NEW_VERSION"] - src = open("Cargo.toml").read() - start = src.index("[workspace.package]") - end = src.find("\n[", start + 1) - end = len(src) if end == -1 else end - section, count = re.subn( - r'(?m)^version\s*=\s*"[^"]+"', f'version = "{new}"', src[start:end], count=1 - ) - assert count == 1, "no version key found in [workspace.package]" - open("Cargo.toml", "w").write(src[:start] + section + src[end:]) - PY - - # Workspace members inherit `version.workspace = true`, so only the - # lockfile's own entries for those members need refreshing. - cargo update --workspace --offline || cargo update --workspace - - ACTUAL="$(read_version)" - if [ "$ACTUAL" != "$NEW_VERSION" ]; then - echo "::error::Version rewrite failed (Cargo.toml still reports '$ACTUAL')" - exit 1 - fi - VERSION="$NEW_VERSION" - fi - TAG="oneclient-$VERSION" + echo "Releasing OneClient $VERSION" # Abort rather than clobber: a published release (or an existing tag, # which only appears once a release is published) means this version # already shipped. A *draft* is a previous failed/partial run of this # same workflow, so reuse it. if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then - echo "::error::Tag $TAG already exists on origin - OneClient $VERSION has already been released. Re-run with a bump, or bump the version in Cargo.toml." + echo "::error::Tag $TAG already exists on origin - OneClient $VERSION has already been released. Add a change file with \`knope document-change\` first." exit 1 fi @@ -123,17 +85,17 @@ jobs: RELEASE_EXISTS=1 fi if [ "$RELEASE_EXISTS" = "1" ] && [ "$IS_DRAFT" != "true" ]; then - echo "::error::Release $TAG already exists and is published - refusing to overwrite OneClient $VERSION. Re-run with a bump, or bump the version in Cargo.toml." + echo "::error::Release $TAG already exists and is published - refusing to overwrite OneClient $VERSION. Add a change file with \`knope document-change\` first." exit 1 fi - # Commit the bump only after the guards pass, so a rejected release - # does not leave a stray version bump on the branch. - if [ "$BUMP" != "none" ]; then + # Commit only after the guards pass, so a rejected release does not + # leave a stray version bump on the branch. + if [ "$RELEASED" = "1" ]; then git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add Cargo.toml Cargo.lock - git commit -m "chore: bump to $VERSION" + git add -A Cargo.toml Cargo.lock .changeset CHANGELOG.md + git commit -m "chore: release $VERSION" git push origin "HEAD:${GITHUB_REF_NAME}" fi @@ -143,11 +105,18 @@ jobs: echo "Reusing existing draft release $TAG" gh release edit "$TAG" --target "$SHA" else + # The body is what the launcher's changelog page shows + awk -v heading="## $VERSION " '/^## /{p = index($0, heading) == 1; next} p' \ + CHANGELOG.md > notes.md 2>/dev/null || true + if ! grep -q '[^[:space:]]' notes.md; then + echo "::error::No changelog entry for $VERSION. Add a change file with \`knope document-change\` first." + exit 1 + fi gh release create "$TAG" \ --draft \ --target "$SHA" \ --title "OneClient $VERSION" \ - --notes "Automated release for OneClient $VERSION." + --notes-file notes.md fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -189,7 +158,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - # Pin to the commit `prepare` resolved - with `bump` that is the version + # Pin to the commit `prepare` resolved - after a release that is the version # commit it just pushed, which github.sha would not point at. - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: @@ -216,6 +185,7 @@ jobs: libfontconfig-dev libfreetype-dev \ libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev libdbus-1-dev \ + libgtk-3-dev libayatana-appindicator3-dev \ libfuse2 - name: Install cargo-packager diff --git a/.sqlx/query-001a994c740f7a015152319eb16fc113954af25ebd2a44d55e95833adda53268.json b/.sqlx/query-001a994c740f7a015152319eb16fc113954af25ebd2a44d55e95833adda53268.json new file mode 100644 index 00000000..d0962fc6 --- /dev/null +++ b/.sqlx/query-001a994c740f7a015152319eb16fc113954af25ebd2a44d55e95833adda53268.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT OR IGNORE INTO cluster_bundle_choices (cluster_id, bundle_name, accepted)\n SELECT ?, bundle_name, accepted\n FROM cluster_bundle_choices\n WHERE cluster_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "001a994c740f7a015152319eb16fc113954af25ebd2a44d55e95833adda53268" +} diff --git a/.sqlx/query-01b2ef24541af92505a3c2bc14037a61470a393dffa06da8bebfba98138709d0.json b/.sqlx/query-01b2ef24541af92505a3c2bc14037a61470a393dffa06da8bebfba98138709d0.json new file mode 100644 index 00000000..c93c3499 --- /dev/null +++ b/.sqlx/query-01b2ef24541af92505a3c2bc14037a61470a393dffa06da8bebfba98138709d0.json @@ -0,0 +1,170 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT name, java_path, resolution, force_fullscreen, mem_max, launch_args, game_args, launch_env,\n\t\t hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\tFROM setting_profiles\n\t\tORDER BY name ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "name" + } + } + }, + { + "name": "java_path", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "java_path" + } + } + }, + { + "name": "resolution", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "resolution" + } + } + }, + { + "name": "force_fullscreen", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "force_fullscreen" + } + } + }, + { + "name": "mem_max", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "mem_max" + } + } + }, + { + "name": "launch_args", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_args" + } + } + }, + { + "name": "game_args", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "game_args" + } + } + }, + { + "name": "launch_env", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_env" + } + } + }, + { + "name": "hook_pre", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_pre" + } + } + }, + { + "name": "hook_wrapper", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_wrapper" + } + } + }, + { + "name": "hook_post", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_post" + } + } + }, + { + "name": "os_extra", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "os_extra" + } + } + }, + { + "name": "browser_update_mode", + "ordinal": 12, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "browser_update_mode" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "01b2ef24541af92505a3c2bc14037a61470a393dffa06da8bebfba98138709d0" +} diff --git a/.sqlx/query-15dc690e91fe0bc7e1932a5051f8edd855deaeb31e52209ae436680962dea954.json b/.sqlx/query-15dc690e91fe0bc7e1932a5051f8edd855deaeb31e52209ae436680962dea954.json new file mode 100644 index 00000000..b3a70c7b --- /dev/null +++ b/.sqlx/query-15dc690e91fe0bc7e1932a5051f8edd855deaeb31e52209ae436680962dea954.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\ttarget_cluster_id, provider, project_id, content_type,\n\t\t\tsource_hash, display_name, expires_at, enabled\n\t\tFROM release_migration_waitlist\n\t\t", + "describe": { + "columns": [ + { + "name": "target_cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "release_migration_waitlist", + "name": "target_cluster_id" + } + } + }, + { + "name": "provider", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "release_migration_waitlist", + "name": "provider" + } + } + }, + { + "name": "project_id", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "release_migration_waitlist", + "name": "project_id" + } + } + }, + { + "name": "content_type", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "release_migration_waitlist", + "name": "content_type" + } + } + }, + { + "name": "source_hash", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "release_migration_waitlist", + "name": "source_hash" + } + } + }, + { + "name": "display_name", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "release_migration_waitlist", + "name": "display_name" + } + } + }, + { + "name": "expires_at", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "release_migration_waitlist", + "name": "expires_at" + } + } + }, + { + "name": "enabled", + "ordinal": 7, + "type_info": "Integer", + "origin": { + "Table": { + "table": "release_migration_waitlist", + "name": "enabled" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "15dc690e91fe0bc7e1932a5051f8edd855deaeb31e52209ae436680962dea954" +} diff --git a/.sqlx/query-171c6771d06656c2fe375f6a0da889f828f400e950f103f0ed44f5f98a48dc50.json b/.sqlx/query-171c6771d06656c2fe375f6a0da889f828f400e950f103f0ed44f5f98a48dc50.json new file mode 100644 index 00000000..aa121266 --- /dev/null +++ b/.sqlx/query-171c6771d06656c2fe375f6a0da889f828f400e950f103f0ed44f5f98a48dc50.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT bundle_name, accepted\n FROM cluster_bundle_choices\n WHERE cluster_id = ?\n ", + "describe": { + "columns": [ + { + "name": "bundle_name", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_bundle_choices", + "name": "bundle_name" + } + } + }, + { + "name": "accepted", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_bundle_choices", + "name": "accepted" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "171c6771d06656c2fe375f6a0da889f828f400e950f103f0ed44f5f98a48dc50" +} diff --git a/.sqlx/query-1e43f93b56bb495e13826c734fe5fb64515cf1d7c28bc6a1bf1fb1645fdba2a1.json b/.sqlx/query-1e43f93b56bb495e13826c734fe5fb64515cf1d7c28bc6a1bf1fb1645fdba2a1.json new file mode 100644 index 00000000..d9bb3c9e --- /dev/null +++ b/.sqlx/query-1e43f93b56bb495e13826c734fe5fb64515cf1d7c28bc6a1bf1fb1645fdba2a1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM release_migration_waitlist WHERE target_cluster_id = ? AND provider = ? AND project_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "1e43f93b56bb495e13826c734fe5fb64515cf1d7c28bc6a1bf1fb1645fdba2a1" +} diff --git a/.sqlx/query-4c6d40e97401e1321abccd7f413b49dda16a5bdf29eb90459ac140d8208db479.json b/.sqlx/query-4c6d40e97401e1321abccd7f413b49dda16a5bdf29eb90459ac140d8208db479.json new file mode 100644 index 00000000..7b4d834b --- /dev/null +++ b/.sqlx/query-4c6d40e97401e1321abccd7f413b49dda16a5bdf29eb90459ac140d8208db479.json @@ -0,0 +1,218 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE clusters\n\t\tSET overall_played = COALESCE(overall_played, 0) + ?,\n\t\t last_played = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + false, + true + ] + }, + "hash": "4c6d40e97401e1321abccd7f413b49dda16a5bdf29eb90459ac140d8208db479" +} diff --git a/.sqlx/query-54dea3f4093ceee9dcd5f0ae9bf072ec3615954b60bffa33bff7fd3f9bf14210.json b/.sqlx/query-54dea3f4093ceee9dcd5f0ae9bf072ec3615954b60bffa33bff7fd3f9bf14210.json new file mode 100644 index 00000000..159da446 --- /dev/null +++ b/.sqlx/query-54dea3f4093ceee9dcd5f0ae9bf072ec3615954b60bffa33bff7fd3f9bf14210.json @@ -0,0 +1,218 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO clusters (\n\t\t\tname, folder_name, mc_version, mc_loader, mc_loader_version,\n\t\t\tsetting_profile_name, stage, created_at,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } + } + ], + "parameters": { + "Right": 13 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + false, + true + ] + }, + "hash": "54dea3f4093ceee9dcd5f0ae9bf072ec3615954b60bffa33bff7fd3f9bf14210" +} diff --git a/.sqlx/query-598a34a0eb0f4d497cfd9bd77696310d1088e8baec677e07843a21ce6978652e.json b/.sqlx/query-598a34a0eb0f4d497cfd9bd77696310d1088e8baec677e07843a21ce6978652e.json new file mode 100644 index 00000000..8d303903 --- /dev/null +++ b/.sqlx/query-598a34a0eb0f4d497cfd9bd77696310d1088e8baec677e07843a21ce6978652e.json @@ -0,0 +1,170 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO setting_profiles (\n\t\t\tname, java_path, resolution, force_fullscreen, mem_max, launch_args, game_args, launch_env,\n\t\t\thook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET\n\t\t\tjava_path = excluded.java_path,\n\t\t\tresolution = excluded.resolution,\n\t\t\tforce_fullscreen = excluded.force_fullscreen,\n\t\t\tmem_max = excluded.mem_max,\n\t\t\tlaunch_args = excluded.launch_args,\n\t\t\tgame_args = excluded.game_args,\n\t\t\tlaunch_env = excluded.launch_env,\n\t\t\thook_pre = excluded.hook_pre,\n\t\t\thook_wrapper = excluded.hook_wrapper,\n\t\t\thook_post = excluded.hook_post,\n\t\t\tos_extra = excluded.os_extra,\n\t\t\tbrowser_update_mode = excluded.browser_update_mode\n\t\tRETURNING name, java_path, resolution, force_fullscreen, mem_max, launch_args, game_args, launch_env,\n hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\t", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "name" + } + } + }, + { + "name": "java_path", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "java_path" + } + } + }, + { + "name": "resolution", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "resolution" + } + } + }, + { + "name": "force_fullscreen", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "force_fullscreen" + } + } + }, + { + "name": "mem_max", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "mem_max" + } + } + }, + { + "name": "launch_args", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_args" + } + } + }, + { + "name": "game_args", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "game_args" + } + } + }, + { + "name": "launch_env", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_env" + } + } + }, + { + "name": "hook_pre", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_pre" + } + } + }, + { + "name": "hook_wrapper", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_wrapper" + } + } + }, + { + "name": "hook_post", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_post" + } + } + }, + { + "name": "os_extra", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "os_extra" + } + } + }, + { + "name": "browser_update_mode", + "ordinal": 12, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "browser_update_mode" + } + } + } + ], + "parameters": { + "Right": 13 + }, + "nullable": [ + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "598a34a0eb0f4d497cfd9bd77696310d1088e8baec677e07843a21ce6978652e" +} diff --git a/.sqlx/query-59a29c97c422e8721c29b9517ae834ea97776c57bb129a9d67007cdd979ae841.json b/.sqlx/query-59a29c97c422e8721c29b9517ae834ea97776c57bb129a9d67007cdd979ae841.json new file mode 100644 index 00000000..e94fb0ea --- /dev/null +++ b/.sqlx/query-59a29c97c422e8721c29b9517ae834ea97776c57bb129a9d67007cdd979ae841.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT OR IGNORE INTO cluster_bundle_type_opt_outs (cluster_id, bundle_name, content_type)\n VALUES (?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "59a29c97c422e8721c29b9517ae834ea97776c57bb129a9d67007cdd979ae841" +} diff --git a/.sqlx/query-5e18763a28af3660503e223c35875454b1e3fc4bb10c19d1c286d3e45afdd159.json b/.sqlx/query-5e18763a28af3660503e223c35875454b1e3fc4bb10c19d1c286d3e45afdd159.json new file mode 100644 index 00000000..00499d44 --- /dev/null +++ b/.sqlx/query-5e18763a28af3660503e223c35875454b1e3fc4bb10c19d1c286d3e45afdd159.json @@ -0,0 +1,218 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\tFROM clusters\n\t\tWHERE folder_name = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + false, + true + ] + }, + "hash": "5e18763a28af3660503e223c35875454b1e3fc4bb10c19d1c286d3e45afdd159" +} diff --git a/.sqlx/query-5f45ecf4e5ac27ad3a7ef4e5f9c2e6466ef51af11c332a2e6089c90759b9871f.json b/.sqlx/query-5f45ecf4e5ac27ad3a7ef4e5f9c2e6466ef51af11c332a2e6089c90759b9871f.json new file mode 100644 index 00000000..8c92362f --- /dev/null +++ b/.sqlx/query-5f45ecf4e5ac27ad3a7ef4e5f9c2e6466ef51af11c332a2e6089c90759b9871f.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT bundle_name, content_type\n FROM cluster_bundle_type_opt_outs\n WHERE cluster_id = ?\n ", + "describe": { + "columns": [ + { + "name": "bundle_name", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_bundle_type_opt_outs", + "name": "bundle_name" + } + } + }, + { + "name": "content_type", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_bundle_type_opt_outs", + "name": "content_type" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "5f45ecf4e5ac27ad3a7ef4e5f9c2e6466ef51af11c332a2e6089c90759b9871f" +} diff --git a/.sqlx/query-86e3ba70da419464c0be3b3971f613c5a112558472cf2dcdeffa5907e3785318.json b/.sqlx/query-86e3ba70da419464c0be3b3971f613c5a112558472cf2dcdeffa5907e3785318.json new file mode 100644 index 00000000..bd9fbeb1 --- /dev/null +++ b/.sqlx/query-86e3ba70da419464c0be3b3971f613c5a112558472cf2dcdeffa5907e3785318.json @@ -0,0 +1,170 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT name, java_path, resolution, force_fullscreen, mem_max, launch_args, game_args, launch_env,\n\t\t hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\tFROM setting_profiles\n\t\tWHERE name = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "name" + } + } + }, + { + "name": "java_path", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "java_path" + } + } + }, + { + "name": "resolution", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "resolution" + } + } + }, + { + "name": "force_fullscreen", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "force_fullscreen" + } + } + }, + { + "name": "mem_max", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "mem_max" + } + } + }, + { + "name": "launch_args", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_args" + } + } + }, + { + "name": "game_args", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "game_args" + } + } + }, + { + "name": "launch_env", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_env" + } + } + }, + { + "name": "hook_pre", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_pre" + } + } + }, + { + "name": "hook_wrapper", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_wrapper" + } + } + }, + { + "name": "hook_post", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_post" + } + } + }, + { + "name": "os_extra", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "os_extra" + } + } + }, + { + "name": "browser_update_mode", + "ordinal": 12, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "browser_update_mode" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "86e3ba70da419464c0be3b3971f613c5a112558472cf2dcdeffa5907e3785318" +} diff --git a/.sqlx/query-9b769a1467201ee998290c51b2cf51144a34c01975b898ee749b4e2111856119.json b/.sqlx/query-9b769a1467201ee998290c51b2cf51144a34c01975b898ee749b4e2111856119.json new file mode 100644 index 00000000..53da235e --- /dev/null +++ b/.sqlx/query-9b769a1467201ee998290c51b2cf51144a34c01975b898ee749b4e2111856119.json @@ -0,0 +1,218 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\tFROM clusters\n\t\tWHERE id = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + false, + true + ] + }, + "hash": "9b769a1467201ee998290c51b2cf51144a34c01975b898ee749b4e2111856119" +} diff --git a/.sqlx/query-9e4cbbc2c299861132749196c4e248e4309c2449e181b3b9ae16e7f7b4d9e627.json b/.sqlx/query-9e4cbbc2c299861132749196c4e248e4309c2449e181b3b9ae16e7f7b4d9e627.json new file mode 100644 index 00000000..0cfc0122 --- /dev/null +++ b/.sqlx/query-9e4cbbc2c299861132749196c4e248e4309c2449e181b3b9ae16e7f7b4d9e627.json @@ -0,0 +1,218 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE clusters\n\t\tSET name = ?,\n\t\t setting_profile_name = ?,\n\t\t mc_loader_version = ?,\n\t\t linked_modpack_hash = ?,\n\t\t description = ?,\n\t\t tags = ?,\n\t\t cover_path = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } + } + ], + "parameters": { + "Right": 8 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + false, + true + ] + }, + "hash": "9e4cbbc2c299861132749196c4e248e4309c2449e181b3b9ae16e7f7b4d9e627" +} diff --git a/.sqlx/query-aeb2fbb78cfa536d6478878ccafb4f4754dfe057468fe06c22d87c3a062132b7.json b/.sqlx/query-aeb2fbb78cfa536d6478878ccafb4f4754dfe057468fe06c22d87c3a062132b7.json new file mode 100644 index 00000000..1336266a --- /dev/null +++ b/.sqlx/query-aeb2fbb78cfa536d6478878ccafb4f4754dfe057468fe06c22d87c3a062132b7.json @@ -0,0 +1,218 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE clusters\n\t\tSET mc_version = ?,\n\t\t mc_loader = ?,\n\t\t mc_loader_version = ?,\n\t\t stage = ?,\n\t\t name = ?,\n\t\t folder_name = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } + } + ], + "parameters": { + "Right": 7 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + false, + true + ] + }, + "hash": "aeb2fbb78cfa536d6478878ccafb4f4754dfe057468fe06c22d87c3a062132b7" +} diff --git a/.sqlx/query-bc1502f185c5e73b59057f9caa0a892fbc2fa5dc45e63b391176e61f83a7676d.json b/.sqlx/query-bc1502f185c5e73b59057f9caa0a892fbc2fa5dc45e63b391176e61f83a7676d.json new file mode 100644 index 00000000..96bd69cb --- /dev/null +++ b/.sqlx/query-bc1502f185c5e73b59057f9caa0a892fbc2fa5dc45e63b391176e61f83a7676d.json @@ -0,0 +1,218 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\tFROM clusters\n\t\tWHERE mc_version = ? AND mc_loader = ? AND user_created = 0\n\t\tLIMIT 1\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + false, + true + ] + }, + "hash": "bc1502f185c5e73b59057f9caa0a892fbc2fa5dc45e63b391176e61f83a7676d" +} diff --git a/.sqlx/query-c06af5530a824d31e6e18a3a52cd57639b74e7e4d2ae30e29a6c49fda9521b4d.json b/.sqlx/query-c06af5530a824d31e6e18a3a52cd57639b74e7e4d2ae30e29a6c49fda9521b4d.json new file mode 100644 index 00000000..6dac2b17 --- /dev/null +++ b/.sqlx/query-c06af5530a824d31e6e18a3a52cd57639b74e7e4d2ae30e29a6c49fda9521b4d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT OR REPLACE INTO cluster_bundle_choices (cluster_id, bundle_name, accepted)\n VALUES (?, ?, ?)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "c06af5530a824d31e6e18a3a52cd57639b74e7e4d2ae30e29a6c49fda9521b4d" +} diff --git a/.sqlx/query-c3084a5369554a3ada17a50c5e33f8a6c414a9a213fd35c001c957eeebca3ff0.json b/.sqlx/query-c3084a5369554a3ada17a50c5e33f8a6c414a9a213fd35c001c957eeebca3ff0.json new file mode 100644 index 00000000..aa5a380b --- /dev/null +++ b/.sqlx/query-c3084a5369554a3ada17a50c5e33f8a6c414a9a213fd35c001c957eeebca3ff0.json @@ -0,0 +1,218 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE clusters\n\t\tSET stage = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + false, + true + ] + }, + "hash": "c3084a5369554a3ada17a50c5e33f8a6c414a9a213fd35c001c957eeebca3ff0" +} diff --git a/.sqlx/query-d636f4db52b3bb77d9d887500afbff5658afa10db3cf56794b8fa5e12535f3f7.json b/.sqlx/query-d636f4db52b3bb77d9d887500afbff5658afa10db3cf56794b8fa5e12535f3f7.json new file mode 100644 index 00000000..ce786f92 --- /dev/null +++ b/.sqlx/query-d636f4db52b3bb77d9d887500afbff5658afa10db3cf56794b8fa5e12535f3f7.json @@ -0,0 +1,218 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash,\n\t\t\tkind, user_created, description, tags, cover_path\n\t\tFROM clusters\n\t\tORDER BY last_played IS NULL, last_played DESC, name ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + }, + { + "name": "kind", + "ordinal": 12, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "kind" + } + } + }, + { + "name": "user_created", + "ordinal": 13, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "user_created" + } + } + }, + { + "name": "description", + "ordinal": 14, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "description" + } + } + }, + { + "name": "tags", + "ordinal": 15, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "tags" + } + } + }, + { + "name": "cover_path", + "ordinal": 16, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "cover_path" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true, + false, + false, + true, + false, + true + ] + }, + "hash": "d636f4db52b3bb77d9d887500afbff5658afa10db3cf56794b8fa5e12535f3f7" +} diff --git a/.sqlx/query-dff3f2fe8e50eaa7ffebab7490306bc40e846ad708975991346aab83870e0968.json b/.sqlx/query-dff3f2fe8e50eaa7ffebab7490306bc40e846ad708975991346aab83870e0968.json new file mode 100644 index 00000000..37ec45cd --- /dev/null +++ b/.sqlx/query-dff3f2fe8e50eaa7ffebab7490306bc40e846ad708975991346aab83870e0968.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM cluster_bundle_type_opt_outs\n WHERE cluster_id = ? AND bundle_name = ? AND content_type = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "dff3f2fe8e50eaa7ffebab7490306bc40e846ad708975991346aab83870e0968" +} diff --git a/.sqlx/query-e12409b04c8a3eea09a4bfc1014bca6fe5fbc1256b3537ed37195ed20075f944.json b/.sqlx/query-e12409b04c8a3eea09a4bfc1014bca6fe5fbc1256b3537ed37195ed20075f944.json new file mode 100644 index 00000000..218e1200 --- /dev/null +++ b/.sqlx/query-e12409b04c8a3eea09a4bfc1014bca6fe5fbc1256b3537ed37195ed20075f944.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO release_migration_waitlist (\n\t\t\ttarget_cluster_id, provider, project_id, content_type,\n\t\t\tsource_hash, display_name, added_at, expires_at, enabled\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(target_cluster_id, provider, project_id) DO UPDATE SET\n\t\t\tcontent_type = excluded.content_type,\n\t\t\tsource_hash = excluded.source_hash,\n\t\t\tdisplay_name = excluded.display_name,\n\t\t\tadded_at = excluded.added_at,\n\t\t\texpires_at = excluded.expires_at,\n\t\t\tenabled = excluded.enabled\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 9 + }, + "nullable": [] + }, + "hash": "e12409b04c8a3eea09a4bfc1014bca6fe5fbc1256b3537ed37195ed20075f944" +} diff --git a/.sqlx/query-ff07b78c0e97f1f38a6900092391d22e475471578b5ef73927e5427c2c0abc77.json b/.sqlx/query-ff07b78c0e97f1f38a6900092391d22e475471578b5ef73927e5427c2c0abc77.json new file mode 100644 index 00000000..e553b21b --- /dev/null +++ b/.sqlx/query-ff07b78c0e97f1f38a6900092391d22e475471578b5ef73927e5427c2c0abc77.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM release_migration_waitlist WHERE expires_at <= ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "ff07b78c0e97f1f38a6900092391d22e475471578b5ef73927e5427c2c0abc77" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..375b3389 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +## 2.8.0 (2026-10-09) + +### Features + +- Added safe multiple minecraft instances launching, only dedicated clusters tho by [rozwader](https://github.com/rozwader) + +### Fixes + +- Added better informations about errors from mclogs. by [rozwader](https://github.com/rozwader) +- Added status bar showing up if a player is logged out or he has a stale refresh token by [rozwader](https://github.com/rozwader) +- Added a filter for choosing modpack loaders by [rozwader](https://github.com/rozwader) +- SkyBlock content installed without asking is removed from 26.3 clusters once, and turning an optional bundle down removes its mods +- Fixed shield.io badges not rendering properly in markdown viewers by [rozwader](https://github.com/rozwader) + +## 2.7.0 (2026-10-05) + +### Features + +- Code blocks are selectable, and the open link confirmation has a copy button by [LynithDev](https://github.com/LynithDev) +- Game settings have a Game Arguments field for passing extra arguments to Minecraft, globally or per cluster by [Wyvest](https://github.com/Wyvest) +- Game logs hide account tokens, and the game output starts with the launch command ready to paste into a terminal by [LynithDev](https://github.com/LynithDev) +- GitHub hosted mods have a "View in browser" option that opens their repository by [LynithDev](https://github.com/LynithDev) +- The changelog page now shows each GitHub release's notes by [LynithDev](https://github.com/LynithDev) +- The mods folder now syncs with the launcher: jars added, renamed or replaced by hand show up in the Mods list, and jars deleted by hand are switched off instead of being put back by [Wyvest](https://github.com/Wyvest) + +- Choose which GPU the game renders on by [LynithDev](https://github.com/LynithDev) + +### Fixes + +- Icons show on Linux systems with a comma decimal locale by [LynithDev](https://github.com/LynithDev) +- The "Uploaded to mclo.gs" notification no longer repeats when switching back to the Logs tab by [LynithDev](https://github.com/LynithDev) +- Open folder and open in browser work again on Linux by [LynithDev](https://github.com/LynithDev) +- Optional bundles are now offered before the first launch of every new version instead of being installed or skipped silently by [Wyvest](https://github.com/Wyvest) +- System information in the settings sidebar shows a tooltip explaining it can be clicked to copy by [LynithDev](https://github.com/LynithDev) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e7e670f..a3771b55 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,15 @@ and per-OS prerequisites. Once you have finished making your changes, create a pull request (PR) to submit them. - Fill out the template to help reviewers understand your changes and the purpose of your PR. +- If your change is user-facing, add a change file with `knope document-change`, or create `.changeset/.md` by hand: + ```md + --- + default: patch + --- + + # Fixed the thing players noticed + ``` + Use `patch` for fixes, `minor` for features and `major` for breaking changes. The title becomes your line in the release notes. PRs without one fail the `Changeset Check` unless a maintainer labels them `no changeset`. - If you are addressing an existing issue, don't forget to [link your PR to the issue]. - Enable the checkbox to [allow maintainer edits] so that the branch can be updated for merging. - Once you submit your PR, a team member will review your proposal. They may ask questions or request additional information. diff --git a/Cargo.lock b/Cargo.lock index 8b48919b..17a607aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,7 +24,7 @@ version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ - "uuid 1.23.1", + "uuid 1.26.1", ] [[package]] @@ -107,8 +107,8 @@ dependencies = [ "accesskit_consumer", "hashbrown 0.16.1", "static_assertions", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", ] [[package]] @@ -128,12 +128,12 @@ dependencies = [ [[package]] name = "actix-codec" -version = "0.5.2" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +checksum = "4c13df95297bcf9014dc89162b0cc69431e192e34e3b419612fc124cfcd45dbf" dependencies = [ - "bitflags 2.13.1", - "bytes 1.11.1", + "bitflags 2.13.2", + "bytes 1.12.1", "futures-core", "futures-sink", "memchr", @@ -145,16 +145,15 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.13.1" +version = "3.18.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2" +checksum = "e2ab7d72c0bb26f10df574ea560177b4de2f27ee8f421b2c1e9b75628bd9db23" dependencies = [ "actix-codec", - "actix-rt", "actix-service", "actix-utils", - "bitflags 2.13.1", - "bytes 1.11.1", + "bitflags 2.13.2", + "bytes 1.12.1", "bytestring", "derive_more", "encoding_rs", @@ -190,9 +189,9 @@ dependencies = [ [[package]] name = "actix-rt" -version = "2.11.0" +version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +checksum = "e5f794807f82bbd36430c12cd600c73bbab0f52fdde4f0ed49978df113f4807f" dependencies = [ "futures-core", "tokio", @@ -200,17 +199,16 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.6.0" +version = "2.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +checksum = "8db2cf6f034e227c0c79825fbf11f3cad48569109515f62542b84d8abedb9376" dependencies = [ "actix-rt", "actix-service", - "actix-utils", "futures-core", "futures-util", "mio", - "socket2 0.5.10", + "socket2", "tokio", "tracing", ] @@ -227,9 +225,9 @@ dependencies = [ [[package]] name = "actix-utils" -version = "3.0.1" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +checksum = "0128396dd7313f697ad05b21b1a7be7d4cbb81888704f55996e4a27db196bb4d" dependencies = [ "local-waker", "pin-project-lite", @@ -237,9 +235,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.14.0" +version = "4.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4" +checksum = "bbacab3593b6b4f7be815076fc52d60a83c873426824675417e2abdd229e2e36" dependencies = [ "actix-codec", "actix-http", @@ -248,7 +246,7 @@ dependencies = [ "actix-server", "actix-service", "actix-utils", - "bytes 1.11.1", + "bytes 1.12.1", "bytestring", "cfg-if", "derive_more", @@ -268,7 +266,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.5", + "socket2", "time", "tracing", "url", @@ -310,9 +308,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -363,7 +361,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.13.1", + "bitflags 2.13.2", "cc", "jni", "libc", @@ -372,7 +370,7 @@ dependencies = [ "ndk-context", "ndk-sys", "num_enum", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -392,18 +390,18 @@ checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -434,9 +432,12 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] [[package]] name = "arg_enum_proc_macro" @@ -449,6 +450,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + [[package]] name = "arrayref" version = "0.3.9" @@ -482,7 +489,7 @@ version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] @@ -498,18 +505,19 @@ dependencies = [ [[package]] name = "astral-tokio-tar" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb50a7aae84a03bf55b067832bc376f4961b790c97e64d3eacee97d389b90277" +checksum = "6f2e989b33246fe9240d39accf4dd9a01e0b6c1f3ce9dd095e0a47fa02505523" dependencies = [ - "filetime", "futures-core", "libc", "portable-atomic", "rustc-hash", + "rustix 1.1.5", "tokio", "tokio-stream", "xattr", + "zerocopy", ] [[package]] @@ -538,9 +546,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" dependencies = [ "compression-codecs", "compression-core", @@ -576,7 +584,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.4", + "rustix 1.1.5", "slab", "windows-sys 0.61.2", ] @@ -607,7 +615,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.4", + "rustix 1.1.5", ] [[package]] @@ -633,7 +641,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix 1.1.5", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -669,23 +677,23 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-tempfile" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8a57b75c36e16f4d015e60e6a177552976a99b6947724403c551bcfa7cb1207" +checksum = "64782aa61953a4fa6f6ddc6b510942adffa37315f1cbf8ca7a96fdc990f913d5" dependencies = [ "tokio", - "uuid 1.23.1", + "uuid 1.26.1", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -693,6 +701,22 @@ name = "async-tungstenite" version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8acc405d38be14342132609f06f02acaf825ddccfe76c4824a69281e0458ebd4" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tungstenite 0.28.0", +] + +[[package]] +name = "async-tungstenite" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5b7664abf9ccae07576888c72bc93750c7090c1ff4ffee9317cc05d11618" dependencies = [ "atomic-waker", "futures-core", @@ -702,23 +726,48 @@ dependencies = [ "log", "pin-project-lite", "tokio", - "tungstenite", + "tungstenite 0.30.0", ] [[package]] name = "async_zip" -version = "0.0.18" +version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6" +checksum = "fb7f5f40e1eb30949a266fc900d37fd3267c7baf50c3705ac09c5d8ced5def63" dependencies = [ "async-compression", + "binrw", "chrono", "crc32fast", "futures-lite", "pin-project", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-util", + "tracing", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", ] [[package]] @@ -794,7 +843,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror 2.0.18", + "thiserror 2.0.21", "v_frame", "y4m", ] @@ -824,9 +873,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -834,9 +883,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -854,7 +903,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "object", "rustc-demangle", "windows-link", @@ -886,21 +935,44 @@ checksum = "d3c3baedf037b161e69da9200947329f52afa3b6e1cdbe6f344d6aa350fedd2f" [[package]] name = "bindgen" -version = "0.72.1" +version = "0.73.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cexpr", "clang-sys", - "itertools 0.13.0", "log", "prettyplease", "proc-macro2", "quote", "regex", "rustc-hash", - "shlex 1.3.0", + "shlex", + "syn 3.0.6", +] + +[[package]] +name = "binrw" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ad120d555272286c1017d25165ab8bd74806f13fc85b258484ec7e4ce75458f" +dependencies = [ + "array-init", + "binrw_derive", + "bytemuck", +] + +[[package]] +name = "binrw_derive" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92e0e9baae4dc82c7bad7715ca40c0a5c71539057bf2ea04a5c29c980410b" +dependencies = [ + "either", + "owo-colors", + "proc-macro2", + "quote", "syn 2.0.117", ] @@ -918,9 +990,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] @@ -978,9 +1050,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -1012,9 +1084,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "serde_core", @@ -1043,13 +1115,13 @@ dependencies = [ [[package]] name = "bytemuck_derive" -version = "1.11.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -1076,9 +1148,9 @@ dependencies = [ [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytestring" @@ -1086,7 +1158,32 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", ] [[package]] @@ -1095,7 +1192,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "log", "polling", "rustix 0.38.44", @@ -1109,9 +1206,9 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "polling", - "rustix 1.1.4", + "rustix 1.1.5", "slab", "tracing", ] @@ -1135,7 +1232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ "calloop 0.14.4", - "rustix 1.1.4", + "rustix 1.1.5", "wayland-backend", "wayland-client", ] @@ -1149,7 +1246,7 @@ dependencies = [ "base64 0.22.1", "cargo-packager-utils", "ctor 0.2.9", - "dirs", + "dirs 5.0.1", "dunce", "flate2", "http 1.5.0", @@ -1178,14 +1275,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 2.0.1", + "shlex", ] [[package]] @@ -1198,10 +1295,20 @@ dependencies = [ ] [[package]] -name = "cfg-if" -version = "1.0.4" +name = "cfg-expr" +version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -1220,20 +1327,20 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1251,7 +1358,7 @@ checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", ] [[package]] @@ -1316,19 +1423,19 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "memchr", ] [[package]] name = "compression-codecs" -version = "0.4.38" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" dependencies = [ "brotli", "compression-core", @@ -1339,9 +1446,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" [[package]] name = "concurrent-queue" @@ -1354,9 +1461,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.4" +version = "0.16.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" dependencies = [ "encode_unicode", "libc", @@ -1449,6 +1556,12 @@ dependencies = [ "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1460,9 +1573,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -1484,18 +1597,27 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] -name = "crossbeam-deque" -version = "0.8.7" +name = "crossbeam-channel" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -1503,27 +1625,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -1592,9 +1714,9 @@ checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "debugid" @@ -1603,7 +1725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" dependencies = [ "serde", - "uuid 1.23.1", + "uuid 1.26.1", ] [[package]] @@ -1693,6 +1815,15 @@ dependencies = [ "dirs-sys 0.4.1", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + [[package]] name = "dirs-sys" version = "0.4.1" @@ -1713,7 +1844,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users 0.5.3", "windows-sys 0.61.2", ] @@ -1728,7 +1859,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_repr", - "thiserror 2.0.18", + "thiserror 2.0.21", "uuid 0.8.2", ] @@ -1744,7 +1875,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -1758,7 +1889,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -1767,7 +1898,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] @@ -1794,7 +1925,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytemuck", "drm-ffi", "drm-fourcc", @@ -1809,7 +1940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" dependencies = [ "drm-sys", - "rustix 1.1.4", + "rustix 1.1.5", ] [[package]] @@ -1851,9 +1982,9 @@ dependencies = [ [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" dependencies = [ "serde", ] @@ -1866,11 +1997,16 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -1938,9 +2074,9 @@ dependencies = [ [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "etcetera" @@ -1990,7 +2126,7 @@ dependencies = [ "bit_field", "half", "lebe", - "miniz_oxide", + "miniz_oxide 0.8.9", "num-complex", "pulp", "rayon-core", @@ -2019,6 +2155,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "filetime" version = "0.2.29" @@ -2031,9 +2177,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "findshlibs" @@ -2055,12 +2201,13 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -2110,7 +2257,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] @@ -2130,31 +2277,33 @@ dependencies = [ [[package]] name = "freya" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "cfg-if", "freya-android", "freya-animation", + "freya-borderless-plugin", "freya-clipboard", "freya-components", "freya-core", "freya-edit", "freya-engine", "freya-markdown", - "freya-performance-plugin", + "freya-metrics-plugin", "freya-query", "freya-radio", "freya-router", "freya-sdk", "freya-winit", "torin", + "tray-icon", ] [[package]] name = "freya-android" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "freya-components", "freya-core", @@ -2166,17 +2315,28 @@ dependencies = [ [[package]] name = "freya-animation" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "easer", "freya-core", ] +[[package]] +name = "freya-borderless-plugin" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" +dependencies = [ + "freya-core", + "freya-engine", + "freya-winit", + "torin", +] + [[package]] name = "freya-clipboard" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "copypasta", "freya-core", @@ -2184,13 +2344,13 @@ dependencies = [ [[package]] name = "freya-components" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "accesskit", "anyhow", "async-lock", - "bytes 1.11.1", + "bytes 1.12.1", "cfg-if", "euclid", "freya-animation", @@ -2202,7 +2362,7 @@ dependencies = [ "futures-channel", "paste", "reqwest 0.12.28", - "thiserror 2.0.18", + "thiserror 2.0.21", "torin", "tracing", "url", @@ -2210,14 +2370,14 @@ dependencies = [ [[package]] name = "freya-core" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "accesskit", "async-io", - "bitflags 2.13.1", + "bitflags 2.13.2", "blocking", - "bytes 1.11.1", + "bytes 1.12.1", "cursor-icon", "freya-engine", "futures-channel", @@ -2225,8 +2385,8 @@ dependencies = [ "futures-util", "generational-box", "hotpath", - "itertools 0.14.0", - "keyboard-types", + "itertools", + "keyboard-types 0.8.3", "mundy", "paste", "pathgraph", @@ -2240,13 +2400,13 @@ dependencies = [ [[package]] name = "freya-edit" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "freya-clipboard", "freya-core", - "keyboard-types", + "keyboard-types 0.8.3", "ropey", "torin", "unicode-segmentation", @@ -2254,16 +2414,16 @@ dependencies = [ [[package]] name = "freya-engine" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "freya-skia-safe", ] [[package]] name = "freya-markdown" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "freya-components", "freya-core", @@ -2272,19 +2432,20 @@ dependencies = [ ] [[package]] -name = "freya-performance-plugin" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +name = "freya-metrics-plugin" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "freya-core", "freya-engine", "freya-winit", + "tracing", ] [[package]] name = "freya-query" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "freya-core", "futures-util", @@ -2292,16 +2453,16 @@ dependencies = [ [[package]] name = "freya-radio" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "freya-core", ] [[package]] name = "freya-router" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "freya-core", "freya-router-macro", @@ -2312,8 +2473,8 @@ dependencies = [ [[package]] name = "freya-router-macro" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "proc-macro2", "quote", @@ -2323,37 +2484,38 @@ dependencies = [ [[package]] name = "freya-sdk" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "freya-core", + "tokio", ] [[package]] name = "freya-skia-bindings" -version = "0.100.0" +version = "0.103.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59be930a7bfe544a7f16fcc6d7326dfabff3ffe65c3321932f4e61234f4941" +checksum = "423c6e5119a363d09120729908d478b6d906dcd7ae76e6b2b2e437d708d42ddf" dependencies = [ "bindgen", "cc", "flate2", - "heck", + "heck 0.5.0", "pkg-config", "regex", "serde_json", "tar", - "toml", + "toml 1.1.6+spec-1.1.0", ] [[package]] name = "freya-skia-safe" -version = "0.100.0" +version = "0.103.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7975576c62caa0b8bccb6f4cfd16c61f57b27fffa15540f37deba9bd246bf009" +checksum = "76caec9c4cc139acd1d73016d63a782708693a54fffe6bc9a53112e2f710d22d" dependencies = [ "base64 0.23.1", - "bitflags 2.13.1", + "bitflags 2.13.2", "freya-skia-bindings", "freya-skia-svg-macros", "percent-encoding", @@ -2361,19 +2523,19 @@ dependencies = [ [[package]] name = "freya-skia-svg-macros" -version = "0.1.0" +version = "0.103.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c622cedc8d1cdaf144bc779734e7f6c5c29876a63e114bd0424d47a0d1aad18e" +checksum = "7f789a635e4ca6346ec08f71f02dd6778428887786f14267f7a3de1c98a44e18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "freya-testing" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "accesskit", "freya-clipboard", @@ -2387,14 +2549,14 @@ dependencies = [ [[package]] name = "freya-winit" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "accesskit", "accesskit_winit", "ash", "ash-window", - "bytes 1.11.1", + "bytes 1.12.1", "freya-clipboard", "freya-components", "freya-core", @@ -2405,9 +2567,10 @@ dependencies = [ "gl", "glutin", "glutin-winit", + "gtk", "hotpath", "image", - "keyboard-types", + "keyboard-types 0.8.3", "mundy", "objc2 0.6.4", "objc2-app-kit 0.3.2", @@ -2422,6 +2585,7 @@ dependencies = [ "softbuffer", "torin", "tracing", + "tray-icon", "windows-sys 0.60.2", "winit", ] @@ -2449,9 +2613,9 @@ checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -2459,9 +2623,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" @@ -2487,9 +2651,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -2506,32 +2670,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", @@ -2544,6 +2708,64 @@ dependencies = [ "tokio-io", ] +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + [[package]] name = "generational-box" version = "0.7.10" @@ -2570,7 +2792,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.4", + "rustix 1.1.5", "windows-link", ] @@ -2638,6 +2860,38 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + [[package]] name = "gl" version = "0.14.0" @@ -2658,6 +2912,53 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + [[package]] name = "glob" version = "0.3.4" @@ -2666,9 +2967,9 @@ checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", @@ -2683,14 +2984,14 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg_aliases", "cgl", "dispatch2", "glutin_egl_sys", "glutin_glx_sys", "glutin_wgl_sys", - "libloading", + "libloading 0.8.9", "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-core-foundation", @@ -2744,13 +3045,76 @@ dependencies = [ ] [[package]] -name = "h2" -version = "0.4.15" +name = "gobject-sys" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", - "bytes 1.11.1", + "bytes 1.12.1", "fnv", "futures-core", "futures-sink", @@ -2808,6 +3172,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -2816,9 +3186,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -2857,24 +3227,24 @@ dependencies = [ [[package]] name = "hotpath" -version = "0.22.0" +version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66750a77f4f6b408a148be5102ef1f3ba7172def7ee92b1cfc75d9f7a3870453" +checksum = "f94954c08de30a8d03236eff084da859eb8020c32fb110c8358e3e765e68e176" dependencies = [ "hotpath-macros", ] [[package]] name = "hotpath-macros" -version = "0.22.0" +version = "0.28.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afe0e1900d2dbe2e2df8e9522b97ebd7a5598ba18478f57e247957022dffedbe" +checksum = "0a57685508a0e0854667ccfd8583f3f71c377d64367a0f7bef64fcacef26646d" [[package]] name = "htmd" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eee9b00ee2e599b4f86507157e3db786e7a3319fc225f0e9584151dbea2291d" +checksum = "a5a1c7113c831fec68cbd79cd8bf281a84e5b6943f51473dc266b0b88a6a017e" dependencies = [ "html5ever", "markup5ever_rcdom", @@ -2897,7 +3267,7 @@ version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "fnv", "itoa", ] @@ -2908,7 +3278,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "itoa", ] @@ -2918,17 +3288,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "http 1.5.0", ] [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "futures-core", "http 1.5.0", "http-body", @@ -2949,21 +3319,21 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", - "bytes 1.11.1", + "bytes 1.12.1", "futures-channel", "futures-core", "h2", @@ -2979,9 +3349,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http 1.5.0", "hyper", @@ -2996,22 +3366,23 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", - "bytes 1.11.1", + "base64 0.23.1", + "bytes 1.12.1", "futures-channel", "futures-util", "http 1.5.0", "http-body", + "httparse", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2", "system-configuration", "tokio", "tower-service", @@ -3031,7 +3402,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -3045,9 +3416,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -3059,9 +3430,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -3072,9 +3443,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3086,16 +3457,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -3106,15 +3478,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -3160,7 +3532,7 @@ dependencies = [ "image-webp", "moxcms", "num-traits", - "png", + "png 0.18.1", "qoi", "ravif", "rayon", @@ -3182,15 +3554,15 @@ dependencies = [ [[package]] name = "imgref" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" [[package]] name = "impl-more" -version = "0.3.5" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" +checksum = "30c0cddce6b7505483307994f60d97c4b156020e1504d0cf1f05a21b1b325e64" [[package]] name = "include-flate" @@ -3227,9 +3599,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -3237,9 +3609,9 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.4" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console", "portable-atomic", @@ -3255,7 +3627,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "inotify-sys", "libc", ] @@ -3271,14 +3643,14 @@ dependencies = [ [[package]] name = "interfrost" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a14453cd2d9454beb9b410dc7b845b2d62ff67a6f42460bce6b9bc6e34d3b9" +checksum = "51cf4ffa08d3e23d9f6328cf64f521f7f50fb9ada957d0292e1738c40b699686" dependencies = [ "chrono", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -3303,9 +3675,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is-docker" @@ -3326,15 +3698,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.14.0" @@ -3362,7 +3725,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -3420,9 +3783,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -3431,21 +3794,32 @@ dependencies = [ [[package]] name = "junction" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "160f2eade097f30263b548aae5deb12ad349c909baa710fa24b92c9090b2e006" +checksum = "cde48f36530cbd91b65f050101e6c056d6eb743cea2a8f0f19beef69c6742ff8" dependencies = [ "scopeguard", "windows-sys 0.61.2", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.2", + "serde", + "unicode-segmentation", +] + [[package]] name = "keyboard-types" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "serde", ] @@ -3471,7 +3845,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", ] @@ -3496,6 +3870,30 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + [[package]] name = "libc" version = "0.2.189" @@ -3504,9 +3902,9 @@ checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libflate" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4da9b700e758e57152a1fd1c52cbdc5727c1aa6d8743dc1acda917398f1d76c" +checksum = "561a8da1a50e1428d3c51321dafeca849df992a5bb67720c386131234caba82e" dependencies = [ "adler32", "crc32fast", @@ -3536,6 +3934,16 @@ dependencies = [ "cc", ] +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libloading" version = "0.8.9" @@ -3563,14 +3971,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.19" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.1", + "redox_syscall 0.9.4", ] [[package]] @@ -3604,9 +4012,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "local-waker" @@ -3625,9 +4033,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "loop9" @@ -3640,9 +4048,9 @@ dependencies = [ [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "malloc_buf" @@ -3695,16 +4103,6 @@ dependencies = [ "rayon", ] -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest 0.10.7", -] - [[package]] name = "md-5" version = "0.11.0" @@ -3787,10 +4185,20 @@ dependencies = [ ] [[package]] -name = "mio" -version = "1.2.2" +name = "miniz_oxide" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -3808,6 +4216,32 @@ dependencies = [ "pxfm", ] +[[package]] +name = "muda" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types 0.7.0", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.17.16", + "thiserror 2.0.21", + "windows-sys 0.60.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "mundy" version = "0.2.3" @@ -3829,7 +4263,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.62.2", + "windows", "zbus", ] @@ -3839,7 +4273,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3875,7 +4309,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -3921,7 +4355,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "fsevent-sys", "inotify", "kqueue", @@ -3939,7 +4373,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -4015,9 +4449,9 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -4069,7 +4503,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -4115,7 +4549,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -4131,7 +4565,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -4152,7 +4586,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location 0.2.2", @@ -4165,7 +4599,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4187,7 +4621,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4199,7 +4633,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4210,7 +4644,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "dispatch2", "libc", @@ -4223,7 +4657,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -4280,7 +4714,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -4292,7 +4726,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -4311,7 +4745,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "dispatch", "libc", @@ -4324,7 +4758,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -4347,7 +4781,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "objc2 0.6.4", "objc2-core-foundation", ] @@ -4370,7 +4804,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4382,7 +4816,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "dispatch2", "objc2 0.6.4", @@ -4407,7 +4841,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -4420,7 +4854,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -4447,7 +4881,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit 0.2.2", @@ -4468,7 +4902,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.6.2", "objc2 0.6.4", "objc2-cloud-kit 0.3.2", @@ -4500,7 +4934,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location 0.2.2", @@ -4534,11 +4968,12 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oneclient_app" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ "anyhow", "arboard", - "bytes 1.11.1", + "base64 0.23.1", + "bytes 1.12.1", "cargo-packager-updater", "chrono", "directories", @@ -4548,7 +4983,9 @@ dependencies = [ "freya-skia-safe", "freya-testing", "futures-util", + "gtk-sys", "image", + "libloading 0.8.9", "mimalloc", "notify", "oneclient_auth", @@ -4568,7 +5005,7 @@ dependencies = [ "polyio", "pulldown-cmark", "regex", - "reqwest 0.13.3", + "reqwest 0.13.5", "rfd", "rust-embed", "serde", @@ -4577,53 +5014,54 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "uuid 1.23.1", - "windows 0.62.2", + "uuid 1.26.1", + "windows", "winresource", ] [[package]] name = "oneclient_auth" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "chrono", - "md-5 0.10.6", + "md-5", "oneclient_common", "oneclient_events", "oneclient_net", "polyio", - "rand 0.10.1", - "reqwest 0.13.3", + "rand 0.10.3", + "reqwest 0.13.5", "serde", "serde_json", - "sha2 0.10.9", - "thiserror 2.0.18", + "sha2 0.11.0", + "thiserror 2.0.21", "tokio", "tokio-util", "tracing", "url", - "uuid 1.23.1", + "uuid 1.26.1", ] [[package]] name = "oneclient_cluster" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "chrono", "image", "oneclient_common", "oneclient_db", "oneclient_net", "polyio", - "reqwest 0.13.3", + "regex", + "reqwest 0.13.5", "serde", "serde_json", "serde_repr", "sqlx", "strum", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "trash", @@ -4631,20 +5069,20 @@ dependencies = [ [[package]] name = "oneclient_common" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ "directories", "interfrost", "serde", "strum", "sysinfo", - "thiserror 2.0.18", + "thiserror 2.0.21", "url", ] [[package]] name = "oneclient_content" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ "async-trait", "async_zip", @@ -4658,32 +5096,32 @@ dependencies = [ "oneclient_net", "polyio", "pulldown-cmark", - "reqwest 0.13.3", + "reqwest 0.13.5", "serde", "serde_json", "sqlx", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", - "toml", + "toml 1.1.6+spec-1.1.0", "tracing", "url", - "uuid 1.23.1", + "uuid 1.26.1", ] [[package]] name = "oneclient_core" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ "async-compression", "async-stream", "async-tempfile", "async-trait", - "async-tungstenite", + "async-tungstenite 0.35.0", "async_zip", - "base64 0.22.1", - "bytes 1.11.1", + "base64 0.23.1", + "bytes 1.12.1", "chrono", - "digest 0.10.7", + "digest 0.11.3", "directories", "discord-rich-presence", "dunce", @@ -4694,7 +5132,7 @@ dependencies = [ "indicatif", "interfrost", "libc", - "md-5 0.10.6", + "md-5", "oneclient_auth", "oneclient_cluster", "oneclient_common", @@ -4708,9 +5146,9 @@ dependencies = [ "oneclient_polyplus", "parking_lot", "polyio", - "rand 0.10.1", + "rand 0.10.3", "regex", - "reqwest 0.13.3", + "reqwest 0.13.5", "reqwest-websocket", "rsa", "sanitize-filename", @@ -4720,11 +5158,12 @@ dependencies = [ "serde_json", "serde_repr", "sha1 0.10.6", - "sha2 0.10.9", + "sha1 0.11.0", + "sha2 0.11.0", "sqlx", "strum", "sysinfo", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", @@ -4733,12 +5172,12 @@ dependencies = [ "tracing-subscriber", "trash", "url", - "uuid 1.23.1", + "uuid 1.26.1", ] [[package]] name = "oneclient_db" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ "async-tempfile", "chrono", @@ -4746,14 +5185,14 @@ dependencies = [ "serde", "serde_json", "sqlx", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", ] [[package]] name = "oneclient_discord" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ "chrono", "discord-rich-presence", @@ -4765,17 +5204,17 @@ dependencies = [ [[package]] name = "oneclient_events" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", - "uuid 1.23.1", + "uuid 1.26.1", ] [[package]] name = "oneclient_java" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ "astral-tokio-tar", "async-tempfile", @@ -4787,10 +5226,10 @@ dependencies = [ "oneclient_events", "oneclient_net", "polyio", - "reqwest 0.13.3", + "reqwest 0.13.5", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "url", @@ -4798,18 +5237,18 @@ dependencies = [ [[package]] name = "oneclient_macro" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "oneclient_mc" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "chrono", "futures-util", "interfrost", @@ -4817,67 +5256,68 @@ dependencies = [ "oneclient_events", "oneclient_net", "polyio", - "reqwest 0.13.3", + "reqwest 0.13.5", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "url", - "uuid 1.23.1", + "uuid 1.26.1", ] [[package]] name = "oneclient_net" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ "arc-swap", "async-trait", - "bytes 1.11.1", + "bytes 1.12.1", "futures-lite", "futures-util", "oneclient_common", "oneclient_events", "polyio", - "reqwest 0.13.3", + "reqwest 0.13.5", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "url", - "uuid 1.23.1", + "uuid 1.26.1", ] [[package]] name = "oneclient_polyplus" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ - "async-tungstenite", - "bytes 1.11.1", + "async-tungstenite 0.35.0", + "bytes 1.12.1", "futures-util", "oneclient_auth", "oneclient_common", "polyio", - "rand 0.10.1", - "reqwest 0.13.3", + "rand 0.10.3", + "reqwest 0.13.5", "reqwest-websocket", "serde", "serde_json", - "sha1 0.10.6", - "thiserror 2.0.18", + "sha1 0.11.0", + "thiserror 2.0.21", "tokio", "tracing", "url", - "uuid 1.23.1", + "uuid 1.26.1", ] [[package]] name = "open" -version = "5.4.0" +version = "5.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" dependencies = [ + "dunce", "is-wsl", "libc", ] @@ -4949,6 +5389,37 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "owo-colors" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parking" version = "2.2.1" @@ -4992,8 +5463,8 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "pathgraph" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" [[package]] name = "pem-rfc7468" @@ -5134,9 +5605,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plain" @@ -5144,17 +5615,30 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "png" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -5167,7 +5651,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -5179,15 +5663,14 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "polyio" -version = "2.5.1-c3" +version = "2.8.0-c1" dependencies = [ "astral-tokio-tar", "async-compression", - "async-stream", "async-tempfile", "async_zip", - "bytes 1.11.1", - "digest 0.10.7", + "bytes 1.12.1", + "digest 0.11.3", "dunce", "futures-lite", "futures-util", @@ -5195,9 +5678,9 @@ dependencies = [ "sanitize-filename", "serde", "serde_json", - "sha1 0.10.6", - "sha2 0.10.9", - "thiserror 2.0.18", + "sha1 0.11.0", + "sha2 0.11.0", + "thiserror 2.0.21", "tokio", "tokio-util", "tracing", @@ -5206,15 +5689,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -5242,12 +5725,32 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "prettyplease" -version = "0.2.37" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 2.0.117", + "syn 3.0.6", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", ] [[package]] @@ -5256,14 +5759,38 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.15+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", ] [[package]] name = "proc-macro-error-attr3" -version = "3.1.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" dependencies = [ "proc-macro2", "quote", @@ -5271,21 +5798,21 @@ dependencies = [ [[package]] name = "proc-macro-error3" -version = "3.1.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" dependencies = [ "proc-macro-error-attr3", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -5315,7 +5842,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "getopts", "memchr", "pulldown-cmark-escape", @@ -5383,19 +5910,19 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.5", - "thiserror 2.0.18", + "socket2", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -5403,22 +5930,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "aws-lc-rs", - "bytes 1.11.1", + "bytes 1.12.1", "getrandom 0.4.3", "lru-slab", - "rand 0.10.1", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -5433,16 +5960,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2", "tracing", "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -5461,12 +5988,12 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "ragnarok" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "euclid", "hotpath", - "itertools 0.14.0", + "itertools", "rustc-hash", "tracing", ] @@ -5493,9 +6020,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -5571,7 +6098,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools 0.14.0", + "itertools", "libc", "libfuzzer-sys", "log", @@ -5585,7 +6112,7 @@ dependencies = [ "rand 0.9.5", "rand_chacha 0.9.0", "simd_helpers", - "thiserror 2.0.18", + "thiserror 2.0.21", "v_frame", "wasm-bindgen", ] @@ -5611,7 +6138,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -5673,16 +6200,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] name = "redox_syscall" -version = "0.9.1" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -5698,20 +6225,19 @@ dependencies = [ [[package]] name = "redox_users" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -5721,9 +6247,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -5749,7 +6275,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", - "bytes 1.11.1", + "bytes 1.12.1", "futures-channel", "futures-core", "futures-util", @@ -5788,12 +6314,12 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", - "bytes 1.11.1", + "base64 0.23.1", + "bytes 1.12.1", "encoding_rs", "futures-channel", "futures-core", @@ -5838,15 +6364,15 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7705b649c3b66b85c4e9c304a6898b1ae3eecb880c474720ebf925e4a932ae02" dependencies = [ - "async-tungstenite", - "bytes 1.11.1", + "async-tungstenite 0.32.1", + "bytes 1.12.1", "futures-util", - "reqwest 0.13.3", - "thiserror 2.0.18", + "reqwest 0.13.5", + "thiserror 2.0.21", "tokio", "tokio-util", "tracing", - "tungstenite", + "tungstenite 0.28.0", "web-sys", ] @@ -5999,7 +6525,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.4.15", @@ -6008,11 +6534,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys 0.12.1", @@ -6021,9 +6547,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -6059,9 +6585,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -6080,15 +6606,15 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -6166,7 +6692,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -6191,13 +6717,13 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "sentry" -version = "0.48.5" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d631477761f57c76148456e55e80e9a479ff3fa4c65b2b4a0c3acf1167fd4638" +checksum = "9fa951f4e644464ebfd2347a7ab03b0828c512d2448a76262cad2607b343dff4" dependencies = [ "cfg_aliases", "httpdate", - "reqwest 0.13.3", + "reqwest 0.13.5", "rustls", "sentry-actix", "sentry-backtrace", @@ -6212,22 +6738,22 @@ dependencies = [ [[package]] name = "sentry-actix" -version = "0.48.5" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "119ede4e37790ec04e8a14073c8414f0a4b2648402856c0563495a9a5cf54d57" +checksum = "909d0a93b15d1ddcde3894aa23d003cbefc39ef53fe6d7c8294357ced2969cfb" dependencies = [ "actix-http", "actix-web", - "bytes 1.11.1", + "bytes 1.12.1", "futures-util", "sentry-core", ] [[package]] name = "sentry-backtrace" -version = "0.48.5" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "448b0981fbde6cdc9eb087ba3dc01035a253fef9a0d9e79aaf198fc26acb2e64" +checksum = "f4f27cf048aa63ccd5bc94c29989133eabb82e9c6bf7441b58e9d4a95837a105" dependencies = [ "backtrace", "regex", @@ -6236,9 +6762,9 @@ dependencies = [ [[package]] name = "sentry-contexts" -version = "0.48.5" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e909d02170ba6d1dc5ebd05bef7999280f5d960e3eaee5d1a18d88d41b334" +checksum = "c5ed680585dbdacdf9859c2b4dc53358ff02bd256766882e22e343836db36b22" dependencies = [ "hostname", "libc", @@ -6250,11 +6776,11 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.48.5" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecf0b1a4a4e9ec88395b52e6fa4868b95564c1fa96b26a0606f5f6288a0f7149" +checksum = "c3ed398494bb8fc412a11a182c1883a10ae147587b8f6314abd222d1d626f31b" dependencies = [ - "rand 0.9.5", + "rand 0.10.3", "sentry-types", "serde", "serde_json", @@ -6263,9 +6789,9 @@ dependencies = [ [[package]] name = "sentry-debug-images" -version = "0.48.5" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f81d7749c57fc78ed52134889e8121da874065bc40da788d5798cce0f8c19f15" +checksum = "55854bb40681e84ffd2a69578ad4ef9bfaf5bf2bf1380a745578b591fa24af22" dependencies = [ "findshlibs", "sentry-core", @@ -6273,9 +6799,9 @@ dependencies = [ [[package]] name = "sentry-panic" -version = "0.48.5" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7d93d6ecb55d2251c5fc084c55c03a2bc68904918d76117e602c999e92f00f" +checksum = "b7153e68642b761ed3aa62c022795c11b2facc728b2db7484084492373c944f9" dependencies = [ "sentry-backtrace", "sentry-core", @@ -6283,11 +6809,11 @@ dependencies = [ [[package]] name = "sentry-tracing" -version = "0.48.5" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58d26379236c4ef97eaf081bca3c7bf0ef6f06b3aa881eca2ee8e8dbc923e5b8" +checksum = "e7e7db1aefdf623f4e48e21ec9e4af535024187aeafb2a4cc7e29d035def5a13" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "sentry-backtrace", "sentry-core", "tracing-core", @@ -6296,26 +6822,26 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.48.5" +version = "0.49.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2239099d47e76857b0825a182ecdb90159add7aa1097b60247c6e7ca6bf49c6a" +checksum = "1c18bf7b159bdfc7c8f97816f476b4947a1937fb6bea9d38d3479e58631614ac" dependencies = [ "debugid", "hex", - "rand 0.9.5", + "rand 0.10.3", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "url", - "uuid 1.23.1", + "uuid 1.26.1", ] [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -6323,29 +6849,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -6356,13 +6882,22 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", ] [[package]] @@ -6404,7 +6939,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -6426,7 +6961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -6439,12 +6974,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "shlex" version = "2.0.1" @@ -6504,9 +7033,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -6516,9 +7045,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" dependencies = [ "serde", ] @@ -6529,7 +7058,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "calloop 0.13.0", "calloop-wayland-source 0.3.0", "cursor-icon", @@ -6554,15 +7083,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "calloop 0.14.4", "calloop-wayland-source 0.4.1", "cursor-icon", "libc", "log", "memmap2", - "rustix 1.1.4", - "thiserror 2.0.18", + "rustix 1.1.5", + "thiserror 2.0.21", "wayland-backend", "wayland-client", "wayland-csd-frame", @@ -6595,16 +7124,6 @@ dependencies = [ "serde", ] -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.5" @@ -6635,7 +7154,7 @@ dependencies = [ "objc2-quartz-core 0.3.2", "raw-window-handle", "redox_syscall 0.5.18", - "rustix 1.1.4", + "rustix 1.1.5", "tiny-xlib", "tracing", "wasm-bindgen", @@ -6686,7 +7205,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ "base64 0.22.1", - "bytes 1.11.1", + "bytes 1.12.1", "cfg-if", "chrono", "crc", @@ -6709,12 +7228,12 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-stream", "tracing", "url", - "uuid 1.23.1", + "uuid 1.26.1", ] [[package]] @@ -6739,7 +7258,7 @@ dependencies = [ "cfg-if", "dotenvy", "either", - "heck", + "heck 0.5.0", "hex", "proc-macro2", "quote", @@ -6751,7 +7270,7 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.117", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "url", ] @@ -6762,9 +7281,9 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "byteorder", - "bytes 1.11.1", + "bytes 1.12.1", "chrono", "crc", "digest 0.11.3", @@ -6779,9 +7298,9 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", - "uuid 1.23.1", + "uuid 1.26.1", ] [[package]] @@ -6792,7 +7311,7 @@ checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.13.1", + "bitflags 2.13.2", "byteorder", "chrono", "crc", @@ -6806,18 +7325,18 @@ dependencies = [ "hmac", "itoa", "log", - "md-5 0.11.0", + "md-5", "memchr", - "rand 0.10.1", + "rand 0.10.3", "serde", "serde_json", "sha2 0.11.0", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", - "uuid 1.23.1", + "uuid 1.26.1", "whoami", ] @@ -6841,10 +7360,10 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "url", - "uuid 1.23.1", + "uuid 1.26.1", ] [[package]] @@ -6922,7 +7441,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -6934,6 +7453,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -6947,9 +7476,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -6967,20 +7496,20 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "sysinfo" -version = "0.39.5" +version = "0.39.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8bd2130a9b60bee2581bf82cfe89ee836424d1f37dcfa4ce21509611684673" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" dependencies = [ "libc", "memchr", @@ -6988,7 +7517,7 @@ dependencies = [ "objc2-core-foundation", "objc2-io-kit", "objc2-open-directory", - "windows 0.62.2", + "windows", ] [[package]] @@ -6997,7 +7526,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -7012,6 +7541,19 @@ dependencies = [ "libc", ] +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + [[package]] name = "tar" version = "0.4.46" @@ -7023,6 +7565,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tempfile" version = "3.27.0" @@ -7032,7 +7580,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -7056,11 +7604,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.21", ] [[package]] @@ -7076,13 +7624,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -7171,16 +7719,16 @@ checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" dependencies = [ "as-raw-xcb-connection", "ctor 0.10.1", - "libloading", + "libloading 0.8.9", "pkg-config", "tracing", ] [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -7188,32 +7736,23 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.5", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -7237,14 +7776,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -7252,9 +7791,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -7264,16 +7803,17 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "futures-core", "futures-io", "futures-sink", "futures-util", "hashbrown 0.15.5", + "libc", "pin-project-lite", "slab", "tokio", @@ -7281,17 +7821,38 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", ] [[package]] @@ -7305,14 +7866,38 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.13+spec-1.1.0" +version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] @@ -7321,7 +7906,7 @@ version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow", + "winnow 1.0.4", ] [[package]] @@ -7332,11 +7917,11 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "torin" -version = "0.5.0-rc.4" -source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +version = "0.5.0-beta.10" +source = "git+https://github.com/Polyfrost/freya?rev=12f696f68c4936617b8627c862b39acb3d9cca2b#12f696f68c4936617b8627c862b39acb3d9cca2b" dependencies = [ "euclid", - "itertools 0.14.0", + "itertools", "rustc-hash", "tracing", ] @@ -7363,8 +7948,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.13.1", - "bytes 1.11.1", + "bitflags 2.13.2", + "bytes 1.12.1", "futures-core", "futures-util", "http 1.5.0", @@ -7479,9 +8064,9 @@ dependencies = [ [[package]] name = "trash" -version = "5.2.6" +version = "5.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7602e0c7d66ec2d92a8c917219fbc7894039efa2063b9064260110828a356f46" +checksum = "be89b3fe156965d29ac4f8522f3a640c655affdd9f21cb4f36857f0c92c00317" dependencies = [ "chrono", "libc", @@ -7492,7 +8077,28 @@ dependencies = [ "percent-encoding", "scopeguard", "urlencoding", - "windows 0.56.0", + "windows", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.17.16", + "thiserror 2.0.21", + "windows-sys 0.60.2", ] [[package]] @@ -7524,17 +8130,33 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ - "bytes 1.11.1", + "bytes 1.12.1", "data-encoding", "http 1.5.0", "httparse", "log", "rand 0.9.5", "sha1 0.10.6", - "thiserror 2.0.18", + "thiserror 2.0.21", "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes 1.12.1", + "data-encoding", + "http 1.5.0", + "httparse", + "log", + "rand 0.10.3", + "sha1 0.11.0", + "thiserror 2.0.21", +] + [[package]] name = "typenum" version = "1.20.1" @@ -7575,9 +8197,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-normalization" @@ -7626,11 +8248,11 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.3.0" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "log", "percent-encoding", "rustls", @@ -7642,11 +8264,11 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.0" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "http 1.5.0", "httparse", "log", @@ -7700,9 +8322,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.1" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -7733,6 +8355,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -7775,9 +8403,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -7788,19 +8416,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7808,22 +8437,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -7856,13 +8485,13 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.4", + "rustix 1.1.5", "scoped-tls", "smallvec", "wayland-sys", @@ -7874,8 +8503,8 @@ version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.13.1", - "rustix 1.1.4", + "bitflags 2.13.2", + "rustix 1.1.5", "wayland-backend", "wayland-scanner", ] @@ -7886,7 +8515,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cursor-icon", "wayland-backend", ] @@ -7897,7 +8526,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.4", + "rustix 1.1.5", "wayland-client", "xcursor", ] @@ -7908,7 +8537,7 @@ version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-scanner", @@ -7920,7 +8549,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7933,7 +8562,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7946,7 +8575,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7959,7 +8588,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7991,9 +8620,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -8011,9 +8640,9 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" dependencies = [ "phf", "phf_codegen", @@ -8047,9 +8676,9 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "whoami" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" [[package]] name = "winapi" @@ -8082,16 +8711,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" -dependencies = [ - "windows-core 0.56.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.62.2" @@ -8099,7 +8718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core 0.62.2", + "windows-core", "windows-future", "windows-numerics", ] @@ -8110,19 +8729,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core 0.62.2", -] - -[[package]] -name = "windows-core" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" -dependencies = [ - "windows-implement 0.56.0", - "windows-interface 0.56.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", + "windows-core", ] [[package]] @@ -8131,10 +8738,10 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link", - "windows-result 0.4.1", + "windows-result", "windows-strings", ] @@ -8144,22 +8751,11 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.62.2", + "windows-core", "windows-link", "windows-threading", ] -[[package]] -name = "windows-implement" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -8171,17 +8767,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "windows-interface" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -8205,7 +8790,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.62.2", + "windows-core", "windows-link", ] @@ -8216,19 +8801,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", - "windows-result 0.4.1", + "windows-result", "windows-strings", ] -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -8496,7 +9072,7 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.13.1", + "bitflags 2.13.2", "block2 0.5.1", "bytemuck", "calloop 0.13.0", @@ -8539,6 +9115,15 @@ dependencies = [ "xkbcommon-dl", ] +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" @@ -8554,7 +9139,7 @@ version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" dependencies = [ - "toml", + "toml 1.1.6+spec-1.1.0", "version_check", ] @@ -8566,15 +9151,15 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wl-clipboard-rs" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" dependencies = [ "libc", "log", "os_pipe", - "rustix 1.1.4", - "thiserror 2.0.18", + "rustix 1.1.5", + "thiserror 2.0.21", "tree_magic_mini", "wayland-backend", "wayland-client", @@ -8584,9 +9169,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "x11-clipboard" @@ -8618,9 +9203,9 @@ dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "libloading", + "libloading 0.8.9", "once_cell", - "rustix 1.1.4", + "rustix 1.1.5", "x11rb-protocol", ] @@ -8637,7 +9222,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.4", + "rustix 1.1.5", ] [[package]] @@ -8652,7 +9237,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dlib", "log", "once_cell", @@ -8667,9 +9252,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xml5ever" @@ -8700,21 +9285,21 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "synstructure", ] [[package]] name = "zbus" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -8732,14 +9317,14 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.4", + "rustix 1.1.5", "serde", "serde_repr", "tracing", "uds_windows", - "uuid 1.23.1", + "uuid 1.26.1", "windows-sys 0.61.2", - "winnow", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -8771,14 +9356,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "zbus_names", "zvariant", "zvariant_utils", @@ -8791,7 +9376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "winnow", + "winnow 1.0.4", "zvariant", ] @@ -8802,25 +9387,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" dependencies = [ "serde", - "winnow", + "winnow 1.0.4", "zbus_names", "zvariant", ] [[package]] -name = "zerocopy" -version = "0.8.55" +name = "zcheapstr" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", @@ -8838,13 +9432,13 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "synstructure", ] @@ -8856,9 +9450,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -8867,9 +9461,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -8878,15 +9472,21 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" @@ -8904,18 +9504,18 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "7.2.4" +version = "7.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.0+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" dependencies = [ "cc", "pkg-config", @@ -8923,9 +9523,9 @@ dependencies = [ [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-inflate" @@ -8947,40 +9547,41 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.13.1" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.13.1" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.5.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", - "winnow", + "syn 3.0.6", + "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index 853b8e2f..c3750939 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ license = "GPL-3.0-only" readme = "README.md" repository = "https://git.oiupoyt.space/oiupoyt/OneLauncher-Cracked" rust-version = "1.97" -version = "2.5.1-c3" +version = "2.8.0-c1" [workspace.dependencies] # core @@ -37,10 +37,10 @@ polyio = {path = "./packages/polyio"} # core (extra) discord-rich-presence = {version = "=1.1.0"} -interfrost = {version = "=1.4.0"} +interfrost = {version = "=1.5.0"} # logging -sentry = {version = "=0.48.5", default-features = false, features = [ +sentry = {version = "=0.49.3", default-features = false, features = [ "backtrace", "contexts", "debug-images", @@ -49,7 +49,7 @@ sentry = {version = "=0.48.5", default-features = false, features = [ "reqwest", "rustls", ]} -sentry-tracing = {version = "=0.48.5"} +sentry-tracing = {version = "=0.49.3"} tracing = {version = "=0.1.44"} tracing-futures = {version = "=0.2.5"} tracing-subscriber = {version = "=0.3.23", features = ["env-filter", "fmt", "ansi", "json", "parking_lot", "registry"]} @@ -68,15 +68,15 @@ sqlx = {version = "=0.9.0", default-features = false, features = [ ]} # async -astral-tokio-tar = {version = "=0.6.2"} -async-compression = {version = "=0.4.42", features = ["tokio", "gzip"]} +astral-tokio-tar = {version = "=0.7.0"} +async-compression = {version = "=0.4.48", features = ["tokio", "gzip"]} async-stream = {version = "=0.3.6"} -async-tempfile = {version = "=0.7.0", features = ["uuid"]} -async-trait = {version = "=0.1.89"} -async_zip = {version = "=0.0.18", features = ["chrono", "tokio", "tokio-fs", "deflate", "deflate64"]} +async-tempfile = {version = "=0.8.0", features = ["uuid"]} +async-trait = {version = "=0.1.92"} +async_zip = {version = "=0.0.19", features = ["tracing", "chrono", "tokio", "tokio-fs", "deflate", "deflate64"]} futures-lite = {version = "=2.6.1"} -futures-util = {version = "=0.3.32", features = ["tokio-io"]} -reqwest = {version = "=0.13.3", default-features = false, features = [ +futures-util = {version = "=0.3.34", features = ["tokio-io"]} +reqwest = {version = "=0.13.5", default-features = false, features = [ "json", "stream", "multipart", @@ -91,67 +91,69 @@ reqwest = {version = "=0.13.3", default-features = false, features = [ "system-proxy", ]} reqwest-websocket = {version = "=0.6.0"} -tokio = {version = "=1.52.3", features = ["full"]} -tokio-stream = {version = "=0.1.18", features = ["full"]} -tokio-util = {version = "=0.7.18", features = ["full"]} +tokio = {version = "=1.53.1", features = ["full"]} +tokio-stream = {version = "=0.1.19", features = ["full"]} +tokio-util = {version = "=0.7.19", features = ["full"]} # crypto (content hashing) byteorder = {version = "=1.5.0"} -digest = {version = "=0.10.7", features = ["std"]} -md-5 = {version = "=0.10.6", default-features = false} -p256 = {version = "=0.13.2", features = ["ecdsa", "pkcs8"]} -sha1 = {version = "=0.10.6", default-features = false} -sha2 = {version = "=0.10.9", default-features = false} +digest = {version = "=0.11.3"} +md-5 = {version = "=0.11.0", default-features = false} +p256 = {version = "=0.14.0", features = ["ecdsa", "pkcs8"]} +sha1 = {version = "=0.11.0", default-features = false} +sha2 = {version = "=0.11.0", default-features = false} # util arboard = {version = "=3.6.1", features = ["wayland-data-control"]} -arc-swap = {version = "=1.7.1"} -base64 = {version = "=0.22.1"} -bytes = {version = "=1.11.1"} -chrono = {version = "=0.4.44", features = ["serde"]} +arc-swap = {version = "=1.9.2"} +base64 = {version = "=0.23.1"} +bytes = {version = "=1.12.1"} +chrono = {version = "=0.4.45", features = ["serde"]} directories = {version = "=6.0.0"} dunce = {version = "=1.0.5"} -htmd = {version = "=0.5.4"} -indicatif = {version = "=0.18.4", features = ["tokio"]} -junction = {version = "=2.0.0"} +htmd = {version = "=0.5.5"} +indicatif = {version = "=0.18.6", features = ["tokio"]} +junction = {version = "=2.1.0"} libc = {version = "=0.2.189"} notify = {version = "=8.2.0"} +open = {version = "=5.4.4", features = ["shellexecute-on-windows"]} parking_lot = {version = "=0.12.5"} pulldown-cmark = {version = "=0.13.4", default-features = false} -rand = {version = "=0.10.1"} -regex = {version = "=1.12.3"} +rand = {version = "=0.10.3"} +regex = {version = "=1.13.1"} +rfd = {version = "=0.17.2"} rust-embed = {version = "=8.12.0", features = ["include-exclude", "compression", "debug-embed"]} sanitize-filename = {version = "=0.6.0"} -serde = {version = "=1.0.228"} -serde_json = {version = "=1.0.150"} -serde_repr = {version = "=0.1.20"} -sysinfo = {version = "=0.39.5"} -toml = {version = "=1.1.4", default-features = false, features = ["parse", "serde"]} -trash = {version = "=5.2.6"} +serde = {version = "=1.0.229"} +serde_json = {version = "=1.0.151"} +serde_repr = {version = "=0.1.21"} +sysinfo = {version = "=0.39.6"} +toml = {version = "=1.1.6", default-features = false, features = ["parse", "serde"]} +trash = {version = "=5.2.9"} percent-encoding = {version = "=2.3.2"} url = {version = "=2.5.8", features = ["serde"]} -uuid = {version = "=1.23.1", features = ["serde", "v4"]} +uuid = {version = "=1.26.1", features = ["serde", "v4"]} windows-sys = {version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"]} # util (codegen/macro) -anyhow = {version = "=1.0.102"} -proc-macro2 = {version = "=1.0.106"} -quote = {version = "=1.0.45"} +anyhow = {version = "=1.0.104"} +proc-macro2 = {version = "=1.0.107"} +quote = {version = "=1.0.47"} strum = {version = "=0.28.0", features = ["derive"]} -syn = {version = "=2.0.117"} -thiserror = {version = "=2.0.18"} +syn = {version = "=3.0.6"} +thiserror = {version = "=2.0.21"} # ui -freya = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver", features = ["engine", "winit", "router", "remote-asset", "sdk", "query", "radio", "titlebar"]} -freya-core = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver"} -freya-testing = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver"} -freya-skia-safe = "=0.100.0" # required for the SKSL player renderer +freya = {git = "https://github.com/Polyfrost/freya", rev = "12f696f68c4936617b8627c862b39acb3d9cca2b", features = ["engine", "winit", "router", "remote-asset", "sdk-tokio", "query", "radio", "borderless", "titlebar", "tray", "metrics"]} +freya-core = {git = "https://github.com/Polyfrost/freya", rev = "12f696f68c4936617b8627c862b39acb3d9cca2b"} +freya-testing = {git = "https://github.com/Polyfrost/freya", rev = "12f696f68c4936617b8627c862b39acb3d9cca2b"} +freya-skia-safe = "=0.103.2" # required for the SKSL player renderer image = {version = "=0.25.10", default-features = false, features = ["png", "jpeg"]} [profile.release] codegen-units = 1 debug = "line-tables-only" -lto = "fat" +lto = "thin" opt-level = 3 panic = "abort" split-debuginfo = "packed" diff --git a/distribution/aur/oneclient-bin/PKGBUILD b/distribution/aur/oneclient-bin/PKGBUILD index 93a4c87c..f73d0787 100644 --- a/distribution/aur/oneclient-bin/PKGBUILD +++ b/distribution/aur/oneclient-bin/PKGBUILD @@ -18,6 +18,7 @@ depends=( 'wayland' 'dbus' 'gtk3' + 'libayatana-appindicator' ) provides=('oneclient') conflicts=('oneclient') diff --git a/distribution/aur/oneclient/PKGBUILD b/distribution/aur/oneclient/PKGBUILD index 7d93ea25..c8e145aa 100644 --- a/distribution/aur/oneclient/PKGBUILD +++ b/distribution/aur/oneclient/PKGBUILD @@ -17,6 +17,7 @@ depends=( 'wayland' 'dbus' 'gtk3' + 'libayatana-appindicator' ) makedepends=( 'rust' diff --git a/distribution/copr/oneclient-bin.spec b/distribution/copr/oneclient-bin.spec index 4edf422c..e85db99d 100644 --- a/distribution/copr/oneclient-bin.spec +++ b/distribution/copr/oneclient-bin.spec @@ -15,6 +15,8 @@ Source0: %{url}/releases/download/oneclient-%{version}/%{_rpmfile} ExclusiveArch: x86_64 BuildRequires: bsdtar Requires: gtk3 +Requires: libayatana-appindicator-gtk3 +Requires: libxdo Requires: dbus-libs Provides: oneclient = %{version}-%{release} diff --git a/docs/superpowers/plans/2026-10-10-update-to-2.8.0.md b/docs/superpowers/plans/2026-10-10-update-to-2.8.0.md new file mode 100644 index 00000000..5b12a277 --- /dev/null +++ b/docs/superpowers/plans/2026-10-10-update-to-2.8.0.md @@ -0,0 +1,108 @@ +# OneLauncher-Cracked 2.8.0 Update Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rebase and upgrade OneLauncher-Cracked onto upstream OneLauncher 2.8.0 (`1d6ec598`), preserving all offline authentication bypasses, the local Yggdrasil skin server with authlib-injector, Forgejo auto-updater, and custom skin changer UI. + +**Architecture:** Port 37 cracked subsystem files onto the clean upstream 2.8.0 codebase. Maintain strict isolation for cracked authentication and skin server logic while adopting upstream's new multi-instance management, package manager filters, and Freya improvements. + +**Tech Stack:** Rust 2024, Freya (Dioxus + Skia), Tokio, Reqwest, RSA PKCS#1v15, Authlib-Injector, Forgejo API v1. + +**Spec:** Direct human instruction: "update to version 2.8.0". + +## Global Constraints +- Target version: `2.8.0-c1`. +- Offline authentication must require 0 Microsoft accounts. +- Local Yggdrasil skin server must eliminate multiplayer skin cloning via RSA-signed profile properties. +- Auto-updater must target Forgejo API at `https://git.oiupoyt.space/api/v1/repos/oiupoyt/OneLauncher-Cracked/releases/latest`. +- All workspace crates must pass tests and compilation without regressions. + +--- + +### Task 1: Project Metadata, Packaging & Workflows + +**Files:** +- Modify: `Cargo.toml` +- Modify: `README.md` +- Modify: `.gitignore` +- Create: `.forgejo/workflows/release.yml` +- Create: `packages/oneclient_app/distribution/linux/oneclient_app.desktop` + +- [ ] **Step 1: Update Cargo.toml metadata and dev profile** +- [ ] **Step 2: Update README.md with warning embed and Forgejo releases link** +- [ ] **Step 3: Add .forgejo release workflow and desktop file** +- [ ] **Step 4: Commit task 1** + +--- + +### Task 2: Port Offline Authentication Bypass (`oneclient_auth`) + +**Files:** +- Modify: `packages/oneclient_auth/src/service.rs` +- Modify: `packages/oneclient_auth/src/store.rs` +- Modify: `packages/oneclient_auth/tests/offline.rs` + +- [ ] **Step 1: In `service.rs`, remove `OfflineRequiresMicrosoft` check from `account_for_launch`** +- [ ] **Step 2: In `store.rs`, allow offline accounts without Microsoft accounts and auto-select default user** +- [ ] **Step 3: In `tests/offline.rs`, ensure tests assert offline launch succeeds without Microsoft** +- [ ] **Step 4: Run `cargo test -p oneclient_auth` and verify all tests pass** +- [ ] **Step 5: Commit task 2** + +--- + +### Task 3: Port Path Helpers, Assets & Offline Skin Engine (`oneclient_common` & `oneclient_core`) + +**Files:** +- Modify: `packages/oneclient_common/src/paths.rs` +- Modify: `packages/oneclient_core/Cargo.toml` +- Copy assets: `packages/oneclient_core/assets/authlib-injector.jar` +- Copy assets: `packages/oneclient_core/assets/offlineskins-fabric.jar` +- Create: `packages/oneclient_core/src/game/skin_server.rs` +- Modify: `packages/oneclient_core/src/game/mod.rs` +- Modify: `packages/oneclient_core/src/game/launch.rs` + +- [ ] **Step 1: Add skin paths and authlib-injector path to `oneclient_common/src/paths.rs`** +- [ ] **Step 2: Copy embedded jar assets and add dependencies to `oneclient_core/Cargo.toml`** +- [ ] **Step 3: Port `skin_server.rs` and register module in `game/mod.rs`** +- [ ] **Step 4: In `game/launch.rs`, inject authlib-injector `-javaagent`, `--userProperties`, and offline skin synchronization** +- [ ] **Step 5: Run `cargo test -p oneclient_core` and verify all tests pass** +- [ ] **Step 6: Commit task 3** + +--- + +### Task 4: Port UI Components, Skins Tab & Auto-Updater (`oneclient_app`) + +**Files:** +- Modify: `packages/oneclient_app/src/constants.rs` +- Modify: `packages/oneclient_app/src/updater.rs` +- Modify: `packages/oneclient_app/src/components/avatar.rs` +- Modify: `packages/oneclient_app/src/components/player_model.rs` +- Modify: `packages/oneclient_app/src/components/navbar/app_navbar.rs` +- Create: `packages/oneclient_app/src/view/app/skins.rs` +- Create: `packages/oneclient_app/src/hooks/queries/skin.rs` +- Modify: `packages/oneclient_app/src/hooks/queries/mod.rs` +- Modify: `packages/oneclient_app/src/hooks/mod.rs` +- Modify: `packages/oneclient_app/src/view/onboarding/account.rs` +- Modify: `packages/oneclient_app/src/view/app/settings/accounts.rs` +- Modify: `packages/oneclient_app/src/layout/root_layout.rs` +- Modify: `packages/oneclient_app/src/routes.rs` + +- [ ] **Step 1: Update `constants.rs` and `updater.rs` with Forgejo releases endpoint and `CrackedVersion` semver parser** +- [ ] **Step 2: Port skin queries in `hooks/queries/skin.rs` and wire into `hooks/mod.rs`** +- [ ] **Step 3: Port `skins.rs` view and register route in `routes.rs` and `root_layout.rs`** +- [ ] **Step 4: Add Skins pill button to `navbar_center` in `app_navbar.rs`** +- [ ] **Step 5: Port avatar persistent cache in `avatar.rs` and player model custom skin loading in `player_model.rs`** +- [ ] **Step 6: Port offline account inputs in onboarding (`onboarding/account.rs`) and settings (`settings/accounts.rs`)** +- [ ] **Step 7: Run `cargo test -p oneclient_app --lib` and verify all tests pass** +- [ ] **Step 8: Commit task 4** + +--- + +### Task 5: Verification, Packaging & Deployment + +- [ ] **Step 1: Run full workspace `cargo check --workspace`** +- [ ] **Step 2: Build release binary locally** +- [ ] **Step 3: Package `.AppImage`, `.deb`, and `.tar.gz` for Linux** +- [ ] **Step 4: Merge `update/2.8.0` into `main`** +- [ ] **Step 5: Tag `v2.8.0-c1` and push branch and tag to Forgejo and GitHub** +- [ ] **Step 6: Publish Forgejo Release `v2.8.0-c1` with all binaries attached** diff --git a/knope.toml b/knope.toml new file mode 100644 index 00000000..189d4e1b --- /dev/null +++ b/knope.toml @@ -0,0 +1,28 @@ +[package] +versioned_files = ["Cargo.toml"] +changelog = "CHANGELOG.md" + +[changes] +ignore_conventional_commits = true + +[release_notes] +change_templates = [ + "- $summary by [$pr_author_login](https://github.com/$pr_author_login)", + "- $summary", +] + +[github] +owner = "Polyfrost" +repo = "OneLauncher" + +[[workflows]] +name = "prepare-release" + +[[workflows.steps]] +type = "PrepareRelease" + +[[workflows]] +name = "document-change" + +[[workflows.steps]] +type = "CreateChangeFile" diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index 07c920e7..8559aab8 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -33,6 +33,7 @@ rust-embed.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +futures-util.workspace = true tokio.workspace = true uuid.workspace = true bytes.workspace = true @@ -48,12 +49,13 @@ percent-encoding.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true reqwest.workspace = true -futures-util = { workspace = true, features = ["std"] } cargo-packager-updater = "=0.2.3" -rfd = "=0.17.2" -open = "=5.4.0" +# rfd = "=0.17.2" +# open = "=5.4.0" +rfd.workspace = true +open.workspace = true # clipboard image copy + decode for it arboard.workspace = true @@ -63,6 +65,8 @@ freya-skia-safe.workspace = true freya.workspace = true freya-core.workspace = true pulldown-cmark.workspace = true +# inlines svg logos embedded in markdown badges as data urls +base64.workspace = true [dev-dependencies] freya-testing.workspace = true @@ -70,6 +74,10 @@ freya-testing.workspace = true [target.'cfg(target_os = "macos")'.dependencies] mimalloc = "=0.1.52" +[target.'cfg(target_os = "linux")'.dependencies] +libloading = "=0.8.9" +gtk-sys = "=0.18.2" + [target.'cfg(windows)'.dependencies] windows = { version = "=0.62.2", features = [ "Win32_Foundation", @@ -154,6 +162,8 @@ desktop-template = "distribution/linux/org.polyfrost.oneclient.template" [package.metadata.packager.appimage] # desktop entry generated from the deb template above via the linux config +libs = ["libayatana-appindicator3.so.1"] +excluded-libraries = ["libwayland-*"] [package.metadata.generate-rpm] auto-req = "builtin" diff --git a/packages/oneclient_app/assets/icons/fabric.png b/packages/oneclient_app/assets/icons/fabric.png new file mode 100644 index 00000000..c6fb4276 Binary files /dev/null and b/packages/oneclient_app/assets/icons/fabric.png differ diff --git a/packages/oneclient_app/assets/icons/forge.png b/packages/oneclient_app/assets/icons/forge.png new file mode 100644 index 00000000..6fb3de4c Binary files /dev/null and b/packages/oneclient_app/assets/icons/forge.png differ diff --git a/packages/oneclient_app/assets/icons/github.svg b/packages/oneclient_app/assets/icons/github.svg new file mode 100644 index 00000000..1d0c61b7 --- /dev/null +++ b/packages/oneclient_app/assets/icons/github.svg @@ -0,0 +1 @@ + diff --git a/packages/oneclient_app/assets/icons/icon-logo.svg b/packages/oneclient_app/assets/icons/icon-logo.svg index 84f43670..44f8e0f8 100644 --- a/packages/oneclient_app/assets/icons/icon-logo.svg +++ b/packages/oneclient_app/assets/icons/icon-logo.svg @@ -1,4 +1,7 @@ - - - + + + + + + diff --git a/packages/oneclient_app/assets/icons/neo-forge.png b/packages/oneclient_app/assets/icons/neo-forge.png new file mode 100644 index 00000000..727fa7e9 Binary files /dev/null and b/packages/oneclient_app/assets/icons/neo-forge.png differ diff --git a/packages/oneclient_app/assets/icons/vanilla.png b/packages/oneclient_app/assets/icons/vanilla.png new file mode 100644 index 00000000..d8081eea Binary files /dev/null and b/packages/oneclient_app/assets/icons/vanilla.png differ diff --git a/packages/oneclient_app/assets/logo.svg b/packages/oneclient_app/assets/logo.svg index 3f305562..0719ece8 100644 --- a/packages/oneclient_app/assets/logo.svg +++ b/packages/oneclient_app/assets/logo.svg @@ -1,4 +1,4 @@ - + diff --git a/packages/oneclient_app/icons/tray.png b/packages/oneclient_app/icons/tray.png new file mode 100644 index 00000000..bd7ede5e Binary files /dev/null and b/packages/oneclient_app/icons/tray.png differ diff --git a/packages/oneclient_app/src/cli.rs b/packages/oneclient_app/src/cli.rs index d7a41ec3..5f0f39a4 100644 --- a/packages/oneclient_app/src/cli.rs +++ b/packages/oneclient_app/src/cli.rs @@ -47,8 +47,14 @@ mod tests { #[test] fn both_spellings_carry_the_folder() { - assert_eq!(parse(&["--launch", "fabric-1-20"]).launch.as_deref(), Some("fabric-1-20")); - assert_eq!(parse(&["--launch=fabric-1-20"]).launch.as_deref(), Some("fabric-1-20")); + assert_eq!( + parse(&["--launch", "fabric-1-20"]).launch.as_deref(), + Some("fabric-1-20") + ); + assert_eq!( + parse(&["--launch=fabric-1-20"]).launch.as_deref(), + Some("fabric-1-20") + ); } #[test] @@ -68,7 +74,12 @@ mod tests { #[test] fn unknown_flags_are_ignored_not_fatal() { - assert_eq!(parse(&["--verbose", "--launch", "x", "leftover"]).launch.as_deref(), Some("x")); + assert_eq!( + parse(&["--verbose", "--launch", "x", "leftover"]) + .launch + .as_deref(), + Some("x") + ); } #[test] diff --git a/packages/oneclient_app/src/components/account_switcher.rs b/packages/oneclient_app/src/components/account_switcher.rs index 0f18cc61..6e0ea580 100644 --- a/packages/oneclient_app/src/components/account_switcher.rs +++ b/packages/oneclient_app/src/components/account_switcher.rs @@ -6,7 +6,6 @@ use freya::{ use oneclient_auth::MinecraftAccount; use crate::{ - ui::{divider}, Route, components::{Avatar, Button, Icon, IconType, OverlayPopup}, hooks::{ @@ -14,6 +13,7 @@ use crate::{ use_accounts, use_current_account, use_dispatch, use_set_default_account, }, theme::colors, + ui::divider, }; #[derive(PartialEq)] @@ -91,7 +91,7 @@ impl Component for AccountPanel { .opacity(progress) .margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.)) .background(colors::page_elevated().with_a(220)) - .blur(12.) + .backdrop_blur(12.) .corner_radius(CornerRadius::new_all(12.)) .border( Border::new() diff --git a/packages/oneclient_app/src/components/active_cluster_panel.rs b/packages/oneclient_app/src/components/active_cluster_panel.rs index 9b81eb45..19b431f6 100644 --- a/packages/oneclient_app/src/components/active_cluster_panel.rs +++ b/packages/oneclient_app/src/components/active_cluster_panel.rs @@ -4,12 +4,16 @@ use freya::router::RouterContext; use crate::components::{Button, Icon, IconType}; use oneclient_common::parse_mc_version; +use oneclient_core::clusters::ClusterKind; -use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata}; +use crate::hooks::{ + settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, + use_launcher, use_version_metadata, +}; use crate::routes::Route; use crate::theme::colors; use crate::utils::sort_clusters_for_home; -use crate::view::app::launch_button_state; +use crate::view::app::{launch_button_state, launch_syncing, shared_dir_taken}; #[derive(PartialEq)] pub struct ActiveClusterPanel; @@ -21,7 +25,6 @@ impl Component for ActiveClusterPanel { let dispatch = use_dispatch(); let game = use_game_snapshot(); let launcher = use_launcher(); - let syncing = launcher.fetching || launcher.syncing_bundles; let clusters = settled_or_loading(&clusters_query).unwrap_or_default(); @@ -72,11 +75,24 @@ impl Component for ActiveClusterPanel { ); }; - let title = format!("{} {}", cluster.mc_version, cluster.mc_loader); - let subtitle = metadata - .map(|m| m.name) - .unwrap_or_else(|| cluster.name.clone()); + let version = format!("{} {}", cluster.mc_version, cluster.mc_loader); + let (title, subtitle) = if cluster.user_created && cluster.kind != ClusterKind::OneClient { + (cluster.name.clone(), version) + } else { + let subtitle = match metadata { + Some(m) => format!("OneClient · {}", m.name), + None => "OneClient".to_string(), + }; + let title = if cluster.user_created { + cluster.name.clone() + } else { + version + }; + (title, subtitle) + }; let cluster_id = cluster.id; + let syncing = launch_syncing(&launcher, cluster.uses_bundles()); + let shared_dir_taken = shared_dir_taken(&game, &sorted, cluster_id); rect() .vertical() @@ -88,6 +104,9 @@ impl Component for ActiveClusterPanel { .child( label() .text(title) + .width(Size::fill()) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) .font_size(56.) .line_height(1.1) .font_weight(FontWeight::BOLD) @@ -109,7 +128,7 @@ impl Component for ActiveClusterPanel { .child(launch_button( cluster_id, dispatch, - launch_button_state(&game, cluster_id, syncing), + launch_button_state(&game, cluster_id, syncing, shared_dir_taken), )) .child(cluster_settings_button(cluster_id)), ) diff --git a/packages/oneclient_app/src/components/asset_image.rs b/packages/oneclient_app/src/components/asset_image.rs new file mode 100644 index 00000000..2a825073 --- /dev/null +++ b/packages/oneclient_app/src/components/asset_image.rs @@ -0,0 +1,41 @@ +use freya::elements::image::{AspectRatio, ImageCover, ImageHandle, image}; +use freya::prelude::*; + +use super::local_image::decode; +use crate::AppAssets; + +#[derive(PartialEq)] +pub struct AssetImage { + path: &'static str, + size: f32, +} + +impl AssetImage { + pub fn new(path: &'static str, size: f32) -> Self { + Self { path, size } + } +} + +impl Component for AssetImage { + fn render(&self) -> impl IntoElement { + let path = self.path; + let size = self.size; + + let handle: Option = + use_hook(move || AppAssets::get_bytes(path).as_ref().and_then(decode)); + + rect() + .key(path) + .width(Size::px(size)) + .height(Size::px(size)) + .center() + .maybe_child(handle.map(|handle| { + image(handle) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::Min) + .image_cover(ImageCover::Center) + .into_element() + })) + } +} diff --git a/packages/oneclient_app/src/components/blocked_downloads_popup.rs b/packages/oneclient_app/src/components/blocked_downloads_popup.rs new file mode 100644 index 00000000..3e5e34a1 --- /dev/null +++ b/packages/oneclient_app/src/components/blocked_downloads_popup.rs @@ -0,0 +1,528 @@ +use std::path::{Path, PathBuf}; + +use freya::prelude::*; +use freya::router::RouterContext; +use notify::{Event, EventKind}; +use oneclient_content::modpacks::BlockedFile; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{use_dispatch, use_folder_watch, use_notifications_snapshot}; +use crate::notifications::BlockedDownloads; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 560.; +const DIALOG_PAD: f32 = 22.; +const LIST_MAX_H: f32 = 300.; +const ROW_H: f32 = 52.; + +#[derive(PartialEq)] +pub struct BlockedDownloadsPopup; + +impl Component for BlockedDownloadsPopup { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + let router = RouterContext::get(); + + use_side_effect_with_deps(&snapshot.open_cluster, move |requested| { + if requested.is_none() { + return; + } + if let Some(cluster_id) = dispatch.take_open_cluster() { + let _ = router.push(Route::ClusterOverview { cluster_id }); + } + }); + + match snapshot.blocked_downloads { + Some(blocked) => BlockedDownloadsDialog { blocked }.into_element(), + None => rect().into_element(), + } + } +} + +fn downloads_dir() -> Option { + directories::UserDirs::new().and_then(|dirs| dirs.download_dir().map(Path::to_path_buf)) +} + +fn touches_file(_root: &Path, event: &Event) -> bool { + matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_)) +} + +type ExtraFolder = State>; + +fn bump(mut tick: State) { + let next = *tick.peek() + 1; + tick.set(next); +} + +#[derive(PartialEq)] +struct ExtraFolderWatch { + folder: PathBuf, + tick: State, + key: DiffKey, +} + +impl KeyExt for ExtraFolderWatch { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for ExtraFolderWatch { + fn render(&self) -> impl IntoElement { + let tick = self.tick; + use_folder_watch(Some(self.folder.clone()), false, touches_file, move || { + bump(tick) + }); + rect() + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) + } +} + +#[derive(PartialEq)] +struct BlockedDownloadsDialog { + blocked: BlockedDownloads, +} + +impl Component for BlockedDownloadsDialog { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let downloads = use_hook(downloads_dir); + let tick = use_state(|| 0u64); + let extra: ExtraFolder = use_state(|| None); + + use_folder_watch(downloads.clone(), false, touches_file, move || bump(tick)); + + let cluster_id = self.blocked.cluster_id; + let chosen = extra + .read() + .as_ref() + .filter(|(owner, _)| *owner == cluster_id) + .map(|(_, folder)| folder.clone()); + + let deps = ( + *tick.read(), + cluster_id, + self.blocked.remaining(), + chosen.clone(), + ); + { + let dispatch = dispatch.clone(); + let downloads = downloads.clone(); + use_side_effect_with_deps(&deps, move |(_, cluster_id, files, chosen)| { + if files.is_empty() { + return; + } + let locations: Vec = + downloads.iter().chain(chosen.iter()).cloned().collect(); + if !locations.is_empty() { + dispatch.scan_blocked_downloads(*cluster_id, locations, files.clone()); + } + }); + } + + let close = dispatch.clone(); + OverlayPopup::new() + .on_close(move |_| close.close_blocked_downloads()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog( + &self.blocked, + downloads.as_deref(), + chosen.as_deref(), + extra, + dispatch, + )), + ) + .maybe_child(chosen.map(|folder| { + let key = folder.to_string_lossy().into_owned(); + ExtraFolderWatch { + folder, + tick, + key: DiffKey::None, + } + .key(key) + .into_element() + })) + .into_element() + } +} + +fn folder_label(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) +} + +fn watched_text(downloads: Option<&Path>, chosen: Option<&Path>) -> String { + match (downloads, chosen) { + (Some(_), Some(chosen)) => format!("Downloads and {}", chosen.display()), + (Some(downloads), None) => downloads.display().to_string(), + (None, Some(chosen)) => chosen.display().to_string(), + (None, None) => "Nothing yet. Add a folder or use Browse.".to_string(), + } +} + +fn pick_extra_folder(cluster_id: i64, start: Option, mut extra: ExtraFolder) { + spawn(async move { + let mut picker = rfd::AsyncFileDialog::new().set_title("Pick the folder you download into"); + if let Some(start) = start { + picker = picker.set_directory(start); + } + if let Some(handle) = picker.pick_folder().await { + extra.set(Some((cluster_id, handle.path().to_path_buf()))); + } + }); +} + +fn folder_row( + cluster_id: i64, + downloads: Option<&Path>, + chosen: Option<&Path>, + extra: ExtraFolder, +) -> impl IntoElement { + let start = chosen + .map(Path::to_path_buf) + .or_else(|| downloads.map(Path::to_path_buf)); + let has_extra = chosen.is_some(); + + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(8., 12.)) + .spacing(10.) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + Icon::new(IconType::FolderCheck) + .size(16.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.)) + .spacing(2.) + .child( + label() + .text("Watching") + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(watched_text(downloads, chosen)) + .font_size(12.5) + .max_lines(2) + .color(colors::fg_primary()), + ), + ) + .child( + Button::new() + .secondary() + .small() + .on_press(move |_| pick_extra_folder(cluster_id, start.clone(), extra)) + .text(if has_extra { + "Change folder" + } else { + "Add folder" + }), + ) + .maybe_child(has_extra.then(|| { + let mut extra = extra; + Button::new() + .ghost() + .small() + .on_press(move |_| extra.set(None)) + .text("Remove") + .into_element() + })), + ) + .child( + label() + .text( + "Your browser saves to its own download folder. To save somewhere else, \ + set it to ask where to save each file.", + ) + .font_size(11.) + .max_lines(2) + .color(colors::fg_secondary()), + ) +} + +fn dialog( + blocked: &BlockedDownloads, + downloads: Option<&Path>, + chosen: Option<&Path>, + extra: ExtraFolder, + dispatch: crate::Actions, +) -> impl IntoElement { + let count = blocked.files.len(); + let added = blocked.added.len().min(count); + let done = added == count; + let plural = crate::utils::plural(count as i64); + let where_to = match (downloads, chosen) { + (_, Some(chosen)) => format!( + "Download each one from its page and it will be picked up from Downloads or {} automatically.", + folder_label(chosen) + ), + (Some(_), None) => { + "Download each one from its page and it will be picked up from your Downloads folder automatically." + .to_string() + } + (None, None) => { + "Download each one from its page, then add the folder you saved them to or use Browse." + .to_string() + } + }; + let subtitle = if done { + format!( + "All {count} file{plural} are in {}. It is ready to play.", + blocked.cluster_name + ) + } else { + format!( + "CurseForge does not let launchers download {count} file{plural} that {} needs. {where_to}", + blocked.cluster_name + ) + }; + let title = if done { + "Manual downloads added".to_string() + } else if added > 0 { + format!("Some files need a manual download ({added} of {count} added)") + } else { + "Some files need a manual download".to_string() + }; + + let cluster_id = blocked.cluster_id; + let files = blocked.remaining(); + let browse = dispatch.clone(); + let close = dispatch.clone(); + let browse_start = chosen + .map(Path::to_path_buf) + .or_else(|| downloads.map(Path::to_path_buf)); + + rect() + .vertical() + .width(Size::px(DIALOG_W)) + .height(Size::Inner) + .max_width(Size::window_percent(95.)) + .max_height(Size::window_percent(85.)) + .overflow(Overflow::Clip) + .corner_radius(CornerRadius::new_all(16.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .shadow(Shadow::from(( + 0., + 18., + 52., + 0., + Color::from_argb(150, 0, 0, 0), + ))) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::Inner) + .padding(Gaps::new_all(DIALOG_PAD)) + .spacing(14.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(3.) + .child( + label() + .text(title) + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.5) + .max_lines(4) + .color(colors::fg_secondary()), + ), + ) + .maybe_child( + (!done) + .then(|| folder_row(cluster_id, downloads, chosen, extra).into_element()), + ) + .child(file_list(blocked)) + .child(if done { + let close = close.clone(); + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .child( + Button::new() + .primary() + .on_press(move |_| close.close_blocked_downloads()) + .child(Icon::new(IconType::Check).size(15.)) + .text("Done"), + ) + .into_element() + } else { + footer(cluster_id, files, browse_start, browse, close).into_element() + }), + ) +} + +fn footer( + cluster_id: i64, + files: Vec, + browse_start: Option, + browse: crate::Actions, + close: crate::Actions, +) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| close.close_blocked_downloads()) + .text("Skip for now"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + let dispatch = browse.clone(); + let files = files.clone(); + let start = browse_start.clone(); + spawn(async move { + let mut picker = + rfd::AsyncFileDialog::new().set_title("Pick the downloaded files"); + if let Some(start) = start { + picker = picker.set_directory(start); + } + if let Some(handles) = picker.pick_files().await { + let picked = handles + .iter() + .map(|handle| handle.path().to_path_buf()) + .collect(); + dispatch.scan_blocked_downloads(cluster_id, picked, files); + } + }); + }) + .child(Icon::new(IconType::Folder).size(15.)) + .text("Browse"), + ) +} + +fn file_list(blocked: &BlockedDownloads) -> impl IntoElement { + ScrollView::new() + .width(Size::fill()) + .height(Size::Inner) + .max_height(Size::px(LIST_MAX_H)) + .child( + rect().vertical().width(Size::fill()).spacing(6.).children( + blocked + .files + .iter() + .map(|file| file_row(file, blocked.is_added(file)).into_element()), + ), + ) +} + +fn file_row(file: &BlockedFile, added: bool) -> impl IntoElement { + let page = file.page_url.clone(); + let (background, border) = if added { + (colors::success().with_a(28), colors::success().with_a(120)) + } else { + (colors::component_bg(), colors::component_border()) + }; + + let trailing = if added { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .padding(Gaps::new_symmetric(0., 4.)) + .child( + Icon::new(IconType::CheckCircle) + .size(14.) + .color(colors::success()), + ) + .child( + label() + .text("Added") + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::success()), + ) + .into_element() + } else { + Button::new() + .secondary() + .small() + .enabled(page.is_some()) + .on_press(move |_| { + if let Some(url) = &page { + crate::platform::open_url(url); + } + }) + .child(Icon::new(IconType::LinkExternal01).size(13.)) + .text("Open page") + .into_element() + }; + + rect() + .key(file.sha1.clone()) + .horizontal() + .width(Size::fill()) + .height(Size::px(ROW_H)) + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(0., 12.)) + .spacing(10.) + .corner_radius(CornerRadius::new_all(10.)) + .background(background) + .border(border_all_color(1., border)) + .child( + rect() + .vertical() + .width(Size::flex(1.)) + .spacing(2.) + .child( + label() + .text(file.project_name.clone()) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(file.file_name.clone()) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .child(trailing) +} diff --git a/packages/oneclient_app/src/components/bundle_choices_popup.rs b/packages/oneclient_app/src/components/bundle_choices_popup.rs new file mode 100644 index 00000000..232b5771 --- /dev/null +++ b/packages/oneclient_app/src/components/bundle_choices_popup.rs @@ -0,0 +1,157 @@ +use std::collections::HashSet; + +use freya::prelude::*; + +use crate::components::{Button, Icon, IconType, OverlayPopup, toggle_controlled}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 460.; +const LIST_MAX_H: f32 = 306.; + +#[derive(PartialEq)] +pub struct BundleChoicesPopup; + +impl Component for BundleChoicesPopup { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + let mut chosen = use_state(HashSet::::new); + + let Some(choices) = snapshot.bundle_choices.clone() else { + return rect().into_element(); + }; + + let mut list = rect().vertical().width(Size::fill()).spacing(6.); + for (name, title) in &choices.bundles { + let key = name.clone(); + let on_toggle: EventHandler<()> = (move |()| { + let mut next = chosen.read().clone(); + if !next.remove(&key) { + next.insert(key.clone()); + } + chosen.set(next); + }) + .into(); + list = list.child( + rect() + .key(name.clone()) + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(8., 10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .child( + label() + .text(title.clone()) + .font_size(13.) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .child(toggle_controlled( + choices.held.contains(name) != chosen.read().contains(name), + on_toggle, + )), + ); + } + + let names: Vec = choices + .bundles + .iter() + .map(|(name, _)| name.clone()) + .collect(); + let held = choices.held.clone(); + let later = dispatch.clone(); + let close = dispatch.clone(); + + OverlayPopup::new() + .on_close(move |_| { + chosen.set(HashSet::new()); + close.close_bundle_choices(None); + }) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(DIALOG_W)) + .height(Size::Inner) + .max_width(Size::window_percent(95.)) + .max_height(Size::window_percent(85.)) + .padding(Gaps::new_all(22.)) + .spacing(14.) + .corner_radius(CornerRadius::new_all(16.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text("Choose your bundles") + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "{} has optional bundles you have not chosen yet. Turn on the ones you want, turning one off removes its mods.", + choices.cluster_name + )) + .font_size(12.5) + .max_lines(4) + .color(colors::fg_secondary()), + ) + .child( + ScrollView::new() + .width(Size::fill()) + .height(Size::Inner) + .max_height(Size::px(LIST_MAX_H)) + .child(list), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| { + chosen.set(HashSet::new()); + later.close_bundle_choices(None); + }) + .text("Ask me later"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + let flipped = chosen.read().clone(); + let picked = names + .iter() + .filter(|name| { + held.contains(*name) + != flipped.contains(*name) + }) + .cloned() + .collect(); + chosen.set(HashSet::new()); + dispatch.close_bundle_choices(Some(picked)); + }) + .child(Icon::new(IconType::Check).size(15.)) + .text("Confirm & launch"), + ), + ), + ), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs index b4276b9a..b4740a05 100644 --- a/packages/oneclient_app/src/components/button.rs +++ b/packages/oneclient_app/src/components/button.rs @@ -56,7 +56,7 @@ pub struct Button { tooltip: Option>, tooltip_placement: TooltipPlacement, on_press: Option>>, - overflow: Overflow, + overflow: Overflow, elements: Vec, key: DiffKey, @@ -118,7 +118,7 @@ impl Button { tooltip_placement: TooltipPlacement::default(), on_press: None, elements: Vec::new(), - overflow: Overflow::Clip, + overflow: Overflow::Clip, key: DiffKey::None, cursor_icon: CursorIcon::Pointer, } @@ -158,10 +158,10 @@ impl Button { self } - pub fn overflow(mut self, overflow: Overflow) -> Self { - self.overflow = overflow; - self - } + pub fn overflow(mut self, overflow: Overflow) -> Self { + self.overflow = overflow; + self + } pub fn font_size(mut self, font_size: impl Into) -> Self { self.text_style.font_size = Some(font_size.into().into()); @@ -258,6 +258,8 @@ impl LayoutExt for Button { impl ContainerExt for Button {} +impl DecorationExt for Button {} + impl StyleExt for Button { fn get_style(&mut self) -> &mut StyleState { &mut self.style @@ -346,7 +348,7 @@ impl Component for Button { if enabled() { rect = rect .on_pointer_down(move |_| pressing.set(true)) - .on_global_pointer_press(move |_| pressing.set(false)) + .on_global_pointer_up(move |_| pressing.set(false)) .on_pointer_over(move |_| hovering.set(true)) .on_pointer_out(move |_| { hovering.set(false); @@ -478,3 +480,29 @@ pub fn open_folder_button(folder: std::path::PathBuf) -> Button { }) .child(Icon::new(IconType::Folder).size(16.)) } + +pub fn copy_button(text: String, copied_body: &'static str, dispatch: crate::Actions) -> Button { + Button::new() + .secondary() + .on_press(move |_| { + if let Err(err) = freya::text_edit::Clipboard::set(text.clone()) { + tracing::warn!("clipboard copy failed: {err:?}"); + dispatch + .notify("Copy failed") + .body("Could not copy to the clipboard.") + .error() + .toast_only() + .send(); + } else { + dispatch + .notify("Copied to clipboard") + .body(copied_body) + .info() + .icon(IconType::ClipboardCheck) + .toast_only() + .send(); + } + }) + .child(Icon::new(IconType::Copy01).size(14.)) + .text("Copy") +} diff --git a/packages/oneclient_app/src/components/charts.rs b/packages/oneclient_app/src/components/charts.rs index 7b6d89c1..bbb300ff 100644 --- a/packages/oneclient_app/src/components/charts.rs +++ b/packages/oneclient_app/src/components/charts.rs @@ -93,7 +93,7 @@ impl Component for PieChart { let values = self.values.clone(); let hit_values = self.values.clone(); - let render_cb = RenderCallback::new(move |ctx: &mut CanvasContext| { + let render_cb = RenderCallback::new(move |ctx: &mut FillRenderContext| { let (w, h) = (ctx.size.width, ctx.size.height); if w <= 0.0 || h <= 0.0 { return; @@ -182,8 +182,13 @@ impl Component for PieChart { .height(Size::fill()) .position(Position::new_absolute()) .interactive(false) - // Childless canvas auto-sizes to 0x0 without an explicit size - .child(canvas(render_cb).width(Size::fill()).height(Size::fill())), + // Childless rect auto-sizes to 0x0 without an explicit size + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .background(render_cb), + ), ) .child( rect() @@ -368,7 +373,7 @@ impl Component for BarChart { rect() .width(Size::fill()) .height(Size::px(h)) - .corner_radius(CornerRadius::new(4., 4., 0., 0.)) + .corner_radius(CornerRadius::new(4., 4., 0., 0.)) .background(color), ), ); diff --git a/packages/oneclient_app/src/components/cluster_update_popup.rs b/packages/oneclient_app/src/components/cluster_update_popup.rs index 24623a54..eee9c61c 100644 --- a/packages/oneclient_app/src/components/cluster_update_popup.rs +++ b/packages/oneclient_app/src/components/cluster_update_popup.rs @@ -4,7 +4,9 @@ use freya::{prelude::*, router::RouterContext}; use oneclient_content::packages::{CachedPackageMeta, ProviderId}; use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea, TabBar, TabItem}; -use crate::hooks::{package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch}; +use crate::hooks::{ + package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch, +}; use crate::notifications::{ClusterUpdateItem, ClusterUpdateSummary}; use crate::routes::Route; use crate::theme::colors; @@ -108,7 +110,11 @@ impl Component for ClusterUpdatePopup { }) .collect(); let mut meta = MetaMap::new(); - for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { + for provider in ProviderId::REMOTE_PROVIDERS + .iter() + .copied() + .chain([ProviderId::Local]) + { let ids: Vec = all_items .iter() .filter(|i| i.provider == provider) @@ -355,11 +361,7 @@ fn change_list( } /// Multi-cluster stand-in for the footer's "Open cluster" shortcut -fn cluster_header( - group: &ClusterGroup, - first: bool, - dispatch: crate::Actions, -) -> impl IntoElement { +fn cluster_header(group: &ClusterGroup, first: bool, dispatch: crate::Actions) -> impl IntoElement { let cluster_id = group.cluster_id; let count = group.names.len(); diff --git a/packages/oneclient_app/src/components/code_block.rs b/packages/oneclient_app/src/components/code_block.rs new file mode 100644 index 00000000..7c369b23 --- /dev/null +++ b/packages/oneclient_app/src/components/code_block.rs @@ -0,0 +1,107 @@ +use std::borrow::Cow; + +use freya::prelude::*; + +use crate::components::ScrollArea; +use crate::theme::{self, colors}; +use crate::ui::border_all_color; + +#[derive(PartialEq)] +pub struct CodeBlock { + code: String, + color: Color, + background: Color, + font_family: Cow<'static, str>, + font_size: f32, + max_lines: Option, + height: Option, + key: DiffKey, +} + +impl KeyExt for CodeBlock { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl CodeBlock { + pub fn new(code: impl Into) -> Self { + Self { + code: code.into(), + color: colors::fg_primary(), + background: colors::component_bg(), + font_family: Cow::Borrowed(theme::MONO_FONT), + font_size: 12., + max_lines: None, + height: None, + key: DiffKey::None, + } + } + + pub fn color(mut self, color: Color) -> Self { + self.color = color; + self + } + + pub fn background(mut self, background: Color) -> Self { + self.background = background; + self + } + + pub fn font_family(mut self, font_family: Cow<'static, str>) -> Self { + self.font_family = font_family; + self + } + + pub fn font_size(mut self, font_size: f32) -> Self { + self.font_size = font_size; + self + } + + pub fn max_lines(mut self, max_lines: usize) -> Self { + self.max_lines = Some(max_lines); + self + } + + /// Fixes the block's height and scrolls the code inside it + pub fn height(mut self, height: f32) -> Self { + self.height = Some(height); + self + } +} + +impl Component for CodeBlock { + fn render(&self) -> impl IntoElement { + let text = SelectableText::new() + .span(self.code.clone()) + .font_family(self.font_family.clone()) + .font_size(self.font_size) + .max_lines(self.max_lines) + .width(Size::fill()) + .color(self.color); + + let block = rect() + .width(Size::fill()) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(self.background) + .border(border_all_color(1., colors::component_border())); + + match self.height { + Some(height) => block + .height(Size::px(height)) + .overflow(Overflow::Clip) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .child(text), + ), + None => block.child(text), + } + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) + } +} diff --git a/packages/oneclient_app/src/components/control_center.rs b/packages/oneclient_app/src/components/control_center.rs index 1fce04f4..f3a626c1 100644 --- a/packages/oneclient_app/src/components/control_center.rs +++ b/packages/oneclient_app/src/components/control_center.rs @@ -57,7 +57,7 @@ impl Component for ControlPanel { .opacity(progress) .margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.)) .background(colors::page_elevated().with_a(220)) - .blur(12.) + .backdrop_blur(12.) .corner_radius(CornerRadius::new_all(14.)) .border( Border::new() @@ -120,7 +120,12 @@ impl Component for AccountHeader { let unusable = query_error(¤t).is_some(); let account = try_default_account(¤t).or_else(|| { unusable - .then(|| try_accounts(&accounts_query).unwrap_or_default().into_iter().next()) + .then(|| { + try_accounts(&accounts_query) + .unwrap_or_default() + .into_iter() + .next() + }) .flatten() }); @@ -164,11 +169,7 @@ impl Component for AccountHeader { .cross_align(Alignment::Center) .spacing(10.) .padding(Gaps::new_all(4.)) - .child( - Avatar::new(uuid) - .width(Size::px(40.)) - .height(Size::px(40.)), - ) + .child(Avatar::new(uuid).width(Size::px(40.)).height(Size::px(40.))) .child( rect() .vertical() @@ -257,11 +258,7 @@ impl Component for NavRow { .on_pointer_enter(move |_| hovered.set(true)) .on_pointer_leave(move |_| hovered.set(false)) .on_press(go) - .child( - Icon::new(self.icon) - .size(20.) - .color(colors::fg_secondary()), - ) + .child(Icon::new(self.icon).size(20.).color(colors::fg_secondary())) .child( label() .text(self.row_label) @@ -286,8 +283,11 @@ impl Component for QuickSettings { let dispatch = use_dispatch(); let discord_on = settings.discord_enabled; - let toggle_discord = move |_| { - dispatch.edit_settings(|s| s.discord_enabled = !s.discord_enabled); + let toggle_discord = { + let dispatch = dispatch.clone(); + move |_| { + dispatch.edit_settings(|s| s.discord_enabled = !s.discord_enabled); + } }; rect() @@ -299,7 +299,6 @@ impl Component for QuickSettings { QuickTile::new(IconType::Discord, "Discord RPC", discord_on) .on_press(toggle_discord), ) - .child(QuickTile::new(IconType::Moon01, "Close on launch", false).disabled()) } } @@ -331,11 +330,6 @@ impl QuickTile { } } - fn disabled(mut self) -> Self { - self.enabled = false; - self - } - fn on_press(mut self, on_press: impl Into>>) -> Self { self.on_press = Some(on_press.into()); self @@ -619,9 +613,7 @@ impl Component for FooterAction { .a11y_role(AccessibilityRole::Button) .on_pointer_enter(move |_| hovered.set(true)) .on_pointer_leave(move |_| hovered.set(false)) - .map(handler, |el, handler| { - el.on_press(move |e| handler.call(e)) - }) + .map(handler, |el, handler| el.on_press(move |e| handler.call(e))) .child(Icon::new(self.icon).size(16.).color(self.color)) .child( label() diff --git a/packages/oneclient_app/src/components/date_range_picker.rs b/packages/oneclient_app/src/components/date_range_picker.rs index 56410f5c..753e6d2e 100644 --- a/packages/oneclient_app/src/components/date_range_picker.rs +++ b/packages/oneclient_app/src/components/date_range_picker.rs @@ -270,8 +270,7 @@ fn month_grid( outside: day.month0() != shown.month0(), enabled: (min..=max).contains(&day), in_range: selected.is_some_and(|(a, b)| (a..=b).contains(&day)), - edge: selected.is_some_and(|(a, b)| day == a || day == b) - || pending == Some(day), + edge: selected.is_some_and(|(a, b)| day == a || day == b) || pending == Some(day), pick: pick.clone(), }); } @@ -349,7 +348,11 @@ impl Component for DayCell { } } -fn footer(has_range: bool, pending: Option, mut clear: impl FnMut() + 'static) -> Element { +fn footer( + has_range: bool, + pending: Option, + mut clear: impl FnMut() + 'static, +) -> Element { let hint = match pending { Some(day) => format!("From {} — pick an end date", format_day(day)), None => "Click a start date, then an end date".to_string(), diff --git a/packages/oneclient_app/src/components/dropdown.rs b/packages/oneclient_app/src/components/dropdown.rs index 4099d385..9bc0e0f2 100644 --- a/packages/oneclient_app/src/components/dropdown.rs +++ b/packages/oneclient_app/src/components/dropdown.rs @@ -14,8 +14,11 @@ pub struct Dropdown { selected: String, options: Vec, on_select: Option>, + leading: Option, width: Size, height: Size, + outlined: bool, + checked: Option>, key: DiffKey, } @@ -25,8 +28,11 @@ impl Dropdown { selected: selected.into(), options, on_select: None, + leading: None, width: Size::px(72.), height: Size::px(24.), + outlined: false, + checked: None, key: DiffKey::None, } } @@ -43,6 +49,21 @@ impl Dropdown { self } + pub fn outlined(mut self) -> Self { + self.outlined = true; + self + } + + pub fn checked(mut self, checked: Vec) -> Self { + self.checked = Some(checked); + self + } + + pub fn leading(mut self, leading: impl IntoElement) -> Self { + self.leading = Some(leading.into_element()); + self + } + pub fn on_select(mut self, handler: impl Into>) -> Self { self.on_select = Some(handler.into()); self @@ -64,17 +85,21 @@ impl Component for Dropdown { let focus = use_focus(a11y_id); let mut button_area = use_state(|| None::); - let mut list_size = use_state(|| None::); + let mut list_area = use_state(|| None::); let selected = self.selected.clone(); + let leading = self.leading.clone(); let options = self.options.clone(); let on_select = self.on_select.clone(); + let outlined = self.outlined; + let checked = self.checked.clone(); + let multi = checked.is_some(); let is_open = open(); use_overlay_claim_when(is_open); - if !is_open && list_size().is_some() { - let _ = list_size.take(); + if !is_open && list_area().is_some() { + let _ = list_area.take(); } let trigger_bg = if hovering() { @@ -87,13 +112,13 @@ impl Component for Dropdown { let list_h = visible as f32 * OPTION_HEIGHT + visible.saturating_sub(1) as f32 * OPTION_SPACING; - let offset_y = match (button_area(), list_size()) { + let offset_y = match (button_area(), list_area()) { (Some(button), Some(list)) => { let root_height = Platform::get().root_size.peek().height; let space_below = root_height - button.max_y(); let space_above = button.min_y(); - if list.height > space_below && list.height <= space_above { - -(button.height() + list.height + 8.) + if list.height() > space_below && list.height() <= space_above { + -(button.height() + list.height() + 8.) } else { 0. } @@ -105,8 +130,15 @@ impl Component for Dropdown { .map(|b| Size::px(b.width())) .unwrap_or_else(|| self.width.clone()); - let on_global_pointer_press = move |_: Event| { - open.set_if_modified(false); + let on_global_pointer_up = move |e: Event| { + let loc = e.global_location(); + let inside = list_area().is_some_and(|area| { + area.translate((0., offset_y).into()) + .contains((loc.x as f32, loc.y as f32).into()) + }); + if !(multi && inside) { + open.set_if_modified(false); + } }; rect() @@ -123,9 +155,13 @@ impl Component for Dropdown { .horizontal() .center() .spacing(4.) - .padding(Gaps::new_symmetric(0., 8.)) - .corner_radius(CornerRadius::new_all(6.)) + .padding(Gaps::new_symmetric(0., if outlined { 12. } else { 8. })) + .corner_radius(CornerRadius::new_all(if outlined { 8. } else { 6. })) .background(trigger_bg) + .maybe(outlined, |el| { + el.main_align(Alignment::SpaceBetween) + .border(border_all_color(1., colors::component_border())) + }) .a11y_id(a11y_id) .a11y_focusable(true) .a11y_role(AccessibilityRole::Button) @@ -134,7 +170,7 @@ impl Component for Dropdown { }) .on_pointer_enter(move |_| hovering.set(true)) .on_pointer_leave(move |_| hovering.set(false)) - .on_global_pointer_press(on_global_pointer_press) + .on_global_pointer_up(on_global_pointer_up) .on_sized(move |e: Event| { button_area.set_if_modified(Some(e.area)); }) @@ -143,10 +179,11 @@ impl Component for Dropdown { e.stop_propagation(); open.toggle(); }) + .maybe_child(leading) .child( label() .text(selected) - .font_size(12.) + .font_size(if outlined { 13. } else { 12. }) .color(colors::fg_primary()), ) .child( @@ -164,7 +201,7 @@ impl Component for Dropdown { .offset_y(offset_y) .content(Content::Fit) .on_sized(move |e: Event| { - list_size.set_if_modified(Some(e.area.size)); + list_area.set_if_modified(Some(e.area)); }) .child( rect() @@ -181,11 +218,16 @@ impl Component for Dropdown { .children(options.into_iter().enumerate().map( |(idx, option)| { let on_select = on_select.clone(); - DropdownOption::new(option, move |_| { + let checked = checked + .as_ref() + .map(|c| c.get(idx).copied().unwrap_or(false)); + DropdownOption::new(option, checked, move |_| { if let Some(handler) = &on_select { handler.call(idx); } - open.set(false); + if !multi { + open.set(false); + } }) .into_element() }, @@ -203,16 +245,19 @@ impl Component for Dropdown { struct DropdownOption { text: String, + checked: Option, on_press: EventHandler>, } impl DropdownOption { pub fn new( text: impl Into, + checked: Option, on_press: impl Into>>, ) -> Self { Self { text: text.into(), + checked, on_press: on_press.into(), } } @@ -220,7 +265,7 @@ impl DropdownOption { impl PartialEq for DropdownOption { fn eq(&self, other: &Self) -> bool { - self.text == other.text + self.text == other.text && self.checked == other.checked } } @@ -233,7 +278,8 @@ impl Component for DropdownOption { rect() .width(Size::fill()) - .padding(Gaps::new_symmetric(6., 8.)) + .height(Size::px(OPTION_HEIGHT)) + .padding(Gaps::new_symmetric(0., 8.)) .corner_radius(CornerRadius::new_all(6.)) .a11y_id(a11y_id) .a11y_focusable(true) @@ -246,11 +292,27 @@ impl Component for DropdownOption { .on_pointer_enter(move |_| hovering.set(true)) .on_pointer_leave(move |_| hovering.set(false)) .on_press(self.on_press.clone()) + .horizontal() + .content(Content::Flex) + .cross_align(Alignment::Center) .child( label() .text(self.text.clone()) + .width(Size::flex(1.)) .font_size(12.) .color(colors::fg_primary()), ) + .maybe_child(self.checked.map(|checked| { + rect() + .width(Size::px(14.)) + .height(Size::px(14.)) + .maybe_child(checked.then(|| { + Icon::new(IconType::Check) + .size(14.) + .color(colors::brand()) + .into_element() + })) + .into_element() + })) } } diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs index 261814aa..eef27a0d 100644 --- a/packages/oneclient_app/src/components/dynamic_art.rs +++ b/packages/oneclient_app/src/components/dynamic_art.rs @@ -1,25 +1,38 @@ +use std::path::PathBuf; +use std::sync::LazyLock; + use bytes::Bytes; +use freya::animation::{ + AnimNum, Ease, Function, OnChange, OnCreation, OnFinish, use_animation_with_dependencies, +}; use freya::prelude::*; -use oneclient_core::clusters::Cluster; -use oneclient_core::images::DEFAULT_IMAGE_EDGE; use oneclient_common::domain::GameLoader; use oneclient_common::{VersionKey, parse_mc_version}; +use oneclient_core::clusters::Cluster; +use oneclient_core::images::{DEFAULT_IMAGE_EDGE, PREVIEW_IMAGE_EDGE}; use crate::AppAssets; -use crate::hooks::{loaded_image, use_cached_image, use_version_metadata}; +use crate::hooks::{ + loaded_image, resolve_art_url, settled_or_loading, use_cached_image, use_local_image, + use_version_art, use_version_metadata, +}; use crate::layout::HOME_BACKGROUND_ASSET; +use crate::theme::colors; use crate::ui::ImageFallbackExt; -/// The size cluster cards request so it is usually already cached when anything else wants it -pub const ART_PREVIEW_EDGE: u32 = 512; +pub const ART_PREVIEW_EDGE: u32 = PREVIEW_IMAGE_EDGE; #[derive(PartialEq, Clone)] pub struct DynamicArt { major: Option, key: Option, loader: Option, + cover: Option, + cover_picked: bool, max_edge: u32, preview_edge: Option, + url: Option, + skeleton: bool, } impl DynamicArt { @@ -28,8 +41,7 @@ impl DynamicArt { major: Some(major), key, loader, - max_edge: DEFAULT_IMAGE_EDGE, - preview_edge: None, + ..Self::fallback() } } @@ -39,8 +51,15 @@ impl DynamicArt { major: parsed.as_ref().map(|p| p.major), key: parsed.and_then(|p| p.key()), loader: Some(cluster.mc_loader), - max_edge: DEFAULT_IMAGE_EDGE, - preview_edge: None, + cover: cluster.cover_file(), + ..Self::fallback() + } + } + + pub fn for_url(url: String) -> Self { + Self { + url: Some(url), + ..Self::fallback() } } @@ -49,11 +68,34 @@ impl DynamicArt { major: None, key: None, loader: None, + cover: None, + cover_picked: false, max_edge: DEFAULT_IMAGE_EDGE, preview_edge: None, + url: None, + skeleton: false, } } + #[must_use] + pub fn cover(mut self, cover: Option) -> Self { + self.cover = cover; + self + } + + #[must_use] + pub fn picked_cover(mut self, cover: Option) -> Self { + self.cover = cover; + self.cover_picked = true; + self + } + + #[must_use] + pub fn skeleton(mut self, skeleton: bool) -> Self { + self.skeleton = skeleton; + self + } + #[must_use] pub fn max_edge(mut self, max_edge: u32) -> Self { self.max_edge = max_edge; @@ -68,26 +110,46 @@ impl DynamicArt { } pub fn use_bytes(&self) -> (String, Bytes) { - use_art_bytes( - self.major, - self.key, - self.loader, - self.max_edge, - self.preview_edge, - ) + use_art_bytes(self) } } -pub fn use_art_bytes( - major: Option, - key: Option, - loader: Option, - max_edge: u32, - preview_edge: Option, -) -> (String, Bytes) { - let fallback = use_memo(|| AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default()); +pub fn use_art_bytes(art: &DynamicArt) -> (String, Bytes) { + let max_edge = art.max_edge; + use_resolved_art(art) + .unwrap_or_else(|| (format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), bundled_art())) +} - let art_url = use_version_metadata(major, key, loader).and_then(|m| m.art_url); +fn bundled_art() -> Bytes { + static ART: LazyLock = + LazyLock::new(|| AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default()); + ART.clone() +} + +fn use_resolved_art(art: &DynamicArt) -> Option<(String, Bytes)> { + let DynamicArt { + major, + key, + loader, + cover, + cover_picked, + max_edge, + preview_edge, + url, + skeleton: _, + } = art.clone(); + + let cover_query = use_local_image(cover.clone().unwrap_or_default(), max_edge, cover_picked); + + let cover_bytes = cover.as_ref().and_then(|path| { + settled_or_loading(&cover_query) + .filter(|bytes: &Bytes| !bytes.is_empty()) + .map(|bytes| (format!("{max_edge}|{}", path.display()), bytes)) + }); + + let curated = use_version_metadata(major, key, loader); + let candidates = use_version_art(major, key); + let art_url = url.or_else(|| resolve_art_url(curated.as_ref(), &candidates)); let image_query = use_cached_image(art_url.clone(), max_edge); @@ -105,17 +167,41 @@ pub fn use_art_bytes( .map(|(url, bytes)| (format!("{edge}|{url}"), bytes)) }; - full.or_else(preview).unwrap_or_else(|| { - ( - format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), - fallback.read().clone(), - ) - }) + cover_bytes.or(full).or_else(preview) } impl Component for DynamicArt { fn render(&self) -> impl IntoElement { - let (key, bytes) = self.use_bytes(); + let resolved = use_resolved_art(self); + let show_skeleton = self.skeleton && resolved.is_none(); + + let pulse = use_animation_with_dependencies(&show_skeleton, |conf, show| { + conf.on_change(OnChange::Rerun); + + if *show { + conf.on_creation(OnCreation::Run); + conf.on_finish(OnFinish::reverse()); + } + + AnimNum::new(0.4, 0.9) + .time(800) + .ease(Ease::InOut) + .function(Function::Sine) + }); + let pulse_v = pulse.read().value(); + + if show_skeleton { + return rect() + .width(Size::fill()) + .height(Size::fill()) + .background(colors::component_bg()) + .opacity(pulse_v) + .into_element(); + } + + let max_edge = self.max_edge; + let (key, bytes) = resolved + .unwrap_or_else(|| (format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), bundled_art())); ImageViewer::new((key, bytes)) .width(Size::fill()) diff --git a/packages/oneclient_app/src/components/essential_confirm.rs b/packages/oneclient_app/src/components/essential_confirm.rs new file mode 100644 index 00000000..9a4f6a17 --- /dev/null +++ b/packages/oneclient_app/src/components/essential_confirm.rs @@ -0,0 +1,146 @@ +use freya::prelude::*; + +use crate::components::{ + Button, Icon, IconType, Markdown, MarkdownStyle, OverlayPopup, ScrollArea, +}; +use crate::hooks::{EssentialGuardKind, use_cluster_mutation, use_essential_guard}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const BODY_MAX_H: f32 = 400.; + +#[derive(PartialEq)] +pub struct EssentialConfirmOverlay; + +impl Component for EssentialConfirmOverlay { + fn render(&self) -> impl IntoElement { + let mut pending = use_essential_guard(); + let cluster = use_cluster_mutation(); + + let Some(request) = pending.read().clone() else { + return rect().into_element(); + }; + + let (title, confirm_label) = match request.kind { + EssentialGuardKind::Disable => (format!("Turn off {}?", request.name), "Turn it off"), + EssentialGuardKind::Remove => (format!("Remove {}?", request.name), "Remove anyway"), + }; + + let action = request.action.clone(); + + OverlayPopup::new() + .on_close(move |_| pending.set(None)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::AlertTriangle) + .size(20.) + .color(colors::code_warn()), + ) + .child( + label() + .text(title) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child(WarningBody { + body: request.body.clone(), + }) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| { + cluster.mutate(action.clone()); + pending.set(None); + }) + .text(confirm_label), + ) + .child( + Button::new() + .primary() + .on_press(move |_| pending.set(None)) + .text("Keep it"), + ), + ), + ), + ) + .into_element() + } +} + +#[derive(PartialEq)] +struct WarningBody { + body: String, +} + +impl Component for WarningBody { + fn render(&self) -> impl IntoElement { + let mut content_h = use_state(|| 0f32); + let height = content_h.read().min(BODY_MAX_H); + + ScrollArea::new() + .width(Size::fill()) + .height(Size::px(height)) + .scrollbar_gutter(true) + .child( + rect() + .width(Size::fill()) + .on_sized(move |e: Event| { + let h = e.area.height(); + if (*content_h.read() - h).abs() > 0.5 { + content_h.set(h); + } + }) + .child( + rect() + .width(Size::fill()) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child(Markdown::new(self.body.clone()).width(Size::fill()).style( + MarkdownStyle { + color: Color::WHITE, + color_link: colors::code_info(), + color_code: Color::WHITE, + background_code: CARD_BG, + background_blockquote: CARD_BG, + border_blockquote: colors::component_border(), + background_divider: colors::component_border(), + headings: [16., 14., 13., 12., 12., 12.], + paragraph_size: 12., + code_font_size: 11., + ..MarkdownStyle::default() + }, + )), + ), + ) + } +} diff --git a/packages/oneclient_app/src/components/file_drop.rs b/packages/oneclient_app/src/components/file_drop.rs index e10dc522..830698a5 100644 --- a/packages/oneclient_app/src/components/file_drop.rs +++ b/packages/oneclient_app/src/components/file_drop.rs @@ -3,17 +3,24 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::time::Duration; use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation}; use freya::prelude::*; use freya::router::use_route; -use oneclient_core::clusters::Cluster; +use oneclient_content::modpacks::detect_format; use oneclient_content::packages::ContentType; +use oneclient_core::clusters::{Cluster, ModpackSource}; use oneclient_db::models::ClusterId; use crate::Route; use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; -use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch}; +use crate::hooks::{ + add_world_datapacks, settled_or_loading, spawn_world_task, try_cluster_worlds, + use_active_cluster_id, use_cluster_worlds, use_clusters, use_datapack_world, use_debounced, + use_dispatch, +}; +use crate::launcher::off_ui; use crate::theme::colors; use crate::ui::border_all_color; @@ -23,19 +30,29 @@ const FILE_ROW_H: f32 = 34.; const FILE_LIST_MAX_ROWS: usize = 5; const TYPE_DROPDOWN_W: f32 = 108.; -/// Worlds are directories and modpacks/datapacks have no view so both are excluded -const IMPORTABLE: [ContentType; 3] = [ +/// Worlds are directories and modpacks have no view so both are excluded +const IMPORTABLE: [ContentType; 4] = [ ContentType::Mod, ContentType::ResourcePack, ContentType::Shader, + ContentType::DataPack, ]; const SHADER_HINTS: [&str; 5] = ["shader", "bsl", "seus", "complementary", "sildur"]; +const DATAPACK_HINTS: [&str; 4] = ["datapack", "data pack", "data_pack", "data-pack"]; + +fn importable(mod_loader: bool) -> Vec { + IMPORTABLE + .into_iter() + .filter(|ct| mod_loader || !ct.needs_mod_loader()) + .collect() +} fn content_label(content_type: ContentType) -> &'static str { match content_type { ContentType::ResourcePack => "Textures", ContentType::Shader => "Shaders", + ContentType::DataPack => "Data packs", _ => "Mods", } } @@ -51,26 +68,87 @@ fn file_name(path: &Path) -> String { } /// Precedence `.jar` is always a mod then the current route then a name sniff -fn infer_content_type(path: &Path, route_type: Option) -> ContentType { +fn infer_content_type( + path: &Path, + route_type: Option, + mod_loader: bool, +) -> Option { if extension(path).as_deref() == Some("jar") { - return ContentType::Mod; + return mod_loader.then_some(ContentType::Mod); } if let Some(route_type) = route_type { - return route_type; + if mod_loader || !route_type.needs_mod_loader() { + return Some(route_type); + } + if route_type == ContentType::Shader { + return None; + } } let name = file_name(path).to_lowercase(); - if SHADER_HINTS.iter().any(|hint| name.contains(hint)) { - ContentType::Shader + if DATAPACK_HINTS.iter().any(|hint| name.contains(hint)) { + Some(ContentType::DataPack) + } else if mod_loader && SHADER_HINTS.iter().any(|hint| name.contains(hint)) { + Some(ContentType::Shader) } else { - ContentType::ResourcePack + Some(ContentType::ResourcePack) } } +const DROP_SETTLE: Duration = Duration::from_millis(300); + +pub fn accept_drop( + paths: &[PathBuf], + mut pending: State>, + mut modpacks: State>, +) { + for path in paths { + match extension(path).as_deref() { + Some("mrpack") => modpacks.write().push(path.clone()), + Some("zip") => { + let path = path.clone(); + spawn(async move { + let probe = path.clone(); + let is_modpack = + off_ui(async move { detect_format(&probe).await.is_ok() }).await; + if is_modpack { + modpacks.write().push(path); + } else { + pending.write().push(path); + } + }); + } + _ => pending.write().push(path.clone()), + } + } +} + +fn use_dropped_modpack_driver(pending: State>, modpacks: State>) { + let dispatch = use_dispatch(); + let settled = use_debounced( + (pending.read().is_empty(), modpacks.read().len()), + DROP_SETTLE, + ); + let settled = *settled.read(); + + use_side_effect_with_deps(&settled, move |&(prompt_closed, queued)| { + if !prompt_closed || queued == 0 || !pending.peek().is_empty() { + return; + } + + let mut modpacks = modpacks; + let dropped = std::mem::take(&mut *modpacks.write()); + for path in dropped { + dispatch.install_modpack(ModpackSource::File(path)); + } + }); +} + fn route_target(route: &Route) -> Option<(ClusterId, ContentType)> { match route { Route::ClusterMods { cluster_id } => Some((*cluster_id, ContentType::Mod)), Route::ClusterShaders { cluster_id } => Some((*cluster_id, ContentType::Shader)), Route::ClusterTextures { cluster_id } => Some((*cluster_id, ContentType::ResourcePack)), + Route::ClusterDataPacks { cluster_id } => Some((*cluster_id, ContentType::DataPack)), _ => None, } } @@ -80,10 +158,12 @@ fn route_target(route: &Route) -> Option<(ClusterId, ContentType)> { pub struct FileDropOverlay { pub hovering: State, pub pending: State>, + pub modpacks: State>, } impl Component for FileDropOverlay { fn render(&self) -> impl IntoElement { + use_dropped_modpack_driver(self.pending, self.modpacks); let files = self.pending.read().clone(); if !files.is_empty() { @@ -191,23 +271,39 @@ impl Component for DropPrompt { let target = route_target(&route); let initial_cluster = target.map(|(id, _)| id).or(*active_cluster.peek()); let selected_cluster = use_state(move || initial_cluster); + let remembered_world = use_datapack_world(); // Absent entries fall back to the inferred type covering drops landing mid-prompt let overrides = use_state(HashMap::::new); let clusters: Vec = settled_or_loading(&clusters_query).unwrap_or_default(); - let body = if clusters.is_empty() { - no_clusters_body(pending) - } else { - prompt_body( + // The remembered cluster may have been deleted while the prompt was open + let cluster_id = selected_cluster + .read() + .filter(|id| clusters.iter().any(|c| c.id == *id)) + .or_else(|| clusters.first().map(|c| c.id)); + + let worlds_query = use_cluster_worlds(cluster_id.unwrap_or_default()); + let worlds: Vec = try_cluster_worlds(&worlds_query) + .unwrap_or_default() + .into_iter() + .map(|w| w.folder_name) + .collect(); + + let body = match cluster_id { + None => no_clusters_body(pending), + Some(cluster_id) => prompt_body( &files, &clusters, + cluster_id, target.map(|(_, ct)| ct), selected_cluster, + &worlds, + remembered_world, overrides, dispatch, pending, - ) + ), }; OverlayPopup::new() @@ -297,40 +393,92 @@ fn no_clusters_body(mut pending: State>) -> Element { .into_element() } +#[allow(clippy::too_many_arguments)] fn prompt_body( files: &[PathBuf], clusters: &[Cluster], + cluster_id: ClusterId, route_type: Option, selected_cluster: State>, + worlds: &[String], + remembered_world: State>, overrides: State>, dispatch: crate::Actions, mut pending: State>, ) -> Element { - // The remembered cluster may have been deleted while the prompt was open - let cluster_id = selected_cluster - .read() - .filter(|id| clusters.iter().any(|c| c.id == *id)) - .unwrap_or(clusters[0].id); let cluster_idx = clusters .iter() .position(|c| c.id == cluster_id) .unwrap_or_default(); + let destination = clusters.get(cluster_idx); + let mod_loader = destination.is_none_or(|c| !c.lacks_mod_loader()); + + let mut rejected: Vec = Vec::new(); let resolved: Vec<(PathBuf, ContentType)> = files .iter() - .map(|path| { + .filter_map(|path| { let content_type = overrides .read() .get(path) .copied() - .unwrap_or_else(|| infer_content_type(path, route_type)); - (path.clone(), content_type) + .filter(|ct| mod_loader || !ct.needs_mod_loader()) + .or_else(|| infer_content_type(path, route_type, mod_loader)); + match content_type { + Some(content_type) => Some((path.clone(), content_type)), + None => { + rejected.push(path.clone()); + None + } + } }) .collect(); + let has_datapacks = resolved.iter().any(|(_, ct)| *ct == ContentType::DataPack); + let world = remembered_world + .read() + .get(&cluster_id) + .filter(|name| worlds.contains(name)) + .cloned() + .or_else(|| worlds.first().cloned()); + let can_import = !resolved.is_empty() && (!has_datapacks || world.is_some()); + let skipped = rejected.len(); + let destination_name = destination.map(|c| c.name.clone()).unwrap_or_default(); + let import_list = resolved.clone(); + let import_world = world.clone(); let import = move |_| { - dispatch.import_local_files(cluster_id, import_list.clone()); + if !can_import { + return; + } + let (datapacks, packages): (Vec<_>, Vec<_>) = import_list + .iter() + .cloned() + .partition(|(_, ct)| *ct == ContentType::DataPack); + dispatch.import_local_files(cluster_id, packages); + if let Some(world) = import_world.clone() + && !datapacks.is_empty() + { + spawn_world_task( + dispatch.clone(), + "Couldn't add data packs", + add_world_datapacks( + cluster_id, + world, + datapacks.into_iter().map(|(path, _)| path).collect(), + ), + ); + } + if skipped > 0 { + dispatch + .notify("Some files weren't added") + .body(format!( + "{destination_name} has no mod loader, so {skipped} mod or shader file{} {} skipped.", + if skipped == 1 { "" } else { "s" }, + if skipped == 1 { "was" } else { "were" }, + )) + .send(); + } pending.set(Vec::new()); }; @@ -347,7 +495,11 @@ fn prompt_body( "Pick a cluster, then check what each one gets installed as.".to_string(), )) .child(cluster_field(clusters, cluster_idx, selected_cluster)) - .child(file_field(&resolved, overrides)) + .maybe_child( + has_datapacks + .then(|| world_field(worlds, world.as_deref(), cluster_id, remembered_world)), + ) + .child(file_field(&resolved, &rejected, mod_loader, overrides)) .child( rect() .horizontal() @@ -364,6 +516,7 @@ fn prompt_body( .child( Button::new() .primary() + .enabled(can_import) .on_press(import) .child(Icon::new(IconType::Plus).size(15.)) .text(match files.len() { @@ -415,14 +568,55 @@ fn cluster_field( ) } +fn world_field( + worlds: &[String], + current: Option<&str>, + cluster_id: ClusterId, + mut remembered: State>, +) -> Element { + let Some(current) = current else { + return field( + "Destination world", + label() + .text("This cluster has no worlds yet. Create one in game, then drop the data packs again.") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element(); + }; + + let names = worlds.to_vec(); + field( + "Destination world", + Dropdown::new(current.to_string(), worlds.to_vec()) + .width(Size::fill()) + .height(Size::px(32.)) + .on_select(move |idx: usize| { + if let Some(name) = names.get(idx) { + remembered.write().insert(cluster_id, name.clone()); + } + }), + ) + .into_element() +} + fn file_field( resolved: &[(PathBuf, ContentType)], + rejected: &[PathBuf], + mod_loader: bool, overrides: State>, ) -> impl IntoElement { - let visible = resolved.len().clamp(1, FILE_LIST_MAX_ROWS); + let visible = (resolved.len() + rejected.len()).clamp(1, FILE_LIST_MAX_ROWS); let rows = resolved .iter() - .map(|(path, content_type)| file_row(path, *content_type, overrides).into_element()) + .map(|(path, content_type)| { + file_row(path, Some(*content_type), mod_loader, overrides).into_element() + }) + .chain( + rejected + .iter() + .map(|path| file_row(path, None, mod_loader, overrides).into_element()), + ) .collect::>(); field( @@ -444,14 +638,33 @@ fn file_field( fn file_row( path: &Path, - content_type: ContentType, + content_type: Option, + mod_loader: bool, mut overrides: State>, ) -> impl IntoElement { let key = path.to_path_buf(); - let options: Vec = IMPORTABLE + let types = importable(mod_loader); + let options: Vec = types .iter() .map(|ct| content_label(*ct).to_string()) .collect(); + let control = match content_type { + Some(content_type) => Dropdown::new(content_label(content_type), options) + .width(Size::px(TYPE_DROPDOWN_W)) + .height(Size::px(24.)) + .on_select(move |idx: usize| { + if let Some(content_type) = types.get(idx) { + overrides.write().insert(key.clone(), *content_type); + } + }) + .into_element(), + None => label() + .text("Needs a mod loader") + .font_size(12.) + .max_lines(1) + .color(colors::fg_secondary()) + .into_element(), + }; rect() .horizontal() @@ -474,14 +687,5 @@ fn file_row( .width(Size::flex(1.0)) .color(colors::fg_primary()), ) - .child( - Dropdown::new(content_label(content_type), options) - .width(Size::px(TYPE_DROPDOWN_W)) - .height(Size::px(24.)) - .on_select(move |idx: usize| { - if let Some(content_type) = IMPORTABLE.get(idx) { - overrides.write().insert(key.clone(), *content_type); - } - }), - ) + .child(control) } diff --git a/packages/oneclient_app/src/components/filter_menu.rs b/packages/oneclient_app/src/components/filter_menu.rs new file mode 100644 index 00000000..8fb765e1 --- /dev/null +++ b/packages/oneclient_app/src/components/filter_menu.rs @@ -0,0 +1,245 @@ +use freya::animation::{AnimNum, Ease, OnCreation, use_animation}; +use freya::prelude::*; + +use crate::components::{Button, Icon, IconType}; +use crate::hooks::use_overlay_claim; +use crate::theme::colors; + +const PANEL_W: f32 = 172.; +const BUTTON_W: f32 = 34.; + +#[derive(Clone, PartialEq)] +pub struct FilterOption { + text: String, + selected: bool, + on_press: EventHandler<()>, +} + +impl FilterOption { + pub fn new( + text: impl Into, + selected: bool, + on_press: impl Into>, + ) -> Self { + Self { + text: text.into(), + selected, + on_press: on_press.into(), + } + } +} + +#[derive(Clone, PartialEq)] +struct FilterSection { + title: &'static str, + options: Vec, +} + +#[derive(PartialEq)] +pub struct FilterMenu { + active: bool, + sections: Vec, +} + +impl FilterMenu { + pub fn new(active: bool) -> Self { + Self { + active, + sections: Vec::new(), + } + } + + pub fn section( + mut self, + title: &'static str, + options: impl IntoIterator, + ) -> Self { + self.sections.push(FilterSection { + title, + options: options.into_iter().collect(), + }); + self + } +} + +impl Component for FilterMenu { + fn render(&self) -> impl IntoElement { + let mut open = use_state(|| false); + + let icon_color = if self.active { + colors::brand() + } else { + colors::fg_secondary() + }; + + rect() + .width(Size::px(BUTTON_W)) + .child( + Button::new() + .secondary() + .icon() + .tooltip("Sort and filter") + .width(Size::px(BUTTON_W)) + .height(Size::px(34.)) + .on_press(move |e: Event| { + e.stop_propagation(); + open.toggle(); + }) + .child(Icon::new(IconType::Sliders04).size(16.).color(icon_color)), + ) + .maybe_child(open().then(|| { + FilterPanel { + sections: self.sections.clone(), + on_close: (move |()| open.set(false)).into(), + } + .into_element() + })) + } +} + +#[derive(PartialEq)] +struct FilterPanel { + sections: Vec, + on_close: EventHandler<()>, +} + +impl Component for FilterPanel { + fn render(&self) -> impl IntoElement { + use_overlay_claim(); + + let backdrop_close = self.on_close.clone(); + let key_close = self.on_close.clone(); + + let a11y_id = use_a11y(); + + let fade = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(160).ease(Ease::Out) + }); + let progress = fade.read().value(); + + let mut panel = rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .padding(Gaps::new_all(8.)) + .opacity(progress) + .offset_y((progress - 1.0) * 6.) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::page_elevated().with_a(230)) + .backdrop_blur(12.) + .border(crate::ui::border_all_color(1., colors::component_border())) + .shadow(Shadow::from(( + 0., + 8., + 32., + 0., + Color::from_argb(120, 0, 0, 0), + ))) + .a11y_id(a11y_id) + .a11y_role(AccessibilityRole::Dialog) + .on_global_key_down(move |e: Event| { + if e.key == Key::Named(NamedKey::Escape) { + key_close.call(()); + } + }); + + for section in &self.sections { + panel = panel.child(section_label(section.title)); + for option in §ion.options { + panel = panel.child(ChoiceRow { + option: option.clone(), + }); + } + } + + rect() + .height(Size::px(0.)) + .width(Size::px(PANEL_W)) + .layer(Layer::Overlay) + .child( + rect() + .layer(Layer::OverlayLevel(10)) + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .on_press(move |_| backdrop_close.call(())), + ) + .child( + rect() + .width(Size::fill()) + .layer(Layer::OverlayLevel(12)) + .margin(Gaps::new(6., 0., 0., -(PANEL_W - BUTTON_W))) + .child(panel), + ) + } +} + +fn section_label(text: &'static str) -> impl IntoElement { + label() + .text(text) + .font_size(10.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_secondary()) +} + +#[derive(PartialEq)] +struct ChoiceRow { + option: FilterOption, +} + +impl Component for ChoiceRow { + fn render(&self) -> impl IntoElement { + let selected = self.option.selected; + let on_press = self.option.on_press.clone(); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let color = if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + }; + + let bg = if selected { + colors::component_bg() + } else if focused { + colors::ghost_overlay_hover() + } else { + Color::TRANSPARENT + }; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(5., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(bg) + .content(Content::Flex) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::MenuItemRadio) + .maybe(focused, |el| { + el.border(crate::ui::border_all_color(1., colors::brand())) + }) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_press.call(())) + .child( + label() + .text(self.option.text.clone()) + .font_size(12.) + .width(Size::flex(1.0)) + .color(color), + ) + .maybe_child(selected.then(|| { + Icon::new(IconType::Check) + .size(14.) + .color(colors::brand()) + .into_element() + })) + } +} diff --git a/packages/oneclient_app/src/components/flagged_install_popup.rs b/packages/oneclient_app/src/components/flagged_install_popup.rs new file mode 100644 index 00000000..38f85005 --- /dev/null +++ b/packages/oneclient_app/src/components/flagged_install_popup.rs @@ -0,0 +1,282 @@ +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_content::packages::ResolvedAlternative; + +use crate::components::{ + Button, Icon, IconType, Markdown, MarkdownStyle, OverlayPopup, remote_icon, +}; +use crate::hooks::{use_cached_image, use_dispatch, use_installs_snapshot}; +use crate::routes::Route; +use crate::state::FlaggedInstallPrompt; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::app::browser::encode_package_id; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 460.; +const ROW_H: f32 = 48.; +const ICON_SIZE: f32 = 32.; +const ROW_GAP: f32 = 6.; +const SECTION_GAP: f32 = 14.; +const CONTENT_MAX_H: f32 = 400.; +const DIALOG_PADDING: f32 = 20.; + +#[derive(PartialEq)] +pub struct FlaggedInstallPopup; + +impl Component for FlaggedInstallPopup { + fn render(&self) -> impl IntoElement { + let installs = use_installs_snapshot(); + let dispatch = use_dispatch(); + + let Some(prompt) = installs.flagged.clone() else { + return rect().into_element(); + }; + + let close = dispatch.clone(); + + OverlayPopup::new() + .on_close(move |_| close.dismiss_flagged_install()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog(prompt, dispatch)), + ) + .into_element() + } +} + +fn dialog(prompt: FlaggedInstallPrompt, dispatch: crate::Actions) -> impl IntoElement { + let description = if prompt.alternatives.is_empty() { + format!( + "{} is flagged as a problematic mod. Installing it is not recommended.", + prompt.name + ) + } else { + format!( + "{} is flagged as a problematic mod. Installing it is not recommended, consider one of these alternatives instead.", + prompt.name + ) + }; + + let mut body = rect() + .vertical() + .width(Size::px(DIALOG_W)) + .height(Size::Inner) + .max_width(Size::window_percent(90.)) + .max_height(Size::window_percent(85.)) + .overflow(Overflow::Clip) + .spacing(SECTION_GAP) + .padding(Gaps::new_all(DIALOG_PADDING)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(SECTION_GAP) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::AlertTriangle) + .size(20.) + .color(colors::code_warn()), + ) + .child( + label() + .text("Flagged mod") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(description) + .font_size(12.) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ); + + if prompt.explanation.is_some() || !prompt.alternatives.is_empty() { + body = body.child(flagged_content(&prompt)); + } + + let cancel = dispatch.clone(); + let anyway = dispatch; + + body.child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| cancel.dismiss_flagged_install()) + .text("Cancel"), + ) + .child( + Button::new() + .danger() + .on_press(move |_| anyway.install_flagged_anyway(prompt.clone())) + .text("Install anyway"), + ), + ) +} + +fn flagged_content(prompt: &FlaggedInstallPrompt) -> impl IntoElement { + let mut content = rect().vertical().width(Size::fill()).spacing(SECTION_GAP); + + if let Some(markdown) = &prompt.explanation { + content = content.child(explanation_panel(markdown.clone())); + } + + if !prompt.alternatives.is_empty() { + content = content.child( + rect() + .vertical() + .width(Size::fill()) + .spacing(ROW_GAP) + .children(prompt.alternatives.iter().map(|alternative| { + AlternativeRow { + alternative: alternative.clone(), + cluster_id: prompt.cluster_id, + mc_version: prompt.mc_version.clone(), + } + .into_element() + })), + ); + } + + ScrollView::new() + .width(Size::fill()) + .height(Size::Inner) + .max_height(Size::px(CONTENT_MAX_H)) + .child(content) +} + +#[derive(PartialEq)] +struct AlternativeRow { + alternative: ResolvedAlternative, + cluster_id: i64, + mc_version: String, +} + +impl Component for AlternativeRow { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let icon_query = use_cached_image(self.alternative.icon_url.clone(), 256); + let icon = remote_icon(self.alternative.icon_url.as_deref(), &icon_query, ICON_SIZE); + let version = self + .alternative + .version_number + .clone() + .unwrap_or_else(|| format!("No version for {}", self.mc_version)); + alternative_row( + self.alternative.clone(), + version, + self.cluster_id, + icon, + dispatch, + ) + } +} + +fn alternative_row( + alternative: ResolvedAlternative, + version: String, + cluster_id: i64, + icon: Element, + dispatch: crate::Actions, +) -> impl IntoElement { + let ResolvedAlternative { + provider, + project_id, + name, + .. + } = alternative; + + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .height(Size::px(ROW_H)) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_symmetric(0., 8.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.)) + .spacing(2.) + .child( + label() + .text(name) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(version) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .primary() + .small() + .on_press(move |_| { + dispatch.dismiss_flagged_install(); + let _ = RouterContext::get().push(Route::BrowserPackage { + cluster_id, + package_type: "mod".to_string(), + package_id: encode_package_id(provider, &project_id), + }); + }) + .child(Icon::new(IconType::ArrowRight).size(14.)) + .text("Go to page"), + ) +} + +fn explanation_panel(markdown: String) -> impl IntoElement { + rect() + .width(Size::fill()) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + Markdown::new(markdown) + .width(Size::fill()) + .style(MarkdownStyle { + color: colors::fg_primary(), + color_link: colors::code_info(), + color_code: colors::fg_primary(), + background_code: CARD_BG, + background_blockquote: CARD_BG, + border_blockquote: colors::component_border(), + background_divider: colors::component_border(), + headings: [16., 14., 13., 12., 12., 12.], + paragraph_size: 12., + code_font_size: 11., + ..MarkdownStyle::default() + }), + ) +} diff --git a/packages/oneclient_app/src/components/generic_prompt.rs b/packages/oneclient_app/src/components/generic_prompt.rs index 96b22e2b..8ac8f7ea 100644 --- a/packages/oneclient_app/src/components/generic_prompt.rs +++ b/packages/oneclient_app/src/components/generic_prompt.rs @@ -79,9 +79,7 @@ impl Component for GenericPromptOverlay { .pick_folder() .await { - dispatch.answer_prompt( - Answer::new(id).with_folder(handle.path()), - ); + dispatch.answer_prompt(Answer::new(id).with_folder(handle.path())); } }); } diff --git a/packages/oneclient_app/src/components/icons.rs b/packages/oneclient_app/src/components/icons.rs index 0675cf11..e2967477 100644 --- a/packages/oneclient_app/src/components/icons.rs +++ b/packages/oneclient_app/src/components/icons.rs @@ -51,7 +51,11 @@ impl Icon { impl Component for Icon { fn render(&self) -> impl IntoElement { let path = self.icon.path(); - let bytes = use_memo(move || AppAssets::get_bytes(path).unwrap_or_default()); + let load = move || (path, AppAssets::get_bytes(path).unwrap_or_default()); + let mut bytes = use_state(load); + if bytes.peek().0 != path { + bytes.set(load()); + } let tint = use_hook(try_consume_context::); let color = self @@ -59,7 +63,7 @@ impl Component for Icon { .or_else(|| tint.map(|tint| *tint.0.read())) .unwrap_or_else(colors::fg_primary); - SvgViewer::new((path, bytes.read().cloned())) + SvgViewer::new((path, bytes.peek().1.clone())) .show_loader(false) .width(Size::px(self.size_px)) .height(Size::px(self.size_px)) @@ -115,6 +119,7 @@ pub enum IconType { Folder, FolderCheck, FolderDownload, + Github, Globe01, HelpCircle, IconLogo, diff --git a/packages/oneclient_app/src/components/instance_row.rs b/packages/oneclient_app/src/components/instance_row.rs new file mode 100644 index 00000000..fa44ceeb --- /dev/null +++ b/packages/oneclient_app/src/components/instance_row.rs @@ -0,0 +1,155 @@ +use freya::prelude::*; +use oneclient_core::clusters::Cluster; + +use crate::components::{ART_PREVIEW_EDGE, DynamicArt}; +use crate::theme::colors; +use crate::ui::{border_all_color, last_played_label}; +use crate::utils::{GridSelection, ReleaseLine, line_art_key, line_title}; + +const ROW_HEIGHT_PX: f32 = 56.; +const THUMB_PX: f32 = 40.; + +pub struct InstanceRow { + key: GridSelection, + title: String, + subtitle: String, + played: String, + art: DynamicArt, + selected: bool, + on_press: EventHandler>, +} + +impl InstanceRow { + pub fn new( + cluster: &Cluster, + selected: bool, + on_press: impl Into>>, + ) -> Self { + Self { + key: GridSelection::Instance(cluster.id), + title: cluster.name.clone(), + subtitle: format!("{} \u{b7} {}", cluster.mc_version, cluster.mc_loader), + played: last_played_label(cluster.last_played), + art: DynamicArt::for_cluster(cluster).max_edge(ART_PREVIEW_EDGE), + selected, + on_press: on_press.into(), + } + } + + pub fn for_line( + line: ReleaseLine, + clusters: &[Cluster], + caption: String, + selected: bool, + on_press: impl Into>>, + ) -> Self { + Self { + key: GridSelection::Line(line), + title: line_title(line, clusters), + subtitle: caption, + played: last_played_label(clusters.iter().filter_map(|c| c.last_played).max()), + art: DynamicArt::for_version(line.major, line_art_key(line, clusters), None) + .max_edge(ART_PREVIEW_EDGE), + selected, + on_press: on_press.into(), + } + } +} + +impl PartialEq for InstanceRow { + fn eq(&self, other: &Self) -> bool { + self.key == other.key + && self.title == other.title + && self.subtitle == other.subtitle + && self.played == other.played + && self.art == other.art + && self.selected == other.selected + } +} + +impl Component for InstanceRow { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let selected = self.selected; + let hovered = *hovering.read(); + let focused = focus().is_focused(); + let on_press = self.on_press.clone(); + + let background = if selected { + colors::brand().with_a(36) + } else if hovered { + colors::component_bg_hover() + } else { + colors::component_bg() + }; + + let border_color = if selected || focused { + colors::brand() + } else if hovered { + colors::component_border_hover() + } else { + colors::component_border() + }; + + rect() + .key(self.key) + .horizontal() + .width(Size::fill()) + .height(Size::px(ROW_HEIGHT_PX)) + .padding(Gaps::new(0., 14., 0., 8.)) + .spacing(14.) + .cross_align(Alignment::Center) + .content(Content::Flex) + .corner_radius(CornerRadius::new_all(12.)) + .background(background) + .border(border_all_color(1., border_color).alignment(BorderAlignment::Inner)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_press(move |e| on_press.call(e)) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .child( + rect() + .width(Size::px(THUMB_PX)) + .height(Size::px(THUMB_PX)) + .corner_radius(CornerRadius::new_all(8.)) + .overflow(Overflow::Clip) + .child(self.art.clone()), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(self.title.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .color(colors::fg_primary()), + ) + .child( + label() + .text(self.subtitle.clone()) + .font_size(12.) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .color(colors::fg_secondary()), + ), + ) + .child( + label() + .text(self.played.clone()) + .font_size(12.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + } +} diff --git a/packages/oneclient_app/src/components/java_prompt.rs b/packages/oneclient_app/src/components/java_prompt.rs index 1a180caf..995e0edd 100644 --- a/packages/oneclient_app/src/components/java_prompt.rs +++ b/packages/oneclient_app/src/components/java_prompt.rs @@ -1,6 +1,6 @@ use freya::prelude::*; -use oneclient_java::{JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JavaVendor}; use oneclient_events::Answer; +use oneclient_java::{JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JavaVendor}; use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup}; use crate::hooks::{use_dispatch, use_notifications_snapshot}; @@ -65,9 +65,8 @@ impl Component for JavaPromptOverlay { .pick_folder() .await { - dispatch.answer_prompt( - Answer::new(JAVA_CHOICE_FOLDER).with_folder(handle.path()), - ); + dispatch + .answer_prompt(Answer::new(JAVA_CHOICE_FOLDER).with_folder(handle.path())); } }); }; @@ -124,9 +123,7 @@ impl Component for JavaPromptOverlay { .child( Button::new() .secondary() - .on_press(move |_| { - cancel.dismiss_prompt() - }) + .on_press(move |_| cancel.dismiss_prompt()) .text("Cancel"), ) .child( diff --git a/packages/oneclient_app/src/components/link_confirm.rs b/packages/oneclient_app/src/components/link_confirm.rs index df537a08..767d3985 100644 --- a/packages/oneclient_app/src/components/link_confirm.rs +++ b/packages/oneclient_app/src/components/link_confirm.rs @@ -1,7 +1,9 @@ use freya::prelude::*; -use crate::components::{Button, Icon, IconType, OverlayPopup}; -use crate::hooks::use_link_confirm; +use crate::components::{ + Button, CodeBlock, Icon, IconType, OVERLAY_BASE_LEVEL, OverlayPopup, copy_button, +}; +use crate::hooks::{use_dispatch, use_link_confirm}; use crate::theme::colors; use crate::ui::border_all_color; @@ -13,13 +15,26 @@ pub struct ConfirmLinkOverlay; impl Component for ConfirmLinkOverlay { fn render(&self) -> impl IntoElement { let mut pending = use_link_confirm(); + let dispatch = use_dispatch(); let Some(url) = pending.read().clone() else { return rect().into_element(); }; let open_url = url.clone(); + let (title, body) = if is_first_party(&url) { + ( + "Leave the launcher?", + "This will open the page in your web browser.", + ) + } else { + ( + "Open external link?", + "This link was provided by a third party. Only open it if you trust the source.", + ) + }; OverlayPopup::new() + .overlay_level(OVERLAY_BASE_LEVEL + 3) .on_close(move |_| pending.set(None)) .child( rect() @@ -48,7 +63,7 @@ impl Component for ConfirmLinkOverlay { ) .child( label() - .text("Open external link?") + .text(title) .font_size(16.) .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), @@ -56,25 +71,14 @@ impl Component for ConfirmLinkOverlay { ) .child( label() - .text("This link was provided by a third party. Only open it if you trust the source.") + .text(body) .font_size(12.) .color(colors::fg_secondary()), ) .child( - rect() - .width(Size::fill()) - .padding(Gaps::new_all(10.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(colors::component_bg()) - .border(border_all_color(1., colors::component_border())) - .child( - label() - .text(url.clone()) - .font_size(12.) - .max_lines(4) - .width(Size::fill()) - .color(colors::code_info()), - ), + CodeBlock::new(url.clone()) + .color(colors::code_info()) + .max_lines(4), ) .child( rect() @@ -82,6 +86,11 @@ impl Component for ConfirmLinkOverlay { .width(Size::fill()) .main_align(Alignment::End) .spacing(8.) + .child(copy_button( + url.clone(), + "Link copied to your clipboard.", + dispatch, + )) .child( Button::new() .secondary() @@ -104,3 +113,13 @@ impl Component for ConfirmLinkOverlay { .into_element() } } + +fn is_first_party(url: &str) -> bool { + let host = url + .split_once("://") + .map_or(url, |(_, rest)| rest) + .split(['/', '?', '#', ':']) + .next() + .unwrap_or_default(); + host == "polyfrost.org" || host.ends_with(".polyfrost.org") +} diff --git a/packages/oneclient_app/src/components/local_image.rs b/packages/oneclient_app/src/components/local_image.rs index 0909fb4c..1ef92136 100644 --- a/packages/oneclient_app/src/components/local_image.rs +++ b/packages/oneclient_app/src/components/local_image.rs @@ -17,6 +17,7 @@ pub struct LocalImage { max_edge: u32, cover: bool, skeleton: bool, + picked: bool, } impl LocalImage { @@ -26,9 +27,15 @@ impl LocalImage { max_edge, cover, skeleton: false, + picked: false, } } + pub fn picked(mut self, picked: bool) -> Self { + self.picked = picked; + self + } + pub fn skeleton(mut self, skeleton: bool) -> Self { self.skeleton = skeleton; self @@ -37,9 +44,9 @@ impl LocalImage { impl Component for LocalImage { fn render(&self) -> impl IntoElement { - let query = use_local_image(self.path.clone(), self.max_edge); - - let bytes: Option = settled_or_loading(&query); + let query = use_local_image(self.path.clone(), self.max_edge, self.picked); + let bytes: Option = + settled_or_loading(&query).filter(|bytes: &Bytes| !bytes.is_empty()); let mut cache = use_state(|| None::<(usize, ImageHandle)>); let holder = bytes.and_then(|bytes| { diff --git a/packages/oneclient_app/src/components/log_viewer.rs b/packages/oneclient_app/src/components/log_viewer.rs index 9cad37fc..2328f9e9 100644 --- a/packages/oneclient_app/src/components/log_viewer.rs +++ b/packages/oneclient_app/src/components/log_viewer.rs @@ -455,7 +455,7 @@ fn scroll_body(args: BodyArgs) -> Element { .height(Size::px(content_h)) .on_pointer_down(on_body_down) .on_capture_global_pointer_move(on_global_move) - .on_capture_global_pointer_press(on_global_release) + .on_capture_global_pointer_up(on_global_release) .on_global_key_down(on_key_down) .child(log_body( &lines[..], diff --git a/packages/oneclient_app/src/components/markdown/image.rs b/packages/oneclient_app/src/components/markdown/image.rs index d1f1c0ae..ff92dc4c 100644 --- a/packages/oneclient_app/src/components/markdown/image.rs +++ b/packages/oneclient_app/src/components/markdown/image.rs @@ -3,12 +3,13 @@ use std::borrow::Cow; use std::rc::Rc; use freya::elements::image::ImageHandle; -use freya::engine::prelude::{ClipOp, Paint, SkRect}; +use freya::engine::prelude::{ClipOp, FontMgr, Paint, Picture, PictureRecorder, SkRect, svg}; use freya::prelude::*; use freya_core::element::{ClipContext, ElementExt, LayoutContext}; use freya_core::tree::DiffModifies; use super::super::local_image::decode; +use super::svg_compat::prepare_svg; use crate::hooks::{loaded_image, use_cached_image}; use crate::theme::colors; @@ -34,24 +35,24 @@ impl Component for MarkdownImage { let query = use_cached_image(Some(self.url.clone()), MAX_EDGE); let loaded = loaded_image(Some(&self.url), &query); - let mut cache = use_state(|| None::<(usize, ImageHandle)>); - let holder = loaded.and_then(|(_, bytes)| { + let mut cache = use_state(|| None::<(usize, ImageContent)>); + let content = loaded.and_then(|(_, bytes)| { let ptr = bytes.as_ptr() as usize; - if let Some((cached_ptr, holder)) = cache.read().clone() + if let Some((cached_ptr, content)) = cache.read().clone() && cached_ptr == ptr { - return Some(holder); + return Some(content); } - let holder = decode(&bytes)?; - cache.set(Some((ptr, holder.clone()))); + let content = decode_any(&bytes)?; + cache.set(Some((ptr, content.clone()))); - Some(holder) + Some(content) }); - match holder { - Some(holder) => scaled_image(holder) + match content { + Some(content) => scaled_content(content) .a11y_role(AccessibilityRole::Image) .a11y_alt(self.alt.clone()) .into_element(), @@ -64,6 +65,90 @@ impl Component for MarkdownImage { } } +#[derive(Clone, PartialEq)] +enum ImageContent { + Bitmap(ImageHandle), + Vector(SvgPicture), +} + +/// An svg recorded as drawing commands, so it is drawn straight onto the +/// window's pixel grid instead of being resampled from a bitmap +#[derive(Clone)] +struct SvgPicture { + picture: Picture, + /// Logical size, scaled to physical pixels at layout like any `Size::px` + size: Size2D, +} + +impl PartialEq for SvgPicture { + fn eq(&self, other: &Self) -> bool { + self.picture.unique_id() == other.picture.unique_id() && self.size == other.size + } +} + +fn decode_any(bytes: &Bytes) -> Option { + if let Some(handle) = decode(bytes) { + return Some(ImageContent::Bitmap(handle)); + } + + // Skia's codecs can't read svg, which is what shields.io badges are + if !looks_like_svg(bytes) { + return None; + } + + record_svg(bytes).map(ImageContent::Vector) +} + +fn looks_like_svg(bytes: &[u8]) -> bool { + let head = &bytes[..bytes.len().min(1024)]; + let head = head.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(head); + let start = head + .iter() + .position(|b| !b.is_ascii_whitespace()) + .unwrap_or(head.len()); + let head = &head[start..]; + + head.starts_with(b" Option { + let intrinsic = root.intrinsic_size(); + let size = if intrinsic.width > 0. && intrinsic.height > 0. { + Size2D::new(intrinsic.width, intrinsic.height) + } else { + let view_box = root.view_box()?; + Size2D::new(view_box.width(), view_box.height()) + }; + + let valid = size.width.is_finite() && size.height.is_finite(); + (valid && size.width > 0. && size.height > 0.).then_some(size) +} + +/// Badges are mostly text, so the dom gets the system fonts rather than an empty manager +fn record_svg(bytes: &[u8]) -> Option { + let mut dom = svg::Dom::from_bytes(&prepare_svg(bytes), FontMgr::default()).ok()?; + let declared = svg_size(&dom.root())?; + dom.set_container_size((declared.width, declared.height)); + + // the dom always draws at its declared size, so an oversized svg is scaled down while recording + let shrink = (MAX_EDGE as f32 / declared.width.max(declared.height)).min(1.); + let size = declared * shrink; + + let bounds = SkRect::from_wh(size.width, size.height); + let mut recorder = PictureRecorder::new(); + let canvas = recorder.begin_recording(bounds, false); + canvas.clip_rect(bounds, None, None); + canvas.scale((shrink, shrink)); + dom.render(canvas); + let picture = recorder.finish_recording_as_picture(Some(&bounds))?; + + Some(SvgPicture { picture, size }) +} + #[derive(Clone)] pub struct ScaledImage { key: DiffKey, @@ -74,18 +159,18 @@ pub struct ScaledImage { pub struct ScaledImageElement { accessibility: AccessibilityData, layout: LayoutData, - image_handle: ImageHandle, + content: ImageContent, sampling_mode: SamplingMode, corner_radius: CornerRadius, } -pub fn scaled_image(image_handle: ImageHandle) -> ScaledImage { +fn scaled_content(content: ImageContent) -> ScaledImage { ScaledImage { key: DiffKey::None, element: ScaledImageElement { accessibility: AccessibilityData::default(), layout: LayoutData::default(), - image_handle, + content, sampling_mode: SamplingMode::default(), corner_radius: CornerRadius::default(), }, @@ -134,6 +219,18 @@ impl From for Element { } } +impl ImageContent { + /// Size in layout (physical) pixels; bitmaps keep their pixel dimensions + fn size(&self, scale_factor: f32) -> Size2D { + match self { + Self::Bitmap(handle) => { + Size2D::new(handle.image.width() as f32, handle.image.height() as f32) + } + Self::Vector(svg) => svg.size * scale_factor, + } + } +} + impl ElementExt for ScaledImageElement { fn changed(&self, other: &Rc) -> bool { let Some(other) = (other.as_ref() as &dyn Any).downcast_ref::() else { @@ -157,10 +254,10 @@ impl ElementExt for ScaledImageElement { diff.insert(DiffModifies::LAYOUT); } - if self.image_handle != other.image_handle { + if self.content != other.content { diff.insert(DiffModifies::STYLE); - if self.image_handle.image.dimensions() != other.image_handle.image.dimensions() { + if self.content.size(1.) != other.content.size(1.) { diff.insert(DiffModifies::LAYOUT); } } @@ -192,11 +289,9 @@ impl ElementExt for ScaledImageElement { } fn measure(&self, context: LayoutContext) -> Option<(Size2D, Rc)> { - let image = &self.image_handle.image; - let natural = Size2D::new(image.width() as f32, image.height() as f32); + let natural = self.content.size(context.scale_factor as f32); - let available = - (*context.area_size - context.torin_node.margin.into()).max(Size2D::zero()); + let available = (*context.area_size - context.torin_node.margin.into()).max(Size2D::zero()); let ratio = (available.width / natural.width).min(1.); let ratio = if ratio.is_finite() && ratio > 0. { @@ -239,16 +334,28 @@ impl ElementExt for ScaledImageElement { .canvas .clip_rrect(clip_rrect, ClipOp::Intersect, true); - let mut paint = Paint::default(); - paint.set_anti_alias(true); + match &self.content { + ImageContent::Bitmap(handle) => { + let mut paint = Paint::default(); + paint.set_anti_alias(true); - context.canvas.draw_image_rect_with_sampling_options( - &self.image_handle.image, - None, - rect, - self.sampling_mode.sampling_options(), - &paint, - ); + context.canvas.draw_image_rect_with_sampling_options( + &handle.image, + None, + rect, + self.sampling_mode.sampling_options(), + &paint, + ); + } + ImageContent::Vector(svg) => { + context.canvas.translate((rect.left, rect.top)); + context.canvas.scale(( + rect.width() / svg.size.width, + rect.height() / svg.size.height, + )); + context.canvas.draw_picture(&svg.picture, None, None); + } + } context.canvas.restore(); } @@ -256,8 +363,8 @@ impl ElementExt for ScaledImageElement { #[cfg(test)] mod tests { - use freya::prelude::Bytes; use freya::engine::prelude::AlphaType; + use freya::prelude::Bytes; use freya_testing::prelude::*; use super::*; @@ -272,7 +379,9 @@ mod tests { rect() .width(Size::px(panel.0)) .height(Size::px(panel.1)) - .child(scaled_image(handle(image.0, image.1))) + .child(scaled_content(ImageContent::Bitmap(handle( + image.0, image.1, + )))) }; let test = launch_test(app); @@ -292,7 +401,7 @@ mod tests { rect().width(Size::px(300.)).height(Size::px(400.)).child( paragraph() .span(Span::new("before ")) - .child(scaled_image(handle(600, 200))) + .child(scaled_content(ImageContent::Bitmap(handle(600, 200)))) .span(Span::new(" after")), ) }; @@ -325,4 +434,175 @@ mod tests { fn short_panel_does_not_shrink_the_width() { assert_eq!(measured((300., 60.), (600, 200)), Size2D::new(300., 100.)); } + + const BADGE: &str = r##"v3.0.1"##; + + #[test] + fn sniffs_svg_documents() { + assert!(looks_like_svg(BADGE.as_bytes())); + assert!(looks_like_svg( + b"\xEF\xBB\xBF \n" + )); + assert!(!looks_like_svg(b"\x89PNG\r\n\x1a\n")); + assert!(!looks_like_svg(b"not found")); + } + + #[test] + fn records_a_badge_at_its_declared_size() { + let svg = record_svg(BADGE.as_bytes()).expect("badge records"); + assert_eq!(svg.size, Size2D::new(88., 20.)); + } + + #[test] + fn falls_back_to_the_view_box() { + let svg = record_svg( + br#""#, + ) + .expect("view box svg records"); + + assert_eq!(svg.size, Size2D::new(40., 10.)); + } + + fn vector_size(scale_factor: f64) -> Size2D { + let svg = record_svg(BADGE.as_bytes()).expect("badge records"); + let app = move || { + rect() + .width(Size::px(300.)) + .height(Size::px(400.)) + .child(scaled_content(ImageContent::Vector(svg.clone()))) + }; + + let (test, _) = TestingRunner::new(app, Size2D::new(500., 500.), |_| {}, scale_factor); + test.find(|node, element| { + (element as &dyn Any) + .downcast_ref::() + .map(|_| node.layout()) + }) + .expect("image node") + .area + .size + } + + #[test] + fn a_vector_follows_the_scale_factor_like_text() { + assert_eq!(vector_size(1.), Size2D::new(88., 20.)); + assert_eq!(vector_size(1.5), Size2D::new(132., 30.)); + } + + #[test] + fn a_vector_at_a_fractional_offset_is_not_resampled() { + // grey ends at 39.5, so after the 10.5 offset the colour edge lands on pixel 50 + let svg = record_svg( + br##""##, + ) + .expect("svg records"); + let app = move || { + rect() + .horizontal() + .child(rect().width(Size::px(10.5)).height(Size::px(1.))) + .child(scaled_content(ImageContent::Vector(svg.clone()))) + }; + + let mut test = launch_test(app); + let png = test.render(); + let snapshot = image::load_from_memory(png.as_bytes()) + .expect("snapshot decodes") + .to_rgb8(); + let rgb = |x: u32| { + let [r, g, b] = snapshot.get_pixel(x, 10).0; + (r, g, b) + }; + + assert_eq!(rgb(49), (0x55, 0x55, 0x55)); + assert_eq!(rgb(50), (0x00, 0x7e, 0xc6)); + } + + /// A grey shields-style badge with a 14px logo slot, rendered at the origin + fn logo_pixel(href: &str) -> (u8, u8, u8) { + let badge = format!( + r##""## + ); + let svg = record_svg(badge.as_bytes()).expect("badge records"); + let app = move || rect().child(scaled_content(ImageContent::Vector(svg.clone()))); + + let mut test = launch_test(app); + let png = test.render(); + let snapshot = image::load_from_memory(png.as_bytes()) + .expect("snapshot decodes") + .to_rgb8(); + let [r, g, b] = snapshot.get_pixel(12, 10).0; + (r, g, b) + } + + fn data_url(mime: &str, bytes: &[u8]) -> String { + use base64::Engine; + format!( + "data:{mime};base64,{}", + base64::engine::general_purpose::STANDARD.encode(bytes) + ) + } + + #[test] + fn draws_a_png_logo_given_by_href() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(4, 4, image::Rgb([255, 0, 0])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .expect("png encodes"); + + assert_eq!(logo_pixel(&data_url("image/png", &png)), (255, 0, 0)); + } + + #[test] + fn draws_an_svg_logo_given_as_a_data_url() { + let logo = br##""##; + + assert_eq!( + logo_pixel(&data_url("image/svg+xml", logo)), + (0x58, 0x65, 0xf2) + ); + } + + /// Replays a recorded svg at its own size on white and reads one pixel + fn picture_pixel(svg: &SvgPicture, x: u32, y: u32) -> (u8, u8, u8) { + use freya::engine::prelude::{EncodedImageFormat, raster_n32_premul}; + + let size = (svg.size.width.ceil() as i32, svg.size.height.ceil() as i32); + let mut surface = raster_n32_premul(size).expect("surface"); + surface.canvas().clear(Color::WHITE); + surface.canvas().draw_picture(&svg.picture, None, None); + let png = surface + .image_snapshot() + .encode(None, EncodedImageFormat::PNG, None) + .expect("png encodes"); + let [r, g, b] = image::load_from_memory(png.as_bytes()) + .expect("png decodes") + .to_rgb8() + .get_pixel(x, y) + .0; + (r, g, b) + } + + #[test] + fn an_oversized_svg_is_scaled_down_rather_than_cropped() { + let svg = record_svg( + br##""##, + ) + .expect("svg records"); + + assert_eq!(svg.size, Size2D::new(1600., 50.)); + assert_eq!(picture_pixel(&svg, 10, 40), (255, 0, 0)); + assert_eq!(picture_pixel(&svg, 1590, 40), (0, 0, 255)); + } + + #[test] + fn a_logo_reusing_the_badges_ids_leaves_the_badge_intact() { + let logo = br##""##; + let badge = format!( + r##""##, + data_url("image/svg+xml", logo) + ); + let svg = record_svg(badge.as_bytes()).expect("badge records"); + + assert_eq!(picture_pixel(&svg, 60, 10), (0x55, 0x55, 0x55)); + } } diff --git a/packages/oneclient_app/src/components/markdown/mod.rs b/packages/oneclient_app/src/components/markdown/mod.rs index b281dba7..ea80aeae 100644 --- a/packages/oneclient_app/src/components/markdown/mod.rs +++ b/packages/oneclient_app/src/components/markdown/mod.rs @@ -6,6 +6,7 @@ use pulldown_cmark::HeadingLevel; mod image; mod parse; mod render; +mod svg_compat; #[derive(Clone, PartialEq)] pub struct MarkdownStyle { @@ -37,7 +38,7 @@ impl Default for MarkdownStyle { paragraph_size: 16., code_font_size: 14., table_font_size: 14., - code_font_family: Cow::Borrowed("Jetbrains Mono"), + code_font_family: Cow::Borrowed(crate::theme::MONO_FONT), } } } @@ -115,4 +116,3 @@ impl Component for Markdown { self.key.clone().or(self.default_key()) } } - diff --git a/packages/oneclient_app/src/components/markdown/parse.rs b/packages/oneclient_app/src/components/markdown/parse.rs index 13a87f99..099a9ef6 100644 --- a/packages/oneclient_app/src/components/markdown/parse.rs +++ b/packages/oneclient_app/src/components/markdown/parse.rs @@ -274,7 +274,10 @@ impl Walker { } else { std::mem::take(&mut self.image_alt) }; - self.place(Inline::Image { url, alt }, |url, alt| Block::Image { url, alt }); + self.place(Inline::Image { url, alt }, |url, alt| Block::Image { + url, + alt, + }); } TagEnd::Link => { self.in_link = false; @@ -488,18 +491,27 @@ mod tests { #[test] fn inline_html_keeps_the_text_it_wrapped() { - assert_eq!(paragraph_text(&blocks("a bold word")[0]), "a bold word"); + assert_eq!( + paragraph_text(&blocks("a bold word")[0]), + "a bold word" + ); } #[test] fn block_html_keeps_its_text() { - assert_eq!(paragraph_text(&blocks("
\nhello\n
")[0]).trim(), "hello"); + assert_eq!( + paragraph_text(&blocks("
\nhello\n
")[0]).trim(), + "hello" + ); } #[test] fn images_arrive_as_paragraph_content() { assert_eq!(paragraph_text(&blocks("![alt](a.png)")[0]), "[img:alt]"); - assert_eq!(paragraph_text(&blocks("see ![alt](a.png) here")[0]), "see [img:alt] here"); + assert_eq!( + paragraph_text(&blocks("see ![alt](a.png) here")[0]), + "see [img:alt] here" + ); } #[test] @@ -530,7 +542,8 @@ mod tests { #[test] fn tables_split_head_from_body() { - let Block::Table { headers, rows } = &blocks("| a | b |\n| - | - |\n| 1 | 2 |\n| 3 | 4 |")[0] + let Block::Table { headers, rows } = + &blocks("| a | b |\n| - | - |\n| 1 | 2 |\n| 3 | 4 |")[0] else { panic!("expected a table"); }; diff --git a/packages/oneclient_app/src/components/markdown/render.rs b/packages/oneclient_app/src/components/markdown/render.rs index bc968ce1..1ba22ca6 100644 --- a/packages/oneclient_app/src/components/markdown/render.rs +++ b/packages/oneclient_app/src/components/markdown/render.rs @@ -3,32 +3,29 @@ use freya::prelude::*; use super::MarkdownStyle; use super::image::MarkdownImage; use super::parse::{Block, Inline, List, TextSpan}; +use crate::components::CodeBlock; use crate::hooks::LinkConfirmState; pub fn render_block(block: &Block, key: usize, style: &MarkdownStyle) -> Element { match block { - Block::Heading { level, spans } => { - render_spans(spans, style.heading_size(*level), style.color, style.color_code) - .font_weight(FontWeight::BOLD) - .key(key) - .into() - } + Block::Heading { level, spans } => render_spans( + spans, + style.heading_size(*level), + style.color, + style.color_code, + ) + .font_weight(FontWeight::BOLD) + .key(key) + .into(), Block::Paragraph { content } => render_content(content, style.paragraph_size, style) .key(key) .into(), - Block::Code { code } => rect() - .key(key) - .width(Size::fill()) + Block::Code { code } => CodeBlock::new(code.clone()) + .color(style.color_code) .background(style.background_code) - .corner_radius(CornerRadius::new_all(6.)) - .padding(Gaps::new_all(12.)) - .child( - label() - .text(code.clone()) - .font_family(style.code_font_family.clone()) - .font_size(style.code_font_size) - .color(style.color_code), - ) + .font_family(style.code_font_family.clone()) + .font_size(style.code_font_size) + .key(key) .into(), Block::List(list) => render_list(list, style).key(key).into(), Block::Image { url, alt } => rect() @@ -53,7 +50,9 @@ pub fn render_block(block: &Block, key: usize, style: &MarkdownStyle) -> Element .alignment(BorderAlignment::Inner), ) .background(style.background_blockquote) - .child(render_content(content, style.paragraph_size, style).font_slant(FontSlant::Italic)) + .child( + render_content(content, style.paragraph_size, style).font_slant(FontSlant::Italic), + ) .into(), Block::Rule => rect() .key(key) @@ -74,7 +73,9 @@ fn styled_span(span: &TextSpan, text_color: Color, code_color: Color) -> Span<'s styled = styled.font_slant(FontSlant::Italic); } if span.code { - styled.font_family("monospace").color(code_color) + styled + .font_family(crate::theme::MONO_FONT) + .color(code_color) } else { styled.color(text_color) } @@ -215,12 +216,7 @@ impl KeyExt for MarkdownLink { } impl MarkdownLink { - fn new( - url: String, - title: Option, - content: Vec, - style: MarkdownStyle, - ) -> Self { + fn new(url: String, title: Option, content: Vec, style: MarkdownStyle) -> Self { Self { url, title, @@ -242,18 +238,24 @@ impl Component for MarkdownLink { let mut text = paragraph().font_size(self.style.paragraph_size); for item in &self.content { text = match item { - Inline::Span(span) => { - text.span(styled_span(span, self.style.color_link, self.style.color_code)) - } + Inline::Span(span) => text.span(styled_span( + span, + self.style.color_link, + self.style.color_code, + )), Inline::Image { url, alt } => { text.child(MarkdownImage::new(url.clone(), alt.clone())) } - Inline::Link { content, .. } => content.iter().fold(text, |text, item| match item { - Inline::Span(span) => { - text.span(styled_span(span, self.style.color_link, self.style.color_code)) - } - _ => text, - }), + Inline::Link { content, .. } => { + content.iter().fold(text, |text, item| match item { + Inline::Span(span) => text.span(styled_span( + span, + self.style.color_link, + self.style.color_code, + )), + _ => text, + }) + } }; } diff --git a/packages/oneclient_app/src/components/markdown/svg_compat.rs b/packages/oneclient_app/src/components/markdown/svg_compat.rs new file mode 100644 index 00000000..4a0aabbb --- /dev/null +++ b/packages/oneclient_app/src/components/markdown/svg_compat.rs @@ -0,0 +1,434 @@ +//! Rewrites the parts of an svg Skia's svg module can't read. +//! +//! shields.io embeds badge logos as ``. Skia only reads +//! the SVG 1.1 `xlink:href`, and its `` only decodes bitmaps, so svg logos +//! (`logo=discord`, `logo=modrinth`, ...) are inlined as nested `` elements instead. + +use std::borrow::Cow; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; + +const XLINK_NS: &str = "http://www.w3.org/1999/xlink"; + +/// Logos are a few kilobytes, anything bigger is left for Skia to skip +const MAX_INLINED_SVG: usize = 256 * 1024; +const MAX_INLINED_IMAGES: usize = 16; + +/// The `` attributes that position the picture, carried onto the inlined `` +const PLACEMENT: [&str; 5] = ["x", "y", "width", "height", "preserveAspectRatio"]; + +pub(super) fn prepare_svg(bytes: &[u8]) -> Cow<'_, [u8]> { + let Ok(text) = std::str::from_utf8(bytes) else { + return Cow::Borrowed(bytes); + }; + + match rewrite_images(text) { + Some(rewritten) => Cow::Owned(rewritten.into_bytes()), + None => Cow::Borrowed(bytes), + } +} + +fn rewrite_images(text: &str) -> Option { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + let mut changed = false; + let mut uses_xlink = false; + let mut inlined = 0; + + while let Some(at) = find_tag(rest, "image") { + out.push_str(&rest[..at]); + let from_tag = &rest[at..]; + + let Some(tag) = Tag::parse(from_tag, "image") else { + out.push_str(from_tag); + rest = ""; + break; + }; + rest = &from_tag[tag.len..]; + + if inlined < MAX_INLINED_IMAGES + && let Some(nested) = inline_svg_image(&tag, &mut rest, inlined) + { + out.push_str(&nested); + inlined += 1; + changed = true; + continue; + } + + if tag.attr("href").is_some() && tag.attr("xlink:href").is_none() { + out.push_str(&tag.renamed("href", "xlink:href").to_source()); + uses_xlink = true; + changed = true; + } else { + out.push_str(&from_tag[..tag.len]); + } + } + out.push_str(rest); + + if !changed { + return None; + } + + if uses_xlink { + declare_xlink(&mut out); + } + + Some(out) +} + +/// Replaces an `` holding an svg data url with that svg, placed where the image was +fn inline_svg_image(tag: &Tag<'_>, rest: &mut &str, index: usize) -> Option { + let href = tag.attr("href").or_else(|| tag.attr("xlink:href"))?; + // the logo's ids now share the badge's document, where shields.io already uses `r`, `s` and `blur` + let document = prefix_ids(&decode_svg_data_url(href)?, &format!("logo{index}-")); + + let start = find_tag(&document, "svg")?; + let inner = Tag::parse(&document[start..], "svg")?; + let body = &document[start + inner.len..]; + + // `` has a closing tag the nested svg must swallow + if !tag.self_closing { + let trimmed = rest.trim_start(); + *rest = trimmed.strip_prefix("")?; + } + + let mut nested = inner.without(&PLACEMENT); + for name in PLACEMENT { + if let Some(value) = tag.attr(name) { + nested.attrs.push((name, value, '"')); + } + } + + let mut out = nested.to_source(); + out.push_str(body); + + Some(match tag.attr("transform") { + Some(transform) => format!("{out}"), + None => out, + }) +} + +fn decode_svg_data_url(href: &str) -> Option { + let payload = href.trim().strip_prefix("data:image/svg+xml")?; + let (params, data) = payload.split_once(',')?; + if !params.split(';').any(|param| param == "base64") { + return None; + } + + let data: String = data.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + // base64 grows by a third, so the encoded length bounds the decoded one + if data.len() / 4 * 3 > MAX_INLINED_SVG { + return None; + } + + let decoded = String::from_utf8(STANDARD.decode(data).ok()?).ok()?; + // one level of nesting, an inlined logo can't pull in further svgs + (!decoded.contains("data:image/svg+xml")).then_some(decoded) +} + +/// Prefixes every `id` attribute and the `url(#id)` / `href="#id"` references to it +fn prefix_ids(svg: &str, prefix: &str) -> String { + let mut ids = Vec::new(); + let mut out = String::with_capacity(svg.len()); + let mut rest = svg; + + while let Some(at) = rest.find("id=") { + let attr_start = at > 0 && rest[..at].ends_with(|c: char| c.is_ascii_whitespace()); + let (before, after) = rest.split_at(at + "id=".len()); + out.push_str(before); + rest = after; + + let Some(quote) = rest.chars().next().filter(|c| *c == '"' || *c == '\'') else { + continue; + }; + let Some(len) = rest[1..].find(quote) else { + continue; + }; + if !attr_start || len == 0 { + continue; + } + + let id = &rest[1..1 + len]; + out.push(quote); + out.push_str(prefix); + out.push_str(id); + out.push(quote); + ids.push(id); + rest = &rest[len + 2..]; + } + out.push_str(rest); + + for id in ids { + for (from, to) in [ + ("url(#", ")"), + ("url('#", "')"), + ("url(\"#", "\")"), + ("href=\"#", "\""), + ("href='#", "'"), + ] { + out = out.replace( + &format!("{from}{id}{to}"), + &format!("{from}{prefix}{id}{to}"), + ); + } + } + + out +} + +fn declare_xlink(svg: &mut String) { + let Some(root) = find_tag(svg, "svg") else { + return; + }; + let Some(tag) = Tag::parse(&svg[root..], "svg") else { + return; + }; + if tag.attr("xmlns:xlink").is_some() { + return; + } + + svg.insert_str(root + " Option { + let open = format!("<{name}"); + let mut from = 0; + + while let Some(found) = text[from..].find(&open) { + let at = from + found; + let next = text[at + open.len()..].chars().next(); + if matches!(next, Some(c) if c.is_ascii_whitespace() || c == '/' || c == '>') { + return Some(at); + } + from = at + open.len(); + } + + None +} + +/// A start tag, values kept as written so re-emitting them needs no escaping +#[derive(Debug, PartialEq)] +struct Tag<'a> { + name: &'a str, + attrs: Vec<(&'a str, &'a str, char)>, + self_closing: bool, + /// Bytes of the source the tag spans, `>` included + len: usize, +} + +impl<'a> Tag<'a> { + fn parse(source: &'a str, name: &'a str) -> Option { + let mut at = source.strip_prefix('<')?.strip_prefix(name)?.len(); + at = source.len() - at; + let mut attrs = Vec::new(); + + loop { + at += whitespace(&source[at..]); + let rest = &source[at..]; + + if let Some(after) = rest.strip_prefix("/>") { + let len = source.len() - after.len(); + return Some(Self { + name, + attrs, + self_closing: true, + len, + }); + } + if rest.starts_with('>') { + return Some(Self { + name, + attrs, + self_closing: false, + len: at + 1, + }); + } + + let name_len = + rest.find(|c: char| c == '=' || c.is_ascii_whitespace() || c == '>' || c == '/')?; + if name_len == 0 { + return None; + } + let attr = &rest[..name_len]; + at += name_len; + at += whitespace(&source[at..]); + source[at..].strip_prefix('=')?; + at += 1; + at += whitespace(&source[at..]); + + let quote = source[at..] + .chars() + .next() + .filter(|c| *c == '"' || *c == '\'')?; + at += 1; + let value_len = source[at..].find(quote)?; + attrs.push((attr, &source[at..at + value_len], quote)); + at += value_len + 1; + } + } + + fn attr(&self, name: &str) -> Option<&'a str> { + self.attrs + .iter() + .find(|(attr, _, _)| *attr == name) + .map(|(_, value, _)| *value) + } + + fn renamed(mut self, from: &str, to: &'a str) -> Self { + for attr in &mut self.attrs { + if attr.0 == from { + attr.0 = to; + } + } + self + } + + fn without(&self, names: &[&str]) -> Self { + Self { + name: self.name, + attrs: self + .attrs + .iter() + .filter(|(attr, _, _)| !names.contains(attr)) + .copied() + .collect(), + self_closing: self.self_closing, + len: self.len, + } + } + + fn to_source(&self) -> String { + let mut out = format!("<{}", self.name); + for (attr, value, quote) in &self.attrs { + out.push_str(&format!(" {attr}={quote}{value}{quote}")); + } + out.push_str(if self.self_closing { "/>" } else { ">" }); + out + } +} + +fn whitespace(text: &str) -> usize { + text.len() + - text + .trim_start_matches(|c: char| c.is_ascii_whitespace()) + .len() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn prepared(svg: &str) -> String { + String::from_utf8(prepare_svg(svg.as_bytes()).into_owned()).expect("utf8") + } + + fn svg_data_url(svg: &str) -> String { + format!("data:image/svg+xml;base64,{}", STANDARD.encode(svg)) + } + + #[test] + fn leaves_an_svg_without_images_untouched() { + let svg = r#""#; + assert!(matches!(prepare_svg(svg.as_bytes()), Cow::Borrowed(_))); + } + + #[test] + fn renames_href_and_declares_xlink_once() { + let svg = r#""#; + assert_eq!( + prepared(svg), + r#""# + ); + } + + #[test] + fn keeps_an_existing_xlink_declaration() { + let svg = r#""#; + assert_eq!( + prepared(svg), + r#""# + ); + } + + #[test] + fn inlines_an_svg_logo_where_the_image_was() { + let logo = r##""##; + let svg = format!( + r#"Discord"#, + svg_data_url(logo) + ); + + assert_eq!( + prepared(&svg), + r##"Discord"## + ); + } + + #[test] + fn swallows_the_closing_tag_of_an_open_image() { + let logo = r#""#; + let svg = format!( + r#" "#, + svg_data_url(logo) + ); + + assert_eq!( + prepared(&svg), + r#""# + ); + } + + #[test] + fn malformed_base64_falls_back_to_the_xlink_rename() { + let svg = r#""#; + assert_eq!( + prepared(svg), + r#""# + ); + } + + #[test] + fn does_not_inline_a_logo_that_nests_another_svg() { + let inner = svg_data_url(r#""#); + let logo = format!(r#""#); + let svg = format!(r#""#, svg_data_url(&logo)); + + assert!(prepared(&svg).contains(""##; + let svg = format!(r#""#, svg_data_url(logo)); + + assert_eq!( + prepared(&svg), + r##""## + ); + } + + #[test] + fn each_inlined_logo_gets_its_own_prefix() { + let logo = svg_data_url(r#""#); + let svg = format!(r#""#); + + assert_eq!( + prepared(&svg), + r#""# + ); + } + + #[test] + fn ignores_tags_that_only_share_the_prefix() { + let svg = r#""#; + assert!(matches!(prepare_svg(svg.as_bytes()), Cow::Borrowed(_))); + } + + #[test] + fn unterminated_tag_is_left_alone() { + let svg = r#", error: Option) -> impl Into } fn guidance_block(guidance: AuthErrorGuidance) -> impl IntoElement { - let mut steps = rect() - .vertical() - .width(Size::fill()) - .spacing(6.) - .child( - label() - .text("What you can do:") - .font_size(12.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ); + let mut steps = rect().vertical().width(Size::fill()).spacing(6.).child( + label() + .text("What you can do:") + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ); for (index, step) in guidance.steps_to_fix.into_iter().enumerate() { steps = steps.child( diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 5dd33b9e..9050938e 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -1,41 +1,52 @@ mod account_switcher; -mod control_center; mod active_cluster_panel; +mod asset_image; mod avatar; +mod blocked_downloads_popup; +mod bundle_choices_popup; mod button; mod charts; mod checkbox; -mod cluster_landscape_art; mod cluster_update_popup; +mod code_block; mod context_menu; +mod control_center; mod date_range_picker; -mod optional_mods_popup; mod dropdown; mod dynamic_art; +mod essential_confirm; mod file_drop; -mod icons; -mod java_install_manager; +mod filter_menu; +mod flagged_install_popup; mod generic_prompt; +mod icons; +mod instance_row; +mod java_install_manager; mod java_prompt; -mod microsoft_java_prompt; mod link_confirm; mod local_image; mod log_viewer; mod markdown; mod memory_field; +mod microsoft_java_prompt; mod microsoft_login; +mod modpack_confirm_popup; mod navbar; mod notifications; +mod optional_mods_popup; mod overlay_popup; +mod package_delete_dialog; mod package_row; mod package_update_popup; mod pagination; mod player_model; mod progress_track; mod recents_row; +mod release_migration_popup; mod screenshot_viewer; mod scrollview; mod segmented_control; +mod spinner; mod splash_curtain; mod status_bar; mod tab_bar; @@ -44,57 +55,76 @@ mod toasts; mod toggle; mod tooltip; mod update_prompt; -mod version_card; pub mod upload_mclogs; +mod version_art_gallery; +mod version_card; pub use account_switcher::AccountSwitcher; -pub use control_center::ControlCenter; pub use active_cluster_panel::ActiveClusterPanel; +pub use asset_image::AssetImage; pub use avatar::Avatar; -pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button}; +pub use blocked_downloads_popup::BlockedDownloadsPopup; +pub use bundle_choices_popup::BundleChoicesPopup; +pub use button::{Button, ButtonSize, ButtonVariant, copy_button, link_button, open_folder_button}; pub use charts::{BarChart, PieChart, ValueUnit, slice_color}; pub use checkbox::checkbox_labeled; -pub use cluster_landscape_art::ClusterLandscapeArt; pub use cluster_update_popup::ClusterUpdatePopup; +pub use code_block::CodeBlock; pub use context_menu::ContextMenu; +pub use control_center::ControlCenter; pub use date_range_picker::{DateRange, DateRangePicker}; -pub use optional_mods_popup::OptionalModsPopup; pub use dropdown::Dropdown; pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt}; -pub use file_drop::FileDropOverlay; -pub use icons::{Icon, IconTint, IconType}; -pub use java_install_manager::JavaInstallManager; +pub use essential_confirm::EssentialConfirmOverlay; +pub use file_drop::{FileDropOverlay, accept_drop}; +pub use filter_menu::{FilterMenu, FilterOption}; +pub use flagged_install_popup::FlaggedInstallPopup; pub use generic_prompt::GenericPromptOverlay; +pub use icons::{Icon, IconTint, IconType}; +pub use instance_row::InstanceRow; +pub use java_install_manager::JavaInstallManager; pub use java_prompt::JavaPromptOverlay; -pub use microsoft_java_prompt::MicrosoftJavaPromptOverlay; pub use link_confirm::ConfirmLinkOverlay; pub use local_image::LocalImage; -pub use markdown::{Markdown, MarkdownStyle}; pub use log_viewer::LogViewer; +pub use markdown::{Markdown, MarkdownStyle}; pub use memory_field::memory_field; -pub use microsoft_login::use_microsoft_login; +pub use microsoft_java_prompt::MicrosoftJavaPromptOverlay; pub(crate) use microsoft_login::login_dialog; +pub use microsoft_login::{MicrosoftLogin, use_microsoft_login}; +pub use modpack_confirm_popup::ModpackConfirmPopup; pub(crate) use navbar::window_controls; pub use navbar::{AppNavbar, OnboardingNavbar}; -pub use notifications::NotificationCenter; +pub use notifications::{ChevronToggle, NotificationCenter}; +pub use optional_mods_popup::OptionalModsPopup; pub use overlay_popup::{OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, OverlayPopup, overlay_is_topmost}; -pub use package_row::{CardLayout, PackageEntry, PackageRow, provider_badge}; -pub(crate) use package_row::{CARD_GRID_H, GRID_GAP, GRID_MIN_W, grid_card, package_icon}; +pub use package_delete_dialog::use_shared_delete; +pub(crate) use package_row::{ + CARD_BG, CARD_GRID_H, CARD_H, CARD_NAME, GRID_GAP, GRID_MIN_W, badge, disable_warning_body, + grid_card, icon_box, kebab_button, meta_size, meta_text, on_secondary, package_icon, pill, + remote_icon, set_enabled_action, toggle_action, +}; +pub use package_row::{ + CardLayout, PackageEntry, PackageRow, github_badge, package_context_menu, provider_badge, +}; pub use package_update_popup::PackageUpdatePopup; pub use pagination::Pagination; pub use player_model::PlayerModel; pub use progress_track::progress_track; pub use recents_row::RecentsRow; +pub use release_migration_popup::ReleaseMigrationPopup; pub use screenshot_viewer::{ScreenshotViewer, screenshot_context_menu}; pub(crate) use scrollview::corrected_scroll; -pub use scrollview::{ScrollArea, ScrollAreaCtx}; +pub use scrollview::{LazySection, ScrollArea, ScrollAreaCtx}; pub use segmented_control::{Segment, SegmentedControl}; +pub use spinner::centered_spinner; pub use splash_curtain::SplashCurtain; pub use status_bar::StatusBar; pub use tab_bar::{TabBar, TabItem}; pub use text_input::{TextInput, validate_memory, validate_number}; pub use toasts::Toasts; pub use toggle::{toggle, toggle_controlled}; -pub use tooltip::{TooltipHost, TooltipPlacement, use_provide_tooltips}; +pub use tooltip::{TooltipHost, TooltipPlacement, use_provide_tooltips, use_tooltip_anchor}; pub use update_prompt::UpdatePromptOverlay; +pub use version_art_gallery::{GALLERY_COVER_EDGE, VersionArtGallery}; pub use version_card::VersionCard; diff --git a/packages/oneclient_app/src/components/modpack_confirm_popup.rs b/packages/oneclient_app/src/components/modpack_confirm_popup.rs new file mode 100644 index 00000000..35371f91 --- /dev/null +++ b/packages/oneclient_app/src/components/modpack_confirm_popup.rs @@ -0,0 +1,259 @@ +use freya::prelude::*; +use oneclient_content::modpacks::ModpackSummary; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::notifications::ModpackConfirm; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::format_size; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 500.; +const DIALOG_PAD: f32 = 22.; +const FACT_LABEL_W: f32 = 110.; + +#[derive(PartialEq)] +pub struct ModpackConfirmPopup; + +impl Component for ModpackConfirmPopup { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + + let Some(confirm) = snapshot.modpack_confirm else { + return rect().into_element(); + }; + + let close = dispatch.clone(); + OverlayPopup::new() + .on_close(move |_| close.cancel_modpack()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog(&confirm, dispatch)), + ) + .into_element() + } +} + +fn count_label(count: usize, one: &str, many: &str) -> String { + format!("{count} {}", if count == 1 { one } else { many }) +} + +fn content_line(summary: &ModpackSummary) -> String { + let parts: Vec = [ + (summary.mods, "mod", "mods"), + (summary.resource_packs, "resource pack", "resource packs"), + (summary.shaders, "shader", "shaders"), + (summary.other_files, "other file", "other files"), + ] + .into_iter() + .filter(|(count, _, _)| *count > 0) + .map(|(count, one, many)| count_label(count, one, many)) + .collect(); + + if parts.is_empty() { + "Configs and settings only".to_string() + } else { + parts.join(", ") + } +} + +fn notes(summary: &ModpackSummary) -> Vec<(String, Color)> { + let mut notes = Vec::new(); + if summary.optional > 0 { + notes.push(( + format!( + "{} optional. They are installed turned off, so you can switch them on in the Mods tab.", + count_label(summary.optional, "file is", "files are") + ), + colors::fg_secondary(), + )); + } + if summary.manual > 0 { + notes.push(( + format!( + "{} must be downloaded by hand from CurseForge. The launcher will show you which ones.", + count_label(summary.manual, "file", "files") + ), + colors::code_warn(), + )); + } + if summary.unresolved > 0 { + notes.push(( + format!( + "{} could not be found on Modrinth or CurseForge and will be skipped.", + count_label(summary.unresolved, "file", "files") + ), + colors::danger(), + )); + } + notes +} + +fn fact(name: &'static str, value: String) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .spacing(10.) + .child( + label() + .text(name) + .width(Size::px(FACT_LABEL_W)) + .font_size(12.5) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(value) + .width(Size::flex(1.)) + .font_size(12.5) + .max_lines(2) + .color(colors::fg_primary()), + ) + .into_element() +} + +fn dialog(confirm: &ModpackConfirm, dispatch: crate::Actions) -> impl IntoElement { + let summary = &confirm.summary; + let version = if confirm.version.trim().is_empty() { + "Not stated by the pack".to_string() + } else { + confirm.version.clone() + }; + + let mut facts = vec![ + fact("Instance", confirm.instance_name.clone()), + fact("Version", version), + fact( + "Minecraft", + format!("{} · {}", confirm.mc_version, confirm.loader), + ), + fact("Content", content_line(summary)), + ]; + if summary.download_bytes > 0 { + facts.push(fact( + "Download", + format!( + "Up to {}, less for files the launcher already has", + format_size(summary.download_bytes) + ), + )); + } + + let cancel = dispatch.clone(); + let add = dispatch; + + rect() + .vertical() + .width(Size::px(DIALOG_W)) + .height(Size::Inner) + .max_width(Size::window_percent(95.)) + .max_height(Size::window_percent(85.)) + .overflow(Overflow::Clip) + .corner_radius(CornerRadius::new_all(16.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .shadow(Shadow::from(( + 0., + 18., + 52., + 0., + Color::from_argb(150, 0, 0, 0), + ))) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::Inner) + .padding(Gaps::new_all(DIALOG_PAD)) + .spacing(16.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(3.) + .child( + label() + .text(format!("Add {}?", confirm.pack_name)) + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(2) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "From {}. It is added as a new instance with its own folder.", + confirm.source + )) + .font_size(12.5) + .max_lines(2) + .color(colors::fg_secondary()), + ), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .children(facts), + ) + .children(notes(summary).into_iter().map(|(text, color)| { + label() + .text(text) + .font_size(12.) + .max_lines(3) + .color(color) + .into_element() + })) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| cancel.cancel_modpack()) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| add.confirm_modpack()) + .child(Icon::new(IconType::Plus).size(15.)) + .text("Add"), + ), + ), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_categories_are_left_out_of_the_content_line() { + let summary = ModpackSummary { + mods: 336, + shaders: 1, + ..ModpackSummary::default() + }; + assert_eq!(content_line(&summary), "336 mods, 1 shader"); + assert_eq!( + content_line(&ModpackSummary::default()), + "Configs and settings only" + ); + } +} diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 16eea40a..c938a1fe 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -9,17 +9,18 @@ use crate::{ components::{Avatar, Icon, IconType}, hooks::{ settled_or_loading, try_default_account, use_active_cluster_id, use_browser_type, - use_clusters, use_current_account, use_dispatch, use_notifications_snapshot, + use_clusters, use_current_account, use_dispatch, use_link_confirm, + use_notifications_snapshot, }, theme, - utils::sort_clusters_for_home, + utils::default_cluster, }; const NAVBAR_INTRO_MS: u64 = 460; const LOGO_HIDE_NAVBAR_W: f32 = 1100.; const NAVBAR_SIDE_PADDING_PX: f32 = 40.; const NAV_LINK_SPACING_PX: f32 = 36.; -const COMPACT_LOGO_PX: f32 = 28.; +const COMPACT_LOGO_PX: f32 = 32.; #[derive(PartialEq)] pub struct Navbar; @@ -84,16 +85,16 @@ fn navbar_left(show_logo: bool) -> impl IntoElement { Size::auto() }) .cross_align(Alignment::Center) + .spacing(NAV_LINK_SPACING_PX / 2.) .maybe(!show_logo, |el| { el.padding(Gaps::new(0., NAV_LINK_SPACING_PX, 0., 0.)) }) - .child(if show_logo { - NavbarLogo.into_element() - } else { + .child( Icon::new(IconType::IconLogo) .size(COMPACT_LOGO_PX) - .into_element() - }) + .into_element(), + ) + .maybe(show_logo, |rect| rect.child(NavbarLogo.into_element())) } #[derive(PartialEq)] @@ -124,11 +125,7 @@ fn navbar_center(is_small: bool) -> impl IntoElement { Alignment::Center }) .cross_align(Alignment::Center) - .spacing(if is_small { - NAV_LINK_SPACING_PX - } else { - NAV_LINK_SPACING_PX / 2. - }) + .spacing(if is_small { 12. } else { 4. }) .child(NavLink { active: route == Route::Home {}, target: NavTarget::Route(Route::Home {}), @@ -139,11 +136,6 @@ fn navbar_center(is_small: bool) -> impl IntoElement { target: NavTarget::Route(Route::Clusters {}), nav_label: "Versions", }) - .child(NavLink { - active: route == Route::AccountSkins {}, - target: NavTarget::Route(Route::AccountSkins {}), - nav_label: "Skins", - }) .child(NavLink { active: matches!( route, @@ -155,6 +147,16 @@ fn navbar_center(is_small: bool) -> impl IntoElement { target: NavTarget::Route(browse_target), nav_label: "Browse", }) + .child(NavLink { + active: matches!(route, Route::AccountSkins {}), + target: NavTarget::Route(Route::AccountSkins {}), + nav_label: "Skins", + }) + .child(NavLink { + active: false, + target: NavTarget::External("https://store.polyfrost.org"), + nav_label: "Cosmetics", + }) } /// Falls back active cluster then most recently played then Versions when none exist @@ -163,14 +165,10 @@ fn browse_target() -> Route { let active = *use_active_cluster_id().read(); let package_type = use_browser_type().read().clone(); - let cluster_id = active - .filter(|id| clusters.iter().any(|cluster| cluster.id == *id)) - .or_else(|| sort_clusters_for_home(clusters).first().map(|c| c.id)); - - match cluster_id { - Some(cluster_id) => Route::Browser { - cluster_id, - package_type, + match default_cluster(clusters, active) { + Some(cluster) => Route::Browser { + cluster_id: cluster.id, + package_type: crate::view::app::browser::browsable_type(&package_type, &cluster), pick_cluster: true, }, None => Route::Clusters {}, @@ -178,7 +176,6 @@ fn browse_target() -> Route { } #[derive(PartialEq, Clone)] -#[allow(dead_code)] enum NavTarget { Route(Route), External(&'static str), @@ -196,6 +193,7 @@ impl Component for NavLink { let mut hovering = use_state(|| false); let a11y_id = use_a11y(); let focused = use_focus(a11y_id); + let mut confirm_link = use_link_confirm(); let active = self.active; let target = self.target.clone(); @@ -207,22 +205,30 @@ impl Component for NavLink { theme::colors::fg_secondary() }; - let underline_width = if active { - 27. + let background = if active { + theme::colors::ghost_overlay() } else if hovering() || focused().is_focused() { - 18. + theme::colors::ghost_overlay_hover() } else { - 0. + Color::TRANSPARENT }; rect() - .vertical() + .horizontal() + .main_align(Alignment::Center) .cross_align(Alignment::Center) - .spacing(2.) - .width(Size::px(nav_label.len() as f32 * 10. + 10.)) - // TODO workaround for a Freya measurement bug a fully transparent background - // measures wrongly so give it alpha 0 red to keep pointer events working - .background(Color::RED.with_a(0)) + .height(Size::px(36.)) + .width(Size::px(nav_label.len() as f32 * 10. + 34.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(background) + .maybe(active, |el| { + el.border( + Border::new() + .fill(theme::colors::component_border()) + .width(1.) + .alignment(BorderAlignment::Inner), + ) + }) .a11y_id(a11y_id) .a11y_focusable(true) .a11y_role(AccessibilityRole::Button) @@ -232,7 +238,7 @@ impl Component for NavLink { NavTarget::Route(route) => { let _ = RouterContext::get().push(route.clone()); } - NavTarget::External(url) => crate::platform::open_url(url), + NavTarget::External(url) => confirm_link.set(Some((*url).to_string())), } }) .on_pointer_over(move |_| hovering.set(true)) @@ -249,17 +255,6 @@ impl Component for NavLink { }) .color(color), ) - .child( - rect() - .height(Size::px(2.)) - .width(Size::px(underline_width)) - .corner_radius(CornerRadius::new_all(2.)) - .background(if active { - theme::colors::fg_primary() - } else { - theme::colors::fg_secondary() - }), - ) } } @@ -309,7 +304,7 @@ impl Component for NavbarRight { ) .child( super::navbar_button() - .overflow(Overflow::None) + .overflow(Overflow::None) .tooltip("Control Center") .padding(0.0) .on_press(open_control_center) @@ -324,11 +319,7 @@ fn avatar_with_gear(uuid: String) -> impl IntoElement { .width(Size::px(28.)) .height(Size::px(28.)) .center() - .child( - Avatar::new(uuid) - .width(Size::px(28.)) - .height(Size::px(28.)), - ) + .child(Avatar::new(uuid).width(Size::px(28.)).height(Size::px(28.))) .child( rect() .position(Position::new_absolute().bottom(-7.).right(-7.)) diff --git a/packages/oneclient_app/src/components/navbar/mod.rs b/packages/oneclient_app/src/components/navbar/mod.rs index 5fba5620..9dde7d8a 100644 --- a/packages/oneclient_app/src/components/navbar/mod.rs +++ b/packages/oneclient_app/src/components/navbar/mod.rs @@ -29,12 +29,7 @@ pub fn window_controls() -> impl IntoElement { }); }; - let close = |_| { - let platform = Platform::get(); - Platform::get().with_window(Platform::window_id(), move |window| { - platform.close_window(window.id()); - }); - }; + let close = |_| crate::platform::close(); rect() .horizontal() diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs index b8d40b1d..52fe6d18 100644 --- a/packages/oneclient_app/src/components/notifications.rs +++ b/packages/oneclient_app/src/components/notifications.rs @@ -5,12 +5,12 @@ use freya::{ use oneclient_events::Level; use crate::{ - ui::{divider, relative_time}, components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea, progress_track}, hooks::{use_dispatch, use_notifications_snapshot}, notifications::{InboxEntry, NotificationActionKind}, theme::colors, transfer::TransferStats, + ui::{divider, relative_time}, utils::{format_durations, format_size}, }; @@ -48,9 +48,13 @@ impl Component for NotificationPanel { let progress = intro.read().value(); - let entries = inbox.len(); let mut rows: Vec = Vec::new(); - if entries == 0 { + // Toast-only notices are ephemeral: never surface them in the center + let visible: Vec = inbox + .into_iter() + .filter(|entry| !entry.toast_only) + .collect(); + if visible.is_empty() { rows.push( label() .text("No notifications") @@ -59,8 +63,8 @@ impl Component for NotificationPanel { .into_element(), ); } else { - let last = entries - 1; - for (i, entry) in inbox.into_iter().enumerate() { + let last = visible.len() - 1; + for (i, entry) in visible.into_iter().enumerate() { let id = entry.id; rows.push(NotifEntryRow::new(entry, i != last).key(id).into_element()); } @@ -70,6 +74,7 @@ impl Component for NotificationPanel { .width(Size::fill()) .height(Size::flex(1.0)) .spacing(8.) + .scrollbar_gutter(true) .children(rows); rect() @@ -82,7 +87,7 @@ impl Component for NotificationPanel { .opacity(progress) .margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.)) .background(colors::page_elevated().with_a(220)) - .blur(12.) + .backdrop_blur(12.) .corner_radius(CornerRadius::new_all(12.)) .border( Border::new() @@ -212,7 +217,9 @@ fn tasks_section(entry: &InboxEntry, mut expanded: State) -> impl IntoElem let now = !*expanded.peek(); expanded.set(now); }) - .child(ChevronToggle { expanded: is_expanded }) + .child(ChevronToggle { + expanded: is_expanded, + }) .child( label() .text(if is_expanded { @@ -279,8 +286,8 @@ fn task_row(task: &crate::notifications::TaskView) -> impl IntoElement { } #[derive(PartialEq)] -struct ChevronToggle { - expanded: bool, +pub struct ChevronToggle { + pub expanded: bool, } impl Component for ChevronToggle { @@ -483,7 +490,10 @@ struct Footer; impl Component for Footer { fn render(&self) -> impl IntoElement { let dispatch = use_dispatch(); - let is_empty = use_notifications_snapshot().inbox.is_empty(); + let is_empty = !use_notifications_snapshot() + .inbox + .iter() + .any(|e| e.dismissable()); rect() .horizontal() @@ -525,4 +535,3 @@ fn level_color(level: &Level) -> Color { Level::Error => colors::danger(), } } - diff --git a/packages/oneclient_app/src/components/optional_mods_popup.rs b/packages/oneclient_app/src/components/optional_mods_popup.rs index 53ad7e73..97c8055a 100644 --- a/packages/oneclient_app/src/components/optional_mods_popup.rs +++ b/packages/oneclient_app/src/components/optional_mods_popup.rs @@ -12,7 +12,9 @@ use crate::hooks::{ package_meta_batch, use_cached_image, use_dispatch, use_notifications_snapshot, use_package_meta_batch, }; -use crate::notifications::{ClusterUpdateItem, OptionalModRef, OptionalModsGroup}; +use crate::notifications::{ + ClusterUpdateItem, OptionalModRef, OptionalModsGroup, OptionalModsOutcome, +}; use crate::theme::colors; use crate::ui::border_all_color; @@ -20,7 +22,9 @@ const CARD_BG: Color = Color::from_rgb(26, 34, 41); const DIALOG_W: f32 = 720.; const DIALOG_PAD: f32 = 22.; const LIST_MAX_H: f32 = 306.; -const ICON_SIZE: f32 = 52.; +const ICON_SIZE: f32 = 40.; +const SKIP_NOTE: &str = + "Anything you don't pick won't be offered again, but you can add it later from the Mods tab."; type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; @@ -33,12 +37,16 @@ impl Component for OptionalModsPopup { fn render(&self) -> impl IntoElement { let snapshot = use_notifications_snapshot(); let dispatch = use_dispatch(); - let picked = use_state(Picked::new); + let mut picked = use_state(Picked::new); let groups = snapshot.optional_mods.clone(); let mut meta = MetaMap::new(); - for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { + for provider in ProviderId::REMOTE_PROVIDERS + .iter() + .copied() + .chain([ProviderId::Local]) + { let ids: Vec = groups .iter() .flatten() @@ -57,11 +65,12 @@ impl Component for OptionalModsPopup { }; let close = dispatch.clone(); - let dismissed: Vec<(i64, OptionalModRef)> = - groups.iter().flat_map(|group| group.offers()).collect(); OverlayPopup::new() - .on_close(move |_| close.decline_optional_mods(dismissed.clone())) + .on_close(move |_| { + picked.set(Picked::new()); + close.close_optional_mods(OptionalModsOutcome::Cancel); + }) .child( rect() .width(Size::window_percent(100.)) @@ -92,9 +101,12 @@ fn entry_from_item( .and_then(|project_id| meta.get(&(item.provider, project_id.clone()))); PackageEntry { + essential: crate::essential::lookup(item.provider, &package_id), package_id, bundle_name: item.offer.as_ref().map(|(bundle, _)| bundle.clone()), provider: item.provider, + github_hosted: item.github_hosted, + github_url: None, name: cached .map(|cached| cached.name.clone()) .filter(|name| !name.is_empty()) @@ -103,6 +115,7 @@ fn entry_from_item( author: cached .map(|cached| cached.author.clone()) .unwrap_or_default(), + version: None, description: cached .map(|cached| cached.summary.clone()) .unwrap_or_default(), @@ -114,6 +127,10 @@ fn entry_from_item( hash: None, manifest_default: false, hidden: false, + opted_in: true, + shadowed: false, + outranked: false, + advanced: false, update_available: false, // Offered, not installed, so there is no recency badge to show seen_status: SeenStatus::default(), @@ -149,7 +166,7 @@ fn dialog( fn content( groups: &[OptionalModsGroup], meta: &MetaMap, - picked: State, + mut picked: State, dispatch: crate::Actions, ) -> impl IntoElement { let total: usize = groups.iter().map(|group| group.mods.len()).sum(); @@ -167,24 +184,23 @@ fn content( let plural = if total == 1 { "" } else { "s" }; let subtitle = match groups { [only] => format!( - "{} includes {total} optional mod{plural} that {} off by default. Pick the {} to turn on.", + "{} includes {total} optional mod{plural} that {} off by default. Pick the {} to turn on. {SKIP_NOTE}", only.cluster_name, if total == 1 { "is" } else { "are" }, if total == 1 { "one" } else { "ones" } ), _ => format!( - "Your bundles include {total} optional mod{plural} across {} clusters that are off by default. Pick the ones to turn on.", + "Your bundles include {total} optional mod{plural} across {} clusters that are off by default. Pick the ones to turn on. {SKIP_NOTE}", groups.len() ), }; let cancel = dispatch.clone(); - let declined = offers.clone(); - let enable_text = if enable.is_empty() { - "Enable".to_string() + let primary_text = if enable.is_empty() { + "Launch".to_string() } else { - format!("Enable {}", enable.len()) + format!("Install {} & launch", enable.len()) }; rect() @@ -209,7 +225,7 @@ fn content( label() .text(subtitle) .font_size(12.5) - .max_lines(3) + .max_lines(4) .color(colors::fg_secondary()), ), ) @@ -224,22 +240,25 @@ fn content( .child( Button::new() .ghost() - .on_press(move |_| cancel.decline_optional_mods(declined.clone())) + .on_press(move |_| { + picked.set(Picked::new()); + cancel.close_optional_mods(OptionalModsOutcome::Cancel); + }) .text("Cancel"), ) - .maybe_child((!offers.is_empty()).then(|| { + .child({ let apply = dispatch.clone(); + let install = !enable.is_empty(); Button::new() .primary() - .disabled(enable.is_empty()) .on_press(move |_| { + picked.set(Picked::new()); apply.record_skipped_optional_mods(skip.clone()); apply.enable_optional_mods(enable.clone()); }) - .child(Icon::new(IconType::Check).size(15.)) - .text(enable_text.clone()) - .into_element() - })), + .maybe_child(install.then(|| Icon::new(IconType::Check).size(15.))) + .text(primary_text) + }), ) } @@ -350,6 +369,7 @@ impl Component for OptionalModCard { fn render(&self) -> impl IntoElement { let icon_query = use_cached_image(self.entry.icon_url.clone(), 256); let icon = package_icon(&self.entry, &icon_query, ICON_SIZE); + let hovered = use_state(|| false); grid_card( &self.entry, @@ -358,6 +378,8 @@ impl Component for OptionalModCard { icon, self.on_toggle.clone(), false, + None, + hovered, ) } } diff --git a/packages/oneclient_app/src/components/package_delete_dialog.rs b/packages/oneclient_app/src/components/package_delete_dialog.rs new file mode 100644 index 00000000..1c6427ba --- /dev/null +++ b/packages/oneclient_app/src/components/package_delete_dialog.rs @@ -0,0 +1,141 @@ +use freya::prelude::*; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); + +pub fn use_shared_delete( + cluster_id: i64, + delete: impl Into>, +) -> (EventHandler<(String, String)>, Option) { + let mut pending = use_state(|| None::<(String, String, usize)>); + let delete = delete.into(); + + let request = { + let delete = delete.clone(); + EventHandler::new_current(move |(name, hash): (String, String)| { + let delete = delete.clone(); + spawn(async move { + let clusters = match crate::launcher::state() { + Ok(state) => oneclient_core::clusters_sharing_artifact( + cluster_id, + &hash, + &state.services.content(), + ) + .await + .unwrap_or_else(|err| { + tracing::warn!(%err, "could not check whether this package is shared"); + None + }), + Err(_) => None, + }; + match clusters { + Some(clusters) if clusters > 1 => pending.set(Some((name, hash, clusters))), + _ => delete.call((name, hash)), + } + }); + }) + }; + + let dialog = pending.read().clone().map(|(name, hash, clusters)| { + SharedPackageDeleteDialog { + name: name.clone(), + clusters, + on_cancel: (move |()| pending.set(None)).into(), + on_confirm: (move |()| { + delete.call((name.clone(), hash.clone())); + pending.set(None); + }) + .into(), + } + .into_element() + }); + + (request, dialog) +} + +#[derive(PartialEq)] +struct SharedPackageDeleteDialog { + name: String, + clusters: usize, + on_cancel: EventHandler<()>, + on_confirm: EventHandler<()>, +} + +impl Component for SharedPackageDeleteDialog { + fn render(&self) -> impl IntoElement { + let title = format!("Delete {} from every cluster?", self.name); + let body = format!( + "Resource packs and shaders are shared, so this deletes it from all {} clusters that use it.", + self.clusters + ); + let close = self.on_cancel.clone(); + let cancel = self.on_cancel.clone(); + let confirm = self.on_confirm.clone(); + + OverlayPopup::new().on_close(move |_| close.call(())).child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::AlertTriangle) + .size(20.) + .color(colors::code_warn()), + ) + .child( + label() + .text(title) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(body) + .font_size(12.) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| cancel.call(())) + .text("Cancel"), + ) + .child( + Button::new() + .danger() + .on_press(move |_| confirm.call(())) + .child(Icon::new(IconType::Trash01).size(14.)) + .text("Delete"), + ), + ), + ), + ) + } +} diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 9f40bae7..d5a37fed 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -3,8 +3,12 @@ use freya::router::RouterContext; use oneclient_content::packages::ProviderId; use oneclient_core::SeenStatus; -use crate::components::{Icon, IconType, toggle_controlled}; -use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation}; +use crate::components::{ContextMenu, Icon, IconType, toggle_controlled}; +use crate::essential::EssentialPackage; +use crate::hooks::{ + ClusterAction, EssentialGuardKind, PendingEssential, disable_warnings, loaded_image, + use_cached_image, use_cluster_mutation, use_disable_warnings, use_essential_guard, +}; use crate::routes::Route; use crate::theme::colors; use crate::ui::{ImageFallbackExt, border_all_color}; @@ -13,9 +17,12 @@ use crate::utils::format_size; pub(crate) const CARD_BG: Color = Color::from_rgb(26, 34, 41); pub(crate) const CARD_NAME: Color = Color::from_rgb(213, 219, 255); pub(crate) const CARD_H: f32 = 84.; -pub(crate) const CARD_GRID_H: f32 = 148.; -pub(crate) const GRID_GAP: f32 = 10.; -pub(crate) const GRID_MIN_W: f32 = 260.; +pub(crate) const CARD_GRID_H: f32 = 112.; +pub(crate) const GRID_GAP: f32 = 12.; +pub(crate) const GRID_MIN_W: f32 = 290.; +const GRID_CARD_PADDING: f32 = 14.; +const BADGE_STRIP_H: f32 = 20.; +const BADGE_STRIP_LIFT: f32 = 2.; #[derive(Clone, Copy, PartialEq)] pub enum CardLayout { @@ -37,9 +44,12 @@ pub struct PackageEntry { pub package_id: String, pub bundle_name: Option, pub provider: ProviderId, + pub github_hosted: bool, + pub github_url: Option, pub name: String, pub file_name: String, pub author: String, + pub version: Option, pub description: String, pub icon_url: Option, pub size: u64, @@ -50,10 +60,17 @@ pub struct PackageEntry { pub manifest_default: bool, /// Private bundle dependency only set for bundle rows pub hidden: bool, + /// False for uninstalled rows of bundles the cluster never took keeps them out of the All tab + pub opted_in: bool, + pub advanced: bool, /// Only set for browser-installed content bundle packages use the bundle update flow pub update_available: bool, + pub shadowed: bool, + /// The user's own copy, older than the bundle's the loader picks over it + pub outranked: bool, /// Recency badge state cleared once the user views the list pub seen_status: SeenStatus, + pub essential: Option<&'static EssentialPackage>, } impl PackageEntry { @@ -85,6 +102,10 @@ pub struct PackageRow { cluster_id: i64, package_type: &'static str, layout: CardLayout, + on_context: EventHandler<(f32, f32)>, + selected: bool, + selecting: bool, + on_select: EventHandler<()>, key: DiffKey, } @@ -95,6 +116,10 @@ impl PackageRow { cluster_id, package_type, layout: CardLayout::List, + on_context: (|_| {}).into(), + selected: false, + selecting: false, + on_select: (|()| {}).into(), key: DiffKey::None, } } @@ -103,6 +128,23 @@ impl PackageRow { self.layout = layout; self } + + pub fn on_context(mut self, on_context: impl Into>) -> Self { + self.on_context = on_context.into(); + self + } + + pub fn selection( + mut self, + selected: bool, + selecting: bool, + on_select: impl Into>, + ) -> Self { + self.selected = selected; + self.selecting = selecting; + self.on_select = on_select.into(); + self + } } impl KeyExt for PackageRow { @@ -118,80 +160,227 @@ impl Component for PackageRow { let package_type = self.package_type; let layout = self.layout; let cluster = use_cluster_mutation(); - let remove_hover = use_state(|| false); + let guard = use_essential_guard(); + let warnings_query = use_disable_warnings(); + let hovered = use_state(|| false); + + let warning_body = disable_warning_body(&item, disable_warnings(&warnings_query)); let icon_size = match layout { CardLayout::List => 44., - CardLayout::Grid => 52., + CardLayout::Grid => 40., }; let icon_query = use_cached_image(item.icon_url.clone(), 256); let icon = package_icon(&item, &icon_query, icon_size); let on_toggle: EventHandler<()> = { - let hash = item.hash.clone(); - let bundle_name = item.bundle_name.clone(); - let package_id = item.package_id.clone(); + let action = toggle_action(&item, cluster_id, !item.enabled); let enabled_now = item.enabled; - let manifest_default = item.manifest_default; + let name = item.name.clone(); + let mut guard = guard; (move |()| { - if let Some(h) = &hash { - cluster.mutate(ClusterAction::SetArtifactEnabled { - cluster_id, - hash: h.clone(), - enabled: !enabled_now, - }); - } else if let Some(bundle) = &bundle_name { - cluster.mutate(ClusterAction::SetBundlePackageEnabled { - cluster_id, - bundle_name: bundle.clone(), - package_id: package_id.clone(), - enabled: !enabled_now, - manifest_default, - }); + let Some(action) = action.clone() else { + return; + }; + + match warning_body.clone().filter(|_| enabled_now) { + Some(body) => guard.set(Some(PendingEssential { + name: name.clone(), + body, + kind: EssentialGuardKind::Disable, + action, + })), + None => cluster.mutate(action), } }) .into() }; - match layout { - CardLayout::List => { - let removable = !item.in_bundle(); - let can_remove = removable && item.installed; - let rm_hash = item.hash.clone(); - let on_remove = move || { - if let Some(h) = &rm_hash { - cluster.mutate(ClusterAction::RemoveArtifact { - cluster_id, - hash: h.clone(), - }); - } - }; + let on_context = Some(self.on_context.clone()); + + let (card, radius) = match layout { + CardLayout::List => ( list_card( &item, package_type, cluster_id, icon, on_toggle, - can_remove, - on_remove, - remove_hover, - ) - } - CardLayout::Grid => grid_card(&item, package_type, cluster_id, icon, on_toggle, true), + on_context.clone(), + ), + 8., + ), + CardLayout::Grid => ( + grid_card( + &item, + package_type, + cluster_id, + icon, + on_toggle, + true, + on_context.clone(), + hovered, + ), + 6., + ), + }; + + let on_select = self.on_select.clone(); + let overlay = self.selecting.then(|| { + rect() + .position(Position::new_absolute().top(0.).left(0.)) + .width(Size::percent(100.)) + .height(Size::percent(100.)) + .layer(Layer::Relative(16)) + .corner_radius(CornerRadius::new_all(radius)) + .maybe(self.selected, |el| { + el.background(colors::brand().with_a(28)).border( + border_all_color(2., colors::brand()).alignment(BorderAlignment::Inner), + ) + }) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_select.call(())) + .on_secondary_down(on_secondary(on_context)) + .into_element() + }); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .child(card) + .maybe_child(overlay) + } +} + +pub(crate) fn toggle_action( + item: &PackageEntry, + cluster_id: i64, + enabled: bool, +) -> Option { + set_enabled_action( + cluster_id, + item.hash.as_deref(), + item.bundle_name.as_deref(), + &item.package_id, + item.manifest_default, + enabled, + ) +} + +pub(crate) fn set_enabled_action( + cluster_id: i64, + hash: Option<&str>, + bundle_name: Option<&str>, + package_id: &str, + manifest_default: bool, + enabled: bool, +) -> Option { + if let Some(hash) = hash { + Some(ClusterAction::SetArtifactEnabled { + cluster_id, + hash: hash.to_string(), + enabled, + }) + } else { + bundle_name.map(|bundle_name| ClusterAction::SetBundlePackageEnabled { + cluster_id, + bundle_name: bundle_name.to_string(), + package_id: package_id.to_string(), + enabled, + manifest_default, + }) + } +} + +pub(crate) fn disable_warning_body( + item: &PackageEntry, + warnings: Option, +) -> Option { + let bundled = item.in_bundle() && item.provider == ProviderId::Modrinth; + + match warnings { + Some(warnings) if bundled => warnings.body_for(&item.package_id).map(str::to_string), + _ => item + .essential + .map(|package| package.disable_body.to_string()), + } +} + +pub fn package_context_menu( + x: f32, + y: f32, + item: &PackageEntry, + cluster_id: i64, + package_type: &'static str, + on_delete: EventHandler<(String, String)>, + on_select: EventHandler<()>, +) -> ContextMenu { + let mut menu = ContextMenu::new(x, y).title(item.name.clone()).action( + IconType::Check, + "Select", + on_select, + ); + + if item.is_remote() { + let provider = item.provider; + let package_id = item.package_id.clone(); + let package_type = package_type.to_string(); + menu = menu.action(IconType::LinkExternal01, "View in browser", move |()| { + let _ = RouterContext::get().push(Route::BrowserPackage { + cluster_id, + package_type: package_type.clone(), + package_id: format!("{}:{}", provider as u8, package_id), + }); + }); + } + + if let Some(url) = item.github_url.clone() { + menu = menu.action(IconType::LinkExternal01, "View in browser", move |()| { + crate::platform::open_url(&url); + }); + } + + if let Some(hash) = item.hash.clone() { + menu = menu.action( + IconType::Folder, + "View in folder", + EventHandler::new_current(move |()| reveal_in_store(hash.clone())), + ); + } + + if item.installed && !item.in_bundle() { + let hash = item.hash.clone(); + let name = item.name.clone(); + menu = menu + .separator() + .danger_action(IconType::Trash01, "Delete", move |()| { + if let Some(hash) = &hash { + on_delete.call((name.clone(), hash.clone())); + } + }); + } + + menu +} + +pub(crate) fn on_secondary( + handler: Option>, +) -> impl FnMut(Event) { + move |e: Event| { + if let (Some(handler), PressEventData::Mouse(m)) = (handler.as_ref(), e.data()) { + e.stop_propagation(); + handler.call((m.global_location.x as f32, m.global_location.y as f32)); } } } -#[allow(clippy::too_many_arguments)] fn list_card( item: &PackageEntry, package_type: &'static str, cluster_id: i64, icon: impl IntoElement, on_toggle: EventHandler<()>, - can_remove: bool, - on_remove: impl FnMut() + 'static, - hovering: State, + on_context: Option>, ) -> Element { rect() .horizontal() @@ -203,13 +392,15 @@ fn list_card( .corner_radius(CornerRadius::new_all(8.)) .background(CARD_BG) .content(Content::Flex) + .on_secondary_down(on_secondary(on_context.clone())) .child(package_info(item, package_type, cluster_id, icon)) .child(meta_size(item.size)) .child(toggle_controlled(item.enabled, on_toggle)) - .child(remove_button(can_remove, on_remove, hovering)) + .maybe_child(on_context.map(kebab_button)) .into_element() } +#[allow(clippy::too_many_arguments)] pub(crate) fn grid_card( item: &PackageEntry, package_type: &'static str, @@ -217,46 +408,34 @@ pub(crate) fn grid_card( icon: impl IntoElement, on_toggle: EventHandler<()>, navigable: bool, // if true, it takes the user to the mod page + on_context: Option>, + mut hovered: State, ) -> Element { - let remote = item.is_remote(); let enabled = item.enabled; let title = item.name.clone(); + let hovering = *hovered.read(); - let (bg, border, content_alpha) = if enabled { - (colors::brand().with_a(38), colors::brand(), 255) - } else { - (CARD_BG, colors::component_border(), 140) + let bg = match (enabled, hovering) { + (_, true) => colors::component_bg_hover(), + (true, false) => colors::component_bg(), + (false, false) => colors::component_bg_disabled(), }; - let badge = if remote && navigable { - let provider = item.provider; - let package_id = item.package_id.clone(); - let package_type_owned = package_type.to_string(); - rect() - .cursor(CursorIcon::Pointer) - .on_press(move |e: Event| { - e.stop_propagation(); - let _ = RouterContext::get().push(Route::BrowserPackage { - cluster_id, - package_type: package_type_owned.clone(), - package_id: format!("{}:{}", provider as u8, package_id), - }); - }) - .child(provider_badge(item.provider)) - .into_element() - } else if remote { - provider_badge(item.provider) + let alpha = if enabled { 255u8 } else { 115u8 }; + + let border = if !hovering { + colors::component_border() } else { - local_badge() + colors::component_border_hover() }; let header = rect() .horizontal() .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(10.) + // .cross_align(Alignment::Center) + .spacing(11.) .content(Content::Flex) - .child(icon) + .child(rect().opacity(alpha as f32 / 255.).child(icon)) .child( rect() .vertical() @@ -266,62 +445,68 @@ pub(crate) fn grid_card( label() .text(title) .font_size(14.) - .font_weight(FontWeight::MEDIUM) + .font_weight(FontWeight::SEMI_BOLD) .max_lines(1) .text_overflow(TextOverflow::Ellipsis) .width(Size::fill()) - .color(CARD_NAME.with_a(content_alpha)), + .color(if enabled { + Color::WHITE + } else { + CARD_NAME.with_a(alpha) + }), ) - .maybe(!item.author.is_empty(), |el| { - el.child( - label() - .text(format!("by {}", item.author)) - .font_size(10.) - .max_lines(1) - .text_overflow(TextOverflow::Ellipsis) - .width(Size::fill()) - .color(colors::fg_secondary().with_a(content_alpha)), - ) - }) - .child( - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(6.) - .content(Content::wrap_spacing(4.)) - .child(badge) - .maybe_child(item.is_outdated().then(outdated_badge)) - .maybe_child(item.recency_badge()), - ), + .child(grid_meta(item, package_type, cluster_id, navigable, alpha)), ) + .maybe_child(on_context.clone().map(kebab_button)) .into_element(); - let description = if item.description.is_empty() { - None - } else { - Some( - label() - .text(item.description.clone()) - .font_size(11.) - .max_lines(3) - .width(Size::fill()) - .color(colors::fg_secondary().with_a(content_alpha)) - .into_element(), - ) - }; + let description = (!item.description.is_empty()).then(|| { + label() + .text(item.description.clone()) + .font_size(11.) + .line_height(1.45) + .max_lines(2) + .text_overflow(TextOverflow::Ellipsis) + .width(Size::fill()) + .color(colors::fg_primary().with_a(scale_a(alpha, 0.72))) + .into_element() + }); + + let badged = + item.is_outdated() || item.shadowed || item.outranked || item.recency_badge().is_some(); + let floating = badged.then(|| { + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(BADGE_STRIP_H)) + .position( + Position::new_absolute() + .bottom(-(GRID_CARD_PADDING + BADGE_STRIP_H / 2.) + BADGE_STRIP_LIFT), + ) + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .spacing(4.) + .maybe_child(item.is_outdated().then(outdated_badge)) + .maybe_child(item.shadowed.then(shadowed_badge)) + .maybe_child(item.outranked.then(outranked_badge)) + .maybe_child(item.recency_badge()) + .into_element() + }); rect() .vertical() .width(Size::fill()) .height(Size::fill()) - .spacing(8.) - .padding(Gaps::new_all(12.)) - .corner_radius(CornerRadius::new_all(8.)) - .background(bg) - .border(border_all_color(1.5, border)) + .spacing(9.) + .padding(Gaps::new_all(GRID_CARD_PADDING)) + .corner_radius(CornerRadius::new_all(6.)) + .background(bg.with_a(alpha)) + .border(border_all_color(1., border)) .content(Content::Flex) .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_secondary_down(on_secondary(on_context)) .on_press(move |_| on_toggle.call(())) .child(header) .child( @@ -329,11 +514,200 @@ pub(crate) fn grid_card( .vertical() .width(Size::fill()) .height(Size::flex(1.0)) + .overflow(Overflow::Clip) .maybe_child(description), ) + .maybe_child(floating) .into_element() } +fn scale_a(alpha: u8, factor: f32) -> u8 { + (alpha as f32 * factor) as u8 +} + +fn grid_meta( + item: &PackageEntry, + package_type: &'static str, + cluster_id: i64, + navigable: bool, + alpha: u8, +) -> Element { + let muted = CARD_NAME.with_a(scale_a(alpha, 0.5)); + + let source = if let Some(url) = item.github_url.clone() { + SourceLink { + text: "GitHub".to_string(), + on_press: EventHandler::new(move |()| crate::platform::open_url(&url)), + alpha, + } + .into_element() + } else if item.github_hosted { + meta_text("GitHub".to_string(), muted) + } else if item.is_remote() && navigable { + let provider = item.provider; + let package_id = item.package_id.clone(); + SourceLink { + text: provider.to_string(), + on_press: EventHandler::new(move |()| { + let _ = RouterContext::get().push(Route::BrowserPackage { + cluster_id, + package_type: package_type.to_string(), + package_id: format!("{}:{}", provider as u8, package_id), + }); + }), + alpha, + } + .into_element() + } else if item.is_remote() { + meta_text(item.provider.to_string(), muted) + } else { + meta_text("Local file".to_string(), muted) + }; + + let mut parts: Vec = Vec::new(); + if !item.author.is_empty() { + parts.push(meta_text(format!("by {}", item.author), muted)); + } + if let Some(version) = &item.version { + parts.push(meta_text(version.clone(), muted)); + } + parts.push(source); + + let mut row = rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(5.) + .overflow(Overflow::Clip); + + for (i, part) in parts.into_iter().enumerate() { + if i > 0 { + row = row.child(meta_text("\u{b7}".to_string(), muted)); + } + row = row.child(part); + } + + row.into_element() +} + +#[derive(PartialEq)] +struct SourceLink { + text: String, + on_press: EventHandler<()>, + alpha: u8, +} + +impl Component for SourceLink { + fn render(&self) -> impl IntoElement { + let mut hovered = use_state(|| false); + let mut pressed = use_state(|| false); + + let color = if *pressed.read() { + colors::fg_primary_pressed() + } else if *hovered.read() { + colors::fg_primary_hover() + } else { + CARD_NAME.with_a(scale_a(self.alpha, 0.68)) + }; + + let on_press = self.on_press.clone(); + + rect() + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| { + hovered.set(false); + pressed.set(false); + }) + .on_pointer_down(move |_| pressed.set(true)) + .on_press(move |e: Event| { + e.stop_propagation(); + pressed.set(false); + on_press.call(()); + }) + .child(meta_text(self.text.clone(), color)) + } +} + +pub(crate) fn meta_text(text: String, color: Color) -> Element { + label() + .text(text) + .font_size(11.) + .max_lines(1) + .color(color) + .into_element() +} + +pub(crate) fn kebab_button(on_context: EventHandler<(f32, f32)>) -> Element { + KebabButton { on_context }.into_element() +} + +#[derive(PartialEq)] +struct KebabButton { + on_context: EventHandler<(f32, f32)>, +} + +impl Component for KebabButton { + fn render(&self) -> impl IntoElement { + let mut hovered = use_state(|| false); + let on_context = self.on_context.clone(); + + rect() + .center() + .width(Size::px(26.)) + .height(Size::px(26.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if *hovered.read() { + colors::ghost_overlay_hover() + } else { + Color::TRANSPARENT + }) + .cursor(CursorIcon::Pointer) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(move |e: Event| { + e.stop_propagation(); + if let PressEventData::Mouse(m) = e.data() { + on_context.call((m.global_location.x as f32, m.global_location.y as f32)); + } + }) + .child( + Icon::new(IconType::DotsVertical) + .size(16.) + .color(if *hovered.read() { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ) + } +} + +fn reveal_in_store(hash: String) { + spawn(async move { + let Ok(state) = crate::launcher::state() else { + return; + }; + let row = match oneclient_db::dao::artifact::get_artifact_by_hash(&state.services.db, &hash) + .await + { + Ok(Some(row)) => row, + Ok(None) => return, + Err(err) => { + tracing::warn!(%err, "failed to look up artifact for reveal"); + return; + } + }; + let Ok(path) = oneclient_content::packages::store::artifact_absolute_path(&row.path) else { + return; + }; + if let Some(dir) = path.parent() { + crate::platform::open_path(&dir.to_string_lossy()); + } + }); +} + fn package_info( item: &PackageEntry, package_type: &'static str, @@ -383,12 +757,25 @@ fn package_info( .max_width(Size::percent(60.)) .color(CARD_NAME), ) - .child(if remote { + .child(if let Some(url) = item.github_url.clone() { + rect() + .cursor(CursorIcon::Pointer) + .on_press(move |e: Event| { + e.stop_propagation(); + crate::platform::open_url(&url); + }) + .child(github_badge()) + .into_element() + } else if item.github_hosted { + github_badge() + } else if remote { provider_badge(item.provider) } else { local_badge() }) .maybe_child(item.is_outdated().then(outdated_badge)) + .maybe_child(item.shadowed.then(shadowed_badge)) + .maybe_child(item.outranked.then(outranked_badge)) .maybe_child(item.recency_badge()), ) .maybe(!item.author.is_empty(), |el| { @@ -422,8 +809,15 @@ pub(crate) fn package_icon( icon_query: &freya::query::UseQuery, size: f32, ) -> Element { - let icon_url = &item.icon_url; - let loaded = loaded_image(icon_url.as_deref(), icon_query); + remote_icon(item.icon_url.as_deref(), icon_query, size) +} + +pub(crate) fn remote_icon( + icon_url: Option<&str>, + icon_query: &freya::query::UseQuery, + size: f32, +) -> Element { + let loaded = loaded_image(icon_url, icon_query); match loaded { Some((url, bytes)) => ImageViewer::new((url, bytes)) @@ -439,7 +833,7 @@ pub(crate) fn package_icon( } } -fn icon_box(icon: IconType, size: f32) -> Element { +pub(crate) fn icon_box(icon: IconType, size: f32) -> Element { rect() .center() .width(Size::px(size)) @@ -454,7 +848,7 @@ fn icon_box(icon: IconType, size: f32) -> Element { .into_element() } -fn meta_size(size: u64) -> impl IntoElement { +pub(crate) fn meta_size(size: u64) -> impl IntoElement { rect() .maybe_child((size > 0).then(|| { label() @@ -472,37 +866,56 @@ pub fn provider_badge(provider: ProviderId) -> Element { ) } -fn outdated_badge() -> Element { - accent_badge( - Icon::new(IconType::RefreshCw01) - .size(12.) - .color(colors::brand()) - .into_element(), - "Update available".to_string(), - colors::brand(), +pub fn github_badge() -> Element { + badge( + Icon::new(IconType::Github).size(12.).into_element(), + "GitHub".to_string(), ) } +fn outdated_badge() -> Element { + status_tag("Update available", colors::brand()) +} + +fn shadowed_badge() -> Element { + status_tag("Using your copy", colors::brand()) +} + +fn outranked_badge() -> Element { + status_tag("Using bundle's newer copy", colors::brand()) +} + fn new_badge() -> Element { - accent_badge( - Icon::new(IconType::Plus) - .size(12.) - .color(colors::success()) - .into_element(), - "New".to_string(), - colors::success(), - ) + status_tag("New", colors::success()) } fn updated_badge() -> Element { - accent_badge( - Icon::new(IconType::RefreshCcw02) - .size(12.) - .color(colors::success()) - .into_element(), - "Updated".to_string(), - colors::success(), - ) + status_tag("Updated", colors::success()) +} + +fn status_tag(text: &'static str, accent: Color) -> Element { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(5.) + .padding(Gaps::new(2., 6., 2., 6.)) + .corner_radius(CornerRadius::new_all(4.)) + .background(colors::component_bg()) + .child( + rect() + .width(Size::px(6.)) + .height(Size::px(6.)) + .corner_radius(CornerRadius::new_all(3.)) + .background(accent), + ) + .child( + label() + .text(text) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .into_element() } fn local_badge() -> Element { @@ -515,11 +928,15 @@ fn local_badge() -> Element { ) } -fn badge(icon: impl IntoElement, text: String) -> Element { +pub(crate) fn badge(icon: impl IntoElement, text: String) -> Element { accent_badge(icon, text, colors::fg_secondary()) } fn accent_badge(icon: impl IntoElement, text: String, accent: Color) -> Element { + pill(Some(icon.into_element()), text, accent) +} + +pub(crate) fn pill(icon: Option, text: String, accent: Color) -> Element { rect() .horizontal() .cross_align(Alignment::Center) @@ -528,7 +945,7 @@ fn accent_badge(icon: impl IntoElement, text: String, accent: Color) -> Element .corner_radius(CornerRadius::new_all(999.)) .border(border_all_color(1., colors::component_border())) .background(colors::component_bg()) - .child(icon) + .maybe_child(icon) .child( label() .text(text) @@ -538,37 +955,3 @@ fn accent_badge(icon: impl IntoElement, text: String, accent: Color) -> Element ) .into_element() } - -fn remove_button( - enabled: bool, - mut on_remove: impl FnMut() + 'static, - mut hovering: State, -) -> impl IntoElement { - let hot = enabled && *hovering.read(); - let color = if !enabled { - colors::fg_secondary().with_a(90) - } else if hot { - colors::danger() - } else { - colors::fg_secondary() - }; - - rect() - .center() - .width(Size::px(30.)) - .height(Size::px(30.)) - .corner_radius(CornerRadius::new_all(7.)) - .background(if hot { - colors::danger().with_a(30) - } else { - Color::TRANSPARENT - }) - .maybe(enabled, |el| { - el.cursor(CursorIcon::Pointer) - .on_pointer_enter(move |_| hovering.set(true)) - .on_pointer_leave(move |_| hovering.set(false)) - .on_press(move |_| on_remove()) - }) - .child(Icon::new(IconType::Trash01).size(14.).color(color)) - .into_element() -} diff --git a/packages/oneclient_app/src/components/package_update_popup.rs b/packages/oneclient_app/src/components/package_update_popup.rs index 89907681..8b82a30a 100644 --- a/packages/oneclient_app/src/components/package_update_popup.rs +++ b/packages/oneclient_app/src/components/package_update_popup.rs @@ -8,7 +8,9 @@ use oneclient_content::packages::{CachedPackageMeta, ProviderId}; use oneclient_core::{BrowserPackageUpdate, ProfileUpdate}; use oneclient_db::models::ClusterId; -use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea, checkbox_labeled}; +use crate::components::{ + Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea, checkbox_labeled, +}; use crate::hooks::{ package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch, }; @@ -210,9 +212,7 @@ fn content( .main_align(Alignment::Start) .cross_align(Alignment::Center) .spacing(6.) - .child( - checkbox_labeled(dont_show_again, "Don't show again?") - ) + .child(checkbox_labeled(dont_show_again, "Don't show again?")), ) .child( rect() @@ -267,7 +267,7 @@ fn content( }) .child(Icon::new(IconType::DownloadCloud02).size(15.)) .text("Proceed"), - ) + ), ), ) } diff --git a/packages/oneclient_app/src/components/pagination.rs b/packages/oneclient_app/src/components/pagination.rs index 5e9f1211..d19669c6 100644 --- a/packages/oneclient_app/src/components/pagination.rs +++ b/packages/oneclient_app/src/components/pagination.rs @@ -52,26 +52,24 @@ impl Component for Pagination { let current = *page.read(); let last = total_pages.saturating_sub(1); - let icon_btn = move |target: usize, - available: bool, - icon: IconType, - tooltip: &'static str| { - let mut page = page; - let enabled = available && interactive; - Button::new() - .secondary() - .icon() - .enabled(enabled) - .tooltip(tooltip) - .tooltip_placement(TooltipPlacement::Top) - .on_press(move |_| page.set(target)) - .child(Icon::new(icon).size(14.).color(if enabled { - colors::fg_primary() - } else { - colors::fg_secondary().with_a(90) - })) - .into_element() - }; + let icon_btn = + move |target: usize, available: bool, icon: IconType, tooltip: &'static str| { + let mut page = page; + let enabled = available && interactive; + Button::new() + .secondary() + .icon() + .enabled(enabled) + .tooltip(tooltip) + .tooltip_placement(TooltipPlacement::Top) + .on_press(move |_| page.set(target)) + .child(Icon::new(icon).size(14.).color(if enabled { + colors::fg_primary() + } else { + colors::fg_secondary().with_a(90) + })) + .into_element() + }; let num_btn = move |target: usize| { let mut page = page; diff --git a/packages/oneclient_app/src/components/player_model.rs b/packages/oneclient_app/src/components/player_model.rs index 46d178ea..002a9b92 100644 --- a/packages/oneclient_app/src/components/player_model.rs +++ b/packages/oneclient_app/src/components/player_model.rs @@ -125,7 +125,7 @@ impl Component for PlayerModel { let effect = player_effect(); - let render_cb = RenderCallback::new(move |ctx: &mut CanvasContext| { + let render_cb = RenderCallback::new(move |ctx: &mut FillRenderContext| { let (Some(effect), Some(skin)) = (&effect, &skin_shader) else { return; }; @@ -159,18 +159,12 @@ impl Component for PlayerModel { ctx.canvas.draw_rect(SkRect::new(0.0, 0.0, w, h), &paint); }); - // `canvas` carries no style data, so the cursor rides on a wrapper `rect` that - // covers exactly the same area rect() + .key(src_ptr as u64) .width(self.width.clone()) .height(self.height.clone()) .cursor(CursorIcon::Grab) - .child( - canvas(render_cb) - .key(src_ptr as u64) - .width(Size::fill()) - .height(Size::fill()), - ) + .background(render_cb) .on_pointer_down(move |e: Event| { let loc = e.global_location(); drag.set(Some((loc.x as f32, loc.y as f32, yaw(), pitch()))); @@ -193,7 +187,7 @@ impl Component for PlayerModel { Platform::get().send(UserEvent::RequestRedraw); }) // Global so a release outside the widget still ends the drag - .on_global_pointer_press(move |_: Event| { + .on_global_pointer_up(move |_: Event| { if drag.peek().is_some() { drag.set(None); } diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index 191b1e72..72f2465b 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -1,10 +1,17 @@ +use std::time::Duration; + use freya::animation::*; use freya::prelude::*; use freya::router::RouterContext; use oneclient_core::clusters::Cluster; -use crate::components::{ART_PREVIEW_EDGE, ContextMenu, DynamicArt, Icon, IconType}; +use freya::elements::image::{AspectRatio, ImageCover, ImageHandle, image}; + +use super::dynamic_art::use_art_bytes; +use super::local_image::decode; +use crate::components::{ART_PREVIEW_EDGE, ContextMenu, DynamicArt, Icon, IconType, LocalImage}; use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters}; +use crate::motion::use_animations_enabled; use crate::routes::Route; use crate::theme; use crate::theme::colors; @@ -20,6 +27,18 @@ const CARD_MS: u64 = 460; const STAGGER_MS: u64 = 85; const CARD_RISE_PX: f32 = 48.0; const INTRO_ITEMS: usize = 6; +const MODPACK_ICON_PX: f32 = 72.0; +const MODPACK_ICON_EDGE: u32 = 192; +const MODPACK_BLUR: f32 = 4.0; +const MODPACK_TINT: Color = Color::from_argb(40, 21, 28, 34); +const MODPACK_BACKDROP: Color = Color::from_rgb(21, 28, 34); +const MODPACK_TITLE_INSET: f32 = 8.0; +const MODPACK_TITLE_LEFT: f32 = 10.5; +const MARQUEE_DELAY: Duration = Duration::from_millis(200); +const MARQUEE_HOLD: Duration = Duration::from_millis(700); +const MARQUEE_FRAME: Duration = Duration::from_millis(16); +const MARQUEE_SPEED_PX_PER_SEC: f32 = 60.0; +const MARQUEE_TRACK_PX: f32 = 4096.0; #[derive(PartialEq)] pub struct RecentsRow; @@ -35,10 +54,8 @@ impl Component for RecentsRow { let sorted: Vec = sort_clusters_for_home(clusters); - let slots = recent_card_slots_for_window( - root_size.read().width, - *scale_factor.read() as f32, - ); + let slots = + recent_card_slots_for_window(root_size.read().width, *scale_factor.read() as f32); // Budget off cluster count not slot count an offset-only change makes the layout // engine reuse a card's cached area freezing survivors at pre-resize widths @@ -153,6 +170,10 @@ impl KeyExt for ClusterCard { impl PartialEq for ClusterCard { fn eq(&self, other: &Self) -> bool { self.cluster.id == other.cluster.id + && self.cluster.name == other.cluster.name + && self.cluster.cover_path == other.cluster.cover_path + && self.cluster.linked_modpack_hash == other.cluster.linked_modpack_hash + && self.cluster.mc_loader == other.cluster.mc_loader && self.index == other.index && self.items == other.items && self.progress == other.progress @@ -173,9 +194,19 @@ impl Component for ClusterCard { let eased = stagger_eased(self.progress, self.index, self.items); let rise = (1.0 - eased) * CARD_RISE_PX; - let title = format!("{} {}", self.cluster.mc_version, self.cluster.mc_loader); + let title = if self.cluster.user_created { + self.cluster.name.clone() + } else { + format!("{} {}", self.cluster.mc_version, self.cluster.mc_loader) + }; let cluster_id = self.cluster.id; + let mod_tabs = !self.cluster.lacks_mod_loader(); + let modpack = self.cluster.linked_modpack_hash.is_some(); + let modpack_icon = self + .cluster + .modpack_icon_file() + .filter(|icon| icon.is_file()); let on_press = move |_| { *active_id.write() = Some(cluster_id); }; @@ -185,7 +216,17 @@ impl Component for ClusterCard { .open_upwards() .title(title.clone()) .on_close(move |_| menu.set(None)); - for (icon, label, route) in cluster_menu_entries(cluster_id) { + for (icon, label, route) in + cluster_menu_entries(cluster_id) + .into_iter() + .filter(|(_, _, route)| { + mod_tabs + || !matches!( + route, + Route::ClusterMods { .. } | Route::ClusterShaders { .. } + ) + }) + { context = context.action(icon, label, move |()| { let _ = RouterContext::get().push(route.clone()); }); @@ -249,20 +290,44 @@ impl Component for ClusterCard { rect() .width(Size::fill()) .height(Size::fill()) - .child( + .child(if modpack { + BlurredArt { + art: DynamicArt::for_cluster(&self.cluster) + .max_edge(ART_PREVIEW_EDGE), + } + .into_element() + } else { rect() .width(Size::fill()) .height(Size::fill()) .position(Position::new_absolute()) .layer(Layer::Relative(1)) - .child(DynamicArt::for_cluster(&self.cluster).max_edge(ART_PREVIEW_EDGE)), - ) + .child( + DynamicArt::for_cluster(&self.cluster) + .max_edge(ART_PREVIEW_EDGE), + ) + .into_element() + }) + .maybe_child(modpack_icon.map(|icon| { + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .center() + .layer(Layer::Relative(5)) + .child(modpack_badge(icon)) + .into_element() + })) .child( rect() .width(Size::fill()) .height(Size::fill()) .position(Position::new_absolute()) - .padding(Gaps::new_symmetric(12., 24.)) + .padding(if modpack { + Gaps::new(12., MODPACK_TITLE_INSET, 12., MODPACK_TITLE_LEFT) + } else { + Gaps::new_symmetric(12., 24.) + }) .main_align(Alignment::End) .cross_align(Alignment::Start) .layer(Layer::Relative(4)) @@ -272,13 +337,15 @@ impl Component for ClusterCard { .stop((Color::from_argb(0, 25, 25, 25), 24.519)) .stop((Color::from_af32rgb(0.75, 17, 17, 21), 65.)), ) - .child( - label() - .text(title) - .font_size(32.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ), + .child(if modpack { + MarqueeTitle { + text: title, + hovered: *hovering.read(), + } + .into_element() + } else { + title_label(title).into_element() + }), ) .child( rect() @@ -302,6 +369,175 @@ impl Component for ClusterCard { } } +fn title_label(text: String) -> Label { + label() + .text(text) + .font_size(32.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()) +} + +#[derive(PartialEq)] +struct MarqueeTitle { + text: String, + hovered: bool, +} + +impl Component for MarqueeTitle { + fn render(&self) -> impl IntoElement { + let animations = use_animations_enabled(); + let mut box_width = use_state(|| 0f32); + let mut text_width = use_state(|| 0f32); + let offset = use_state(|| 0f32); + let run = use_state(|| 0u64); + + let overflow = (*text_width.read() - *box_width.read()).max(0.); + let deps = (self.hovered && animations, overflow); + use_side_effect_with_deps(&deps, move |&(hovered, overflow)| { + let mut run = run; + let mut offset = offset; + let current = *run.peek() + 1; + run.set(current); + + if !hovered || overflow < 1. { + offset.set(0.); + return; + } + + spawn(async move { + let alive = move || *run.peek() == current; + tokio::time::sleep(MARQUEE_DELAY).await; + if !alive() { + return; + } + if !slide(offset, 0., -overflow, &alive).await { + return; + } + tokio::time::sleep(MARQUEE_HOLD).await; + if !alive() { + return; + } + slide(offset, -overflow, 0., &alive).await; + }); + }); + + rect() + .width(Size::fill()) + .overflow(Overflow::Clip) + .on_sized(move |event: Event| { + let width = event.data().area.width(); + if (width - *box_width.peek()).abs() > 0.5 { + box_width.set(width); + } + }) + .child( + rect().width(Size::px(MARQUEE_TRACK_PX)).child( + rect() + .offset_x(*offset.read()) + .on_sized(move |event: Event| { + let width = event.data().area.width(); + if (width - *text_width.peek()).abs() > 0.5 { + text_width.set(width); + } + }) + .child(title_label(self.text.clone())), + ), + ) + } +} + +async fn slide(mut offset: State, from: f32, to: f32, alive: &impl Fn() -> bool) -> bool { + let distance = (to - from).abs(); + let total = Duration::from_secs_f32(distance / MARQUEE_SPEED_PX_PER_SEC); + let started = std::time::Instant::now(); + + loop { + if !alive() { + return false; + } + let progress = (started.elapsed().as_secs_f32() / total.as_secs_f32().max(0.001)).min(1.); + offset.set(from + (to - from) * progress); + if progress >= 1. { + return true; + } + tokio::time::sleep(MARQUEE_FRAME).await; + } +} + +#[derive(PartialEq)] +struct BlurredArt { + art: DynamicArt, +} + +impl Component for BlurredArt { + fn render(&self) -> impl IntoElement { + let (key, bytes) = use_art_bytes(&self.art); + let mut cache = use_state(|| None::<(String, ImageHandle)>); + + let cached = cache + .read() + .clone() + .filter(|(cached_key, _)| *cached_key == key) + .map(|(_, holder)| holder); + let holder = cached.or_else(|| { + let holder = decode(&bytes)?; + cache.set(Some((key.clone(), holder.clone()))); + Some(holder) + }); + + let backdrop = rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .overflow(Overflow::Clip) + .background(MODPACK_BACKDROP); + + let Some(holder) = holder else { + return backdrop.into_element(); + }; + + backdrop + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .overflow(Overflow::Clip) + .layer(Layer::Relative(1)) + .child( + image(holder) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::Max) + .image_cover(ImageCover::Center), + ), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .overflow(Overflow::Clip) + .layer(Layer::Relative(3)) + .backdrop_blur(MODPACK_BLUR) + .background(MODPACK_TINT), + ) + .into_element() + } +} + +fn modpack_badge(icon: std::path::PathBuf) -> Element { + rect() + .width(Size::px(MODPACK_ICON_PX)) + .height(Size::px(MODPACK_ICON_PX)) + .min_width(Size::px(MODPACK_ICON_PX)) + .corner_radius(CornerRadius::new_all(14.)) + .overflow(Overflow::Clip) + .child(LocalImage::new(icon, MODPACK_ICON_EDGE, false)) + .into_element() +} + #[derive(PartialEq)] struct OtherVersionsTile { index: usize, @@ -339,7 +575,7 @@ impl Component for OtherVersionsTile { } else { colors::ghost_overlay_hover() }) - .blur(32.) + .backdrop_blur(32.) .a11y_id(a11y_id) .a11y_focusable(true) .a11y_role(AccessibilityRole::Button) diff --git a/packages/oneclient_app/src/components/release_migration_popup.rs b/packages/oneclient_app/src/components/release_migration_popup.rs new file mode 100644 index 00000000..7f2ae794 --- /dev/null +++ b/packages/oneclient_app/src/components/release_migration_popup.rs @@ -0,0 +1,1363 @@ +use std::collections::{HashMap, HashSet}; + +use freya::prelude::*; +use freya::router::use_route; +use oneclient_common::domain::{ContentType, GameLoader}; +use oneclient_common::version::parse_mc_version; +use oneclient_content::packages::release_migration::{ + ReleaseMigrationDependency, ReleaseMigrationPackage, ReleaseMigrationPlan, + ReleaseMigrationSkip, SkipReason, WAITLIST_DAYS, +}; +use oneclient_content::packages::{CachedPackageMeta, ProviderId}; +use oneclient_core::clusters::Cluster; + +use crate::components::{ + Button, Dropdown, DynamicArt, Icon, IconType, OverlayPopup, ScrollArea, checkbox_labeled, +}; +use crate::hooks::{ + loaded_image, package_meta_batch, use_cached_image, use_dispatch, use_game_snapshot, + use_launcher, use_notifications_snapshot, use_package_meta_batch, use_release_migration, +}; +use crate::routes::Route; +use crate::state::{ReleaseMigrationPrompt, ReleasePlanState}; +use crate::theme::colors; +use crate::ui::{ImageFallbackExt, border_all_color}; +use crate::utils::format_size; + +const DIALOG_W: f32 = 1020.; +const DIALOG_H: f32 = 616.; +const ART_W: f32 = 368.; +const PANEL_BG: Color = Color::from_rgb(22, 28, 35); +const FOOTER_BG: Color = Color::from_rgb(19, 25, 31); +const ROW_ICON: f32 = 32.; +const CHECK_SIZE: f32 = 24.; +const SKIPPED_ICON: f32 = 20.; +const SKIPPED_NAME_W: f32 = 168.; +const REQUIRED_BY_W: f32 = 190.; + +type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; +type SelectionKey = (i64, String); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum PackageTab { + Mods, + ResourcePacks, + Shaders, +} + +impl PackageTab { + const ALL: [Self; 3] = [Self::Mods, Self::ResourcePacks, Self::Shaders]; + + fn label(self) -> &'static str { + match self { + Self::Mods => "Mods", + Self::ResourcePacks => "Resource packs", + Self::Shaders => "Shaders", + } + } + + fn content_type(self) -> ContentType { + match self { + Self::Mods => ContentType::Mod, + Self::ResourcePacks => ContentType::ResourcePack, + Self::Shaders => ContentType::Shader, + } + } +} + +fn blocked_by_route(route: &Route) -> bool { + matches!( + route, + Route::Startup {} + | Route::Relocating {} + | Route::OnboardingWelcome {} + | Route::OnboardingLocation {} + | Route::OnboardingTerms {} + | Route::OnboardingMigration {} + | Route::OnboardingLanguage {} + | Route::OnboardingAccount {} + | Route::OnboardingPreferences {} + | Route::OnboardingBundles {} + | Route::OnboardingSummary {} + ) +} + +fn cluster_title(cluster: &Cluster) -> String { + cluster.name.clone() +} + +fn selection_key(package: &ReleaseMigrationPackage, source_id: i64) -> SelectionKey { + (source_id, package.source_hash.clone()) +} + +#[derive(PartialEq)] +pub struct ReleaseMigrationPopup; + +impl Component for ReleaseMigrationPopup { + fn render(&self) -> impl IntoElement { + let launcher = use_launcher(); + let dispatch = use_dispatch(); + let route = use_route::(); + let snapshot = use_notifications_snapshot(); + let game = use_game_snapshot(); + let prompt = use_release_migration(); + + let mut excluded = use_state(HashSet::::new); + let mut tab = use_state(|| PackageTab::Mods); + let mut copy_configs = use_state(|| true); + + let settled = launcher.ready && !launcher.fetching && !launcher.syncing_bundles; + let checked = use_state(|| false); + let check_dispatch = dispatch.clone(); + use_side_effect_with_deps(&settled, move |&settled| { + let mut checked = checked; + if settled && !*checked.peek() { + checked.set(true); + check_dispatch.check_release_migration(); + check_dispatch.process_release_waitlist(); + } + }); + + let mut wont_open = use_state(|| true); + let mut deps_open = use_state(|| false); + + let projects: Vec<(ProviderId, String)> = + prompt + .as_ref() + .and_then(|prompt| match prompt.plan() { + Some(ReleasePlanState::Ready(plan)) => Some( + plan.packages + .iter() + .map(|package| (package.provider, package.project_id.clone())) + .chain( + plan.unavailable + .iter() + .map(|skip| (skip.provider, skip.project_id.clone())), + ) + .chain(plan.dependencies.iter().map(|dependency| { + (dependency.provider, dependency.project.id.clone()) + })) + .collect(), + ), + _ => None, + }) + .unwrap_or_default(); + + let mut meta = MetaMap::new(); + for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { + let ids: Vec = projects + .iter() + .filter(|(owner, _)| *owner == provider) + .map(|(_, project_id)| project_id.clone()) + .collect(); + let query = use_package_meta_batch(provider, ids); + for (project_id, cached) in package_meta_batch(&query) { + meta.insert((provider, project_id), cached); + } + } + + let Some(prompt) = prompt else { + excluded.set_if_modified(HashSet::new()); + tab.set_if_modified(PackageTab::Mods); + wont_open.set_if_modified(true); + deps_open.set_if_modified(false); + copy_configs.set_if_modified(true); + return rect().into_element(); + }; + + let other_popup = snapshot.cluster_update.is_some() + || snapshot.optional_mods.is_some() + || snapshot.bundle_choices.is_some() + || snapshot.package_updates.is_some() + || snapshot.pending_prompt.is_some(); + if blocked_by_route(&route) || other_popup { + return rect().into_element(); + } + + let target_running = game.is_running(prompt.target.id); + let close = dispatch.clone(); + let panel_dispatch = dispatch.clone(); + + OverlayPopup::new() + .on_close(move |_| close.dismiss_release_migration()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog( + &prompt, + &meta, + panel_dispatch, + excluded, + tab, + wont_open, + deps_open, + copy_configs, + target_running, + )), + ) + .into_element() + } +} + +#[allow(clippy::too_many_arguments)] +fn dialog( + prompt: &ReleaseMigrationPrompt, + meta: &MetaMap, + dispatch: crate::Actions, + excluded: State>, + tab: State, + wont_open: State, + deps_open: State, + copy_configs: State, + target_running: bool, +) -> impl IntoElement { + let plan = match prompt.plan() { + Some(ReleasePlanState::Ready(plan)) => Some(plan), + _ => None, + }; + + rect() + .horizontal() + .width(Size::px(DIALOG_W)) + .height(Size::px(DIALOG_H)) + .max_width(Size::window_percent(95.)) + .max_height(Size::window_percent(92.)) + .overflow(Overflow::Clip) + .corner_radius(CornerRadius::new_all(18.)) + .background(PANEL_BG) + .border(border_all_color(1., colors::component_border())) + .shadow(Shadow::from(( + 0., + 24., + 64., + 0., + Color::from_argb(160, 0, 0, 0), + ))) + .content(Content::Flex) + .child(art_panel(prompt, plan, &excluded.read())) + .child(content_panel( + prompt, + plan, + meta, + dispatch, + excluded, + tab, + wont_open, + deps_open, + copy_configs, + target_running, + )) +} + +fn art_panel( + prompt: &ReleaseMigrationPrompt, + plan: Option<&ReleaseMigrationPlan>, + excluded: &HashSet, +) -> impl IntoElement { + let target = &prompt.target; + let parsed = parse_mc_version(&target.mc_version); + let art = parsed + .map(|parsed| DynamicArt::for_version(parsed.major, parsed.key(), Some(target.mc_loader))); + + let mut runtime = Vec::new(); + if target.mc_loader != GameLoader::Vanilla { + runtime.push(match target.mc_loader_version.as_deref() { + Some(version) if !version.is_empty() => format!("{} {version}", target.mc_loader), + _ => target.mc_loader.to_string(), + }); + } + if let Some(java) = prompt.java_major { + runtime.push(format!("Java {java}")); + } + + rect() + .width(Size::px(ART_W)) + .height(Size::fill()) + .overflow(Overflow::Clip) + .background(colors::page()) + .maybe_child(art.map(|art| { + rect() + .position(Position::new_absolute()) + .width(Size::fill()) + .height(Size::fill()) + .layer(Layer::Relative(1)) + .child(art) + })) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .padding(Gaps::new(24., 24., 24., 24.)) + .main_align(Alignment::End) + .layer(Layer::Relative(3)) + .background( + LinearGradient::new() + .angle(0.) + .stop((Color::from_af32rgb(0.0, 13, 17, 21), 28.)) + .stop((Color::from_af32rgb(0.85, 13, 17, 21), 58.)) + .stop((Color::from_af32rgb(0.98, 13, 17, 21), 100.)), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .child( + label() + .text(cluster_title(target)) + .font_size(32.) + .font_weight(FontWeight::BOLD) + .max_lines(1) + .color(Color::WHITE), + ) + .maybe_child((!runtime.is_empty()).then(|| { + label() + .text(runtime.join(" · ")) + .font_size(13.) + .margin(Gaps::new(12., 0., 0., 0.)) + .color(colors::fg_primary()) + })) + .child(summary_card(plan, excluded)), + ), + ) +} + +fn summary_card( + plan: Option<&ReleaseMigrationPlan>, + excluded: &HashSet, +) -> impl IntoElement { + let mut rows = rect().vertical().width(Size::fill()).spacing(10.); + for tab in PackageTab::ALL { + let value = plan.map_or_else( + || "–".to_string(), + |plan| { + let dependencies = if tab.content_type() == ContentType::Mod { + plan.dependencies.len() + } else { + 0 + }; + let needed = if tab.content_type() == ContentType::Mod { + let chosen = selected_hashes(plan, excluded); + plan.dependencies + .iter() + .filter(|dependency| dependency.is_needed_by(&chosen)) + .count() + } else { + 0 + }; + + let total = plan.offered(tab.content_type()) + + dependencies + + plan + .unavailable + .iter() + .filter(|skip| skip.content_type == tab.content_type()) + .count(); + let selected = needed + + plan + .packages + .iter() + .filter(|package| package.content_type == tab.content_type()) + .filter(|package| { + !excluded.contains(&selection_key(package, plan.source_cluster_id)) + }) + .count(); + format!("{selected} of {total}") + }, + ); + rows = rows.child(summary_row(tab.label(), value, colors::fg_primary())); + } + + let wont = plan.map_or_else( + || "–".to_string(), + |plan| plan.unavailable.len().to_string(), + ); + + rect() + .vertical() + .width(Size::fill()) + .margin(Gaps::new(20., 0., 0., 0.)) + .child(rows) + .child( + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .margin(Gaps::new(12., 0., 12., 0.)) + .background(colors::component_border()), + ) + .child(summary_row("Won't migrate", wont, colors::fg_secondary())) +} + +fn summary_row(name: &str, value: String, name_color: Color) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .child( + label() + .text(name.to_string()) + .font_size(13.) + .width(Size::flex(1.)) + .color(name_color), + ) + .child( + label() + .text(value) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(Color::WHITE), + ) +} + +#[allow(clippy::too_many_arguments)] +fn content_panel( + prompt: &ReleaseMigrationPrompt, + plan: Option<&ReleaseMigrationPlan>, + meta: &MetaMap, + dispatch: crate::Actions, + excluded: State>, + tab: State, + wont_open: State, + deps_open: State, + copy_configs: State, + target_running: bool, +) -> impl IntoElement { + let close = dispatch.clone(); + let picker_dispatch = dispatch.clone(); + let source_title = prompt.source().map(cluster_title).unwrap_or_default(); + + let target_title = if prompt.target.user_created { + prompt.target.name.as_str() + } else { + prompt.target.mc_version.as_str() + }; + let subtitle = format!( + "Files are copied into {target_title}. Your {source_title} cluster is left exactly as it is." + ); + + rect() + .vertical() + .width(Size::flex(1.)) + .height(Size::fill()) + .content(Content::Flex) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.)) + .content(Content::Flex) + .padding(Gaps::new(22., 24., 0., 24.)) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .child( + label() + .text("Migrate your packages") + .font_size(21.) + .font_weight(FontWeight::SEMI_BOLD) + .width(Size::flex(1.)) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + Button::new() + .ghost() + .icon() + .on_press(move |_| close.dismiss_release_migration()) + .child( + Icon::new(IconType::XClose) + .size(16.) + .color(colors::fg_secondary()), + ), + ), + ) + .child( + label() + .text(subtitle) + .font_size(13.) + .max_lines(2) + .margin(Gaps::new(4., 0., 0., 0.)) + .color(colors::fg_secondary()), + ) + .child(source_picker(prompt, picker_dispatch)) + .child(tab_row(plan, excluded, tab)) + .child(package_list( + prompt, plan, meta, excluded, tab, wont_open, deps_open, + )), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .background(colors::component_border()), + ) + .child(footer( + prompt, + plan, + dispatch, + excluded, + copy_configs, + target_running, + )) +} + +fn source_picker(prompt: &ReleaseMigrationPrompt, dispatch: crate::Actions) -> impl IntoElement { + let sources: Vec = prompt.sources.iter().map(|cluster| cluster.id).collect(); + let options: Vec = prompt.sources.iter().map(cluster_title).collect(); + let selected = prompt.source().map(cluster_title).unwrap_or_default(); + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(12.) + .margin(Gaps::new(18., 0., 0., 0.)) + .child( + label() + .text("Migrate from") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + Dropdown::new(selected, options) + .outlined() + .width(Size::px(212.)) + .height(Size::px(32.)) + .on_select(move |index: usize| { + if let Some(source_id) = sources.get(index) { + dispatch.select_release_migration_source(*source_id); + } + }), + ) +} + +fn tab_row( + plan: Option<&ReleaseMigrationPlan>, + excluded: State>, + tab: State, +) -> impl IntoElement { + let active = *tab.read(); + + let mut pills = rect() + .horizontal() + .spacing(8.) + .cross_align(Alignment::Center); + for option in PackageTab::ALL { + let count = plan.map_or(0, |plan| plan.offered(option.content_type())); + let mut tab = tab; + pills = pills.child(tab_pill( + format!("{} · {count}", option.label()), + option == active, + move || tab.set(option), + )); + } + + let all_keys: Vec = plan + .map(|plan| { + plan.packages + .iter() + .map(|package| selection_key(package, plan.source_cluster_id)) + .collect() + }) + .unwrap_or_default(); + let any_selected = all_keys.iter().any(|key| !excluded.read().contains(key)); + let has_packages = !all_keys.is_empty(); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .margin(Gaps::new(18., 0., 10., 0.)) + .child(rect().width(Size::flex(1.)).child(pills)) + .maybe_child(has_packages.then(|| { + let mut excluded = excluded; + rect() + .padding(Gaps::new_symmetric(6., 4.)) + .cursor(CursorIcon::Pointer) + .on_press(move |_| { + let mut set = excluded.write(); + if any_selected { + set.extend(all_keys.iter().cloned()); + } else { + for key in &all_keys { + set.remove(key); + } + } + }) + .child( + label() + .text(if any_selected { + "Clear all" + } else { + "Select all" + }) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + })) +} + +fn tab_pill(text: String, active: bool, mut on_press: impl FnMut() + 'static) -> impl IntoElement { + rect() + .height(Size::px(34.)) + .padding(Gaps::new_symmetric(0., 13.)) + .center() + .corner_radius(CornerRadius::new_all(8.)) + .background(if active { + colors::brand() + } else { + Color::TRANSPARENT + }) + .border(border_all_color( + 1., + if active { + colors::brand() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_press()) + .child( + label() + .text(text) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(if active { + Color::WHITE + } else { + colors::fg_primary() + }), + ) +} + +fn package_list( + prompt: &ReleaseMigrationPrompt, + plan: Option<&ReleaseMigrationPlan>, + meta: &MetaMap, + excluded: State>, + tab: State, + wont_open: State, + deps_open: State, +) -> impl IntoElement { + let source_title = prompt.source().map(cluster_title).unwrap_or_default(); + + let message = match (prompt.plan(), plan) { + (Some(ReleasePlanState::Loading) | None, _) => { + Some("Checking which packages have a compatible version…".to_string()) + } + (Some(ReleasePlanState::Failed), _) => Some( + "Couldn't reach Modrinth or CurseForge. Check your connection and try again." + .to_string(), + ), + _ => None, + }; + if let Some(message) = message { + return list_message(message).into_element(); + } + + let Some(plan) = plan else { + return rect().into_element(); + }; + + let content_type = tab.read().content_type(); + let rows: Vec<&ReleaseMigrationPackage> = plan + .packages + .iter() + .filter(|package| package.content_type == content_type) + .collect(); + + let skipped: Vec<&ReleaseMigrationSkip> = plan + .unavailable + .iter() + .filter(|skip| skip.content_type == content_type) + .collect(); + + let dependencies: Vec<&ReleaseMigrationDependency> = if content_type == ContentType::Mod { + plan.dependencies.iter().collect() + } else { + Vec::new() + }; + + if rows.is_empty() && skipped.is_empty() && dependencies.is_empty() { + return list_message(format!( + "Nothing from {source_title} has a {} version yet.", + prompt.target.mc_version + )) + .into_element(); + } + + let mut scroll = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.)) + .spacing(0.); + + let mut has_content = !rows.is_empty(); + for package in rows { + let key = selection_key(package, plan.source_cluster_id); + let selected = !excluded.read().contains(&key); + + scroll = scroll.child( + MigrationRow { + name: package_name( + meta, + package.provider, + &package.project_id, + &package.display_name, + ), + icon_url: meta + .get(&(package.provider, package.project_id.clone())) + .and_then(|cached| cached.icon_url.clone()), + package: package.clone(), + selected, + selection: key.clone(), + excluded, + key: DiffKey::None, + } + .key(format!("{}:{}", key.0, key.1)) + .into_element(), + ); + } + + if !dependencies.is_empty() { + let selected_hashes = selected_hashes(plan, &excluded.read()); + let open = *deps_open.read(); + scroll = scroll.child(section_header( + IconType::Link03, + format!("Dependencies · {}", dependencies.len()), + Some("Installed with the packages that need them".to_string()), + open, + deps_open, + has_content, + )); + has_content = true; + + if open { + for dependency in dependencies { + let required_by: Vec = dependency + .required_by + .iter() + .filter_map(|hash| { + plan.packages + .iter() + .find(|package| &package.source_hash == hash) + }) + .map(|package| { + package_name( + meta, + package.provider, + &package.project_id, + &package.display_name, + ) + }) + .collect(); + let cached = meta.get(&(dependency.provider, dependency.project.id.clone())); + + scroll = scroll.child( + DependencyRow { + name: package_name( + meta, + dependency.provider, + &dependency.project.id, + &dependency.project.name, + ), + icon_url: cached + .and_then(|cached| cached.icon_url.clone()) + .or_else(|| dependency.project.icon_url.clone()), + provider: dependency.provider, + version_name: dependency.version_name(), + size: dependency.size(), + required_by: format!("Required by {}", required_by.join(", ")), + needed: dependency.is_needed_by(&selected_hashes), + key: DiffKey::None, + } + .key(format!( + "dependency:{}:{}", + plan.source_cluster_id, dependency.project.id + )) + .into_element(), + ); + } + } + } + + if !skipped.is_empty() { + let cross_loader = prompt.is_cross_loader(); + let build = build_label(prompt); + let mc_version = &build; + let open = *wont_open.read(); + scroll = scroll.child(section_header( + IconType::AlertCircle, + format!("Won't migrate · {}", skipped.len()), + most_common_reason(&skipped).map(|reason| reason_text(reason, mc_version)), + open, + wont_open, + has_content, + )); + + if open { + if !cross_loader { + scroll = scroll.child( + label() + .text(format!( + "Added automatically when a {mc_version} build appears (up to {WAITLIST_DAYS} days)" + )) + .font_size(12.) + .max_lines(1) + .margin(Gaps::new(0., 12., 6., 12.)) + .color(colors::fg_secondary()), + ); + } + for skip in skipped { + let cached = meta.get(&(skip.provider, skip.project_id.clone())); + scroll = scroll.child( + SkippedRow { + name: package_name( + meta, + skip.provider, + &skip.project_id, + &skip.display_name, + ), + icon_url: cached.and_then(|cached| cached.icon_url.clone()), + reason: reason_text(skip.reason, mc_version), + key: DiffKey::None, + } + .key(format!( + "skip:{}:{}", + plan.source_cluster_id, skip.project_id + )) + .into_element(), + ); + } + } + } + + scroll.into_element() +} + +fn package_name(meta: &MetaMap, provider: ProviderId, project_id: &str, fallback: &str) -> String { + meta.get(&(provider, project_id.to_string())) + .map(|cached| cached.name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn selected_hashes( + plan: &ReleaseMigrationPlan, + excluded: &HashSet, +) -> HashSet { + plan.packages + .iter() + .filter(|package| !excluded.contains(&selection_key(package, plan.source_cluster_id))) + .map(|package| package.source_hash.clone()) + .collect() +} + +fn build_label(prompt: &ReleaseMigrationPrompt) -> String { + let mc_version = &prompt.target.mc_version; + if !prompt.is_cross_loader() { + return mc_version.clone(); + } + match prompt.target.mc_loader { + GameLoader::Ornithe => format!("Ornithe {mc_version}"), + loader => format!("{loader} {mc_version}"), + } +} + +fn reason_text(reason: SkipReason, mc_version: &str) -> String { + match reason { + SkipReason::NoCompatibleVersion => format!("No {mc_version} build yet"), + SkipReason::ProviderUnavailable => "Couldn't check its provider".to_string(), + SkipReason::MissingDependency => "Depends on a package that can't migrate".to_string(), + } +} + +fn most_common_reason(skipped: &[&ReleaseMigrationSkip]) -> Option { + let mut counts: HashMap = HashMap::new(); + for skip in skipped { + *counts.entry(skip.reason).or_default() += 1; + } + [ + SkipReason::NoCompatibleVersion, + SkipReason::MissingDependency, + SkipReason::ProviderUnavailable, + ] + .into_iter() + .filter(|reason| counts.contains_key(reason)) + .max_by_key(|reason| counts[reason]) +} + +fn section_header( + icon: IconType, + title: String, + subtitle: Option, + open: bool, + mut state: State, + after_content: bool, +) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(44.)) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .margin(Gaps::new(if after_content { 10. } else { 0. }, 0., 6., 0.)) + .padding(Gaps::new_symmetric(0., 12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::ghost_overlay()) + .border(border_all_color(1., colors::component_border()).alignment(BorderAlignment::Inner)) + .cursor(CursorIcon::Pointer) + .on_press(move |_| state.toggle()) + .child(Icon::new(icon).size(16.).color(colors::fg_secondary())) + .child( + label() + .text(title) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle.unwrap_or_default()) + .font_size(12.) + .width(Size::flex(1.)) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .child( + Icon::new(if open { + IconType::ChevronDown + } else { + IconType::ChevronRight + }) + .size(16.) + .color(colors::fg_secondary()), + ) +} + +#[derive(PartialEq)] +struct DependencyRow { + name: String, + icon_url: Option, + provider: ProviderId, + version_name: String, + size: u64, + required_by: String, + needed: bool, + key: DiffKey, +} + +impl KeyExt for DependencyRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for DependencyRow { + fn render(&self) -> impl IntoElement { + let icon_query = use_cached_image(self.icon_url.clone(), 96); + let icon = match loaded_image(self.icon_url.as_deref(), &icon_query) { + Some((url, bytes)) => rect() + .width(Size::px(ROW_ICON)) + .height(Size::px(ROW_ICON)) + .corner_radius(CornerRadius::new_all(8.)) + .overflow(Overflow::Clip) + .child( + ImageViewer::new((url, bytes)) + .width(Size::px(ROW_ICON)) + .height(Size::px(ROW_ICON)) + .aspect_ratio(AspectRatio::Min) + .fallback(placeholder_icon()), + ) + .into_element(), + None => placeholder_icon().into_element(), + }; + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(56.)) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(14.) + .padding(Gaps::new_symmetric(0., 12.)) + .opacity(if self.needed { 1. } else { 0.45 }) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.)) + .spacing(2.) + .child( + label() + .text(self.name.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "{} · {}", + provider_label(self.provider), + self.version_name + )) + .font_size(12.) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ) + .maybe_child((self.size > 0).then(|| { + label() + .text(format_size(self.size)) + .font_size(12.) + .color(colors::fg_secondary()) + })) + .child( + label() + .text(self.required_by.clone()) + .font_size(12.) + .width(Size::px(REQUIRED_BY_W)) + .max_lines(1) + .text_align(TextAlign::End) + .color(colors::fg_secondary()), + ) + } +} + +fn provider_label(provider: ProviderId) -> &'static str { + match provider { + ProviderId::Modrinth => "Modrinth", + ProviderId::CurseForge => "CurseForge", + ProviderId::Local => "Local", + } +} + +#[derive(PartialEq)] +struct SkippedRow { + name: String, + icon_url: Option, + reason: String, + key: DiffKey, +} + +impl KeyExt for SkippedRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for SkippedRow { + fn render(&self) -> impl IntoElement { + let icon_query = use_cached_image(self.icon_url.clone(), 96); + let icon = match loaded_image(self.icon_url.as_deref(), &icon_query) { + Some((url, bytes)) => rect() + .width(Size::px(SKIPPED_ICON)) + .height(Size::px(SKIPPED_ICON)) + .corner_radius(CornerRadius::new_all(6.)) + .overflow(Overflow::Clip) + .child( + ImageViewer::new((url, bytes)) + .width(Size::px(SKIPPED_ICON)) + .height(Size::px(SKIPPED_ICON)) + .aspect_ratio(AspectRatio::Min), + ) + .into_element(), + None => Icon::new(IconType::DotsGrid) + .size(16.) + .color(colors::fg_secondary()) + .into_element(), + }; + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(40.)) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(14.) + .padding(Gaps::new_symmetric(0., 12.)) + .opacity(0.6) + .child(rect().width(Size::px(ROW_ICON)).center().child(icon)) + .child( + label() + .text(self.name.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .width(Size::px(SKIPPED_NAME_W)) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(self.reason.clone()) + .font_size(12.) + .width(Size::flex(1.)) + .max_lines(1) + .color(colors::fg_secondary()), + ) + } +} + +fn list_message(text: String) -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::flex(1.)) + .center() + .child( + label() + .text(text) + .font_size(13.) + .color(colors::fg_secondary()), + ) +} + +fn footer( + prompt: &ReleaseMigrationPrompt, + plan: Option<&ReleaseMigrationPlan>, + dispatch: crate::Actions, + excluded: State>, + copy_configs: State, + target_running: bool, +) -> impl IntoElement { + let dismiss = dispatch.clone(); + + let chosen: Vec = plan + .map(|plan| { + plan.packages + .iter() + .filter(|package| { + !excluded + .read() + .contains(&selection_key(package, plan.source_cluster_id)) + }) + .cloned() + .collect() + }) + .unwrap_or_default(); + let total = plan.map_or(0, |plan| plan.packages.len()); + let dependency_bytes: u64 = plan.map_or(0, |plan| { + let selected = selected_hashes(plan, &excluded.read()); + plan.dependencies + .iter() + .filter(|dependency| dependency.is_needed_by(&selected)) + .map(ReleaseMigrationDependency::size) + .sum() + }); + let bytes: u64 = chosen.iter().map(|package| package.size).sum::() + dependency_bytes; + let count = chosen.len(); + + let summary = format!( + "{count} of {total} packages selected · {} to copy", + format_size(bytes) + ); + + let enabled = count > 0 && !target_running; + let mut migrate = Button::new() + .primary() + .enabled(enabled) + .on_press(move |_| { + dispatch.migrate_release_packages(chosen.clone(), *copy_configs.peek()); + }) + .text("Migrate"); + if target_running { + migrate = migrate.tooltip(format!("Close {} before migrating", prompt.target.name)); + } + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new(16., 24., 16., 24.)) + .background(FOOTER_BG) + .child( + label() + .text(summary) + .font_size(12.) + .width(Size::flex(1.)) + .max_lines(2) + .color(colors::fg_secondary()), + ) + .maybe_child( + prompt + .target_dedicated + .then(|| checkbox_labeled(copy_configs, "Copy configs")), + ) + .child( + Button::new() + .ghost() + .on_press(move |_| dismiss.dismiss_release_migration()) + .text("Not now"), + ) + .child(migrate) +} + +struct MigrationRow { + name: String, + icon_url: Option, + package: ReleaseMigrationPackage, + selected: bool, + selection: SelectionKey, + excluded: State>, + key: DiffKey, +} + +impl PartialEq for MigrationRow { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + && self.icon_url == other.icon_url + && self.package.source_hash == other.package.source_hash + && self.package.version.version_id == other.package.version.version_id + && self.package.enabled == other.package.enabled + && self.selected == other.selected + && self.selection == other.selection + } +} + +impl KeyExt for MigrationRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for MigrationRow { + fn render(&self) -> impl IntoElement { + let icon_query = use_cached_image(self.icon_url.clone(), 96); + let mut hovered = use_state(|| false); + let mut excluded = self.excluded; + let selection = self.selection.clone(); + let selected = self.selected; + + let icon = match loaded_image(self.icon_url.as_deref(), &icon_query) { + Some((url, bytes)) => rect() + .width(Size::px(ROW_ICON)) + .height(Size::px(ROW_ICON)) + .corner_radius(CornerRadius::new_all(8.)) + .overflow(Overflow::Clip) + .child( + ImageViewer::new((url, bytes)) + .width(Size::px(ROW_ICON)) + .height(Size::px(ROW_ICON)) + .aspect_ratio(AspectRatio::Min) + .fallback(placeholder_icon()), + ) + .into_element(), + None => placeholder_icon().into_element(), + }; + + let provider = provider_label(self.package.provider); + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(56.)) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(14.) + .padding(Gaps::new_symmetric(0., 12.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(if *hovered.read() { + colors::ghost_overlay() + } else { + Color::TRANSPARENT + }) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(move |_| { + let mut set = excluded.write(); + if selected { + set.insert(selection.clone()); + } else { + set.remove(&selection); + } + }) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.)) + .spacing(2.) + .child( + label() + .text(self.name.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_primary()), + ) + .child( + label() + .text(if self.package.enabled { + format!("{provider} · {}", self.package.version_name) + } else { + format!("{provider} · {} · Disabled", self.package.version_name) + }) + .font_size(12.) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ) + .maybe_child((self.package.size > 0).then(|| { + label() + .text(format_size(self.package.size)) + .font_size(12.) + .color(colors::fg_secondary()) + })) + .child(check_box(selected)) + } +} + +fn placeholder_icon() -> impl IntoElement { + rect() + .width(Size::px(ROW_ICON)) + .height(Size::px(ROW_ICON)) + .center() + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., colors::component_border()).alignment(BorderAlignment::Inner)) + .child( + Icon::new(IconType::DotsGrid) + .size(14.) + .color(colors::fg_secondary()), + ) +} + +fn check_box(checked: bool) -> impl IntoElement { + rect() + .width(Size::px(CHECK_SIZE)) + .height(Size::px(CHECK_SIZE)) + .center() + .corner_radius(CornerRadius::new_all(6.)) + .background(if checked { + colors::brand() + } else { + Color::TRANSPARENT + }) + .border( + border_all_color( + 1., + if checked { + colors::brand() + } else { + colors::component_border() + }, + ) + .alignment(BorderAlignment::Inner), + ) + .maybe_child(checked.then(|| Icon::new(IconType::Check).size(14.).color(Color::WHITE))) +} diff --git a/packages/oneclient_app/src/components/screenshot_viewer.rs b/packages/oneclient_app/src/components/screenshot_viewer.rs index 5caae228..37710d1b 100644 --- a/packages/oneclient_app/src/components/screenshot_viewer.rs +++ b/packages/oneclient_app/src/components/screenshot_viewer.rs @@ -45,6 +45,8 @@ impl Component for ScreenshotViewer { move || start }); let mut menu = use_state(|| None::<(f32, f32)>); + let action = use_screenshot_action(); + let dispatch = use_dispatch(); if len == 0 { return rect().into_element(); @@ -52,9 +54,6 @@ impl Component for ScreenshotViewer { let idx = (*index.read()).min(len - 1); let info = shots[idx].clone(); - let action = use_screenshot_action(); - let dispatch = use_dispatch(); - let close = self.on_close.clone(); let scrim_close = self.on_close.clone(); let delete_close = self.on_close.clone(); @@ -81,6 +80,7 @@ impl Component for ScreenshotViewer { action, dispatch.clone(), move |()| menu_close.call(()), + None, ) .overlay_level(OVERLAY_MAX_LEVEL) .on_close(move |_| menu.set(None)) @@ -182,6 +182,7 @@ impl Component for ScreenshotViewer { .body("Screenshot copied to your clipboard.") .info() .icon(IconType::ClipboardCheck) + .toast_only() .send(); }) .child(Icon::new(IconType::Copy01).size(14.)) @@ -216,13 +217,20 @@ pub fn screenshot_context_menu( action: UseScreenshotAction, dispatch: Actions, on_deleted: impl Into>, + on_select: Option>, ) -> ContextMenu { let open_path = path.clone(); let copy_path = path.clone(); let delete_path = path; let on_deleted = on_deleted.into(); - ContextMenu::new(x, y) + let context_menu = match on_select { + Some(on_select) => ContextMenu::new(x, y) + .action(IconType::Check, "Select", on_select) + .separator(), + None => ContextMenu::new(x, y), + }; + context_menu .action(IconType::Folder, "Open in folder", move |()| { if let Some(dir) = open_path.parent() { crate::platform::open_path(&dir.to_string_lossy()); @@ -235,6 +243,7 @@ pub fn screenshot_context_menu( .body("Screenshot copied to your clipboard.") .info() .icon(IconType::ClipboardCheck) + .toast_only() .send(); }) .separator() diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs index cadeed8e..520dbcea 100644 --- a/packages/oneclient_app/src/components/scrollview.rs +++ b/packages/oneclient_app/src/components/scrollview.rs @@ -11,11 +11,11 @@ pub(crate) fn normalize_wheel_delta(delta: f64, scale_factor: f64) -> f32 { return 0.0; } - const FREYA_LINE_SPEED: f64 = 53.0; - const FREYA_PIXEL_SPEED: f64 = 2.0; - const LINE_DELTA_TOLERANCE: f64 = 1e-6; - const LINE_SCROLL: f64 = 53.0; - const PIXEL_SCROLL: f64 = 1.0; + const FREYA_LINE_SPEED: f64 = 53.0; + const FREYA_PIXEL_SPEED: f64 = 2.0; + const LINE_DELTA_TOLERANCE: f64 = 1e-6; + const LINE_SCROLL: f64 = 53.0; + const PIXEL_SCROLL: f64 = 1.0; let lines = (delta / FREYA_LINE_SPEED).round(); if lines != 0.0 && (delta - lines * FREYA_LINE_SPEED).abs() <= LINE_DELTA_TOLERANCE { @@ -39,7 +39,7 @@ pub(crate) fn scroll_pos_from_wheel(wheel: f32, inner: f32, viewport: f32, curre return 0; } if new_pos <= -(inner - viewport) && wheel < 0.0 { - return -(inner - viewport) as i32; + return -(inner - viewport).ceil() as i32; } new_pos as i32 } @@ -50,7 +50,7 @@ pub(crate) fn corrected_scroll(inner: f32, viewport: f32, pos: f32) -> f32 { } if (-pos + viewport) > inner { return if viewport < inner { - -(inner - viewport) + -(inner - viewport).ceil() } else { 0.0 }; @@ -81,6 +81,22 @@ pub struct ScrollAreaCtx { pub viewport_left: f32, } +#[derive(Clone, Copy)] +pub struct LazySection { + pub header: bool, + pub count: usize, +} + +enum LazyRow { + Header(usize), + Items { + section: usize, + row: usize, + start: usize, + end: usize, + }, +} + pub struct ScrollArea { width: Size, height: Size, @@ -197,6 +213,11 @@ impl ScrollArea { self } + pub fn append_children(mut self, children: impl IntoIterator) -> Self { + self.children.extend(children); + self + } + pub fn content(mut self, builder: impl Fn(ScrollAreaCtx) -> Element + 'static) -> Self { self.builder = Some(Box::new(builder)); self @@ -246,6 +267,173 @@ impl ScrollArea { self } + pub fn lazy_grid( + mut self, + count: usize, + item_height: f32, + gap: f32, + min_width: f32, + max_cols: usize, + render: impl Fn(usize) -> Element + 'static, + ) -> Self { + let slot = (item_height + gap).max(1.); + self.builder = Some(Box::new(move |ctx: ScrollAreaCtx| { + let cols = (((ctx.viewport_w + gap) / (min_width + gap)).floor() as usize) + .clamp(1, max_cols.max(1)); + let rows_total = count.div_ceil(cols); + + let first = + (((-ctx.corrected_y) / slot).floor() as i64 - LAZY_OVERSCAN).max(0) as usize; + let span = ((ctx.viewport_h / slot).ceil() as i64 + 2 * LAZY_OVERSCAN).max(0) as usize; + let last = (first + span).min(rows_total); + + let top_pad = first as f32 * slot; + let bottom_pad = rows_total.saturating_sub(last) as f32 * slot; + + let mut container = rect().vertical().width(Size::fill()); + if top_pad > 0. { + container = container.child(rect().width(Size::fill()).height(Size::px(top_pad))); + } + for r in first..last { + let mut row = rect() + .key(r) + .horizontal() + .width(Size::fill()) + .height(Size::px(slot)) + .spacing(gap) + .content(Content::Flex); + for c in 0..cols { + let idx = r * cols + c; + let cell = rect().width(Size::flex(1.0)).height(Size::px(item_height)); + row = row.child(if idx < count { + cell.child(render(idx)) + } else { + cell + }); + } + container = container.child(row); + } + if bottom_pad > 0. { + container = + container.child(rect().width(Size::fill()).height(Size::px(bottom_pad))); + } + container.into_element() + })); + self + } + + #[allow(clippy::too_many_arguments)] + pub fn lazy_sections( + mut self, + sections: Vec, + item_height: f32, + gap: f32, + min_width: f32, + max_cols: usize, + header_height: f32, + render: impl Fn(usize) -> Element + 'static, + render_header: impl Fn(usize) -> Element + 'static, + ) -> Self { + let slot = (item_height + gap).max(1.); + let header_slot = header_height + gap; + self.builder = Some(Box::new(move |ctx: ScrollAreaCtx| { + let cols = (((ctx.viewport_w + gap) / (min_width + gap)).floor() as usize) + .clamp(1, max_cols.max(1)); + + let mut rows = Vec::new(); + let mut offset = 0; + for (s, section) in sections.iter().enumerate() { + if section.header { + rows.push(LazyRow::Header(s)); + } + for r in 0..section.count.div_ceil(cols) { + let start = offset + r * cols; + rows.push(LazyRow::Items { + section: s, + row: r, + start, + end: (start + cols).min(offset + section.count), + }); + } + offset += section.count; + } + + let row_slot = |row: &LazyRow| match row { + LazyRow::Header(_) => header_slot, + LazyRow::Items { .. } => slot, + }; + + let overscan = LAZY_OVERSCAN as f32 * slot; + let visible_top = -ctx.corrected_y - overscan; + let visible_bottom = -ctx.corrected_y + ctx.viewport_h + overscan; + + let mut top_pad = 0.; + let mut bottom_pad = 0.; + let mut visible = Vec::new(); + let mut y = 0.; + for row in &rows { + let h = row_slot(row); + if y + h <= visible_top { + top_pad += h; + } else if y >= visible_bottom { + bottom_pad += h; + } else { + visible.push(row); + } + y += h; + } + + let mut container = rect().vertical().width(Size::fill()); + if top_pad > 0. { + container = container.child(rect().width(Size::fill()).height(Size::px(top_pad))); + } + for row in visible { + let el = match *row { + LazyRow::Header(section) => rect() + .key(("h", section)) + .width(Size::fill()) + .height(Size::px(header_slot)) + .child( + rect() + .width(Size::fill()) + .height(Size::px(header_height)) + .child(render_header(section)), + ), + LazyRow::Items { + section, + row, + start, + end, + } => { + let mut items = rect() + .key(("r", section, row)) + .horizontal() + .width(Size::fill()) + .height(Size::px(slot)) + .spacing(gap) + .content(Content::Flex); + for idx in start..start + cols { + let cell = rect().width(Size::flex(1.0)).height(Size::px(item_height)); + items = items.child(if idx < end { + cell.child(render(idx)) + } else { + cell + }); + } + items + } + }; + container = container.child(el); + } + if bottom_pad > 0. { + container = + container.child(rect().width(Size::fill()).height(Size::px(bottom_pad))); + } + container.into_element() + })); + self + } + fn view(&self) -> impl IntoElement { let horizontal = self.horizontal; let content_w = self.content_width; @@ -472,7 +660,7 @@ impl ScrollArea { }) .on_wheel(on_wheel) .on_capture_global_pointer_move(on_global_move) - .on_capture_global_pointer_press(on_global_release) + .on_capture_global_pointer_up(on_global_release) .on_global_key_down(move |e: Event| { let held = e.modifiers.contains(Modifiers::SHIFT) || matches!(e.code, Code::ShiftLeft | Code::ShiftRight); @@ -520,7 +708,7 @@ impl ScrollArea { content_h.set(h); if stick_bottom { let vp = *viewport_h.read(); - let target = -((h - vp).max(0.)); + let target = -((h - vp).max(0.).ceil()); controller.scroll_to_y(target as i32); } } diff --git a/packages/oneclient_app/src/components/spinner.rs b/packages/oneclient_app/src/components/spinner.rs new file mode 100644 index 00000000..fcff1c9e --- /dev/null +++ b/packages/oneclient_app/src/components/spinner.rs @@ -0,0 +1,53 @@ +use freya::animation::{AnimNum, Function, OnCreation, OnFinish, use_animation}; +use freya::prelude::*; + +use super::{Icon, IconType}; +use crate::theme::colors; + +const SPIN_TIME: u64 = 900; + +#[derive(PartialEq)] +pub struct Spinner { + size: f32, +} + +impl Spinner { + pub fn new(size: f32) -> Self { + Self { size } + } +} + +impl Component for Spinner { + fn render(&self) -> impl IntoElement { + let spin = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + conf.on_finish(OnFinish::restart()); + AnimNum::new(0., 360.) + .time(SPIN_TIME) + .function(Function::Linear) + }); + + rect().rotate(spin.get().value()).child( + Icon::new(IconType::Loading02) + .size(self.size) + .color(colors::fg_secondary()), + ) + } +} + +pub fn centered_spinner(message: &str) -> Element { + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .center() + .spacing(12.) + .child(Spinner::new(22.)) + .child( + label() + .text(message.to_string()) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/components/status_bar.rs b/packages/oneclient_app/src/components/status_bar.rs index 58dd86c4..6e0dec8b 100644 --- a/packages/oneclient_app/src/components/status_bar.rs +++ b/packages/oneclient_app/src/components/status_bar.rs @@ -2,9 +2,12 @@ use std::collections::HashSet; use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation}; use freya::prelude::*; +use freya::router::RouterContext; use oneclient_net::status::{self, ServiceStatus}; use crate::components::{Icon, IconType}; +use crate::hooks::{try_default_account, use_current_account}; +use crate::routes::Route; use crate::theme::colors; const BAR_HEIGHT: f32 = 34.; @@ -18,32 +21,48 @@ const BAR_LAYER: u8 = 14; #[derive(Clone, Copy, PartialEq, Eq, Hash)] enum Issue { NoInternet, + SignedOut, McAuthDown, PolyfrostDown, } +/// What the bar reacts to the network probe plus whether the active account +/// lost its sign-in +#[derive(Clone, PartialEq)] +struct Conditions { + status: ServiceStatus, + signed_out_as: Option, +} + impl Issue { - fn is_active(self, s: &ServiceStatus) -> bool { + fn is_active(self, c: &Conditions) -> bool { + let s = &c.status; match self { Self::NoInternet => !s.online, + Self::SignedOut => c.signed_out_as.is_some(), Self::McAuthDown => s.online && !s.mc_auth_up, Self::PolyfrostDown => s.online && !s.polyfrost_up, } } - fn message(self) -> &'static str { + fn message(self, c: &Conditions) -> String { match self { - Self::NoInternet => "No internet connection.", + Self::NoInternet => "No internet connection.".to_string(), + Self::SignedOut => format!( + "You have been signed out of {}. Sign in again to keep playing.", + c.signed_out_as.as_deref().unwrap_or("your account") + ), Self::McAuthDown => { - "Minecraft authentication servers are unreachable. Logging in may fail." + "Minecraft authentication servers are unreachable. Logging in may fail.".to_string() } - Self::PolyfrostDown => "Polyfrost services are experiencing issues.", + Self::PolyfrostDown => "Polyfrost services are experiencing issues.".to_string(), } } fn icon(self) -> IconType { match self { Self::NoInternet => IconType::Globe01, + Self::SignedOut => IconType::Users01, Self::McAuthDown => IconType::AlertTriangle, Self::PolyfrostDown => IconType::AlertCircle, } @@ -51,7 +70,7 @@ impl Issue { fn background(self) -> Color { match self { - Self::NoInternet => colors::danger(), + Self::NoInternet | Self::SignedOut => colors::danger(), Self::McAuthDown | Self::PolyfrostDown => AMBER, } } @@ -59,13 +78,23 @@ impl Issue { fn closeable(self) -> bool { !matches!(self, Self::NoInternet) } + + /// Where pressing the message takes the user if anywhere + fn route(self) -> Option { + matches!(self, Self::SignedOut).then_some(Route::SettingsAccounts {}) + } } -fn active_issues(s: &ServiceStatus) -> Vec { - [Issue::NoInternet, Issue::McAuthDown, Issue::PolyfrostDown] - .into_iter() - .filter(|i| i.is_active(s)) - .collect() +fn active_issues(c: &Conditions) -> Vec { + [ + Issue::NoInternet, + Issue::SignedOut, + Issue::McAuthDown, + Issue::PolyfrostDown, + ] + .into_iter() + .filter(|i| i.is_active(c)) + .collect() } #[derive(PartialEq)] @@ -75,6 +104,7 @@ impl Component for StatusBar { fn render(&self) -> impl IntoElement { let mut status = use_state(status::current); let mut dismissed = use_state(HashSet::::new); + let current_account = use_current_account(); use_hook(move || { status::request_recheck(); @@ -86,21 +116,35 @@ impl Component for StatusBar { }); }); + let conditions = move || Conditions { + status: *status.read(), + signed_out_as: try_default_account(¤t_account) + .filter(|a| a.needs_sign_in()) + .map(|a| a.username), + }; + + // A dismissal lasts only while its issue does so the next sign-out + // shows the bar again use_side_effect(move || { - let s = *status.read(); + let c = conditions(); let cur = dismissed.peek().clone(); - let next: HashSet = cur.iter().copied().filter(|i| i.is_active(&s)).collect(); + let next: HashSet = cur.iter().copied().filter(|i| i.is_active(&c)).collect(); if next != cur { dismissed.set(next); } }); - let s = *status.read(); + let c = conditions(); let dset = dismissed.read(); - let visible = active_issues(&s).into_iter().find(|i| !dset.contains(i)); + let visible = active_issues(&c).into_iter().find(|i| !dset.contains(i)); match visible { - Some(issue) => StatusBanner { issue, dismissed }.into_element(), + Some(issue) => StatusBanner { + issue, + text: issue.message(&c), + dismissed, + } + .into_element(), None => rect().into_element(), } } @@ -109,6 +153,7 @@ impl Component for StatusBar { #[derive(PartialEq)] struct StatusBanner { issue: Issue, + text: String, dismissed: State>, } @@ -153,6 +198,35 @@ impl Component for StatusBanner { // to the top of the content box inside the bar's padding let leading = rect().width(Size::flex(1.0)).height(Size::fill()); + let mut action_hover = use_state(|| false); + + let action = issue.route().map(|route| { + rect() + .center() + .height(Size::px(22.)) + .padding(Gaps::new_symmetric(0., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if *action_hover.read() { + Color::WHITE.with_a(46) + } else { + Color::WHITE.with_a(26) + }) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| action_hover.set(true)) + .on_pointer_leave(move |_| action_hover.set(false)) + .on_press(move |_| { + let _ = RouterContext::get().push(route.clone()); + }) + .child( + label() + .text("Sign in") + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(Color::WHITE), + ) + .into_element() + }); + let message = rect() .horizontal() .height(Size::fill()) @@ -162,12 +236,13 @@ impl Component for StatusBanner { .child(Icon::new(issue.icon()).size(15.).color(Color::WHITE)) .child( label() - .text(issue.message()) + .text(self.text.clone()) .font_size(12.) .font_weight(FontWeight::MEDIUM) .max_lines(1) .color(Color::WHITE), - ); + ) + .maybe_child(action); let trailing = rect() .width(Size::flex(1.0)) diff --git a/packages/oneclient_app/src/components/tab_bar.rs b/packages/oneclient_app/src/components/tab_bar.rs index 39679069..4c0739b2 100644 --- a/packages/oneclient_app/src/components/tab_bar.rs +++ b/packages/oneclient_app/src/components/tab_bar.rs @@ -117,7 +117,11 @@ fn tab_label(text: &str, font_size: f32, active: bool) -> impl IntoElement { FontWeight::NORMAL }) .max_lines(1) - .color(colors::fg_primary()) + .color(if active { + colors::fg_primary() + } else { + colors::fg_secondary() + }) .text_align(TextAlign::Center), ), ) @@ -221,7 +225,7 @@ impl Component for TabButton { .child(tab_label(&self.label, font_size, active)) .child( rect() - .height(Size::px(1.5)) + .height(Size::px(2.)) .width(Size::fill_minimum()) .margin(Gaps::new_symmetric(0., 4.0)) .corner_radius(CornerRadius::new_all(2.)) diff --git a/packages/oneclient_app/src/components/text_input.rs b/packages/oneclient_app/src/components/text_input.rs index 6fad7509..b300234d 100644 --- a/packages/oneclient_app/src/components/text_input.rs +++ b/packages/oneclient_app/src/components/text_input.rs @@ -1,13 +1,18 @@ use freya::prelude::*; +use crate::components::{Button, Icon, IconType, OverlayPopup}; use crate::theme::colors; +use crate::ui::clamp_to_window; const GAP_VERT: f32 = 8.0; const GAP_HORI: f32 = 12.0; +const EXPANDED_WIDTH: f32 = 440.; +const EXPANDED_HEIGHT: f32 = 180.; + fn text_input(value: impl Into>) -> Input { Input::new(value) - .inner_margin(Gaps::new_symmetric(GAP_VERT, GAP_HORI)) + .padding(Gaps::new_symmetric(GAP_VERT, GAP_HORI)) .corner_radius(CornerRadius::new_all(8.)) .background(colors::component_bg()) .focus_background(colors::component_bg_pressed()) @@ -40,6 +45,10 @@ pub fn validate_memory(validator: InputValidator) { #[derive(Clone, PartialEq)] pub struct TextInput { input: Input, + value: Writable, + on_submit: Option>, + multiline: bool, + expandable: bool, layout: LayoutData, style: StyleState, text_style: TextStyleData, @@ -50,12 +59,13 @@ pub struct TextInput { #[allow(dead_code)] impl TextInput { pub fn new(value: impl Into>) -> Self { - Self::from_input(text_input(value)) - } - - pub fn from_input(input: Input) -> Self { + let value = value.into(); Self { - input, + input: text_input(value.clone()), + value, + on_submit: None, + multiline: false, + expandable: false, layout: LayoutData::default(), style: StyleState::default(), text_style: TextStyleData::default(), @@ -81,7 +91,27 @@ impl TextInput { } pub fn on_submit(mut self, on_submit: impl Into>) -> Self { - self.input = self.input.on_submit(on_submit); + let on_submit = on_submit.into(); + self.input = self.input.on_submit(on_submit.clone()); + self.on_submit = Some(on_submit); + self + } + + pub fn compact(mut self) -> Self { + self.input = self + .input + .padding(Gaps::new_symmetric(GAP_VERT / 2., GAP_HORI / 1.5)); + self.font_size(12.) + } + + pub fn multiline(mut self, multiline: bool) -> Self { + self.input = self.input.multiline(multiline); + self.multiline = multiline; + self + } + + pub fn expandable(mut self, expandable: bool) -> Self { + self.expandable = expandable; self } @@ -147,23 +177,118 @@ impl StyleExt for TextInput { } } +fn submit_on_modifier_enter( + value: Writable, + on_submit: EventHandler, +) -> Callback, bool> { + Callback::new(move |e: Event| match &e.key { + Key::Named(NamedKey::Enter) if e.modifiers.contains(Modifiers::ctrl_or_meta()) => { + e.stop_propagation(); + e.prevent_default(); + on_submit.call(value.read().clone()); + false + } + Key::Named(NamedKey::Escape) | Key::Named(NamedKey::Shift) => true, + Key::Named(NamedKey::Tab) => false, + _ => { + e.stop_propagation(); + e.prevent_default(); + true + } + }) +} + impl Component for TextInput { fn render(&self) -> impl IntoElement { + let mut expanded = use_state(|| false); + let mut area = use_state(Area::default); + let mut card = rect() .layout(self.layout.clone()) .text_style(self.text_style.clone()) - .children(self.elements.clone()); + .children(self.elements.clone()) + .maybe(self.expandable, |el| { + el.on_sized(move |e: Event| area.set_if_modified(e.area)) + }); card.get_style().clone_from(&self.style); - card.child( - self.input - .clone() + let mut input = self.input.clone(); + if self.multiline + && let Some(on_submit) = self.on_submit.clone() + { + input = input.on_pre_key_down(submit_on_modifier_enter(self.value.clone(), on_submit)); + } + if !matches!(self.layout.height, Size::Inner) { + input = input.height(Size::fill()); + } + + let expanded_input = (self.expandable && expanded()).then(|| { + let area = area(); + let (left, top) = clamp_to_window( + area.max_x() - EXPANDED_WIDTH, + area.min_y(), + EXPANDED_WIDTH, + EXPANDED_HEIGHT, + ); + + OverlayPopup::new() + .backdrop(false) + .position(Position::new_global().left(left).top(top)) + .on_close(move |_| expanded.set(false)) + .child( + rect() + .corner_radius(CornerRadius::new_all(8.)) + .shadow(Shadow::from(( + 0., + 8., + 32., + 0., + Color::from_argb(120, 0, 0, 0), + ))) + .child( + self.input + .clone() + .multiline(true) + .auto_focus(true) + .width(Size::px(EXPANDED_WIDTH)) + .height(Size::px(EXPANDED_HEIGHT)), + ), + ) + }); + + if self.expandable { + input = input.trailing( + rect() + .on_pointer_down(|e: Event| e.stop_propagation()) + .child( + Button::new() + .ghost() + .icon() + .width(Size::px(22.)) + .height(Size::px(22.)) + .padding(3.) + .tooltip("Expand") + .on_press(move |e: Event| { + e.stop_propagation(); + expanded.set(true); + }) + .child( + Icon::new(IconType::Maximize01) + .size(14.) + .color(colors::fg_secondary()), + ), + ), + ); + } + + card.maybe_child(expanded_input).child( + input .map(self.text_style.text_align, |mut el, text_align| { el = el.text_align(text_align); if text_align == TextAlign::Center { - el = el.inner_margin(Gaps::new_symmetric(GAP_VERT, 0.)); + el = el.padding(Gaps::new_symmetric(GAP_VERT, 0.)); } el diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs index 8a63f263..56babcee 100644 --- a/packages/oneclient_app/src/components/toasts.rs +++ b/packages/oneclient_app/src/components/toasts.rs @@ -10,12 +10,12 @@ use freya::{ use oneclient_events::Level; use crate::{ - ui::{divider}, components::{Button, ButtonSize, Icon, IconType, TooltipPlacement, progress_track}, hooks::{use_dispatch, use_notifications_snapshot}, motion::use_animations_enabled, notifications::{InboxEntry, NotificationActionKind}, theme::colors, + ui::divider, }; const TOAST_W: f32 = 300.; @@ -339,12 +339,7 @@ fn close_button(dispatch: crate::Actions, id: u64) -> impl IntoElement { ) } -fn message_card( - entry: &InboxEntry, - dispatch: crate::Actions, - id: u64, - ttl_pct: f32, -) -> Rect { +fn message_card(entry: &InboxEntry, dispatch: crate::Actions, id: u64, ttl_pct: f32) -> Rect { toast_shell(entry) .child( rect() diff --git a/packages/oneclient_app/src/components/tooltip.rs b/packages/oneclient_app/src/components/tooltip.rs index 029cd694..131735ec 100644 --- a/packages/oneclient_app/src/components/tooltip.rs +++ b/packages/oneclient_app/src/components/tooltip.rs @@ -287,11 +287,19 @@ fn place(anchor: AnchorRect, placement: TooltipPlacement, size: Size2D) -> (f32, let (x, y) = match placement { TooltipPlacement::Top => ( centered_x, - if fits_above || !fits_below { above } else { below }, + if fits_above || !fits_below { + above + } else { + below + }, ), TooltipPlacement::Bottom => ( centered_x, - if fits_below || !fits_above { below } else { above }, + if fits_below || !fits_above { + below + } else { + above + }, ), TooltipPlacement::Left => ( if fits_before || !fits_after { diff --git a/packages/oneclient_app/src/components/upload_mclogs.rs b/packages/oneclient_app/src/components/upload_mclogs.rs index 81ead70a..9682f2b7 100644 --- a/packages/oneclient_app/src/components/upload_mclogs.rs +++ b/packages/oneclient_app/src/components/upload_mclogs.rs @@ -14,10 +14,7 @@ pub struct UploadToMclogs { impl UploadToMclogs { pub fn new(has_log: bool, confirm: State>) -> Self { - Self { - has_log, - confirm - } + Self { has_log, confirm } } } @@ -34,37 +31,64 @@ impl Component for UploadToMclogs { } } +pub fn notify_log_truncated(dispatch: &crate::Actions) { + dispatch + .notify("Log was shortened") + .body("The log was too large for mclo.gs, so only its most recent part was uploaded.") + .info() + .icon(IconType::AlertTriangle) + .send(); +} + pub fn use_mclogs_feedback(upload: UseUploadLog) { let dispatch = use_dispatch(); - let mut handled = use_state(|| None::); - - use_side_effect(move || match &*upload.read().state() { + let mut handled = use_state(|| match &*upload.peek().state() { MutationStateData::Settled { - res: Ok(result), .. - } => { - if handled.peek().as_deref() == Some(result.url.as_str()) { - return; - } + settlement_instant, .. + } => Some(*settlement_instant), + _ => None, + }); - handled.set(Some(result.url.clone())); - let _ = Clipboard::set(result.url.clone()); + use_side_effect(move || { + let reader = upload.read(); + let state = reader.state(); + let MutationStateData::Settled { + res, + settlement_instant, + } = &*state + else { + return; + }; - dispatch - .notify("Uploaded to mclo.gs") - .body(format!("{} (copied to clipboard)", result.url)) - .info() - .icon(IconType::LinkExternal01) - .send(); + if *handled.peek() == Some(*settlement_instant) { + return; } - MutationStateData::Settled { res: Err(err), .. } => { - let msg = err.to_string(); - if handled.peek().as_deref() == Some(msg.as_str()) { - return; - } + handled.set(Some(*settlement_instant)); - handled.set(Some(msg.clone())); - dispatch.notify("Upload failed").body(msg).error().send(); + match res { + Ok(result) => { + if let Err(err) = Clipboard::set(result.url.clone()) { + tracing::warn!("clipboard copy failed: {err:?}"); + } + + dispatch + .notify("Uploaded to mclo.gs") + .body(format!("{} (copied to clipboard)", result.url)) + .info() + .icon(IconType::LinkExternal01) + .send(); + + if result.truncated { + notify_log_truncated(&dispatch); + } + } + Err(err) => { + dispatch + .notify("Upload failed") + .body(err.to_string()) + .error() + .send(); + } } - _ => {} }); } diff --git a/packages/oneclient_app/src/components/version_art_gallery.rs b/packages/oneclient_app/src/components/version_art_gallery.rs new file mode 100644 index 00000000..ef8df418 --- /dev/null +++ b/packages/oneclient_app/src/components/version_art_gallery.rs @@ -0,0 +1,306 @@ +use freya::prelude::*; +use oneclient_core::images::DEFAULT_IMAGE_EDGE; + +use crate::components::overlay_popup::OVERLAY_MAX_LEVEL; +use crate::components::spinner::Spinner; +use crate::components::{ + ART_PREVIEW_EDGE, Button, DynamicArt, Icon, IconType, OverlayPopup, ScrollArea, +}; +use crate::hooks::{refresh_version_art_gallery, use_version_art_gallery}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const DIALOG_WIDTH: f32 = 880.; +const DIALOG_HEIGHT: f32 = 620.; +const PANE_PADDING: f32 = 24.; +const TILE_W: f32 = 248.; +const TILE_H: f32 = 140.; +const TILE_GAP: f32 = 14.; +const COLUMNS: usize = 3; + +#[derive(PartialEq, Clone)] +pub struct VersionArtGallery { + pending: State>, + error: State>, + on_pick: EventHandler, + on_close: EventHandler<()>, +} + +impl VersionArtGallery { + pub fn new( + pending: State>, + error: State>, + on_pick: impl Into>, + on_close: impl Into>, + ) -> Self { + Self { + pending, + error, + on_pick: on_pick.into(), + on_close: on_close.into(), + } + } +} + +impl Component for VersionArtGallery { + fn render(&self) -> impl IntoElement { + let urls = use_version_art_gallery(); + let mut pending = self.pending; + let mut error = self.error; + let in_flight = pending.read().clone(); + let failure = error.read().clone(); + + let scrim_close = self.on_close.clone(); + let header_close = self.on_close.clone(); + let footer_close = self.on_close.clone(); + let picked = self.on_pick.clone(); + let on_pick: EventHandler = EventHandler::from(move |url: String| { + if pending.read().is_some() { + return; + } + error.set(None); + pending.set(Some(url.clone())); + picked.call(url); + }); + + let rows = urls.len().div_ceil(COLUMNS); + + let scroll = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new(0., PANE_PADDING, 8., PANE_PADDING)) + .scrollbar_gutter(true); + + let scroll = if urls.is_empty() { + scroll.child(empty_state()) + } else { + scroll.lazy(rows, TILE_H, TILE_GAP, move |index| { + let start = index * COLUMNS; + let end = (start + COLUMNS).min(urls.len()); + tile_row(&urls[start..end], &on_pick, in_flight.as_deref()) + }) + }; + + OverlayPopup::new() + .overlay_level(OVERLAY_MAX_LEVEL) + .on_close(move |()| scrim_close.call(())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(DIALOG_WIDTH)) + .height(Size::px(DIALOG_HEIGHT)) + .max_width(Size::window_percent(94.)) + .max_height(Size::window_percent(92.)) + .content(Content::Flex) + .corner_radius(CornerRadius::new_all(16.)) + .overflow(Overflow::Clip) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child(header(move |_| header_close.call(()))) + .child(scroll) + .child(footer(failure, move |_| footer_close.call(()))), + ), + ) + .into_element() + } +} + +fn tile_row(row: &[String], on_pick: &EventHandler, in_flight: Option<&str>) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .spacing(TILE_GAP) + .children( + row.iter() + .map(|url| { + GalleryTile { + busy: in_flight == Some(url.as_str()), + dimmed: in_flight.is_some(), + url: url.clone(), + on_pick: on_pick.clone(), + } + .into_element() + }) + .collect::>(), + ) + .into_element() +} + +#[derive(PartialEq, Clone)] +struct GalleryTile { + url: String, + busy: bool, + dimmed: bool, + on_pick: EventHandler, +} + +impl Component for GalleryTile { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let mut hovering = use_state(|| false); + let hovered = *hovering.read(); + + let on_pick = self.on_pick.clone(); + let picked = self.url.clone(); + + let border_color = if hovered || self.busy { + colors::brand() + } else { + colors::component_border() + }; + + let opacity = if self.dimmed && !self.busy { 0.45 } else { 1.0 }; + + rect() + .width(Size::px(TILE_W)) + .height(Size::px(TILE_H)) + .corner_radius(CornerRadius::new_all(12.)) + .overflow(Overflow::Clip) + .opacity(opacity) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_press(move |_| on_pick.call(picked.clone())) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .child( + DynamicArt::for_url(self.url.clone()) + .max_edge(ART_PREVIEW_EDGE) + .skeleton(true), + ), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .layer(Layer::Relative(3)) + .corner_radius(CornerRadius::new_all(12.)) + .border(border_all_color(2., border_color).alignment(BorderAlignment::Inner)) + .center() + .maybe_child(self.busy.then(|| Spinner::new(22.).into_element())), + ) + .into_element() + } +} + +fn empty_state() -> Element { + rect() + .vertical() + .width(Size::fill()) + .height(Size::px(220.)) + .center() + .cross_align(Alignment::Center) + .spacing(12.) + .child( + label() + .text("The art gallery could not be loaded. Check your connection.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + Button::new() + .secondary() + .small() + .on_press(move |_| { + spawn(async move { + refresh_version_art_gallery().await; + }); + }) + .text("Try again"), + ) + .into_element() +} + +fn header(on_close: impl FnMut(Event) + 'static) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Start) + .spacing(16.) + .padding(Gaps::new(22., PANE_PADDING, 16., PANE_PADDING)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(5.) + .child( + label() + .text("Art gallery") + .font_size(20.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("Pick any of the official version artworks as the background for this instance.") + .font_size(13.) + .line_height(1.35) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .ghost() + .icon() + .alt("Close") + .on_press(on_close) + .child(Icon::new(IconType::XClose).size(16.)), + ) + .into_element() +} + +fn footer( + failure: Option, + on_close: impl FnMut(Event) + 'static, +) -> Element { + let (note, note_color) = match failure { + Some(message) => (message, colors::danger()), + None => ( + "Choosing an artwork pins it to this instance, so it stays put if you change the version later." + .to_string(), + colors::fg_secondary(), + ), + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(20.) + .padding(Gaps::new(16., PANE_PADDING, 16., PANE_PADDING)) + .border( + Border::new() + .fill(colors::component_border()) + .width(BorderWidth { + top: 1., + right: 0., + bottom: 0., + left: 0., + }), + ) + .child( + label() + .text(note) + .width(Size::flex(1.0)) + .font_size(12.) + .line_height(1.35) + .max_lines(2) + .color(note_color), + ) + .child(Button::new().ghost().on_press(on_close).text("Cancel")) + .into_element() +} + +pub const GALLERY_COVER_EDGE: u32 = DEFAULT_IMAGE_EDGE; diff --git a/packages/oneclient_app/src/components/version_card.rs b/packages/oneclient_app/src/components/version_card.rs index b8c806e7..12c00424 100644 --- a/packages/oneclient_app/src/components/version_card.rs +++ b/packages/oneclient_app/src/components/version_card.rs @@ -1,19 +1,19 @@ use freya::prelude::*; -use oneclient_common::VersionKey; use oneclient_core::clusters::Cluster; -use crate::components::ClusterLandscapeArt; +use crate::components::{ART_PREVIEW_EDGE, DynamicArt}; use crate::theme::colors; use crate::ui::border_all_color; -use crate::utils::{ReleaseLine, line_art_key, line_title, loader_tags}; +use crate::utils::{GridSelection, ReleaseLine, line_art_key, line_title}; -const CARD_HEIGHT_PX: f32 = 240.; +const CARD_HEIGHT_PX: f32 = 150.; pub struct VersionCard { - pub line: ReleaseLine, - pub art_key: Option, - pub tags: Vec, + pub key: GridSelection, + pub art: DynamicArt, pub title: String, + pub caption: String, + pub count: usize, pub selected: bool, pub on_press: EventHandler>, } @@ -22,14 +22,33 @@ impl VersionCard { pub fn new( line: ReleaseLine, clusters: &[Cluster], + caption: String, selected: bool, on_press: impl Into>>, ) -> Self { Self { - line, - art_key: line_art_key(line, clusters), - tags: loader_tags(clusters), + key: GridSelection::Line(line), + art: DynamicArt::for_version(line.major, line_art_key(line, clusters), None) + .max_edge(ART_PREVIEW_EDGE), title: line_title(line, clusters), + caption, + count: clusters.len(), + selected, + on_press: on_press.into(), + } + } + + pub fn for_instance( + cluster: &Cluster, + selected: bool, + on_press: impl Into>>, + ) -> Self { + Self { + key: GridSelection::Instance(cluster.id), + art: DynamicArt::for_cluster(cluster).max_edge(ART_PREVIEW_EDGE), + title: cluster.name.clone(), + caption: format!("{} \u{b7} {}", cluster.mc_version, cluster.mc_loader), + count: 1, selected, on_press: on_press.into(), } @@ -38,10 +57,11 @@ impl VersionCard { impl PartialEq for VersionCard { fn eq(&self, other: &Self) -> bool { - self.line == other.line - && self.art_key == other.art_key - && self.tags == other.tags + self.key == other.key + && self.art == other.art && self.title == other.title + && self.caption == other.caption + && self.count == other.count && self.selected == other.selected } } @@ -58,101 +78,91 @@ impl Component for VersionCard { let focused = focus().is_focused(); let on_press = self.on_press.clone(); - let opacity = if selected || hovered || focused { - if selected { 1.0 } else { 0.85 } + let art_opacity = if selected { + 1.0 + } else if hovered || focused { + 0.85 } else { 0.6 }; - let border_color = if selected || focused { - colors::brand() + let border = if selected || focused { + border_all_color(2., colors::brand()) } else if hovered { - colors::component_border_hover() + border_all_color(1., colors::component_border_hover()) } else { - colors::component_border() + border_all_color(1., colors::component_border()) }; rect() - .key(self.line) - .width(Size::flex(1.0)) + .key(self.key) + .width(Size::fill()) .height(Size::px(CARD_HEIGHT_PX)) + .corner_radius(CornerRadius::new_all(16.)) + .overflow(Overflow::Clip) + .background(colors::page_elevated()) .a11y_id(a11y_id) .a11y_focusable(true) .a11y_role(AccessibilityRole::Button) .on_press(move |e| on_press.call(e)) - .on_pointer_enter(move |_| { - *hovering.write() = true; - }) - .on_pointer_leave(move |_| { - *hovering.write() = false; - }) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) .child( rect() .width(Size::fill()) .height(Size::fill()) - .overflow(Overflow::Clip) - .corner_radius(CornerRadius::new_all(12.)) - .opacity(opacity) - .child( - rect() - .width(Size::fill()) - .height(Size::fill()) - .position(Position::new_absolute()) - .child(ClusterLandscapeArt::for_version( - self.line.major, - self.art_key, - None, - false, - )), + .position(Position::new_absolute()) + .opacity(art_opacity) + .child(self.art.clone()), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .padding(12.) + .spacing(5.) + .main_align(Alignment::End) + .corner_radius(CornerRadius::new_all(16.)) + .border(border.alignment(BorderAlignment::Inner)) + .layer(Layer::Relative(3)) + .background( + LinearGradient::new() + .angle(0.) + .stop((Color::from_af32rgb(0.1, 11, 16, 19), 0.)) + .stop((Color::from_af32rgb(0.6, 11, 16, 19), 55.)) + .stop((Color::from_af32rgb(0.95, 11, 16, 19), 100.)), ) .child( - rect() - .width(Size::fill()) - .height(Size::fill()) - .padding(Gaps::new_symmetric(12., 16.)) - .main_align(Alignment::SpaceBetween) - .corner_radius(CornerRadius::new_all(12.)) - .cross_align(Alignment::Start) - .border( - border_all_color(1., border_color) - .alignment(BorderAlignment::Inner), - ) - .layer(Layer::Relative(3)) - .background( - LinearGradient::new() - .angle(0.) - .stop((Color::from_af32rgb(0.8, 0, 0, 0), 0.)) - .stop((Color::from_af32rgb(0.3, 0, 0, 0), 20.)) - .stop((Color::from_af32rgb(0.3, 0, 0, 0), 60.)) - .stop((Color::from_af32rgb(0.8, 0, 0, 0), 100.)), - ) - .child( - rect() - .horizontal() - .spacing(8.) - .children(self.tags.iter().map(|tag| { - rect() - .padding(Gaps::new_symmetric(4., 8.)) - .corner_radius(CornerRadius::new_all(999.)) - .background(colors::fg_primary()) - .child( - label() - .text(tag.clone()) - .font_size(12.) - .font_weight(FontWeight::MEDIUM) - .color(colors::brand()), - ) - .into_element() - })), - ) - .child( - label() - .text(format!("Version {}", self.title)) - .font_size(32.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ), + label() + .text(self.title.clone()) + .font_size(22.) + .font_weight(FontWeight::SEMI_BOLD) + .color(Color::WHITE), + ) + .child( + label() + .text(self.caption.clone()) + .font_size(11.) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .color(colors::fg_secondary()), ), ) + .maybe_child((self.count > 1).then(|| { + rect() + .position(Position::new_absolute().top(8.).right(8.)) + .layer(Layer::Relative(4)) + .padding(Gaps::new_symmetric(4., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(Color::from_af32rgb(0.66, 11, 16, 19)) + .child( + label() + .text(format!("{} instances", self.count)) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .into_element() + })) } } diff --git a/packages/oneclient_app/src/essential.rs b/packages/oneclient_app/src/essential.rs new file mode 100644 index 00000000..383f3330 --- /dev/null +++ b/packages/oneclient_app/src/essential.rs @@ -0,0 +1,44 @@ +use oneclient_content::packages::ProviderId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EssentialPackage { + pub provider: ProviderId, + pub project_id: &'static str, + pub name: &'static str, + pub disable_body: &'static str, + pub remove_body: &'static str, +} + +pub const ESSENTIAL_PACKAGES: &[EssentialPackage] = &[EssentialPackage { + provider: ProviderId::Modrinth, + project_id: "Iw9mZi4a", + name: "PolyPlus+", + disable_body: "PolyPlus+ powers your Poly+ cosmetics and playtime tracking. Turning it off disables them in game, and it stays off through future bundle updates until you turn it back on.", + remove_body: "PolyPlus+ powers your Poly+ cosmetics and playtime tracking. Removing it disables them in game and deletes the file from this cluster, so you would have to install it again from the Browser.", +}]; + +pub fn lookup(provider: ProviderId, package_id: &str) -> Option<&'static EssentialPackage> { + ESSENTIAL_PACKAGES + .iter() + .find(|package| package.provider == provider && package.project_id == package_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn polyplus_is_essential_on_modrinth_only() { + assert_eq!( + lookup(ProviderId::Modrinth, "Iw9mZi4a").map(|p| p.name), + Some("PolyPlus+") + ); + assert!(lookup(ProviderId::CurseForge, "Iw9mZi4a").is_none()); + assert!(lookup(ProviderId::Local, "Iw9mZi4a").is_none()); + } + + #[test] + fn ordinary_packages_are_not_guarded() { + assert!(lookup(ProviderId::Modrinth, "AibBIVmj").is_none()); + } +} diff --git a/packages/oneclient_app/src/events.rs b/packages/oneclient_app/src/events.rs index 56bd0328..777435f7 100644 --- a/packages/oneclient_app/src/events.rs +++ b/packages/oneclient_app/src/events.rs @@ -30,6 +30,8 @@ impl EventPump { // Hovering any toast pauses every toast including ones arriving while hovering let mut paused = false; let mut game_flush: Option = None; + let mut launched: HashSet = HashSet::new(); + let mut playing: Option = None; loop { let next_deadline = armed @@ -96,6 +98,10 @@ impl EventPump { if folded.saw_game_log && game_flush.is_none() { game_flush = Some(tokio::time::Instant::now() + GAME_LOG_FLUSH); } + if folded.saw_stage { + launched.extend(&folded.launched); + playing = crate::platform::follow_game(&self.station, &launched, playing); + } } signal = self.signals.recv() => { @@ -139,6 +145,10 @@ impl EventPump { folded.clusters = true; } Event::Game(GameEvent::Stage { cluster_id, stage }) => { + folded.saw_stage = true; + if stage.is_busy() { + folded.launched.push(cluster_id); + } stages.push((cluster_id, stage)); } Event::Game(GameEvent::Log { cluster_id, line }) => { @@ -148,7 +158,10 @@ impl EventPump { Event::Game(GameEvent::Failed { cluster_id, message, - }) => failed = Some((cluster_id, message)), + }) => { + folded.saw_stage = true; + failed = Some((cluster_id, message)); + } Event::Progress(ProgressEvent::Update { id, ref label, @@ -183,7 +196,10 @@ impl EventPump { guard.game.stages.insert(cluster_id, stage); if stage == LaunchStage::Checking { guard.game.error = None; - guard.game.logs.insert(cluster_id, std::sync::Arc::new(Vec::new())); + guard + .game + .logs + .insert(cluster_id, std::sync::Arc::new(Vec::new())); } } for (cluster_id, line) in logs { @@ -246,6 +262,8 @@ impl EventPump { struct Folded { touched_engine: bool, saw_game_log: bool, + saw_stage: bool, + launched: Vec, clusters: bool, java: bool, } @@ -264,7 +282,9 @@ fn reconcile( .collect(); for id in &want { - armed.entry(*id).or_insert_with(|| ToastTimer::armed(paused)); + armed + .entry(*id) + .or_insert_with(|| ToastTimer::armed(paused)); } armed.retain(|id, _| want.contains(id)); } @@ -320,7 +340,9 @@ pub async fn start_launcher( station: RadioStation, events: oneclient_events::EventBus, ) -> Result<(), anyhow::Error> { - let state = crate::launcher::install(oneclient_core::LauncherState::new(events).await?); + let state = crate::launcher::install( + crate::launcher::off_ui(oneclient_core::LauncherState::new(events)).await?, + ); oneclient_net::status::start(state.services.requester.clone()); oneclient_polyplus::start(std::sync::Arc::clone(&state.auth)); @@ -358,10 +380,7 @@ pub async fn start_launcher( Ok(()) } -pub fn report_startup_failure( - station: &RadioStation, - err: &anyhow::Error, -) { +pub fn report_startup_failure(station: &RadioStation, err: &anyhow::Error) { let message = err.to_string(); tracing::error!("launcher init failed: {err:#}"); @@ -379,7 +398,9 @@ pub fn report_startup_failure( let mut guard = station.write_channel(AppChannel::Notifications); let AppState { - notifications, inbox, .. + notifications, + inbox, + .. } = &mut **guard; notifications.dispatch( inbox, diff --git a/packages/oneclient_app/src/file_content.rs b/packages/oneclient_app/src/file_content.rs index 16ee825f..bf115be7 100644 --- a/packages/oneclient_app/src/file_content.rs +++ b/packages/oneclient_app/src/file_content.rs @@ -104,7 +104,10 @@ pub fn bundle_slug(folder: &str) -> String { pub fn info_plist(name: &str, executable: &str, folder: &str, icon: Option<&str>) -> String { let display = xml_escape(&single_line(name)); let icon_entry = icon.map_or_else(String::new, |icon| { - format!("\tCFBundleIconFile\n\t{}\n", xml_escape(icon)) + format!( + "\tCFBundleIconFile\n\t{}\n", + xml_escape(icon) + ) }); format!( diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 674a3f17..cfa8eddb 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -3,6 +3,11 @@ //! Always `spawn_forever` never `spawn` Freya's `spawn` cancels the task when //! the calling component unmounts this work is app-scoped not component-scoped +mod modpacks; + +pub use modpacks::modpack_job_running; +mod release_migration; + use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -24,12 +29,13 @@ use oneclient_events::{Answer, Level}; use tokio::sync::mpsc; use crate::components::IconType; -use crate::{invalidate_java_queries, launcher}; +use crate::launcher::off_ui; use crate::notifications::{ - ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, OptionalModsGroup, - PackageUpdateGroup, PendingPrompt, + BundleChoices, ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, + OptionalModsGroup, OptionalModsOutcome, PackageUpdateGroup, PendingPrompt, }; -use crate::state::{AppChannel, AppState, AsyncStatus, RelocationState}; +use crate::state::{AppChannel, AppState, AsyncStatus, FlaggedInstallPrompt, RelocationState}; +use crate::{invalidate_java_queries, launcher}; /// Over-disabling is the cheaper mistake a dead button beats a second game const LAUNCH_HOLD: Duration = Duration::from_secs(2); @@ -172,7 +178,10 @@ impl Actions { fn with_engine(&self, mutate: impl FnOnce(&mut AppState)) { { - let mut guard = self.station.clone().write_channel(AppChannel::Notifications); + let mut guard = self + .station + .clone() + .write_channel(AppChannel::Notifications); mutate(&mut guard); } self.nudge(PumpSignal::Reconcile); @@ -196,10 +205,8 @@ impl Actions { guard.settings.error = None; } - let loaded = oneclient_core::settings::store::load_settings(Some( - &state.services.events, - )) - .await; + let loaded = + oneclient_core::settings::store::load_settings(Some(&state.services.events)).await; let discord_enabled = loaded.discord_enabled; *state.settings.write() = loaded.clone(); state.discord.set_enabled(discord_enabled); @@ -231,7 +238,10 @@ impl Actions { }); } - fn mutate_settings(&self, mutate: impl FnOnce(&mut LauncherSettings)) -> Option { + fn mutate_settings( + &self, + mutate: impl FnOnce(&mut LauncherSettings), + ) -> Option { let state = launcher::state().ok()?; let updated = { let mut lock = state.settings.write(); @@ -255,6 +265,10 @@ impl Actions { }); } + pub fn refresh_settings_from_core(&self) { + self.mutate_settings(|_| {}); + } + pub fn edit_settings(&self, edit: impl FnOnce(&mut LauncherSettings)) { let Some(updated) = self.mutate_settings(edit) else { return; @@ -266,7 +280,11 @@ impl Actions { } pub fn set_settings(&self, settings: LauncherSettings) { - let Some(updated) = self.mutate_settings(|s| *s = settings) else { + let Some(updated) = self.mutate_settings(|s| { + let pending = std::mem::take(&mut s.pending_release_migrations); + *s = settings; + s.pending_release_migrations = pending; + }) else { return; }; if let Ok(state) = launcher::state() { @@ -312,7 +330,7 @@ impl Actions { self.persist(updated); } } - + pub fn reset_onboarding(&self) { if let Some(updated) = self.mutate_settings(|settings| { settings.seen_onboarding = false; @@ -534,8 +552,14 @@ impl Actions { spawn_forever(async move { let Ok(state) = launcher::state() else { return }; let events = state.services.events.clone(); - match oneclient_core::import_migration_game_dir(&state, source, &folder_name, target) - .await + match off_ui({ + let state = state.clone(); + async move { + oneclient_core::import_migration_game_dir(&state, source, &folder_name, target) + .await + } + }) + .await { Ok(()) => { events @@ -557,18 +581,21 @@ impl Actions { spawn_forever(async move { let Ok(state) = launcher::state() else { return }; let events = state.services.events.clone(); - match state.java.install_runtime_from(&vendor, major).await { + match off_ui({ + let state = state.clone(); + async move { state.java.install_runtime_from(&vendor, major).await } + }) + .await + { Ok(_) => { events.signal(oneclient_events::Signal::JavaChanged); invalidate_java_queries().await - }, - Err(err) => { - events + } + Err(err) => events .notify("Java install failed") .body(err.to_string()) .error() - .send() - } + .send(), } }); } @@ -686,13 +713,16 @@ impl Actions { pub fn clear_notification_inbox(&self) { self.with_engine(|state| { - state.inbox.clear(); - state.notifications.clear_inbox(); + state.notifications.clear_inbox(&mut state.inbox); }); } pub fn dismiss_toast(&self, entry_id: u64) { - self.with_engine(|state| state.notifications.dismiss_toast(&mut state.inbox, entry_id)); + self.with_engine(|state| { + state + .notifications + .dismiss_toast(&mut state.inbox, entry_id) + }); } pub fn mark_notification_read(&self, entry_id: u64) { @@ -721,18 +751,17 @@ impl Actions { pub fn open_optional_mods( &self, groups: Vec, - done: Option>, + done: Option>, ) { self.with_engine(move |state| state.notifications.open_optional_mods(groups, done)); } - pub fn close_optional_mods(&self) { - self.with_engine(|state| state.notifications.finish_optional_mods()); + pub fn close_bundle_choices(&self, chosen: Option>) { + self.with_engine(move |state| state.notifications.finish_bundle_choices(chosen)); } - pub fn decline_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) { - self.close_optional_mods(); - self.record_skipped_optional_mods(mods); + pub fn close_optional_mods(&self, outcome: OptionalModsOutcome) { + self.with_engine(move |state| state.notifications.finish_optional_mods(outcome)); } pub fn record_skipped_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) { @@ -762,7 +791,7 @@ impl Actions { /// `mods/` once at startup, so finishing after the process starts is useless pub fn enable_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) { if mods.is_empty() { - self.close_optional_mods(); + self.close_optional_mods(OptionalModsOutcome::Launch); return; } @@ -774,92 +803,98 @@ impl Actions { spawn_forever(async move { let Ok(state) = launcher::state() else { - actions.close_optional_mods(); + actions.close_optional_mods(OptionalModsOutcome::Launch); return; }; - let content = state.services.content(); let events = state.services.events.clone(); - let session = oneclient_events::GroupedProgressSession::start( - &events, - "Adding optional mods", - ); - let opt_in = session.child( - "Enabling mods", - mods.len() as u64, - oneclient_events::TaskCategory::Packages, - ); - opt_in.set_phase(oneclient_events::TaskPhase::Installing); + let (installed, failed) = off_ui(async move { + let content = state.services.content(); + let session = oneclient_events::GroupedProgressSession::start( + &state.services.events, + "Adding optional mods", + ); + let opt_in = session.child( + "Enabling mods", + mods.len() as u64, + oneclient_events::TaskCategory::Packages, + ); + opt_in.set_phase(oneclient_events::TaskPhase::Installing); - let mut clusters: Vec = Vec::new(); - let mut enabled: Vec<(ClusterId, OptionalModRef)> = Vec::new(); - let mut failed = 0usize; - for (cluster_id, (bundle_name, package_id)) in &mods { - match oneclient_core::set_bundle_package_enabled( - *cluster_id, - bundle_name, - package_id, - true, - false, - &content, - ) - .await - { - Ok(()) => { - if !clusters.contains(cluster_id) { - clusters.push(*cluster_id); + let mut clusters: Vec = Vec::new(); + let mut enabled: Vec<(ClusterId, OptionalModRef)> = Vec::new(); + let mut failed = 0usize; + for (cluster_id, (bundle_name, package_id)) in &mods { + match oneclient_core::set_bundle_package_enabled( + *cluster_id, + bundle_name, + package_id, + true, + false, + &content, + ) + .await + { + Ok(()) => { + if !clusters.contains(cluster_id) { + clusters.push(*cluster_id); + } + enabled.push((*cluster_id, (bundle_name.clone(), package_id.clone()))); + } + Err(err) => { + failed += 1; + tracing::warn!( + cluster_id, + %bundle_name, + %package_id, + error = %err, + "failed to opt in to an optional mod" + ); } - enabled.push((*cluster_id, (bundle_name.clone(), package_id.clone()))); - } - Err(err) => { - failed += 1; - tracing::warn!( - cluster_id, - %bundle_name, - %package_id, - error = %err, - "failed to opt in to an optional mod" - ); } } - } - opt_in.finish(); + opt_in.finish(); - let mut installed = 0usize; - let mut applied: Vec = Vec::new(); - for cluster_id in &clusters { - match oneclient_core::apply_bundle_updates_with( - *cluster_id, - state.bundles.as_ref(), - &content, - Some(&session), - None, - ) - .await - { - Ok(result) => { - installed += result.additions_applied.len(); - failed += result.additions_failed.len(); - applied.push(*cluster_id); - } - Err(err) => { - tracing::warn!(cluster_id, error = %err, "failed to install optional mods"); + let mut installed = 0usize; + let mut applied: Vec = Vec::new(); + for cluster_id in &clusters { + match oneclient_core::apply_bundle_updates_with( + *cluster_id, + state.bundles.as_ref(), + &content, + Some(&session), + None, + ) + .await + { + Ok(result) => { + installed += result.additions_applied.len(); + failed += result.additions_failed.len(); + applied.push(*cluster_id); + } + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to install optional mods"); + } } } - } - // An offer only counts as answered once its mod really was enabled - // and installed otherwise the click is lost and never comes back - enabled.retain(|(cluster_id, _)| applied.contains(cluster_id)); - for (cluster_id, package_ids) in group_by_cluster(&enabled) { - if let Err(err) = - oneclient_core::resolve_optional_mods(cluster_id, &package_ids, &content).await - { - tracing::warn!(cluster_id, error = %err, "failed to clear answered offers"); + // An offer only counts as answered once its mod really was enabled + // and installed otherwise the click is lost and never comes back + enabled.retain(|(cluster_id, _)| applied.contains(cluster_id)); + for (cluster_id, package_ids) in group_by_cluster(&enabled) { + if let Err(err) = + oneclient_core::resolve_optional_mods(cluster_id, &package_ids, &content) + .await + { + tracing::warn!(cluster_id, error = %err, "failed to clear answered offers"); + } } - } - session.finish(); + session.finish(); + (installed, failed) + }) + .await; + super::invalidate_cluster_queries().await; if installed > 0 { @@ -882,14 +917,12 @@ impl Actions { .send(); } - actions.close_optional_mods(); + actions.close_optional_mods(OptionalModsOutcome::Launch); }); } pub fn proceed_package_updates(&self, chosen: Vec) { - self.with_engine(move |state| { - state.notifications.proceed_package_updates(chosen) - }); + self.with_engine(move |state| state.notifications.proceed_package_updates(chosen)); } pub fn close_package_updates(&self) { @@ -941,6 +974,7 @@ impl Actions { icon: None, progress: None, actions: Vec::new(), + toast_only: false, }, } } @@ -961,6 +995,13 @@ impl Actions { } pub fn launch_cluster(&self, cluster_id: ClusterId) { + if modpack_job_running(cluster_id) { + self.notify("The modpack is still being set up") + .body("Wait for it to finish installing, then press Play again.") + .send(); + return; + } + let claimed = self .station .clone() @@ -1005,15 +1046,16 @@ impl Actions { .error = None; } - pub fn import_local_file(&self, cluster_id: ClusterId, content_type: ContentType, path: PathBuf) { + pub fn import_local_file( + &self, + cluster_id: ClusterId, + content_type: ContentType, + path: PathBuf, + ) { self.import_local_files(cluster_id, vec![(path, content_type)]); } - pub fn import_local_files( - &self, - cluster_id: ClusterId, - files: Vec<(PathBuf, ContentType)>, - ) { + pub fn import_local_files(&self, cluster_id: ClusterId, files: Vec<(PathBuf, ContentType)>) { if files.is_empty() { return; } @@ -1021,14 +1063,16 @@ impl Actions { spawn_forever(async move { let Ok(state) = launcher::state() else { return }; let events = state.services.events.clone(); - match oneclient_content::packages::PackageStore::import_local_files( - &files, - cluster_id, - &state.services.content(), - ) - .await - { - Ok(report) => { + let imported = off_ui({ + let state = state.clone(); + async move { + let report = oneclient_content::packages::PackageStore::import_local_files( + &files, + cluster_id, + &state.services.content(), + ) + .await?; + let mut deferred = false; for row in &report.imported { let live = oneclient_content::packages::PackageStore::sync_live_content( @@ -1041,8 +1085,18 @@ impl Actions { deferred |= live == LiveSync::Deferred; } + Ok::<_, oneclient_content::ContentError>((report, deferred)) + } + }) + .await; - notify_import(&events, &report, deferred && state.games.is_active(cluster_id)); + match imported { + Ok((report, deferred)) => { + notify_import( + &events, + &report, + deferred && state.games.is_active(cluster_id), + ); super::invalidate_cluster_queries().await; } Err(err) => events @@ -1060,8 +1114,47 @@ impl Actions { provider: ProviderId, project_id: impl Into, version_id: impl Into, + world: Option, + ) { + self.start_install( + cluster_id, + provider, + project_id.into(), + version_id.into(), + world, + false, + ); + } + + pub fn install_flagged_anyway(&self, prompt: FlaggedInstallPrompt) { + self.dismiss_flagged_install(); + self.start_install( + prompt.cluster_id, + prompt.provider, + prompt.project_id, + prompt.version_id, + None, + true, + ); + } + + pub fn dismiss_flagged_install(&self) { + self.station + .clone() + .write_channel(AppChannel::Installs) + .installs + .flagged = None; + } + + fn start_install( + &self, + cluster_id: ClusterId, + provider: ProviderId, + project_id: String, + version_id: String, + world: Option, + allow_flagged: bool, ) { - let (project_id, version_id) = (project_id.into(), version_id.into()); let actions = self.clone(); self.station @@ -1085,14 +1178,44 @@ impl Actions { return; }; - let install = crate::install::install_package( - &state, - provider, - &project_id, - &version_id, - cluster_id, - ) - .await; + let install = match off_ui({ + let state = state.clone(); + let project_id = project_id.clone(); + let version_id = version_id.clone(); + let world = world.clone(); + async move { + crate::install::install_package( + &state, + provider, + &project_id, + &version_id, + cluster_id, + world, + allow_flagged, + ) + .await + } + }) + .await + { + Ok(install) => install, + Err(flagged) => { + let mut station = actions.station; + let mut app = station.write_channel(AppChannel::Installs); + app.installs.flagged = Some(FlaggedInstallPrompt { + cluster_id, + provider, + project_id: project_id.clone(), + version_id: version_id.clone(), + name: flagged.name, + mc_version: flagged.mc_version, + explanation: flagged.explanation, + alternatives: flagged.alternatives, + }); + app.installs.finish(cluster_id, provider, &project_id); + return; + } + }; // Replaces the download's progress notification in place rather // than arriving as a second one @@ -1109,6 +1232,7 @@ impl Actions { icon: Some(IconType::Download01), progress: None, actions: Vec::new(), + toast_only: true, }, Err(err) => NotificationSpec { title: "Install failed".to_string(), @@ -1117,6 +1241,7 @@ impl Actions { icon: None, progress: None, actions: Vec::new(), + toast_only: false, }, }; @@ -1139,6 +1264,9 @@ impl Actions { } super::invalidate_cluster_content_queries().await; + if let Some(world) = world { + super::invalidate_world_contents(cluster_id, world).await; + } state .services @@ -1189,13 +1317,8 @@ impl Actions { }; let events = state.services.events.clone(); - match oneclient_core::remove_artifact_from_cluster( - cluster_id, - &hash, - true, - &state.services.content(), - ) - .await + match oneclient_core::delete_artifact(cluster_id, &hash, &state.services.content()) + .await { Ok(()) => { events.notify("Removed").body(display_name).send(); @@ -1226,13 +1349,20 @@ impl Actions { spawn_forever(async move { let Ok(state) = launcher::state() else { return }; let events = state.services.events.clone(); - match oneclient_core::install_bundle( - cluster_id, - &bundle_name, - skip_compatibility, - state.bundles.as_ref(), - &state.services.content(), - ) + match off_ui({ + let state = state.clone(); + let bundle_name = bundle_name.clone(); + async move { + oneclient_core::install_bundle( + cluster_id, + &bundle_name, + skip_compatibility, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + } + }) .await { Ok(_) => { @@ -1255,12 +1385,18 @@ impl Actions { let actions = self.clone(); spawn_forever(async move { let Ok(state) = launcher::state() else { return }; - let result = match oneclient_core::apply_bundle_updates( - cluster_id, - state.bundles.as_ref(), - &state.services.content(), - None, - ) + let result = match off_ui({ + let state = state.clone(); + async move { + oneclient_core::apply_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + None, + ) + .await + } + }) .await { Ok(result) => result, @@ -1315,14 +1451,26 @@ impl Actions { let mut station = self.station; spawn_forever(async move { - // The copy is on this thread's executor, so it can write the count - // straight into the channel the move screen reads - let result = oneclient_core::relocate::relocate(&state, &plan, |copied, total| { + let (progress, mut seen) = tokio::sync::watch::channel((0, 0)); + let mut copy = std::pin::pin!(off_ui(async move { + oneclient_core::relocate::relocate(&state, &plan, |copied, total| { + let _ = progress.send((copied, total)); + }) + .await + })); + + let mut show_progress = |(copied, total)| { let mut guard = station.write_channel(AppChannel::Relocation); guard.relocation.copied = copied; guard.relocation.total = total; - }) - .await; + }; + let result = loop { + tokio::select! { + result = &mut copy => break result, + Ok(()) = seen.changed() => show_progress(*seen.borrow_and_update()), + } + }; + show_progress(*seen.borrow()); if let Err(message) = &result { tracing::error!("moving the data folder failed: {message}"); @@ -1353,8 +1501,26 @@ impl Actions { .relocation = relocation; } - /// `syncing_bundles` gates every launch button so the readiness check must + /// `syncing_bundles` gates every launch button so the readiness check must /// happen before the flag is raised not inside the task + /// Lets the signed-out bar show at startup instead of on the first launch + pub fn check_sessions() { + let state = match launcher::state() { + Ok(state) => state, + Err(err) => { + tracing::error!("session check skipped, launcher not ready: {err:#}"); + return; + } + }; + + spawn_forever(async move { + let signed_out = off_ui(async move { state.auth.check_expired_sessions().await }).await; + if signed_out { + super::invalidate_auth_queries(None).await; + } + }); + } + pub fn sync_bundles(&self) { let state = match launcher::state() { Ok(state) => state, @@ -1372,16 +1538,18 @@ impl Actions { .syncing_bundles = true; spawn_forever(async move { - - if let Err(err) = state.bundles.sync(&state.services.content()).await { - tracing::error!("bundle catalog sync failed: {err:#}"); - } - if let Err(err) = oneclient_core::clusters::apply_remote_migrations(&state).await { - tracing::error!("cluster migrations failed: {err:#}"); - } - if let Err(err) = oneclient_core::clusters::ensure_from_bundles(&state).await { - tracing::error!("bundle cluster provisioning failed: {err:#}"); - } + off_ui(async move { + if let Err(err) = state.bundles.sync(&state.services.content()).await { + tracing::error!("bundle catalog sync failed: {err:#}"); + } + if let Err(err) = oneclient_core::clusters::apply_remote_migrations(&state).await { + tracing::error!("cluster migrations failed: {err:#}"); + } + if let Err(err) = oneclient_core::clusters::ensure_from_bundles(&state).await { + tracing::error!("bundle cluster provisioning failed: {err:#}"); + } + }) + .await; actions .station @@ -1416,9 +1584,14 @@ impl Actions { .launcher .syncing_bundles = true; - let synced = match tokio::time::timeout(BUNDLE_SYNC_BUDGET, state.bundles.sync(&content)) - .await - { + let sync = { + let bundles = state.bundles.clone(); + let content = content.clone(); + off_ui( + async move { tokio::time::timeout(BUNDLE_SYNC_BUDGET, bundles.sync(&content)).await }, + ) + }; + let synced = match sync.await { Ok(Ok(_)) => true, Ok(Err(err)) => { tracing::warn!( @@ -1429,10 +1602,7 @@ impl Actions { false } Err(_elapsed) => { - tracing::debug!( - cluster_id, - "bundle catalog sync exceeded its launch budget" - ); + tracing::debug!(cluster_id, "bundle catalog sync exceeded its launch budget"); false } }; @@ -1445,12 +1615,19 @@ impl Actions { // Bundle content is forced, so the update mode never gates it and the // modal never lists it - let result = match oneclient_core::apply_bundle_updates( - cluster_id, - state.bundles.as_ref(), - &content, - Some(Instant::now() + BUNDLE_APPLY_BUDGET), - ) + let deadline = Instant::now() + BUNDLE_APPLY_BUDGET; + let result = match off_ui({ + let state = state.clone(); + async move { + oneclient_core::apply_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + Some(deadline), + ) + .await + } + }) .await { Ok(result) => result, @@ -1499,10 +1676,13 @@ impl Actions { // Bounded a hanging network would otherwise hold the launch for // reqwest's much longer timeouts - let check = match tokio::time::timeout( - UPDATE_CHECK_BUDGET, - oneclient_core::refresh_browser_package_updates(cluster_id, &content), - ) + let check = match off_ui(async move { + tokio::time::timeout( + UPDATE_CHECK_BUDGET, + oneclient_core::refresh_browser_package_updates(cluster_id, &content), + ) + .await + }) .await { Ok(Ok(check)) => check, @@ -1574,39 +1754,45 @@ impl Actions { state: &Arc, updates: &[oneclient_core::BrowserPackageUpdate], ) { - let content = state.services.content(); - let session = oneclient_events::GroupedProgressSession::start( - &state.services.events, - "Updating packages", - ); - session.expect( - oneclient_events::TaskCategory::Packages, - updates.len() as u64, - updates.len() as u64, - ); - - let mut applied = 0usize; - for update in updates { - let child = session.child( - update.display_name.clone(), - 1, - oneclient_events::TaskCategory::Packages, + let state = state.clone(); + let updates = updates.to_vec(); + let (applied, session_id) = off_ui(async move { + let content = state.services.content(); + let session = oneclient_events::GroupedProgressSession::start( + &state.services.events, + "Updating packages", + ); + session.expect( + oneclient_events::TaskCategory::Packages, + updates.len() as u64, + updates.len() as u64, ); - match oneclient_core::apply_browser_package_update(update, Some(&child), &content).await - { - Ok(_) => applied += 1, - // Not a reason to hold the game back the cache row survives - // so the next launch offers it again - Err(err) => tracing::warn!( - package = %update.display_name, - error = %err, - "automatic package update failed" - ), - } - child.finish(); - } - let session_id = session.detach(); + let mut applied = 0usize; + for update in &updates { + let child = session.child( + update.display_name.clone(), + 1, + oneclient_events::TaskCategory::Packages, + ); + match oneclient_core::apply_browser_package_update(update, Some(&child), &content) + .await + { + Ok(_) => applied += 1, + // Not a reason to hold the game back the cache row survives + // so the next launch offers it again + Err(err) => tracing::warn!( + package = %update.display_name, + error = %err, + "automatic package update failed" + ), + } + child.finish(); + } + + (applied, session.detach()) + }) + .await; let spec = (applied > 0).then(|| NotificationSpec { title: "Packages updated".to_string(), body: format!( @@ -1617,6 +1803,7 @@ impl Actions { icon: Some(IconType::DownloadCloud02), progress: None, actions: Vec::new(), + toast_only: true, }); self.with_engine(|app| { @@ -1631,17 +1818,106 @@ impl Actions { } } - async fn resolve_optional_mods_before_launch( + async fn resolve_bundle_choices_before_launch( &self, state: &Arc, cluster_id: ClusterId, ) { - let pending = match oneclient_core::pending_optional_mods( + let bundles = match off_ui({ + let state = state.clone(); + async move { + oneclient_content::bundles::pending_bundle_choices( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + } + }) + .await + { + Ok(bundles) if !bundles.is_empty() => bundles, + Ok(_) => return, + Err(err) => { + tracing::warn!(cluster_id, error = %err, "could not read the bundles to ask about, launching anyway"); + return; + } + }; + + let choices_for: Vec = bundles.iter().map(|a| a.manifest.name.clone()).collect(); + let held = match oneclient_db::dao::cluster_bundle::list_bundle_tracked( + &state.services.db, cluster_id, - state.bundles.as_ref(), - &state.services.content(), ) .await + { + Ok(tracked) => tracked + .into_iter() + .filter_map(|row| row.bundle_name) + .collect(), + Err(err) => { + tracing::warn!(cluster_id, error = %err, "could not read what the cluster holds, launching without asking about bundles"); + return; + } + }; + let choices = BundleChoices { + held, + cluster_name: crate::install::cluster_display_name(cluster_id, &state.services).await, + bundles: bundles + .iter() + .map(|a| { + ( + a.manifest.name.clone(), + crate::utils::bundle_display_name(a), + ) + }) + .collect(), + }; + let (done, wait) = tokio::sync::oneshot::channel(); + self.with_engine(move |state| { + state.notifications.open_bundle_choices(choices, done); + state.center_open = false; + }); + let Ok(Some(chosen)) = wait.await else { return }; + + let choices: Vec<_> = choices_for + .iter() + .map(|name| (name.clone(), chosen.contains(name))) + .collect(); + if let Err(err) = off_ui({ + let state = state.clone(); + async move { + oneclient_content::bundles::set_bundle_choices( + cluster_id, + &choices, + &state.services.content(), + ) + .await + } + }) + .await + { + tracing::warn!(cluster_id, error = %err, "could not record the bundle choices"); + } + } + + async fn resolve_optional_mods_before_launch( + &self, + state: &Arc, + cluster_id: ClusterId, + ) -> OptionalModsOutcome { + let pending = match off_ui({ + let state = state.clone(); + async move { + oneclient_core::pending_optional_mods( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + } + }) + .await { Ok(pending) => pending, Err(err) => { @@ -1650,7 +1926,7 @@ impl Actions { error = %err, "could not read queued optional mods, launching anyway" ); - return; + return OptionalModsOutcome::Launch; } }; @@ -1659,16 +1935,18 @@ impl Actions { let Some(group) = crate::install::pending_optional_group(cluster_id, &pending, &state.services).await else { - return; + return OptionalModsOutcome::Launch; }; let (done, wait) = tokio::sync::oneshot::channel(); self.with_engine(move |state| { - state.notifications.open_optional_mods(vec![group], Some(done)); + state + .notifications + .open_optional_mods(vec![group], Some(done)); state.center_open = false; }); - let _ = wait.await; + wait.await.unwrap_or(OptionalModsOutcome::Launch) } async fn prompt_package_updates( @@ -1678,13 +1956,86 @@ impl Actions { let (done, wait) = tokio::sync::oneshot::channel(); self.with_engine(move |state| { - state.notifications.open_package_updates(vec![group], Some(done)); + state + .notifications + .open_package_updates(vec![group], Some(done)); state.center_open = false; }); wait.await.unwrap_or_default() } + pub fn apply_package_update(&self, update: oneclient_core::BrowserPackageUpdate) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + + let (result, session_id) = off_ui({ + let update = update.clone(); + async move { + let session = oneclient_events::GroupedProgressSession::start( + &state.services.events, + format!("Updating {}", update.display_name), + ); + let child = session.child( + update.display_name.clone(), + 1, + oneclient_events::TaskCategory::Packages, + ); + + let result = oneclient_core::apply_browser_package_update( + &update, + Some(&child), + &state.services.content(), + ) + .await; + + child.finish(); + (result, session.detach()) + } + }) + .await; + + let spec = match &result { + Ok(_) => NotificationSpec { + title: "Updated".to_string(), + body: format!( + "{} is now on {}", + update.display_name, update.latest_version_name + ), + level: Level::Info, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: Vec::new(), + toast_only: true, + }, + Err(err) => NotificationSpec { + title: "Update failed".to_string(), + body: err.to_string(), + level: Level::Error, + icon: None, + progress: None, + actions: Vec::new(), + toast_only: false, + }, + }; + + actions.with_engine(|app| { + app.notifications + .finish_grouped_as_actions(&mut app.inbox, session_id, Some(spec)); + }); + + // A failed update stays in the list so the user can retry + if result.is_ok() { + actions.with_engine(|app| { + app.notifications + .resolve_package_update(update.cluster_id, &update.hash); + }); + super::invalidate_cluster_queries().await; + } + }); + } + /// The package stays marked out of date only the modal stops asking and /// only for this version pub fn skip_package_update(&self, cluster_id: ClusterId, hash: impl Into) { @@ -1745,10 +2096,26 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) { return; }; + // The renewal may have just marked the account signed out the accounts + // page and the status bar must pick that up while the game still starts + if account.needs_sign_in() { + super::invalidate_auth_queries(None).await; + } + crate::microsoft_java::offer_for_pinned_cluster(actions, cluster_id).await; + let content = state.services.content(); + tokio::spawn(async move { + if let Err(err) = oneclient_content::packages::refresh_bad_mods(&content).await { + tracing::warn!(%err, "bad mods list refresh failed, keeping the last one"); + } + }); + // Before the game process never after Minecraft reads its mods once at // startup + actions + .resolve_bundle_choices_before_launch(&state, cluster_id) + .await; actions .resolve_bundle_updates_before_launch(&state, cluster_id) .await; @@ -1757,20 +2124,28 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) { .await; // After the updates so the player never faces two modals at once - actions + if actions .resolve_optional_mods_before_launch(&state, cluster_id) - .await; - - if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await { - // A missing file is the one failure the launcher can fix itself and a - // path inside our metadata folder gives the user nothing to act on - if err.indicates_missing_files() { - repair_and_relaunch(&state, cluster_id, &account, err).await; - return; - } - - events.game_failed(cluster_id, format!("{err:#}")); + .await + == OptionalModsOutcome::Cancel + { + events.game_stage(cluster_id, oneclient_events::LaunchStage::Exited); + return; } + + off_ui(async move { + if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await { + // A missing file is the one failure the launcher can fix itself and a + // path inside our metadata folder gives the user nothing to act on + if err.indicates_missing_files() { + repair_and_relaunch(&state, cluster_id, &account, err).await; + return; + } + + events.game_failed(cluster_id, format!("{err:#}")); + } + }) + .await; } /// Only ever one retry if the game still will not start after a full rehash and @@ -1825,10 +2200,10 @@ fn notify_import(events: &oneclient_events::EventBus, report: &LocalImportReport [only] => Some(format!("Added {}", only.file_name)), rows => Some(format!("Added {} files", rows.len())), }) else { - let reason = report - .failed - .first() - .map_or_else(|| "Nothing could be read".to_string(), |(_, err)| err.to_string()); + let reason = report.failed.first().map_or_else( + || "Nothing could be read".to_string(), + |(_, err)| err.to_string(), + ); events.notify("Import failed").body(reason).error().send(); return; @@ -1895,6 +2270,13 @@ impl NotificationBuilder { self } + /// Marks the notice as ephemeral: it shows as a toast but never sticks + /// around in the notification center + pub fn toast_only(mut self) -> Self { + self.spec.toast_only = true; + self + } + pub fn send(self) { self.actions.push_notification(self.spec); } @@ -2014,6 +2396,11 @@ mod tests { last_played: None, overall_played: Duration::ZERO, linked_modpack_hash: None, + kind: oneclient_db::models::ClusterKind::OneClient, + user_created: false, + description: None, + tags: Vec::new(), + cover_path: None, } } @@ -2056,7 +2443,10 @@ mod tests { #[test] fn every_cluster_holding_the_runtime_is_named() { let profiles = vec![profile("a", Some(JDK_25)), profile("b", Some(JDK_25))]; - let clusters = vec![cluster(1, "Alpha", Some("a")), cluster(2, "Beta", Some("b"))]; + let clusters = vec![ + cluster(1, "Alpha", Some("a")), + cluster(2, "Beta", Some("b")), + ]; assert_eq!( clusters_pinned_to_java(&profiles, &clusters, JDK_25), diff --git a/packages/oneclient_app/src/hooks/actions/modpacks.rs b/packages/oneclient_app/src/hooks/actions/modpacks.rs new file mode 100644 index 00000000..ae52889d --- /dev/null +++ b/packages/oneclient_app/src/hooks/actions/modpacks.rs @@ -0,0 +1,529 @@ +use std::path::PathBuf; +use std::sync::{Mutex, PoisonError}; + +use freya::prelude::spawn_forever; +use oneclient_content::modpacks::{ + BlockedFile, ModpackInstallReport, find_blocked_downloads, import_blocked_files, +}; +use oneclient_core::clusters::{ + ModpackCluster, ModpackSource, PreparedModpack, create_modpack_instance, + install_modpack_instance, prepare_modpack, repair_modpack_cluster, update_modpack_cluster, +}; +use oneclient_db::models::ClusterId; +use oneclient_events::GroupedProgressSession; + +use super::Actions; +use crate::launcher::{self, off_ui}; +use crate::notifications::{BlockedDownloads, ModpackConfirm}; +use crate::state::AppChannel; + +const MAX_LISTED: usize = 3; + +enum JobSlot { + Idle, + Preparing, + AwaitingConfirm(Box), + Running(ClusterId), +} + +static JOB: Mutex = Mutex::new(JobSlot::Idle); + +fn with_slot(change: impl FnOnce(&mut JobSlot) -> R) -> R { + let mut slot = JOB.lock().unwrap_or_else(PoisonError::into_inner); + change(&mut slot) +} + +#[must_use] +pub fn modpack_job_running(cluster_id: ClusterId) -> bool { + with_slot(|slot| matches!(slot, JobSlot::Running(id) if *id == cluster_id)) +} + +fn take_prepared() -> Option { + with_slot(|slot| match std::mem::replace(slot, JobSlot::Preparing) { + JobSlot::AwaitingConfirm(prepared) => Some(*prepared), + other => { + *slot = other; + None + } + }) +} + +enum ModpackJob { + Install(Box), + Update { + cluster_id: ClusterId, + version_id: String, + }, + Repair { + cluster_id: ClusterId, + }, +} + +struct JobTexts { + progress: &'static str, + done: &'static str, + problems: &'static str, + failed: &'static str, +} + +impl ModpackJob { + fn texts(&self) -> JobTexts { + match self { + Self::Install(_) => JobTexts { + progress: "Installing modpack", + done: "Modpack installed", + problems: "Modpack installed with problems", + failed: "Modpack install failed", + }, + Self::Update { .. } => JobTexts { + progress: "Updating modpack", + done: "Modpack updated", + problems: "Modpack updated with problems", + failed: "Modpack update failed", + }, + Self::Repair { .. } => JobTexts { + progress: "Reinstalling modpack files", + done: "Modpack files reinstalled", + problems: "Some modpack files are still missing", + failed: "Could not reinstall the modpack files", + }, + } + } + + fn done_body(&self, cluster_name: &str) -> String { + match self { + Self::Install(_) => format!("{cluster_name} is ready to play."), + Self::Update { .. } => format!("{cluster_name} is on the new version."), + Self::Repair { .. } => format!("{cluster_name} has everything the pack lists."), + } + } +} + +fn confirm_view(prepared: &PreparedModpack) -> ModpackConfirm { + let manifest = &prepared.manifest; + let loader = match (&manifest.loader, &manifest.loader_version) { + (loader, _) if !loader.is_modded() => "Vanilla".to_string(), + (loader, Some(version)) => format!("{loader} {version}"), + (loader, None) => loader.to_string(), + }; + let source = match &prepared.source { + ModpackSource::Provider { provider, .. } => provider.to_string(), + ModpackSource::File(path) => path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "a file".to_string()), + }; + + ModpackConfirm { + pack_name: if manifest.name.trim().is_empty() { + prepared.instance_name.clone() + } else { + manifest.name.clone() + }, + version: manifest.version.clone(), + instance_name: prepared.instance_name.clone(), + mc_version: manifest.mc_version.clone(), + loader, + source, + summary: manifest.summary(), + } +} + +impl Actions { + fn claim_modpack_job(&self) -> bool { + let claimed = with_slot(|slot| match slot { + JobSlot::Idle => { + *slot = JobSlot::Preparing; + true + } + _ => false, + }); + if !claimed { + self.notify("A modpack is already being installed") + .body("Wait for it to finish, then try again.") + .error() + .send(); + return false; + } + self.station + .clone() + .write_channel(AppChannel::Installs) + .installs + .modpack_busy = true; + true + } + + fn lock_modpack_cluster(&self, cluster_id: ClusterId) { + with_slot(|slot| *slot = JobSlot::Running(cluster_id)); + self.station + .clone() + .write_channel(AppChannel::Installs) + .installs + .modpack_cluster = Some(cluster_id); + } + + fn release_modpack_job(&self) { + with_slot(|slot| *slot = JobSlot::Idle); + let mut station = self.station; + let mut app = station.write_channel(AppChannel::Installs); + app.installs.modpack_busy = false; + app.installs.modpack_project = None; + app.installs.modpack_cluster = None; + } + + pub fn install_modpack(&self, source: ModpackSource) { + if !self.claim_modpack_job() { + return; + } + let from_browser = match &source { + ModpackSource::Provider { + provider, + project_id, + .. + } => { + self.station + .clone() + .write_channel(AppChannel::Installs) + .installs + .modpack_project = Some((*provider, project_id.clone())); + true + } + ModpackSource::File(_) => false, + }; + + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { + actions.release_modpack_job(); + return; + }; + + let prepared = off_ui({ + let state = state.clone(); + async move { + let session = + GroupedProgressSession::start(&state.services.events, "Reading modpack"); + let prepared = prepare_modpack(&state, &source).await; + session.finish(); + prepared + } + }) + .await; + + match prepared { + Ok(prepared) if from_browser => { + actions.run_modpack_job(ModpackJob::Install(Box::new(prepared))); + } + Ok(prepared) => { + let view = confirm_view(&prepared); + with_slot(|slot| *slot = JobSlot::AwaitingConfirm(Box::new(prepared))); + actions.with_engine(move |app| app.notifications.open_modpack_confirm(view)); + } + Err(err) => { + tracing::warn!(error = %err, "could not read the modpack"); + actions + .notify("Could not read the modpack") + .body(err.to_string()) + .error() + .send(); + actions.release_modpack_job(); + } + } + }); + } + + pub fn confirm_modpack(&self) { + self.with_engine(|app| app.notifications.close_modpack_confirm()); + match take_prepared() { + Some(prepared) => self.run_modpack_job(ModpackJob::Install(Box::new(prepared))), + None => self.release_modpack_job(), + } + } + + pub fn cancel_modpack(&self) { + self.with_engine(|app| app.notifications.close_modpack_confirm()); + self.release_modpack_job(); + } + + pub fn update_modpack(&self, cluster_id: ClusterId, version_id: String) { + if self.refuse_while_running(cluster_id) || !self.claim_modpack_job() { + return; + } + self.run_modpack_job(ModpackJob::Update { + cluster_id, + version_id, + }); + } + + pub fn repair_modpack(&self, cluster_id: ClusterId) { + if self.refuse_while_running(cluster_id) || !self.claim_modpack_job() { + return; + } + self.run_modpack_job(ModpackJob::Repair { cluster_id }); + } + + fn refuse_while_running(&self, cluster_id: ClusterId) -> bool { + let running = launcher::state().is_ok_and(|state| state.games.is_active(cluster_id)); + if running { + self.notify("Close the game first") + .body("The modpack's files cannot be changed while Minecraft is running.") + .error() + .send(); + } + running + } + + fn run_modpack_job(&self, job: ModpackJob) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { + actions.release_modpack_job(); + return; + }; + let events = state.services.events.clone(); + let texts = job.texts(); + + let cluster_id = match &job { + ModpackJob::Install(prepared) => { + let created = off_ui({ + let state = state.clone(); + let prepared = prepared.clone(); + async move { create_modpack_instance(&state, &prepared).await } + }) + .await; + match created { + Ok(cluster) => { + events.signal(oneclient_events::Signal::ClustersChanged); + cluster.id + } + Err(err) => { + tracing::warn!(error = %err, "{}", texts.failed); + events + .notify(texts.failed) + .body(err.to_string()) + .error() + .send(); + actions.release_modpack_job(); + return; + } + } + } + ModpackJob::Update { cluster_id, .. } | ModpackJob::Repair { cluster_id } => { + *cluster_id + } + }; + actions.lock_modpack_cluster(cluster_id); + + let (job, outcome) = off_ui({ + let state = state.clone(); + async move { + let session = + GroupedProgressSession::start(&state.services.events, job.texts().progress); + let outcome = match &job { + ModpackJob::Install(prepared) => { + install_modpack_instance(&state, cluster_id, prepared, Some(&session)) + .await + } + ModpackJob::Update { version_id, .. } => { + update_modpack_cluster(&state, cluster_id, version_id, Some(&session)) + .await + } + ModpackJob::Repair { .. } => { + repair_modpack_cluster(&state, cluster_id, Some(&session)).await + } + }; + session.finish(); + (job, outcome) + } + }) + .await; + + match outcome { + Ok(ModpackCluster { cluster, report }) => { + events.signal(oneclient_events::Signal::ClustersChanged); + crate::hooks::invalidate_cluster_queries().await; + notify_outcome(&actions, &job, &cluster.name, &report); + + let opens_page = matches!(job, ModpackJob::Install(_)); + if !report.blocked.is_empty() { + actions.open_blocked_downloads(BlockedDownloads { + cluster_id: cluster.id, + cluster_name: cluster.name.clone(), + files: report.blocked, + added: Default::default(), + open_when_done: opens_page, + }); + } else if opens_page && report.failed.is_empty() { + actions.open_cluster_page(cluster.id); + } + } + Err(err) => { + tracing::warn!(error = %err, "{}", texts.failed); + let body = match &job { + ModpackJob::Install(_) => { + let removed = off_ui({ + let state = state.clone(); + async move { state.clusters.delete(cluster_id, true).await } + }) + .await; + if let Err(cleanup) = &removed { + tracing::warn!(cluster_id, error = %cleanup, "could not remove the unfinished instance"); + } + events.signal(oneclient_events::Signal::ClustersChanged); + crate::hooks::invalidate_cluster_queries().await; + match removed { + Ok(()) => format!("{err} The unfinished instance was removed."), + Err(_) => format!( + "{err} Use Reinstall Missing Files in the instance's settings to finish it." + ), + } + } + _ => format!( + "{err} Use Reinstall Missing Files in the instance's settings to try again." + ), + }; + events.notify(texts.failed).body(body).error().send(); + } + } + + actions.release_modpack_job(); + }); + } + + pub fn open_cluster_page(&self, cluster_id: ClusterId) { + self.with_engine(move |state| state.notifications.request_open_cluster(cluster_id)); + } + + pub fn take_open_cluster(&self) -> Option { + let mut cluster_id = None; + self.with_engine(|state| cluster_id = state.notifications.take_open_cluster()); + cluster_id + } + + pub fn open_blocked_downloads(&self, blocked: BlockedDownloads) { + self.with_engine(move |state| state.notifications.open_blocked_downloads(blocked)); + } + + pub fn close_blocked_downloads(&self) { + let mut remaining = None; + self.with_engine(|state| remaining = state.notifications.take_blocked_downloads()); + + let Some(blocked) = remaining else { + return; + }; + let missing = blocked.remaining(); + if missing.is_empty() { + if blocked.open_when_done { + self.open_cluster_page(blocked.cluster_id); + } + return; + } + + self.notify("Some mods are still missing") + .body(format!( + "{} is missing {}. It may not start until they are added.", + blocked.cluster_name, + listed(missing.iter().map(|file| file.project_name.clone())), + )) + .error() + .send(); + } + + pub fn scan_blocked_downloads( + &self, + cluster_id: ClusterId, + locations: Vec, + files: Vec, + ) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + + let imported = off_ui({ + let state = state.clone(); + async move { + let found = find_blocked_downloads(&locations, &files).await; + if found.is_empty() { + return Ok(Vec::new()); + } + import_blocked_files(cluster_id, &found, &state.services.content()) + .await + .map(|_| found.into_iter().map(|(_, file)| file).collect::>()) + } + }) + .await; + + match imported { + Ok(found) if found.is_empty() => {} + Ok(found) => { + let sha1s: Vec = found.iter().map(|file| file.sha1.clone()).collect(); + actions.with_engine(|app| { + app.notifications + .resolve_blocked_downloads(cluster_id, &sha1s); + }); + crate::hooks::invalidate_cluster_queries().await; + } + Err(err) => { + tracing::warn!(cluster_id, error = %err, "could not add a manual download"); + actions + .notify("Could not add a download") + .body(err.to_string()) + .error() + .send(); + } + } + }); + } +} + +fn notify_outcome( + actions: &Actions, + job: &ModpackJob, + cluster_name: &str, + report: &ModpackInstallReport, +) { + let texts = job.texts(); + if report.failed.is_empty() { + actions + .notify(texts.done) + .body(job.done_body(cluster_name)) + .send(); + return; + } + + actions + .notify(texts.problems) + .body(format!( + "{cluster_name} could not get {}.", + listed(report.failed.iter().cloned()) + )) + .error() + .send(); +} + +fn listed(names: impl Iterator) -> String { + let names: Vec = names.collect(); + let shown = names + .iter() + .take(MAX_LISTED) + .cloned() + .collect::>() + .join(", "); + match names.len().saturating_sub(MAX_LISTED) { + 0 => shown, + extra => format!("{shown} and {extra} more"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn long_lists_are_cut_short() { + let names = |n: usize| (1..=n).map(|i| format!("m{i}")); + assert_eq!(listed(names(2)), "m1, m2"); + assert_eq!(listed(names(3)), "m1, m2, m3"); + assert_eq!(listed(names(5)), "m1, m2, m3 and 2 more"); + } +} diff --git a/packages/oneclient_app/src/hooks/actions/release_migration.rs b/packages/oneclient_app/src/hooks/actions/release_migration.rs new file mode 100644 index 00000000..2e5d5f64 --- /dev/null +++ b/packages/oneclient_app/src/hooks/actions/release_migration.rs @@ -0,0 +1,853 @@ +use std::collections::HashMap; + +use freya::prelude::spawn_forever; +use std::collections::HashSet; + +use oneclient_content::packages::release_migration::{ + ReleaseMigrationDependency, ReleaseMigrationPackage, ReleaseMigrationPlan, + ReleaseMigrationSkip, SkipReason, add_to_waitlist, apply_release_migration_dependency, + apply_release_migration_package, plan_release_migrations, process_waitlist, +}; +use oneclient_content::packages::{ + ContentType, GameLoader, PackageBody, ProjectDetail, ProviderId, VersionDetail, VersionFile, +}; +use oneclient_core::ReleaseTarget; +use oneclient_core::clusters::{ + Cluster, OfferLookup, ReleaseMigrationOffer, copy_configs, manual_migration_offer, + record_new_versions, release_migration_offer, +}; +use oneclient_events::Level; + +use super::Actions; +use crate::components::IconType; +use crate::launcher; +use crate::notifications::NotificationSpec; +use crate::state::{AppChannel, PromptOrigin, ReleaseMigrationPrompt, ReleasePlanState}; + +enum SourcePlan { + Offer(ReleaseMigrationPlan), + Nothing, + Unreachable, +} + +fn classify(plan: Result) -> SourcePlan { + match plan { + Ok(plan) if plan.unreachable => SourcePlan::Unreachable, + Ok(plan) if plan.packages.is_empty() => SourcePlan::Nothing, + Ok(plan) => SourcePlan::Offer(plan), + Err(err) => { + tracing::warn!(error = %err, "release migration plan failed"); + SourcePlan::Unreachable + } + } +} + +enum SourcePlans { + Ready { + sources: Vec, + plans: HashMap, + }, + Nothing, + Unreachable, +} + +async fn plan_sources( + offer: &ReleaseMigrationOffer, + bundles: &oneclient_content::bundles::BundlesManager, + content: &oneclient_content::ContentCtx, +) -> SourcePlans { + let ids: Vec = offer.sources.iter().map(|source| source.id).collect(); + let mut checked: HashMap = + plan_release_migrations(&ids, offer.target.id, bundles, content) + .await + .into_iter() + .map(|(source_id, plan)| (source_id, classify(plan))) + .collect(); + + let mut sources = Vec::new(); + let mut plans = HashMap::new(); + + for source in &offer.sources { + match checked.remove(&source.id) { + Some(SourcePlan::Offer(plan)) => { + plans.insert(source.id, ReleasePlanState::Ready(plan)); + sources.push(source.clone()); + } + Some(SourcePlan::Nothing) | None => {} + Some(SourcePlan::Unreachable) => return SourcePlans::Unreachable, + } + } + + if sources.is_empty() { + SourcePlans::Nothing + } else { + SourcePlans::Ready { sources, plans } + } +} + +fn fake_version(name: &str, project_id: &str, size: u64) -> VersionDetail { + VersionDetail { + version_id: format!("fake-version-{project_id}"), + project_id: project_id.to_string(), + name: name.to_string(), + version_number: "1.0.0".to_string(), + changelog: None, + game_versions: Vec::new(), + loaders: vec![GameLoader::Fabric], + published: chrono::Utc::now(), + downloads: 0, + files: vec![VersionFile { + sha1: format!("fake-{project_id}"), + url: String::new(), + file_name: format!("{project_id}.jar"), + primary: true, + size, + fingerprint: None, + }], + dependencies: Vec::new(), + } +} + +fn fake_package( + name: &str, + project_id: &str, + content_type: ContentType, + enabled: bool, +) -> ReleaseMigrationPackage { + ReleaseMigrationPackage { + source_hash: format!("fake-{project_id}"), + enabled, + provider: ProviderId::Modrinth, + project_id: project_id.to_string(), + content_type, + display_name: name.to_string(), + version: fake_version(name, project_id, 2_400_000), + version_name: "1.0.0".to_string(), + size: 2_400_000, + } +} + +fn fake_skip(name: &str, project_id: &str, reason: SkipReason) -> ReleaseMigrationSkip { + ReleaseMigrationSkip { + source_hash: format!("fake-{project_id}"), + enabled: true, + provider: ProviderId::Modrinth, + project_id: project_id.to_string(), + content_type: ContentType::Mod, + display_name: name.to_string(), + reason, + } +} + +fn fake_dependency( + name: &str, + project_id: &str, + required_by: Vec, +) -> ReleaseMigrationDependency { + let now = chrono::Utc::now(); + ReleaseMigrationDependency { + provider: ProviderId::Modrinth, + project: ProjectDetail { + id: project_id.to_string(), + slug: project_id.to_string(), + provider: ProviderId::Modrinth, + content_type: ContentType::Mod, + name: name.to_string(), + summary: "Simulated dependency".to_string(), + author: "nobody".to_string(), + members: Vec::new(), + gallery: Vec::new(), + body: PackageBody::Raw(String::new()), + license: None, + links: Vec::new(), + version_ids: Vec::new(), + game_versions: Vec::new(), + loaders: vec![GameLoader::Fabric], + icon_url: None, + created: now, + updated: now, + downloads: 0, + }, + version: fake_version(name, project_id, 900_000), + required_by, + } +} + +fn fake_plan(source_cluster_id: i64, target_cluster_id: i64) -> ReleaseMigrationPlan { + let packages = vec![ + fake_package("Sodium", "AANobbMI", ContentType::Mod, true), + fake_package( + "Just Enough Items (JEI)", + "u6dRKJwZ", + ContentType::Mod, + true, + ), + fake_package("Mod Menu", "mOgUt4GM", ContentType::Mod, false), + fake_package( + "Fresh Animations", + "50dA9Sha", + ContentType::ResourcePack, + true, + ), + fake_package( + "Complementary Shaders", + "R6NEzAwj", + ContentType::Shader, + true, + ), + ]; + + ReleaseMigrationPlan { + source_cluster_id, + target_cluster_id, + dependencies: vec![fake_dependency( + "Fabric API", + "P7dR8mSH", + vec!["fake-AANobbMI".to_string(), "fake-u6dRKJwZ".to_string()], + )], + unavailable: vec![ + fake_skip("Iris Shaders", "YL57xq9U", SkipReason::NoCompatibleVersion), + fake_skip( + "Distant Horizons", + "uCdwusMi", + SkipReason::MissingDependency, + ), + ], + packages, + unreachable: false, + } +} + +impl Actions { + fn write_release_migration(&self, edit: impl FnOnce(&mut Option)) { + let mut guard = self + .station + .clone() + .write_channel(AppChannel::ReleaseMigration); + edit(&mut guard.release_migration); + } + + fn remove_pending_release_migrations(&self, keys: Vec) { + if keys.is_empty() { + return; + } + self.edit_settings(move |settings| { + settings + .pending_release_migrations + .retain(|key| !keys.contains(key)); + }); + } + + async fn open_release_prompt( + &self, + state: &std::sync::Arc, + offer: ReleaseMigrationOffer, + sources: Vec, + plans: HashMap, + origin: PromptOrigin, + ) { + let java_major = oneclient_core::required_java_major(state, offer.target.id) + .await + .ok() + .flatten(); + + let selected = sources[0].id; + self.write_release_migration(move |prompt| { + *prompt = Some(ReleaseMigrationPrompt { + key: offer.release.key(), + target_dedicated: offer.target.uses_dedicated_dir(), + target: offer.target, + java_major, + sources, + selected, + plans, + origin, + }); + }); + } + + pub fn check_release_migration(&self) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + + if let Err(err) = record_new_versions(&state).await { + tracing::warn!(error = %err, "could not record new versions for migration"); + } + actions.refresh_settings_from_core(); + + let (pending, onboarded) = { + let settings = state.settings.read(); + ( + settings.pending_release_migrations.clone(), + settings.seen_onboarding, + ) + }; + if pending.is_empty() { + return; + } + + if !onboarded { + actions.remove_pending_release_migrations(pending); + return; + } + + let content = state.services.content(); + let mut finished = Vec::new(); + + for key in pending { + let Some(release) = ReleaseTarget::from_key(&key) else { + finished.push(key); + continue; + }; + + if !state.versions.shows_initial_migration(&release).await { + tracing::info!( + key, + "pending version opts out of the initial migration offer, dropping it" + ); + finished.push(key); + continue; + } + + let offer = match release_migration_offer(&state, &release).await { + Ok(OfferLookup::Offer(offer)) => offer, + Ok(OfferLookup::NoSources) => { + finished.push(key); + continue; + } + Ok(OfferLookup::MissingCluster) => continue, + Err(err) => { + tracing::warn!(error = %err, key, "release migration lookup failed"); + continue; + } + }; + + let (sources, plans) = + match plan_sources(&offer, state.bundles.as_ref(), &content).await { + SourcePlans::Ready { sources, plans } => (sources, plans), + SourcePlans::Nothing => { + finished.push(key); + continue; + } + SourcePlans::Unreachable => continue, + }; + + actions.remove_pending_release_migrations(finished); + actions + .open_release_prompt(&state, *offer, sources, plans, PromptOrigin::NewRelease) + .await; + return; + } + + actions.remove_pending_release_migrations(finished); + }); + } + + pub fn select_release_migration_source(&self, source_id: i64) { + let mut target_id = None; + self.write_release_migration(|prompt| { + let Some(prompt) = prompt.as_mut() else { + return; + }; + prompt.selected = source_id; + if let std::collections::hash_map::Entry::Vacant(entry) = prompt.plans.entry(source_id) + { + entry.insert(ReleasePlanState::Loading); + target_id = Some(prompt.target.id); + } + }); + + let Some(target_id) = target_id else { return }; + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let result = plan_release_migrations( + &[source_id], + target_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .pop() + .map(|(_, plan)| plan); + let next = match result { + Some(Ok(plan)) if !plan.unreachable => ReleasePlanState::Ready(plan), + Some(Ok(_)) | None => ReleasePlanState::Failed, + Some(Err(err)) => { + tracing::warn!(error = %err, source_id, "release migration plan failed"); + ReleasePlanState::Failed + } + }; + actions.write_release_migration(move |prompt| { + if let Some(prompt) = prompt.as_mut() { + prompt.plans.insert(source_id, next); + } + }); + }); + } + + /// Debug only: opens the popup on a hand-built plan, so the UI can be + /// reviewed without a cluster that has migratable packages + pub fn simulate_fake_release_migration(&self, target_cluster_id: i64) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let Ok(clusters) = state.clusters.list().await else { + return; + }; + let Some(target) = clusters + .iter() + .find(|cluster| cluster.id == target_cluster_id) + .cloned() + else { + return; + }; + + let source = clusters + .into_iter() + .find(|cluster| cluster.id != target.id) + .unwrap_or_else(|| target.clone()); + let selected = source.id; + let release = ReleaseTarget { + mc_version: target.mc_version.clone(), + loader: target.mc_loader, + }; + let plan = fake_plan(selected, target.id); + + actions.write_release_migration(move |prompt| { + *prompt = Some(ReleaseMigrationPrompt { + key: release.key(), + target_dedicated: target.uses_dedicated_dir(), + target, + java_major: None, + sources: vec![source], + selected, + plans: HashMap::from([(selected, ReleasePlanState::Ready(plan))]), + origin: PromptOrigin::Fake, + }); + }); + }); + } + + pub fn simulate_release_migration(&self, target_cluster_id: i64) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + + let target = match state.clusters.get(target_cluster_id).await { + Ok(target) => target, + Err(err) => { + actions + .notify("Release migration simulation failed") + .body(err.to_string()) + .level(Level::Error) + .send(); + return; + } + }; + + let release = ReleaseTarget { + mc_version: target.mc_version.clone(), + loader: target.mc_loader, + }; + + let offer = match release_migration_offer(&state, &release).await { + Ok(OfferLookup::Offer(offer)) => offer, + Ok(OfferLookup::NoSources | OfferLookup::MissingCluster) => { + actions + .notify("Nothing to migrate") + .body("All found packages were migrated") + .level(Level::Error) + .send(); + return; + } + Err(err) => { + actions + .notify("Release migration simulation failed") + .body(err.to_string()) + .level(Level::Error) + .send(); + return; + } + }; + + let (sources, plans) = + match plan_sources(&offer, state.bundles.as_ref(), &state.services.content()).await + { + SourcePlans::Ready { sources, plans } => (sources, plans), + SourcePlans::Nothing => { + actions + .notify("Nothing to migrate") + .body("All found packages were migrated") + .level(Level::Error) + .send(); + return; + } + SourcePlans::Unreachable => { + actions + .notify("Release migration simulation failed") + .body("Couldn't reach Modrinth or CurseForge.") + .level(Level::Error) + .send(); + return; + } + }; + + actions + .open_release_prompt(&state, *offer, sources, plans, PromptOrigin::Simulated) + .await; + }); + } + + fn set_release_migration_checking(&self, cluster_id: i64, checking: bool) { + let mut guard = self + .station + .clone() + .write_channel(AppChannel::ReleaseMigration); + if checking { + guard.release_migration_checking.insert(cluster_id); + } else { + guard.release_migration_checking.remove(&cluster_id); + } + } + + pub fn open_manual_migration(&self, target_cluster_id: i64, source_cluster_id: i64) { + self.set_release_migration_checking(target_cluster_id, true); + self.set_release_migration_checking(source_cluster_id, true); + let actions = self.clone(); + spawn_forever(async move { + actions + .run_manual_migration(target_cluster_id, source_cluster_id) + .await; + actions.set_release_migration_checking(target_cluster_id, false); + actions.set_release_migration_checking(source_cluster_id, false); + }); + } + + async fn run_manual_migration(&self, target_cluster_id: i64, source_cluster_id: i64) { + let Ok(state) = launcher::state() else { return }; + + let nothing = || { + self.notify("Nothing to migrate") + .body("No packages to migrate") + .send(); + }; + + let offer = match manual_migration_offer(&state, target_cluster_id, source_cluster_id).await + { + Ok(OfferLookup::Offer(offer)) => offer, + Ok(OfferLookup::NoSources) => { + nothing(); + return; + } + Ok(OfferLookup::MissingCluster) => return, + Err(err) => { + tracing::warn!(error = %err, target_cluster_id, "manual migration lookup failed"); + self.notify("Couldn't check for packages to migrate") + .body(err.to_string()) + .level(Level::Error) + .send(); + return; + } + }; + + let (sources, plans) = match plan_sources( + &offer, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + { + SourcePlans::Ready { sources, plans } => (sources, plans), + SourcePlans::Nothing => { + nothing(); + return; + } + SourcePlans::Unreachable => { + self.notify("Couldn't check for packages to migrate") + .body("Couldn't reach Modrinth or CurseForge. Check your connection and try again.") + .level(Level::Error) + .send(); + return; + } + }; + + self.open_release_prompt(&state, *offer, sources, plans, PromptOrigin::Manual) + .await; + } + + pub fn queue_release_migration(&self, target_cluster_id: i64) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let Ok(target) = state.clusters.get(target_cluster_id).await else { + return; + }; + let key = ReleaseTarget { + mc_version: target.mc_version, + loader: target.mc_loader, + } + .key(); + + actions.write_release_migration(|prompt| *prompt = None); + actions.edit_settings(move |settings| { + if !settings.pending_release_migrations.contains(&key) { + settings.pending_release_migrations.push(key); + } + }); + actions.check_release_migration(); + }); + } + + pub fn process_release_waitlist(&self) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let content = state.services.content(); + + let installs = match process_waitlist( + state.bundles.as_ref(), + |cluster_id| oneclient_core::game::is_running(&state, cluster_id), + &content, + ) + .await + { + Ok(installs) => installs, + Err(err) => { + tracing::warn!(error = %err, "release migration waitlist check failed"); + return; + } + }; + + if installs.is_empty() { + return; + } + + for install in &installs { + let verb = if install.names.len() == 1 { + "supports" + } else { + "support" + }; + actions + .notify(format!("New {} builds added", install.mc_version)) + .body(format!( + "{} now {verb} {} and {} added to {}.", + install.names.join(", "), + install.mc_version, + if install.names.len() == 1 { + "was" + } else { + "were" + }, + install.cluster_name + )) + .send(); + } + + super::super::invalidate_cluster_queries().await; + }); + } + + pub fn dismiss_release_migration(&self) { + let mut key = None; + self.write_release_migration(|prompt| { + key = prompt + .take() + .filter(|prompt| prompt.origin == PromptOrigin::NewRelease) + .map(|prompt| prompt.key); + }); + if let Some(key) = key { + self.remove_pending_release_migrations(vec![key]); + } + } + + pub fn migrate_release_packages( + &self, + packages: Vec, + copy_configs_too: bool, + ) { + let mut taken = None; + self.write_release_migration(|prompt| taken = prompt.take()); + let Some(prompt) = taken else { return }; + match prompt.origin { + PromptOrigin::Fake => { + self.notify("Fake plan") + .body("Nothing was installed") + .send(); + return; + } + PromptOrigin::Simulated => {} + PromptOrigin::NewRelease | PromptOrigin::Manual => { + self.remove_pending_release_migrations(vec![prompt.key.clone()]); + } + } + + if let Some(source) = prompt.source() { + let (source_id, target_id) = (source.id, prompt.target.id); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + if let Err(err) = oneclient_content::bundles::inherit_bundle_choices( + source_id, + target_id, + &state.services.content(), + ) + .await + { + tracing::warn!(error = %err, "could not carry the bundle choices over to the target"); + } + }); + } + + if packages.is_empty() { + return; + } + + let chosen: HashSet = packages + .iter() + .map(|package| package.source_hash.clone()) + .collect(); + let (dependencies, unavailable): (Vec<_>, Vec<_>) = match prompt.plans.get(&prompt.selected) + { + Some(ReleasePlanState::Ready(plan)) => ( + plan.dependencies + .iter() + .filter(|dependency| dependency.is_needed_by(&chosen)) + .map(|dependency| (dependency.clone(), dependency.enabled_for(&packages))) + .collect(), + plan.unavailable + .iter() + .filter(|skip| skip.reason == SkipReason::NoCompatibleVersion) + .cloned() + .collect(), + ), + _ => (Vec::new(), Vec::new()), + }; + let waitlisted = !prompt.is_cross_loader(); + + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let content = state.services.content(); + let source = prompt.source().filter(|_| copy_configs_too).cloned(); + let target = prompt.target; + let total = packages.len() + dependencies.len(); + + let session = oneclient_events::GroupedProgressSession::start( + &state.services.events, + format!("Migrating to {}", target.name), + ); + session.expect( + oneclient_events::TaskCategory::Packages, + total as u64, + total as u64, + ); + + let mut migrated = 0usize; + let mut blocked: HashSet = HashSet::new(); + for (dependency, enabled) in &dependencies { + let child = session.child( + dependency.project.name.clone(), + 1, + oneclient_events::TaskCategory::Packages, + ); + match apply_release_migration_dependency( + target.id, + dependency, + *enabled, + Some(&child), + &content, + ) + .await + { + Ok(_) => migrated += 1, + Err(err) => { + tracing::warn!( + dependency = %dependency.project.name, + error = %err, + "release migration failed for dependency" + ); + blocked.extend(dependency.required_by.iter().cloned()); + } + } + child.finish(); + } + + for package in &packages { + let child = session.child( + package.display_name.clone(), + 1, + oneclient_events::TaskCategory::Packages, + ); + if blocked.contains(&package.source_hash) { + tracing::warn!( + package = %package.display_name, + "skipping release migration for a package whose dependency failed" + ); + child.finish(); + continue; + } + match apply_release_migration_package(target.id, package, Some(&child), &content) + .await + { + Ok(_) => migrated += 1, + Err(err) => tracing::warn!( + package = %package.display_name, + error = %err, + "release migration failed for package" + ), + } + child.finish(); + } + + if !waitlisted { + tracing::info!( + target_cluster_id = target.id, + skipped = unavailable.len(), + "cross-loader migration, not adding packages without a build to the waitlist" + ); + } else if let Err(err) = add_to_waitlist(target.id, &unavailable, &content).await { + tracing::warn!(error = %err, "could not add packages without a build to the migration waitlist"); + } + + if let Some(source) = &source { + match copy_configs(&state, source, &target).await { + Ok(copied) => tracing::info!(copied, "copied config files into the target"), + Err(err) => tracing::warn!(error = %err, "could not copy config files"), + } + } + + let failed = total - migrated; + let session_id = session.detach(); + let spec = Some(NotificationSpec { + title: format!("Migrated to {}", target.name), + body: if failed == 0 { + format!( + "{migrated} package{} copied into {}", + if migrated == 1 { "" } else { "s" }, + target.name + ) + } else { + format!("{migrated} of {total} packages copied, {failed} failed") + }, + level: if failed == 0 { + Level::Info + } else { + Level::Error + }, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: Vec::new(), + toast_only: false, + }); + + actions.with_engine(|app| { + app.notifications + .finish_grouped_as_actions(&mut app.inbox, session_id, spec); + }); + + super::super::invalidate_cluster_queries().await; + }); + } +} diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs index 6608f6ff..18c31f41 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -1,9 +1,13 @@ use std::collections::{HashMap, HashSet}; use freya::prelude::*; +use oneclient_common::domain::GameLoader; use oneclient_content::packages::ProviderId; +use oneclient_content::packages::types::SearchSort; use oneclient_db::models::ClusterId; +use crate::hooks::ClusterAction; + #[derive(Clone)] pub struct ActiveClusterState(pub State>); @@ -44,6 +48,8 @@ pub fn use_start_maximized() -> bool { consume_root_context::().0 } +pub const BROWSER_COMPAT_DEFAULT: bool = true; + #[derive(Clone)] pub struct BrowserCompatState(pub State); @@ -66,6 +72,17 @@ pub fn use_browser_type() -> State { consume_root_context::().0 } +#[derive(Clone)] +pub struct DataPackWorldState(pub State>); + +pub fn use_provide_datapack_world(state: DataPackWorldState) { + use_hook(move || provide_root_context(state)); +} + +pub fn use_datapack_world() -> State> { + consume_root_context::().0 +} + #[derive(Clone)] pub struct LinkConfirmState(pub State>); @@ -77,11 +94,38 @@ pub fn use_link_confirm() -> State> { consume_root_context::().0 } +#[derive(Clone, Copy, PartialEq)] +pub enum EssentialGuardKind { + Disable, + Remove, +} + +#[derive(Clone, PartialEq)] +pub struct PendingEssential { + pub name: String, + pub body: String, + pub kind: EssentialGuardKind, + pub action: ClusterAction, +} + +#[derive(Clone)] +pub struct EssentialGuardState(pub State>); + +pub fn use_provide_essential_guard(state: EssentialGuardState) { + use_provide_root_context(move || state.clone()); +} + +pub fn use_essential_guard() -> State> { + consume_root_context::().0 +} + #[derive(Clone)] pub struct BrowserUiState { pub query: String, pub provider: ProviderId, pub categories: Vec, + pub sort: SearchSort, + pub loader: Option, pub page: usize, } @@ -91,6 +135,8 @@ impl Default for BrowserUiState { query: String::new(), provider: ProviderId::Modrinth, categories: Vec::new(), + sort: SearchSort::Relevance, + loader: None, page: 0, } } @@ -117,8 +163,6 @@ pub struct OnboardingSelectionState { pub migrated_categories: State>>, pub language: State, pub reduce_motion: State, - pub predownload: State, - pub setup_started: State, /// `None` = don't import pub import_folder: State>, pub import_dedicated: State, diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index 0e1d3c5f..feab502b 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -1,8 +1,9 @@ +mod actions; mod active_cluster; mod debounce; -mod actions; mod overlay_claims; mod queries; +mod selection; mod shortcut_actions; mod view_state; @@ -11,69 +12,78 @@ pub use overlay_claims::{ OverlayClaims, use_overlay_claim, use_overlay_claim_when, use_overlay_claims, use_provide_overlay_claims, }; +pub use selection::{Selection, use_selection}; pub use view_state::{PersistedView, use_view_state}; pub use active_cluster::{ - ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserTypeState, BrowserUiState, - LinkConfirmState, OnboardingSelectionState, SplashState, StartMaximizedState, + ActiveClusterState, BROWSER_COMPAT_DEFAULT, BrowserCompatState, BrowserStateStore, + BrowserTypeState, BrowserUiState, DataPackWorldState, EssentialGuardKind, EssentialGuardState, + LinkConfirmState, OnboardingSelectionState, PendingEssential, SplashState, StartMaximizedState, use_active_cluster_id, use_browser_compat, use_browser_state_store, use_browser_type, - use_link_confirm, use_onboarding_selection, use_provide_active_cluster, - use_provide_browser_compat, use_provide_browser_state, use_provide_browser_type, + use_datapack_world, use_essential_guard, use_link_confirm, use_onboarding_selection, + use_provide_active_cluster, use_provide_browser_compat, use_provide_browser_state, + use_provide_browser_type, use_provide_datapack_world, use_provide_essential_guard, use_provide_link_confirm, use_provide_onboarding_selection, use_provide_splash, use_provide_start_maximized, use_splash, use_start_maximized, }; -pub use actions::{Actions, NotificationBuilder, PumpSignal}; +pub use actions::{Actions, NotificationBuilder, PumpSignal, modpack_job_running}; +pub use queries::ALL_VERSIONS; +pub(crate) use queries::use_folder_watch; pub use queries::{ - AddOfflineAccountKeys, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, CachedImageQuery, - CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles, - ClusterLogsQuery, FinishMicrosoftLoginMutation, LogAction, LogContentQuery, MigrationQuery, - OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, - SetDefaultAccountKeys, StorageAction, StorageActionMutation, StorageReportQuery, TermsQuery, - UploadLogKeys, UploadLogMutation, UseLogAction, - UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount, - UseStorageAction, UseUploadLog, - VERSIONS_PAGE_SIZE, accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, - category_list, changelog_error, changelog_groups, changelog_is_loading, cluster_content_items, - content_type_for_slug, has_migration_data, invalidate_cluster_content_queries, - invalidate_cluster_queries, invalidate_java_queries, + AddOfflineAccountKeys, AvailableBundlesQuery, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, + CachedImageQuery, CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, + ClusterBundles, ClusterLogsQuery, ClusterMutation, DisableWarningsQuery, DiscardLeftoversKeys, + DiscardLeftoversMutation, FinishMicrosoftLoginMutation, GameVersion, JavaMajorsQuery, + LeftoversQuery, LogAction, LogContentQuery, MigrationQuery, OnboardingBundlesQuery, + RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, SetDefaultAccountKeys, StorageAction, + StorageActionMutation, StorageReportQuery, TermsQuery, UploadLogKeys, UploadLogMutation, + UseDiscardLeftovers, UseLogAction, UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, + UseSetDefaultAccount, UseStorageAction, UseUploadLog, VERSIONS_PAGE_SIZE, + accounts_have_microsoft, available_bundles, bundle_overrides_map, bundles_with_status_items, + category_list, changelog_entries, changelog_error, changelog_is_loading, cluster_art_url, + cluster_content_items, content_type_for_slug, disable_warnings, game_versions, + has_migration_data, invalidate_auth_queries, invalidate_cluster_content_queries, + invalidate_cluster_queries, invalidate_java_queries, invalidate_leftovers_queries, invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries, - invalidate_storage_queries, try_storage_report, use_storage_action, use_storage_report, - DiscardLeftoversKeys, DiscardLeftoversMutation, LeftoversQuery, - UseDiscardLeftovers, invalidate_leftovers_queries, mutation_ok, try_leftovers, - use_discard_leftovers, use_leftovers, - java_runtimes, latest_changelog_version, loaded_image, loader_versions, - login_code_already_handled, migration_detection, - mutation_error, mutation_is_pending, mutation_is_running, onboarding_bundles_items, package_meta_batch, - package_updates, pick_version_metadata, project_detail, provider_versions, query_error, - query_is_busy, stale_hashes, use_package_updates, - query_is_loading, reset_login_code_dedup, search_items, search_pending, search_total, - settled_or_loading, terms_document, terms_error, terms_is_loading, try_account, + invalidate_storage_queries, java_majors, java_runtimes, latest_changelog_version, loaded_image, + loader_versions, login_code_already_handled, migration_detection, mutation_error, + mutation_is_pending, mutation_is_running, mutation_ok, onboarding_bundles_items, + package_meta_batch, package_updates, pick_version_metadata, project_detail, provider_versions, + query_error, query_is_busy, query_is_loading, refresh_version_art_gallery, + reset_login_code_dedup, resolve_art_url, search_items, search_pending, search_total, + settled_or_loading, stale_hashes, terms_document, terms_error, terms_is_loading, try_account, try_accounts, try_cluster_analytics, try_cluster_logs, try_cluster_screenshots, - try_default_account, try_game_profile, try_global_analytics, try_log_content, use_account, - use_accounts, use_add_microsoft_account, use_add_offline_account, use_begin_microsoft_login, + try_default_account, try_game_profile, try_global_analytics, try_leftovers, try_log_content, + try_storage_report, use_account, use_accounts, use_add_microsoft_account, + use_add_offline_account, use_available_bundles, use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates, use_bundles_with_status, use_cached_image, - use_cancel_microsoft_login, use_changelog, use_cluster_analytics, use_cluster_content, - use_cluster_logs, use_cluster_mutation, use_cluster_profile, use_cluster_screenshots, - use_cluster, use_cluster_settings, use_clusters, use_current_account, use_default_account, - use_finish_microsoft_login, use_game_profile, use_global_analytics, use_java_runtimes, - use_loader_versions, use_local_image, use_log_action, use_log_content, use_migration, - use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch, - use_package_project, use_package_search, use_package_versions, use_package_versions_when, - use_player_profile, + use_cancel_microsoft_login, use_changelog, use_cluster, use_cluster_analytics, + use_cluster_content, use_cluster_logs, use_cluster_mutation, use_cluster_profile, + use_cluster_screenshots, use_cluster_settings, use_clusters, use_current_account, + use_default_account, use_disable_warnings, use_discard_leftovers, use_finish_microsoft_login, + use_game_profile, use_game_versions, use_global_analytics, use_java_majors, use_java_runtimes, + use_leftovers, use_loader_versions, use_local_image, use_log_action, use_log_content, + use_migratable_routes, use_migration, use_mods_folder_sync, use_named_profiles, + use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project, + use_package_search, use_package_updates, use_package_versions_when, use_player_profile, use_player_skin, use_custom_skin, delete_account_skin, save_account_skin, invalidate_skin_queries, use_provider_versions, use_refresh_account, use_refresh_all_accounts, - use_remove_account, use_screenshot_action, use_screenshot_folder_watch, use_set_default_account, - use_terms, use_upload_log, + use_remove_account, use_screenshot_action, use_screenshot_folder_watch, + use_set_default_account, use_shadowed_mods, use_storage_action, use_storage_report, use_terms, + use_upload_log, use_version_art, use_version_art_gallery, use_version_loaders, use_version_metadata, use_versions, version_list, versions_metadata, versions_total, }; - -pub(crate) use queries::invalidate_auth_queries; +pub use queries::{ + add_world_datapacks, delete_world, delete_world_datapack, invalidate_world_contents, + spawn_world_task, try_cluster_worlds, try_world_datapacks, try_world_size, use_cluster_worlds, + use_saves_folder_watch, use_world_datapacks, use_world_size, +}; use crate::notifications::NotificationSnapshot; use crate::state::{ - AppChannel, GameState, InstallState, LauncherInit, LoginProgress, RelocationState, - SettingsState, StorageScanProgress, + AppChannel, GameState, InstallState, LauncherInit, LoginProgress, ReleaseMigrationPrompt, + RelocationState, SettingsState, StorageScanProgress, }; use freya::prelude::*; use freya::radio::use_radio; @@ -93,6 +103,20 @@ pub fn use_launcher() -> LauncherInit { use_radio(AppChannel::Launcher).read().launcher.clone() } +pub fn use_release_migration_checking(cluster_id: i64) -> bool { + use_radio(AppChannel::ReleaseMigration) + .read() + .release_migration_checking + .contains(&cluster_id) +} + +pub fn use_release_migration() -> Option { + use_radio(AppChannel::ReleaseMigration) + .read() + .release_migration + .clone() +} + pub fn use_relocation() -> RelocationState { use_radio(AppChannel::Relocation).read().relocation.clone() } @@ -120,7 +144,9 @@ pub fn use_account_switcher_open() -> bool { } pub fn use_control_center_open() -> bool { - use_radio(AppChannel::ControlCenter).read().control_center_open + use_radio(AppChannel::ControlCenter) + .read() + .control_center_open } pub fn use_game_snapshot() -> GameState { @@ -151,4 +177,3 @@ pub fn use_microsoft_login_status() -> Option { .microsoft_login .clone() } - diff --git a/packages/oneclient_app/src/hooks/overlay_claims.rs b/packages/oneclient_app/src/hooks/overlay_claims.rs index 3d226f91..3e7cd207 100644 --- a/packages/oneclient_app/src/hooks/overlay_claims.rs +++ b/packages/oneclient_app/src/hooks/overlay_claims.rs @@ -18,7 +18,10 @@ impl OverlayClaims { fn release(&self) { let held = self.0.get(); - debug_assert!(held > 0, "overlay claim released without a matching acquire"); + debug_assert!( + held > 0, + "overlay claim released without a matching acquire" + ); self.0.set(held.saturating_sub(1)); } } diff --git a/packages/oneclient_app/src/hooks/queries/auth.rs b/packages/oneclient_app/src/hooks/queries/auth.rs index 527feba8..735005e3 100644 --- a/packages/oneclient_app/src/hooks/queries/auth.rs +++ b/packages/oneclient_app/src/hooks/queries/auth.rs @@ -1,10 +1,11 @@ use std::sync::Mutex; use freya::query::{ - Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, use_mutation, use_query, + Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability, + UseMutation, UseQuery, use_mutation, use_query, }; -use oneclient_core::LauncherError; use oneclient_auth::{AccountKind, MicrosoftLoginSession, MinecraftAccount}; +use oneclient_core::LauncherError; use uuid::Uuid; static HANDLED_LOGIN_CODE: Mutex> = Mutex::new(None); @@ -112,7 +113,7 @@ pub fn try_account(query: &UseQuery) -> Option { super::state::settled_or_loading(query).flatten() } -pub(crate) async fn invalidate_auth_queries(account_id: Option) { +pub async fn invalidate_auth_queries(account_id: Option) { QueriesStorage::::invalidate_matching(ListAccountsKeys).await; for fallback in [false, true] { QueriesStorage::::invalidate_matching(DefaultAccountKeys { fallback }) @@ -132,7 +133,10 @@ impl MutationCapability for BeginMicrosoftLoginMutation { type Keys = (); async fn run(&self, _keys: &Self::Keys) -> Result { - Ok(crate::launcher::state()?.auth.begin_microsoft_login().await?) + Ok(crate::launcher::state()? + .auth + .begin_microsoft_login() + .await?) } } @@ -145,7 +149,10 @@ impl MutationCapability for FinishMicrosoftLoginMutation { type Keys = MicrosoftLoginSession; async fn run(&self, flow: &Self::Keys) -> Result { - Ok(crate::launcher::state()?.auth.finish_microsoft_login(flow.clone()).await?) + Ok(crate::launcher::state()? + .auth + .finish_microsoft_login(flow.clone()) + .await?) } async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { @@ -170,7 +177,10 @@ impl MutationCapability for CancelMicrosoftLoginMutation { type Keys = CancelMicrosoftLoginKeys; async fn run(&self, keys: &Self::Keys) -> Result { - crate::launcher::state()?.auth.cancel_microsoft_login(&keys.state_token).await; + crate::launcher::state()? + .auth + .cancel_microsoft_login(&keys.state_token) + .await; Ok(()) } } @@ -210,7 +220,10 @@ impl MutationCapability for AddOfflineAccountMutation { type Keys = AddOfflineAccountKeys; async fn run(&self, keys: &Self::Keys) -> Result { - Ok(crate::launcher::state()?.auth.add_offline_account(keys.username.clone()).await?) + Ok(crate::launcher::state()? + .auth + .add_offline_account(keys.username.clone()) + .await?) } async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { @@ -234,7 +247,10 @@ impl MutationCapability for RemoveAccountMutation { type Keys = RemoveAccountKeys; async fn run(&self, keys: &Self::Keys) -> Result { - Ok(crate::launcher::state()?.auth.remove_account(keys.id).await?) + Ok(crate::launcher::state()? + .auth + .remove_account(keys.id) + .await?) } async fn on_settled(&self, keys: &Self::Keys, result: &Result) { @@ -258,7 +274,10 @@ impl MutationCapability for SetDefaultAccountMutation { type Keys = SetDefaultAccountKeys; async fn run(&self, keys: &Self::Keys) -> Result { - Ok(crate::launcher::state()?.auth.set_default_account(keys.id).await?) + Ok(crate::launcher::state()? + .auth + .set_default_account(keys.id) + .await?) } async fn on_settled(&self, keys: &Self::Keys, result: &Result) { @@ -282,13 +301,16 @@ impl MutationCapability for RefreshAccountMutation { type Keys = RefreshAccountKeys; async fn run(&self, keys: &Self::Keys) -> Result { - Ok(crate::launcher::state()?.auth.refresh_account(keys.id).await?) + Ok(crate::launcher::state()? + .auth + .refresh_account(keys.id) + .await?) } - async fn on_settled(&self, keys: &Self::Keys, result: &Result) { - if result.is_ok() { - invalidate_auth_queries(Some(keys.id)).await; - } + /// A failure can still change the account a rejected token marks it + /// signed out + async fn on_settled(&self, keys: &Self::Keys, _result: &Result) { + invalidate_auth_queries(Some(keys.id)).await; } } @@ -301,13 +323,14 @@ impl MutationCapability for RefreshAllAccountsMutation { type Keys = (); async fn run(&self, _keys: &Self::Keys) -> Result { - Ok(crate::launcher::state()?.auth.refresh_all_accounts().await?) + Ok(crate::launcher::state()? + .auth + .refresh_all_accounts() + .await?) } - async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { - if result.is_ok() { - invalidate_auth_queries(None).await; - } + async fn on_settled(&self, _keys: &Self::Keys, _result: &Result) { + invalidate_auth_queries(None).await; } } diff --git a/packages/oneclient_app/src/hooks/queries/bundles.rs b/packages/oneclient_app/src/hooks/queries/bundles.rs index f24b88cf..7658a65b 100644 --- a/packages/oneclient_app/src/hooks/queries/bundles.rs +++ b/packages/oneclient_app/src/hooks/queries/bundles.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; +use std::sync::Arc; use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_common::domain::GameLoader; use oneclient_core::clusters::Cluster; use oneclient_core::{ BundleArchive, BundleUpdateCheckResult, BundleWithUpdateStatus, LauncherError, @@ -8,6 +10,8 @@ use oneclient_core::{ }; use oneclient_db::models::ClusterId; +use crate::launcher::off_ui; + #[derive(Clone, Debug)] pub struct ClusterBundles { pub cluster: Cluster, @@ -27,18 +31,25 @@ impl QueryCapability for OnboardingBundlesQuery { async fn run(&self, _keys: &Self::Keys) -> Result { let state = crate::launcher::state()?; - let clusters = state.clusters.list().await?; + off_ui(async move { + let clusters = state.clusters.list().await?; - let mut out = Vec::with_capacity(clusters.len()); - for cluster in clusters { - let archives = state - .bundles - .archives_for(&state.services.content(), &cluster.mc_version, cluster.mc_loader) - .await - .unwrap_or_default(); - out.push(ClusterBundles { cluster, archives }); - } - Ok(out) + let mut out = Vec::with_capacity(clusters.len()); + for cluster in clusters.into_iter().filter(|cluster| !cluster.user_created) { + let archives = state + .bundles + .archives_for( + &state.services.content(), + &cluster.mc_version, + cluster.mc_loader, + ) + .await + .unwrap_or_default(); + out.push(ClusterBundles { cluster, archives }); + } + Ok(out) + }) + .await } } @@ -70,14 +81,16 @@ impl QueryCapability for BundlesWithStatusQuery { async fn run(&self, keys: &Self::Keys) -> Result { let _ = keys; let state = crate::launcher::state()?; - Ok( + let cluster_id = self.cluster_id; + Ok(off_ui(async move { get_bundles_with_update_status( - self.cluster_id, + cluster_id, state.bundles.as_ref(), &state.services.content(), ) - .await?, - ) + .await + }) + .await?) } } @@ -112,7 +125,8 @@ impl QueryCapability for BundleOverridesQuery { async fn run(&self, keys: &Self::Keys) -> Result { let _ = keys; let state = crate::launcher::state()?; - let rows = list_cluster_bundle_overrides(self.cluster_id, &state.services.content()).await?; + let rows = + list_cluster_bundle_overrides(self.cluster_id, &state.services.content()).await?; Ok(rows .into_iter() .map(|(bundle, pkg, ty)| ((bundle, pkg), ty)) @@ -151,11 +165,15 @@ impl QueryCapability for BundleUpdatesQuery { async fn run(&self, keys: &Self::Keys) -> Result { let _ = keys; let state = crate::launcher::state()?; - Ok(oneclient_core::check_bundle_updates( - self.cluster_id, - state.bundles.as_ref(), - &state.services.content(), - ) + let cluster_id = self.cluster_id; + Ok(off_ui(async move { + oneclient_core::check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + }) .await?) } } @@ -166,3 +184,45 @@ pub fn use_bundle_updates(cluster_id: ClusterId) -> UseQuery BundleUpdatesQuery { cluster_id }, )) } + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AvailableBundlesQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct AvailableBundlesKeys { + pub mc_version: String, + pub loader: GameLoader, +} + +impl QueryCapability for AvailableBundlesQuery { + type Ok = Arc<[BundleArchive]>; + type Err = LauncherError; + type Keys = AvailableBundlesKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + if keys.mc_version.is_empty() { + return Ok(Arc::from([])); + } + let state = crate::launcher::state()?; + Ok(state + .bundles + .archives_for(&state.services.content(), &keys.mc_version, keys.loader) + .await + .unwrap_or_default() + .into()) + } +} + +pub fn use_available_bundles( + mc_version: String, + loader: GameLoader, +) -> UseQuery { + use_query(Query::new( + AvailableBundlesKeys { mc_version, loader }, + AvailableBundlesQuery, + )) +} + +pub fn available_bundles(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} diff --git a/packages/oneclient_app/src/hooks/queries/changelog.rs b/packages/oneclient_app/src/hooks/queries/changelog.rs index 55d29431..19917853 100644 --- a/packages/oneclient_app/src/hooks/queries/changelog.rs +++ b/packages/oneclient_app/src/hooks/queries/changelog.rs @@ -1,40 +1,30 @@ use freya::query::{Query, QueryCapability, UseQuery, use_query}; -use oneclient_core::{ - ChangelogGroup, LauncherError, fetch_changelog, parse_changelog, -}; - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct ChangelogKeys { - pub meta_url_base: String, -} +use oneclient_core::{ChangelogEntry, LauncherError, fetch_changelog}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ChangelogQuery; impl QueryCapability for ChangelogQuery { - type Ok = Vec; + type Ok = Vec; type Err = LauncherError; - type Keys = ChangelogKeys; + type Keys = (); async fn run(&self, _keys: &Self::Keys) -> Result { let state = crate::launcher::state()?; - let markdown = fetch_changelog(&state.services.requester).await?; - Ok(parse_changelog(&markdown)) + fetch_changelog(&state.services.requester).await } } pub fn use_changelog() -> UseQuery { - let meta_url_base = super::use_meta_url_key(); - - use_query(Query::new(ChangelogKeys { meta_url_base }, ChangelogQuery)) + use_query(Query::new((), ChangelogQuery)) } -pub fn changelog_groups(query: &UseQuery) -> Option> { +pub fn changelog_entries(query: &UseQuery) -> Option> { super::state::settled_or_loading(query) } pub fn latest_changelog_version(query: &UseQuery) -> Option { - changelog_groups(query).and_then(|groups| groups.first().map(|group| group.version.clone())) + changelog_entries(query).and_then(|entries| entries.first().map(|entry| entry.version.clone())) } pub fn changelog_error(query: &UseQuery) -> Option { diff --git a/packages/oneclient_app/src/hooks/queries/cluster_content.rs b/packages/oneclient_app/src/hooks/queries/cluster_content.rs index 33f62445..6c448951 100644 --- a/packages/oneclient_app/src/hooks/queries/cluster_content.rs +++ b/packages/oneclient_app/src/hooks/queries/cluster_content.rs @@ -1,8 +1,18 @@ +use freya::prelude::{spawn, use_hook}; use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use notify::{Event, EventKind}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use oneclient_content::packages::release_migration::has_packages_to_migrate; use oneclient_content::packages::{ContentType, PackageStore}; +use oneclient_core::game::DuplicateMods; use oneclient_core::{LauncherError, LinkedArtifactInfo}; use oneclient_db::models::ClusterId; +use crate::launcher::off_ui; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ClusterContentQuery { pub cluster_id: ClusterId, @@ -22,7 +32,8 @@ impl QueryCapability for ClusterContentQuery { async fn run(&self, _keys: &Self::Keys) -> Result { let state = crate::launcher::state()?; - let all = PackageStore::list_linked_artifacts(self.cluster_id, &state.services.content()).await?; + let all = + PackageStore::list_linked_artifacts(self.cluster_id, &state.services.content()).await?; Ok(all .into_iter() .filter(|item| item.content_type == self.content_type) @@ -49,3 +60,94 @@ pub fn use_cluster_content( pub fn cluster_content_items(query: &UseQuery) -> Vec { super::state::settled_or_loading(query).unwrap_or_default() } + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MigratableRoutesQuery; + +impl QueryCapability for MigratableRoutesQuery { + type Ok = Arc>; + type Err = LauncherError; + type Keys = Vec<(ClusterId, ClusterId)>; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let routes = keys.clone(); + off_ui(async move { + let content = state.services.content(); + let mut migratable = HashSet::new(); + for (target, source) in routes { + if has_packages_to_migrate(source, target, &state.bundles, &content).await? { + migratable.insert((target, source)); + } + } + Ok(Arc::new(migratable)) + }) + .await + } +} + +pub fn use_migratable_routes( + routes: Vec<(ClusterId, ClusterId)>, +) -> Arc> { + let query = use_query(Query::new(routes, MigratableRoutesQuery)); + super::state::settled_or_loading(&query).unwrap_or_default() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ShadowedModsQuery; + +impl QueryCapability for ShadowedModsQuery { + type Ok = Arc; + type Err = LauncherError; + type Keys = ClusterId; + + async fn run(&self, cluster_id: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let cluster_id = *cluster_id; + Ok(off_ui(async move { + Arc::new(oneclient_core::game::shadowed_bundle_mods(&state, cluster_id).await) + }) + .await) + } +} + +pub fn use_shadowed_mods(cluster_id: ClusterId) -> Arc { + let query = use_query(Query::new(cluster_id, ShadowedModsQuery)); + super::state::settled_or_loading(&query).unwrap_or_default() +} + +pub fn use_mods_folder_sync(cluster_id: ClusterId, folder: Option) { + let sync = move || { + spawn(async move { + let changed = off_ui(async move { + let Ok(state) = crate::launcher::state() else { + return false; + }; + oneclient_core::game::sync_cluster_mods(&state, cluster_id).await + }) + .await; + + if changed { + super::mutations::invalidate_cluster_queries().await; + } + }); + }; + + use_hook(sync); + super::folder_watch::use_folder_watch(folder, false, touches_jar, sync); +} + +fn touches_jar(_root: &Path, event: &Event) -> bool { + if matches!(event.kind, EventKind::Access(_)) { + return false; + } + + event.paths.iter().any(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + let name = name.to_ascii_lowercase(); + name.ends_with(".jar") || name.ends_with(".disabled") + }) + }) +} diff --git a/packages/oneclient_app/src/hooks/queries/disable_warnings.rs b/packages/oneclient_app/src/hooks/queries/disable_warnings.rs new file mode 100644 index 00000000..d7bbd08f --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/disable_warnings.rs @@ -0,0 +1,34 @@ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::{DisableWarnings, LauncherError, fetch_disable_warnings}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct DisableWarningsKeys { + pub meta_url_base: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct DisableWarningsQuery; + +impl QueryCapability for DisableWarningsQuery { + type Ok = DisableWarnings; + type Err = LauncherError; + type Keys = DisableWarningsKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + fetch_disable_warnings(&state.services.requester).await + } +} + +pub fn use_disable_warnings() -> UseQuery { + let meta_url_base = super::use_meta_url_key(); + + use_query(Query::new( + DisableWarningsKeys { meta_url_base }, + DisableWarningsQuery, + )) +} + +pub fn disable_warnings(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} diff --git a/packages/oneclient_app/src/hooks/queries/folder_watch.rs b/packages/oneclient_app/src/hooks/queries/folder_watch.rs new file mode 100644 index 00000000..52ad1ae8 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/folder_watch.rs @@ -0,0 +1,115 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use freya::prelude::{spawn, use_hook}; +use notify::{Event, RecursiveMode, Watcher}; +use tokio::sync::mpsc; + +const WATCH_QUIET: Duration = Duration::from_millis(400); + +pub(crate) fn use_folder_watch( + folder: Option, + watch_children: bool, + relevant: fn(&Path, &Event) -> bool, + on_change: impl Fn() + 'static, +) { + use_hook(move || { + let Some(folder) = folder else { + return; + }; + + spawn(async move { + if let Err(err) = watch_folder(&folder, watch_children, relevant, on_change).await { + tracing::warn!( + folder = %folder.display(), + error = %err, + "not watching the folder; the list will refresh on re-entry only" + ); + } + }); + }); +} + +async fn watch_folder( + folder: &Path, + watch_children: bool, + relevant: fn(&Path, &Event) -> bool, + on_change: impl Fn(), +) -> notify::Result<()> { + std::fs::create_dir_all(folder)?; + + let root = folder.to_path_buf(); + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut watcher = + notify::recommended_watcher(move |event: notify::Result| match event { + Ok(event) if relevant(&root, &event) => { + let _ = tx.send(()); + } + Ok(_) => {} + Err(err) => tracing::debug!(error = %err, "folder watcher reported an error"), + })?; + watcher.watch(folder, RecursiveMode::NonRecursive)?; + + let mut children = HashSet::new(); + if watch_children { + sync_children(&mut watcher, folder, &mut children); + } + + let mut pending = false; + loop { + tokio::select! { + biased; + + event = rx.recv() => { + if event.is_none() { + return Ok(()); + } + pending = true; + } + + () = quiet_period(pending) => { + pending = false; + if watch_children { + sync_children(&mut watcher, folder, &mut children); + } + on_change(); + } + } + } +} + +fn sync_children(watcher: &mut impl Watcher, folder: &Path, watched: &mut HashSet) { + let current: HashSet = std::fs::read_dir(folder) + .map(|entries| { + entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect() + }) + .unwrap_or_default(); + + let mut paths = watcher.paths_mut(); + for gone in watched.difference(¤t) { + let _ = paths.remove(gone); + } + for added in current.difference(watched) { + if let Err(err) = paths.add(added, RecursiveMode::NonRecursive) { + tracing::debug!(path = %added.display(), error = %err, "could not watch subfolder"); + } + } + if let Err(err) = paths.commit() { + tracing::debug!(error = %err, "could not update subfolder watches"); + } + + *watched = current; +} + +async fn quiet_period(pending: bool) { + if pending { + tokio::time::sleep(WATCH_QUIET).await; + } else { + std::future::pending::<()>().await; + } +} diff --git a/packages/oneclient_app/src/hooks/queries/image.rs b/packages/oneclient_app/src/hooks/queries/image.rs index 85d2e506..e35c94e2 100644 --- a/packages/oneclient_app/src/hooks/queries/image.rs +++ b/packages/oneclient_app/src/hooks/queries/image.rs @@ -37,7 +37,10 @@ impl QueryCapability for CachedImageQuery { /// Returns the url alongside the bytes callers key their image handle on it /// and a stale url with fresh bytes renders the wrong picture for a frame -pub fn loaded_image(url: Option<&str>, query: &UseQuery) -> Option<(String, Bytes)> { +pub fn loaded_image( + url: Option<&str>, + query: &UseQuery, +) -> Option<(String, Bytes)> { let url = url?; let bytes = super::state::settled_or_loading(query)?; Some((url.to_string(), bytes)) diff --git a/packages/oneclient_app/src/hooks/queries/java.rs b/packages/oneclient_app/src/hooks/queries/java.rs index 5c26f125..b65ec305 100644 --- a/packages/oneclient_app/src/hooks/queries/java.rs +++ b/packages/oneclient_app/src/hooks/queries/java.rs @@ -14,9 +14,7 @@ impl QueryCapability for ListJavaRuntimesQuery { async fn run(&self, _keys: &Self::Keys) -> Result { let state = crate::launcher::state().map_err(|e| e.to_string())?; - state.java.list_runtimes() - .await - .map_err(|e| e.to_string()) + state.java.list_runtimes().await.map_err(|e| e.to_string()) } } @@ -47,7 +45,9 @@ impl QueryCapability for ProviderVersionsQuery { async fn run(&self, keys: &Self::Keys) -> Result { let state = crate::launcher::state().map_err(|e| e.to_string())?; - state.java.available_versions(&keys.vendor) + state + .java + .available_versions(&keys.vendor) .await .map_err(|e| e.to_string()) } diff --git a/packages/oneclient_app/src/hooks/queries/logs.rs b/packages/oneclient_app/src/hooks/queries/logs.rs index 53d1a4b1..75d29069 100644 --- a/packages/oneclient_app/src/hooks/queries/logs.rs +++ b/packages/oneclient_app/src/hooks/queries/logs.rs @@ -1,13 +1,15 @@ use std::path::PathBuf; use freya::query::{ - Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, - UseMutation, UseQuery, use_mutation, use_query, + Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, + use_mutation, use_query, }; use oneclient_core::{ LauncherError, LogFileInfo, LogLevel, LogLine, MclogsUploadResponse, ReadOptions, }; +use crate::launcher::{off_ui, off_ui_blocking}; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct ClusterLogsKeys { pub cluster_id: i64, @@ -24,7 +26,7 @@ impl QueryCapability for ClusterLogsQuery { async fn run(&self, keys: &Self::Keys) -> Result { let state = crate::launcher::state()?; let cluster = state.clusters.get(keys.cluster_id).await?; - Ok(oneclient_core::list_cluster_logs(&cluster)?) + Ok(off_ui_blocking(move || oneclient_core::list_cluster_logs(&cluster)).await?) } } @@ -48,15 +50,13 @@ impl QueryCapability for LogContentQuery { if keys.path.as_os_str().is_empty() { return Ok(Vec::new()); } - Ok(oneclient_core::read_log_at( - &keys.path, - &ReadOptions { - level_filter: keys.level, - search: keys.search.clone(), - max_lines: keys.max_lines, - }, - ) - .await?) + let path = keys.path.clone(); + let options = ReadOptions { + level_filter: keys.level, + search: keys.search.clone(), + max_lines: keys.max_lines, + }; + Ok(off_ui(async move { oneclient_core::read_log_at(&path, &options).await }).await?) } } @@ -109,7 +109,11 @@ impl MutationCapability for UploadLogMutation { async fn run(&self, keys: &Self::Keys) -> Result { let state = crate::launcher::state()?; - Ok(oneclient_core::upload_log_at(&state.services.requester, &keys.path).await?) + let path = keys.path.clone(); + Ok(off_ui( + async move { oneclient_core::upload_log_at(&state.services.requester, &path).await }, + ) + .await?) } } diff --git a/packages/oneclient_app/src/hooks/queries/migration.rs b/packages/oneclient_app/src/hooks/queries/migration.rs index 4a9d862c..7299a0d0 100644 --- a/packages/oneclient_app/src/hooks/queries/migration.rs +++ b/packages/oneclient_app/src/hooks/queries/migration.rs @@ -27,10 +27,13 @@ impl QueryCapability for MigrationQuery { if instance.mc_version.is_empty() { continue; } - let resolved = + let (version, loader) = resolve_migration_chain(&instance.mc_version, instance.mc_loader, &rules); - if resolved != instance.mc_version { - instance.target_mc_version = Some(resolved); + if version != instance.mc_version { + instance.target_mc_version = Some(version); + } + if loader != instance.mc_loader { + instance.target_mc_loader = Some(loader); } } } diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index e65694c6..1c0939b2 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -15,6 +15,8 @@ mod bundles; mod changelog; mod cluster_content; mod clusters; +mod disable_warnings; +mod folder_watch; mod image; mod java; mod logs; @@ -31,6 +33,7 @@ mod storage; mod tos; mod version_metadata; mod versions; +mod worlds; pub use analytics::{ try_cluster_analytics, try_global_analytics, use_cluster_analytics, use_global_analytics, @@ -39,23 +42,31 @@ pub use auth::{ AddOfflineAccountKeys, BeginMicrosoftLoginMutation, CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount, - UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error, - mutation_is_pending, mutation_is_running, mutation_ok, reset_login_code_dedup, try_account, try_accounts, try_default_account, + UseSetDefaultAccount, accounts_have_microsoft, invalidate_auth_queries, + login_code_already_handled, mutation_error, mutation_is_pending, mutation_is_running, + mutation_ok, reset_login_code_dedup, try_account, try_accounts, try_default_account, use_account, use_accounts, use_add_microsoft_account, use_add_offline_account, use_begin_microsoft_login, use_cancel_microsoft_login, use_current_account, use_default_account, use_finish_microsoft_login, use_refresh_account, use_refresh_all_accounts, use_remove_account, use_set_default_account, }; pub use bundles::{ - ClusterBundles, OnboardingBundlesQuery, bundle_overrides_map, bundles_with_status_items, - onboarding_bundles_items, use_bundle_overrides, use_bundle_updates, use_bundles_with_status, + AvailableBundlesQuery, ClusterBundles, OnboardingBundlesQuery, available_bundles, + bundle_overrides_map, bundles_with_status_items, onboarding_bundles_items, + use_available_bundles, use_bundle_overrides, use_bundle_updates, use_bundles_with_status, use_onboarding_bundles, }; pub use changelog::{ - changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, use_changelog, + changelog_entries, changelog_error, changelog_is_loading, latest_changelog_version, + use_changelog, +}; +pub use cluster_content::{ + cluster_content_items, use_cluster_content, use_migratable_routes, use_mods_folder_sync, + use_shadowed_mods, }; -pub use cluster_content::{cluster_content_items, use_cluster_content}; pub use clusters::{use_cluster, use_clusters}; +pub use disable_warnings::{DisableWarningsQuery, disable_warnings, use_disable_warnings}; +pub(crate) use folder_watch::use_folder_watch; pub use image::{CachedImageQuery, loaded_image, use_cached_image}; pub use java::{ invalidate_java_queries, java_runtimes, provider_versions, use_java_runtimes, @@ -67,23 +78,16 @@ pub use logs::{ use_log_action, use_log_content, use_upload_log, }; pub use migration::{MigrationQuery, has_migration_data, migration_detection, use_migration}; -pub use storage::{ - DiscardLeftoversKeys, DiscardLeftoversMutation, LeftoversQuery, - StorageAction, StorageActionMutation, StorageReportQuery, UseDiscardLeftovers, - UseStorageAction, invalidate_leftovers_queries, invalidate_storage_queries, try_leftovers, - try_storage_report, use_discard_leftovers, use_leftovers, use_storage_action, - use_storage_report, -}; pub use mutations::{ - ClusterAction, invalidate_cluster_content_queries, invalidate_cluster_queries, + ClusterAction, ClusterMutation, invalidate_cluster_content_queries, invalidate_cluster_queries, invalidate_profile_queries, use_cluster_mutation, }; pub use package_updates::{package_updates, stale_hashes, use_package_updates}; +pub use packages::ALL_VERSIONS; pub use packages::{ BROWSE_PAGE_SIZE, VERSIONS_PAGE_SIZE, category_list, content_type_for_slug, package_meta_batch, project_detail, search_items, search_pending, search_total, use_package_categories, - use_package_meta_batch, use_package_project, use_package_search, use_package_versions, - use_package_versions_when, + use_package_meta_batch, use_package_project, use_package_search, use_package_versions_when, version_list, versions_total, }; pub use player_profile::use_player_profile; @@ -100,8 +104,23 @@ pub use skin::{ use_player_skin, }; pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading}; +pub use storage::{ + DiscardLeftoversKeys, DiscardLeftoversMutation, LeftoversQuery, StorageAction, + StorageActionMutation, StorageReportQuery, UseDiscardLeftovers, UseStorageAction, + invalidate_leftovers_queries, invalidate_storage_queries, try_leftovers, try_storage_report, + use_discard_leftovers, use_leftovers, use_storage_action, use_storage_report, +}; pub use tos::{TermsQuery, terms_document, terms_error, terms_is_loading, use_terms}; -pub use version_metadata::{pick_version_metadata, use_version_metadata}; -pub use versions::{loader_versions, use_loader_versions, use_versions, versions_metadata}; - -pub(crate) use auth::invalidate_auth_queries; +pub use version_metadata::{ + cluster_art_url, pick_version_metadata, refresh_version_art_gallery, resolve_art_url, + use_version_art, use_version_art_gallery, use_version_metadata, +}; +pub use versions::{ + GameVersion, JavaMajorsQuery, game_versions, java_majors, loader_versions, use_game_versions, + use_java_majors, use_loader_versions, use_version_loaders, use_versions, versions_metadata, +}; +pub use worlds::{ + add_world_datapacks, delete_world, delete_world_datapack, invalidate_world_contents, + spawn_world_task, try_cluster_worlds, try_world_datapacks, try_world_size, use_cluster_worlds, + use_saves_folder_watch, use_world_datapacks, use_world_size, +}; diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index e6cdda32..a5d4d9df 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -1,9 +1,13 @@ use freya::query::{Mutation, MutationCapability, QueriesStorage, UseMutation, use_mutation}; +use oneclient_common::domain::GameLoader; use oneclient_content::packages::LiveSync; -use oneclient_db::models::ClusterId; +use oneclient_core::BundleArchive; +use oneclient_db::models::{ClusterId, ClusterKind, OverrideType}; +use std::collections::HashSet; +use std::path::PathBuf; use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery}; -use super::cluster_content::ClusterContentQuery; +use super::cluster_content::{ClusterContentQuery, MigratableRoutesQuery, ShadowedModsQuery}; use super::clusters::ListClustersQuery; use super::package_updates::PackageUpdatesQuery; use super::settings_profiles::{ @@ -23,12 +27,46 @@ async fn timed(step: &'static str, fut: impl std::future::Future) { pub async fn invalidate_cluster_queries() { let started = std::time::Instant::now(); - timed("cluster_content", QueriesStorage::::invalidate_all()).await; - timed("bundle_overrides", QueriesStorage::::invalidate_all()).await; - timed("bundles_with_status", QueriesStorage::::invalidate_all()).await; - timed("clusters", QueriesStorage::::invalidate_all()).await; - timed("bundle_updates", QueriesStorage::::invalidate_all()).await; - timed("package_updates", QueriesStorage::::invalidate_all()).await; + timed( + "cluster_content", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "shadowed_mods", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundle_overrides", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundles_with_status", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "migratable_routes", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "clusters", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundle_updates", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "package_updates", + QueriesStorage::::invalidate_all(), + ) + .await; tracing::debug!( target: "oneclient_app::perf", ms = started.elapsed().as_millis() as u64, @@ -40,6 +78,7 @@ pub async fn invalidate_cluster_queries() { /// this before dropping its busy flag pub async fn invalidate_cluster_content_queries() { QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_all().await; } /// Everything [`invalidate_cluster_queries`] does bar the cluster list and the @@ -47,10 +86,26 @@ pub async fn invalidate_cluster_content_queries() { /// The bundle queries do move a toggle writes a bundle override and both read /// those back async fn invalidate_enabled_flag_queries() { - timed("cluster_content", QueriesStorage::::invalidate_all()).await; - timed("bundle_overrides", QueriesStorage::::invalidate_all()).await; - timed("bundles_with_status", QueriesStorage::::invalidate_all()).await; - timed("bundle_updates", QueriesStorage::::invalidate_all()).await; + timed( + "cluster_content", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundle_overrides", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundles_with_status", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundle_updates", + QueriesStorage::::invalidate_all(), + ) + .await; } pub async fn invalidate_profile_queries() { @@ -61,6 +116,36 @@ pub async fn invalidate_profile_queries() { QueriesStorage::::invalidate_all().await; } +fn bundle_selection_overrides( + archives: &[BundleArchive], + selected: &[String], +) -> Vec<(String, String, OverrideType)> { + let taken = |archive: &BundleArchive| selected.contains(&archive.manifest.name); + + let mut kept: HashSet = HashSet::new(); + for archive in archives.iter().filter(|archive| taken(archive)) { + for file in archive.manifest.files.iter().filter(|file| file.enabled) { + kept.insert(file.kind.package_id()); + } + } + + let mut overrides = Vec::new(); + for archive in archives.iter().filter(|archive| !taken(archive)) { + for file in archive.manifest.files.iter().filter(|file| file.enabled) { + let package_id = file.kind.package_id(); + if kept.contains(&package_id) { + continue; + } + overrides.push(( + archive.manifest.name.clone(), + package_id, + OverrideType::Removed, + )); + } + } + overrides +} + #[derive(Clone, PartialEq, Eq, Hash)] pub struct ClusterMutation; @@ -95,6 +180,29 @@ pub enum ClusterAction { VerifyFiles { cluster_id: ClusterId, }, + CreateInstance { + kind: ClusterKind, + name: String, + mc_version: String, + mc_loader: GameLoader, + mc_loader_version: Option, + description: Option, + tags: Vec, + cover_source: Option, + bundles: Option>, + }, + UpdateInstance { + cluster_id: ClusterId, + name: String, + description: Option, + tags: Vec, + cover_source: Option, + clear_cover: bool, + }, + DeleteInstance { + cluster_id: ClusterId, + }, + Batch(Vec), } impl MutationCapability for ClusterMutation { @@ -103,6 +211,19 @@ impl MutationCapability for ClusterMutation { type Keys = ClusterAction; async fn run(&self, keys: &ClusterAction) -> Result<(), String> { + if let ClusterAction::Batch(actions) = keys { + let mut errors = Vec::new(); + for action in actions { + if let Err(err) = Box::pin(self.run(action)).await { + errors.push(err); + } + } + return if errors.is_empty() { + Ok(()) + } else { + Err(errors.join("\n")) + }; + } let started = std::time::Instant::now(); let state = crate::launcher::state().map_err(|e| e.to_string())?; let services = &state.services; @@ -124,7 +245,7 @@ impl MutationCapability for ClusterMutation { } }), ClusterAction::RemoveArtifact { cluster_id, hash } => { - oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, true, content).await + oneclient_core::delete_artifact(*cluster_id, hash, content).await } ClusterAction::RemoveBundlePackageFromDisk { cluster_id, hash } => { oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, false, content).await @@ -165,7 +286,12 @@ impl MutationCapability for ClusterMutation { ClusterAction::VerifyFiles { cluster_id } => { // Reports its own outcome not the generic failure toast a // verify that finds nothing wrong is still a useful result - match oneclient_core::verify_cluster_files(&state, *cluster_id).await { + let (state, cluster_id) = (state.clone(), *cluster_id); + match crate::launcher::off_ui(async move { + oneclient_core::verify_cluster_files(&state, cluster_id).await + }) + .await + { Ok(report) => { let notify = services.events.notify("Verification complete"); let notify = notify.body(report.summary()); @@ -181,6 +307,178 @@ impl MutationCapability for ClusterMutation { }), } } + ClusterAction::CreateInstance { + kind, + name, + mc_version, + mc_loader, + mc_loader_version, + description, + tags, + cover_source, + bundles, + } => { + let global = state.settings.read().global_game_settings.clone(); + let mut options = oneclient_core::clusters::CreateClusterOptions::new( + name.clone(), + mc_version.clone(), + *mc_loader, + ) + .kind(*kind) + .user_created(true) + .tags(tags.clone()); + options.mc_loader_version = mc_loader_version.clone(); + options.description = description.clone(); + + match state.clusters.create(&global, options).await { + Ok(cluster) => { + services + .events + .signal(oneclient_events::Signal::ClustersChanged); + + if let Some(source) = cover_source + && let Err(err) = + state.clusters.set_cover_from_file(cluster.id, source).await + { + tracing::warn!(cluster_id = cluster.id, error = %err, "failed to store the instance cover"); + } + + if cluster.uses_bundles() { + if let Some(selected) = bundles { + let archives = state + .bundles + .archives_for(content, &cluster.mc_version, cluster.mc_loader) + .await + .unwrap_or_default(); + let overrides = bundle_selection_overrides(&archives, selected); + if let Err(err) = oneclient_core::set_bundle_package_overrides( + cluster.id, &overrides, content, + ) + .await + { + tracing::warn!(cluster_id = cluster.id, error = %err, "failed to record the bundle choices for the new instance"); + } + let choices: Vec<_> = archives + .iter() + .filter(|archive| !archive.manifest.enabled) + .map(|archive| { + let name = &archive.manifest.name; + (name.clone(), selected.contains(name)) + }) + .collect(); + if let Err(err) = oneclient_content::bundles::set_bundle_choices( + cluster.id, &choices, content, + ) + .await + { + tracing::warn!(cluster_id = cluster.id, error = %err, "failed to record the bundle choices for the new instance"); + } + } + + let session = oneclient_events::GroupedProgressSession::start( + &services.events, + format!("Setting up {}", cluster.name), + ); + if let Err(err) = oneclient_content::bundles::install_cluster_bundles( + cluster.id, + state.bundles.as_ref(), + Some(&session), + content, + ) + .await + { + tracing::warn!(cluster_id = cluster.id, error = %err, "failed to install bundle content for the new instance"); + } + if let Err(err) = oneclient_core::apply_bundle_java_override( + &state, + cluster.id, + true, + true, + Some(&session), + ) + .await + { + tracing::warn!(cluster_id = cluster.id, error = %err, "failed to apply the bundle java override for the new instance"); + } + session.finish(); + } + Ok(()) + } + Err(err) => Err(oneclient_content::ContentError::InvalidData { + reason: err.to_string(), + }), + } + } + ClusterAction::UpdateInstance { + cluster_id, + name, + description, + tags, + cover_source, + clear_cover, + } => { + let cover = if *clear_cover { + state.clusters.clear_cover(*cluster_id).await.ok(); + oneclient_common::Patch::Clear + } else if let Some(source) = cover_source { + match state.clusters.set_cover_from_file(*cluster_id, source).await { + Ok(_) => oneclient_common::Patch::Unchanged, + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to store the instance cover"); + oneclient_common::Patch::Unchanged + } + } + } else { + oneclient_common::Patch::Unchanged + }; + + let update = oneclient_core::clusters::ClusterUpdate { + name: Some(name.clone()), + description: match description { + Some(text) => oneclient_common::Patch::Set(text.clone()), + None => oneclient_common::Patch::Clear, + }, + tags: Some(tags.clone()), + cover_path: cover, + ..Default::default() + }; + + state + .clusters + .update(*cluster_id, update) + .await + .map(|_| ()) + .map_err(|err| oneclient_content::ContentError::InvalidData { + reason: err.to_string(), + }) + } + ClusterAction::DeleteInstance { cluster_id } => { + if crate::hooks::modpack_job_running(*cluster_id) { + Err(oneclient_content::ContentError::InvalidData { + reason: "Wait for the modpack to finish installing before deleting this instance." + .to_string(), + }) + } else if state.games.is_active(*cluster_id) { + Err(oneclient_content::ContentError::InvalidData { + reason: "Close the game before deleting this instance.".to_string(), + }) + } else { + let outcome = state + .clusters + .delete(*cluster_id, true) + .await + .map_err(|err| oneclient_content::ContentError::InvalidData { + reason: err.to_string(), + }); + if outcome.is_ok() { + services + .events + .signal(oneclient_events::Signal::ClustersChanged); + } + outcome + } + } + ClusterAction::Batch(_) => unreachable!(), }; tracing::debug!( target: "oneclient_app::perf", @@ -195,9 +493,21 @@ impl MutationCapability for ClusterMutation { if let Err(err) = result && let Ok(state) = crate::launcher::state() { - state.services.events.notify("Action failed").body(err).error().send(); + state + .services + .events + .notify("Action failed") + .body(err) + .error() + .send(); } - if matches!(keys, ClusterAction::SetArtifactEnabled { .. }) { + let enabled_only = match keys { + ClusterAction::Batch(actions) => actions + .iter() + .all(|a| matches!(a, ClusterAction::SetArtifactEnabled { .. })), + keys => matches!(keys, ClusterAction::SetArtifactEnabled { .. }), + }; + if enabled_only { invalidate_enabled_flag_queries().await; } else { invalidate_cluster_queries().await; diff --git a/packages/oneclient_app/src/hooks/queries/packages.rs b/packages/oneclient_app/src/hooks/queries/packages.rs index 548276b9..e0a0ad68 100644 --- a/packages/oneclient_app/src/hooks/queries/packages.rs +++ b/packages/oneclient_app/src/hooks/queries/packages.rs @@ -12,6 +12,7 @@ use oneclient_core::LauncherError; pub const BROWSE_PAGE_SIZE: usize = DEFAULT_PAGE_SIZE; pub const VERSIONS_PAGE_SIZE: usize = 20; +pub const ALL_VERSIONS: usize = usize::MAX; const VERSIONS_STALE: Duration = Duration::from_secs(5 * 60); @@ -19,6 +20,8 @@ pub fn content_type_for_slug(slug: &str) -> ContentType { match slug { "shader" => ContentType::Shader, "texture" => ContentType::ResourcePack, + "datapack" => ContentType::DataPack, + "modpack" => ContentType::Modpack, _ => ContentType::Mod, } } @@ -207,6 +210,7 @@ pub struct PackageVersionsKeys { pub game_version: Option, pub loader: Option, pub page: usize, + pub page_size: usize, } impl QueryCapability for PackageVersionsQuery { @@ -216,30 +220,26 @@ impl QueryCapability for PackageVersionsQuery { async fn run(&self, keys: &Self::Keys) -> Result { let state = crate::launcher::state()?; - let provider = state.services.packages.get(keys.provider)?; - Ok(provider - .list_versions( - &keys.project_id, - keys.game_version.as_deref(), - keys.loader, - keys.page * VERSIONS_PAGE_SIZE, - VERSIONS_PAGE_SIZE, - &state.services.content(), - ) - .await?) + let keys = keys.clone(); + crate::launcher::off_ui(async move { + Ok(state + .services + .packages + .get(keys.provider)? + .list_versions( + &keys.project_id, + keys.game_version.as_deref(), + keys.loader, + keys.page.saturating_mul(keys.page_size), + keys.page_size, + &state.services.content(), + ) + .await?) + }) + .await } } -pub fn use_package_versions( - provider: ProviderId, - project_id: String, - game_version: Option, - loader: Option, - page: usize, -) -> UseQuery { - use_package_versions_when(true, provider, project_id, game_version, loader, page) -} - pub fn use_package_versions_when( enabled: bool, provider: ProviderId, @@ -247,6 +247,7 @@ pub fn use_package_versions_when( game_version: Option, loader: Option, page: usize, + page_size: usize, ) -> UseQuery { let keys = enabled.then_some(PackageVersionsKeys { provider, @@ -254,6 +255,7 @@ pub fn use_package_versions_when( game_version, loader, page, + page_size, }); use_query(Query::new(keys, PackageVersionsQuery).stale_time(VERSIONS_STALE)) diff --git a/packages/oneclient_app/src/hooks/queries/player_profile.rs b/packages/oneclient_app/src/hooks/queries/player_profile.rs index 39e3687b..ec19930f 100644 --- a/packages/oneclient_app/src/hooks/queries/player_profile.rs +++ b/packages/oneclient_app/src/hooks/queries/player_profile.rs @@ -22,27 +22,15 @@ impl QueryCapability for FetchPlayerProfileQuery { type Keys = PlayerProfileQueryKeys; async fn run(&self, keys: &Self::Keys) -> Result { - let uuid_str = &keys.uuid; - if uuid_str.is_empty() { - return Ok(PlayerProfileView::default()); - } - - if let Ok(u) = uuid::Uuid::parse_str(uuid_str) { - let is_offline = u.get_version() == Some(uuid::Version::Md5) || u.is_nil(); - if is_offline { - return Ok(PlayerProfileView { - uuid: uuid_str.clone(), - ..Default::default() - }); - } - } - let access_token = keys.access_token.clone(); let state = crate::launcher::state()?; let client = &state.services.requester; - Ok(minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()).await?) + Ok( + minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()) + .await?, + ) } // TODO Cache diff --git a/packages/oneclient_app/src/hooks/queries/screenshots.rs b/packages/oneclient_app/src/hooks/queries/screenshots.rs index c7d2fa1e..4c8d7238 100644 --- a/packages/oneclient_app/src/hooks/queries/screenshots.rs +++ b/packages/oneclient_app/src/hooks/queries/screenshots.rs @@ -1,17 +1,17 @@ use std::ffi::OsStr; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; -use std::time::Duration; use bytes::Bytes; -use freya::prelude::{spawn, use_hook}; use freya::query::{ - Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, - UseMutation, UseQuery, use_mutation, use_query, + Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, + use_mutation, use_query, }; -use notify::{EventKind, RecursiveMode, Watcher}; +use notify::{Event, EventKind}; use oneclient_core::{LauncherError, ScreenshotInfo}; -use tokio::sync::{Semaphore, mpsc}; +use tokio::sync::Semaphore; + +use crate::launcher::off_ui_blocking; static LOCAL_IMAGE_SEMAPHORE: OnceLock> = OnceLock::new(); @@ -31,7 +31,7 @@ impl QueryCapability for ClusterScreenshotsQuery { async fn run(&self, keys: &Self::Keys) -> Result { let state = crate::launcher::state()?; let cluster = state.clusters.get(keys.cluster_id).await?; - Ok(oneclient_core::list_cluster_screenshots(&cluster)?) + Ok(off_ui_blocking(move || oneclient_core::list_cluster_screenshots(&cluster)).await?) } } @@ -42,7 +42,9 @@ pub struct LocalImageKeys { } #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct LocalImageQuery; +pub struct LocalImageQuery { + picked: bool, +} impl QueryCapability for LocalImageQuery { type Ok = Bytes; @@ -50,6 +52,10 @@ impl QueryCapability for LocalImageQuery { type Keys = LocalImageKeys; async fn run(&self, keys: &Self::Keys) -> Result { + if keys.path.as_os_str().is_empty() { + return Ok(Bytes::new()); + } + let path = keys.path.clone(); let max_edge = (keys.max_edge != 0).then_some(keys.max_edge); @@ -66,11 +72,20 @@ impl QueryCapability for LocalImageQuery { None }; - Ok( - tokio::task::spawn_blocking(move || oneclient_core::load_screenshot(&path, max_edge)) - .await - .map_err(|e| LauncherError::Minecraft(e.to_string()))??, - ) + let picked = self.picked; + Ok(tokio::task::spawn_blocking(move || { + if picked { + oneclient_core::load_picked_image(&path, max_edge) + } else { + oneclient_core::load_screenshot(&path, max_edge) + } + }) + .await + .map_err(|e| LauncherError::Minecraft(e.to_string()))??) + } + + fn matches(&self, _keys: &Self::Keys) -> bool { + !self.picked } } @@ -81,84 +96,21 @@ pub fn use_cluster_screenshots(cluster_id: i64) -> UseQuery UseQuery { +pub fn use_local_image(path: PathBuf, max_edge: u32, picked: bool) -> UseQuery { use_query(Query::new( LocalImageKeys { path, max_edge }, - LocalImageQuery, + LocalImageQuery { picked }, )) } -const WATCH_QUIET: Duration = Duration::from_millis(400); - pub fn use_screenshot_folder_watch( folder: Option, query: UseQuery, ) { - use_hook(move || { - let Some(folder) = folder else { - return; - }; - - spawn(async move { - if let Err(err) = watch_folder(&folder, query).await { - tracing::warn!( - folder = %folder.display(), - error = %err, - "not watching the screenshot folder; the list will refresh on re-entry only" - ); - } - }); - }); + super::folder_watch::use_folder_watch(folder, false, touches_image, move || query.invalidate()); } -async fn watch_folder( - folder: &Path, - query: UseQuery, -) -> notify::Result<()> { - // If there is no screenshots folder it creates it - std::fs::create_dir_all(folder)?; - - let (tx, mut rx) = mpsc::unbounded_channel(); - let mut watcher = notify::recommended_watcher(move |event: notify::Result| { - match event { - Ok(event) if touches_image(&event) => { - let _ = tx.send(()); - } - Ok(_) => {} - Err(err) => tracing::debug!(error = %err, "screenshot watcher reported an error"), - } - })?; - watcher.watch(folder, RecursiveMode::NonRecursive)?; - - let mut pending = false; - loop { - tokio::select! { - biased; - - event = rx.recv() => { - if event.is_none() { - return Ok(()); - } - pending = true; - } - - () = quiet_period(pending) => { - pending = false; - query.invalidate(); - } - } - } -} - -async fn quiet_period(pending: bool) { - if pending { - tokio::time::sleep(WATCH_QUIET).await; - } else { - std::future::pending::<()>().await; - } -} - -fn touches_image(event: ¬ify::Event) -> bool { +fn touches_image(_root: &Path, event: &Event) -> bool { if matches!(event.kind, EventKind::Access(_)) { return false; } @@ -180,7 +132,11 @@ pub fn try_cluster_screenshots( pub async fn invalidate_screenshots_queries() { QueriesStorage::::invalidate_all().await; - QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_matching(LocalImageKeys { + path: PathBuf::new(), + max_edge: 0, + }) + .await; } #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -198,7 +154,10 @@ impl MutationCapability for ScreenshotActionMutation { async fn run(&self, keys: &Self::Keys) -> Result { match keys { - ScreenshotAction::Delete { path } => Ok(oneclient_core::delete_screenshot(path)?), + ScreenshotAction::Delete { path } => { + let path = path.clone(); + Ok(off_ui_blocking(move || oneclient_core::delete_screenshot(&path)).await?) + } } } diff --git a/packages/oneclient_app/src/hooks/queries/settings_profiles.rs b/packages/oneclient_app/src/hooks/queries/settings_profiles.rs index 329339e2..6977b829 100644 --- a/packages/oneclient_app/src/hooks/queries/settings_profiles.rs +++ b/packages/oneclient_app/src/hooks/queries/settings_profiles.rs @@ -1,8 +1,8 @@ use freya::query::{Query, QueryCapability, UseQuery, use_query}; -use oneclient_core::settings::GameSettingsProfile; use oneclient_cluster::profiles::{ get_profile_or_default, list_named_profiles, resolve_cluster_profile, }; +use oneclient_core::settings::GameSettingsProfile; use oneclient_db::models::ClusterId; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -110,11 +110,15 @@ impl QueryCapability for ClusterSettingsQuery { async fn run(&self, keys: &Self::Keys) -> Result { let cluster_id = keys.cluster_id; let state = crate::launcher::state().map_err(|e| e.to_string())?; - let cluster = state.clusters.get(cluster_id) + let cluster = state + .clusters + .get(cluster_id) .await .map_err(|e| e.to_string())?; let global = state.settings.read().global_game_settings.clone(); - state.clusters.resolve_settings(&global, &cluster) + state + .clusters + .resolve_settings(&global, &cluster) .await .map_err(|e| e.to_string()) } diff --git a/packages/oneclient_app/src/hooks/queries/storage.rs b/packages/oneclient_app/src/hooks/queries/storage.rs index 658294dd..4dad13c6 100644 --- a/packages/oneclient_app/src/hooks/queries/storage.rs +++ b/packages/oneclient_app/src/hooks/queries/storage.rs @@ -5,6 +5,8 @@ use freya::query::{ use_mutation, use_query, }; use oneclient_core::LauncherError; + +use crate::launcher::off_ui; use oneclient_core::relocate::{Leftovers, discard_leftovers, leftovers}; use oneclient_core::storage::{StorageReport, storage_report}; @@ -21,7 +23,7 @@ impl QueryCapability for StorageReportQuery { async fn run(&self, _keys: &Self::Keys) -> Result { let state = crate::launcher::state()?; - storage_report(&state).await + off_ui(async move { storage_report(&state).await }).await } } @@ -55,19 +57,23 @@ impl MutationCapability for StorageActionMutation { async fn run(&self, keys: &Self::Keys) -> Result { let state = crate::launcher::state().map_err(|e| e.to_string())?; + let keys = *keys; - match keys { - StorageAction::CleanUnreferencedCache => { - oneclient_core::storage::clean_unreferenced_cache(&state) - .await - .map(|_| ()) + off_ui(async move { + match keys { + StorageAction::CleanUnreferencedCache => { + oneclient_core::storage::clean_unreferenced_cache(&state) + .await + .map(|_| ()) + } + StorageAction::CleanLegacyClusterContent => { + oneclient_core::storage::clean_cluster_leftovers(&state) + .await + .map(|_| ()) + } } - StorageAction::CleanLegacyClusterContent => { - oneclient_core::storage::clean_cluster_leftovers(&state) - .await - .map(|_| ()) - } - } + }) + .await .map_err(|e| e.to_string()) } @@ -97,7 +103,7 @@ impl QueryCapability for LeftoversQuery { async fn run(&self, _keys: &Self::Keys) -> Result { let state = crate::launcher::state()?; - Ok(leftovers(&state).await) + Ok(off_ui(async move { leftovers(&state).await }).await) } } @@ -126,7 +132,7 @@ impl MutationCapability for DiscardLeftoversMutation { async fn run(&self, _keys: &Self::Keys) -> Result { let state = crate::launcher::state().map_err(|err| err.to_string())?; - discard_leftovers(&state).await + off_ui(async move { discard_leftovers(&state).await }).await } async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { diff --git a/packages/oneclient_app/src/hooks/queries/version_metadata.rs b/packages/oneclient_app/src/hooks/queries/version_metadata.rs index fb90e3d9..c30f27c1 100644 --- a/packages/oneclient_app/src/hooks/queries/version_metadata.rs +++ b/packages/oneclient_app/src/hooks/queries/version_metadata.rs @@ -1,8 +1,13 @@ -use oneclient_common::VersionKey; -use oneclient_core::VersionMetadata; use oneclient_common::domain::GameLoader; +use oneclient_common::{VersionKey, parse_mc_version}; +use oneclient_core::VersionMetadata; +use oneclient_core::clusters::Cluster; +use oneclient_core::versions::VersionArts; + +use freya::query::QueriesStorage; use super::use_versions; +use super::versions::{VersionArtsKeys, VersionArtsQuery, use_version_arts}; pub fn pick_version_metadata( list: &[VersionMetadata], @@ -57,6 +62,70 @@ pub fn use_version_metadata( pick_version_metadata(list, major, key, loader) } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct VersionArtCandidates { + pub minor: Option, + pub fallback: Option, +} + +pub fn use_version_art(major: Option, key: Option) -> VersionArtCandidates { + let arts_query = use_version_arts(); + let reader = arts_query.read(); + let state = reader.state(); + + let Some(arts) = state.ok() else { + return VersionArtCandidates::default(); + }; + + VersionArtCandidates { + minor: arts.specific_art_url(major, key), + fallback: arts.fallback_art_url(major), + } +} + +pub async fn refresh_version_art_gallery() { + QueriesStorage::::invalidate_matching(VersionArtsKeys).await; +} + +pub fn use_version_art_gallery() -> Vec { + let arts_query = use_version_arts(); + let reader = arts_query.read(); + let state = reader.state(); + + state.ok().map(|arts| arts.gallery()).unwrap_or_default() +} + +pub fn resolve_art_url( + curated: Option<&VersionMetadata>, + candidates: &VersionArtCandidates, +) -> Option { + let curated_minor = curated + .filter(|m| m.minor_version.is_some()) + .and_then(|m| m.art_url.clone()); + + curated_minor + .or_else(|| candidates.minor.clone()) + .or_else(|| curated.and_then(|m| m.art_url.clone())) + .or_else(|| candidates.fallback.clone()) +} + +pub fn cluster_art_url( + cluster: &Cluster, + list: &[VersionMetadata], + arts: &VersionArts, +) -> Option { + let parsed = parse_mc_version(&cluster.mc_version); + let major = parsed.as_ref().map(|p| p.major); + let key = parsed.and_then(|p| p.key()); + let curated = + major.and_then(|major| pick_version_metadata(list, major, key, Some(cluster.mc_loader))); + let candidates = VersionArtCandidates { + minor: arts.specific_art_url(major, key), + fallback: arts.fallback_art_url(major), + }; + resolve_art_url(curated.as_ref(), &candidates) +} + #[cfg(test)] mod tests { use super::*; @@ -77,7 +146,6 @@ mod tests { art_url: None, long_description: None, tags: Vec::new(), - predownload: false, } } @@ -89,6 +157,62 @@ mod tests { ] } + fn candidates(minor: Option<&str>, fallback: Option<&str>) -> VersionArtCandidates { + VersionArtCandidates { + minor: minor.map(str::to_string), + fallback: fallback.map(str::to_string), + } + } + + fn with_art(mut entry: VersionMetadata, art: &str) -> VersionMetadata { + entry.art_url = Some(art.to_string()); + entry + } + + #[test] + fn a_minor_specific_art_beats_the_curated_cluster_row() { + let cluster = with_art(entry(21, None, None, None, "major"), "trials"); + let resolved = resolve_art_url(Some(&cluster), &candidates(Some("garden"), Some("trials"))); + assert_eq!(resolved.as_deref(), Some("garden")); + } + + #[test] + fn a_curated_entry_art_beats_the_arts_manifest() { + let curated = with_art( + entry(21, Some(10), None, Some("fabric"), "copper"), + "copper", + ); + let resolved = resolve_art_url(Some(&curated), &candidates(Some("other"), Some("trials"))); + assert_eq!(resolved.as_deref(), Some("copper")); + } + + #[test] + fn the_cluster_row_still_wins_over_a_line_level_art() { + let cluster = with_art(entry(21, None, None, None, "major"), "trials"); + let resolved = resolve_art_url(Some(&cluster), &candidates(None, Some("line"))); + assert_eq!(resolved.as_deref(), Some("trials")); + } + + #[test] + fn nothing_curated_falls_through_to_the_arts_manifest() { + assert_eq!( + resolve_art_url(None, &candidates(None, Some("dirt"))).as_deref(), + Some("dirt") + ); + assert_eq!( + resolve_art_url(None, &candidates(Some("combat"), Some("dirt"))).as_deref(), + Some("combat") + ); + assert_eq!(resolve_art_url(None, &candidates(None, None)), None); + } + + #[test] + fn a_curated_row_without_art_does_not_shadow_the_manifest() { + let cluster = entry(21, None, None, None, "major"); + let resolved = resolve_art_url(Some(&cluster), &candidates(None, Some("dirt"))); + assert_eq!(resolved.as_deref(), Some("dirt")); + } + #[test] fn patch_and_bare_minor_are_distinct() { let bare = pick_version_metadata(&list(), 26, Some((1, None)), Some(GameLoader::Fabric)); diff --git a/packages/oneclient_app/src/hooks/queries/versions.rs b/packages/oneclient_app/src/hooks/queries/versions.rs index be744e63..88c04a91 100644 --- a/packages/oneclient_app/src/hooks/queries/versions.rs +++ b/packages/oneclient_app/src/hooks/queries/versions.rs @@ -1,6 +1,11 @@ +use std::collections::HashMap; +use std::sync::Arc; + use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; use oneclient_common::domain::GameLoader; -use oneclient_core::{LauncherError, VersionMetadata}; +use oneclient_core::{GameVersionKind, LauncherError, VersionArts, VersionMetadata}; + +use crate::launcher::off_ui; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct VersionsMetadataQuery; @@ -15,12 +20,21 @@ impl QueryCapability for VersionsMetadataQuery { async fn run(&self, _keys: &Self::Keys) -> Result { let state = crate::launcher::state()?; - let metadata = state.versions.metadata(&state.services.requester.config().meta_url_base).await; - if !metadata.is_empty() { - return Ok(metadata); - } - state.versions.sync(&state.services).await?; - Ok(state.versions.metadata(&state.services.requester.config().meta_url_base).await) + off_ui(async move { + let metadata = state + .versions + .metadata(&state.services.requester.config().meta_url_base) + .await; + if !metadata.is_empty() { + return Ok(metadata); + } + state.versions.sync(&state.services).await?; + Ok(state + .versions + .metadata(&state.services.requester.config().meta_url_base) + .await) + }) + .await } } @@ -28,6 +42,40 @@ pub fn use_versions() -> UseQuery { use_query(Query::new(VersionsMetadataKeys, VersionsMetadataQuery)) } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct VersionArtsQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct VersionArtsKeys; + +impl QueryCapability for VersionArtsQuery { + type Ok = Arc; + type Err = LauncherError; + type Keys = VersionArtsKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let arts = state + .versions + .arts(&state.services.requester.config().meta_url_base) + .await; + if !arts.is_empty() { + return Ok(Arc::new(arts)); + } + state.versions.sync(&state.services).await?; + Ok(Arc::new( + state + .versions + .arts(&state.services.requester.config().meta_url_base) + .await, + )) + } +} + +pub fn use_version_arts() -> UseQuery { + use_query(Query::new(VersionArtsKeys, VersionArtsQuery)) +} + /// A failed fetch reads as an empty list rather than `None` so callers that /// block on this don't wait forever when the network is down pub fn versions_metadata(query: &UseQuery) -> Option> { @@ -49,20 +97,25 @@ pub struct LoaderVersionsKeys { } impl QueryCapability for LoaderVersionsQuery { - type Ok = Vec; + type Ok = Arc<[String]>; type Err = LauncherError; type Keys = LoaderVersionsKeys; async fn run(&self, keys: &Self::Keys) -> Result { let state = crate::launcher::state()?; - let mut metadata = state.metadata.lock().await; - Ok(oneclient_core::get_loader_versions( - &mut metadata, - &state.services.mc(), - &keys.mc_version, - keys.loader, - ) - .await?) + let keys = keys.clone(); + Ok(off_ui(async move { + let mut metadata = state.metadata.lock().await; + oneclient_core::get_loader_versions( + &mut metadata, + &state.services.mc(), + &keys.mc_version, + keys.loader, + ) + .await + }) + .await? + .into()) } } @@ -76,6 +129,165 @@ pub fn use_loader_versions( )) } -pub fn loader_versions(query: &UseQuery) -> Vec { +pub fn loader_versions(query: &UseQuery) -> Arc<[String]> { + super::state::settled_or_loading(query).unwrap_or_else(|| Arc::from([])) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GameVersionsQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GameVersionsKeys; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GameVersion { + pub id: String, + pub kind: GameVersionKind, + pub released: String, +} + +impl QueryCapability for GameVersionsQuery { + type Ok = Arc<[GameVersion]>; + type Err = LauncherError; + type Keys = GameVersionsKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + + tokio::spawn(async move { + let raw = { + let mut metadata = state.metadata.lock().await; + oneclient_core::get_version_ids(&mut metadata, &state.services.mc()).await? + }; + + let prepared: Arc<[GameVersion]> = raw + .into_iter() + .map(|info| GameVersion { + id: info.id, + kind: info.kind, + released: info.released.format("%d %b %Y").to_string(), + }) + .collect(); + + Ok(prepared) + }) + .await + .map_err(|err| LauncherError::Minecraft(err.to_string()))? + } +} + +pub fn use_game_versions() -> UseQuery { + use_query(Query::new(GameVersionsKeys, GameVersionsQuery)) +} + +pub fn game_versions(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct VersionLoadersQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct VersionLoadersKeys { + pub mc_version: String, +} + +impl QueryCapability for VersionLoadersQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = VersionLoadersKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let mut metadata = state.metadata.lock().await; + Ok(oneclient_core::get_loaders_for_version( + &mut metadata, + &state.services.mc(), + &keys.mc_version, + ) + .await?) + } +} + +pub fn use_version_loaders(mc_version: String) -> UseQuery { + use_query(Query::new( + VersionLoadersKeys { mc_version }, + VersionLoadersQuery, + )) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct JavaMajorsQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct JavaMajorsKeys { + pub versions: Vec, +} + +impl QueryCapability for JavaMajorsQuery { + type Ok = HashMap; + type Err = LauncherError; + type Keys = JavaMajorsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + if keys.versions.is_empty() { + return Ok(HashMap::new()); + } + + let state = crate::launcher::state()?; + let wanted = keys.versions.clone(); + + tokio::spawn(async move { + let mc = state.services.mc(); + + let resolved = { + let mut metadata = state.metadata.lock().await; + let mut resolved = Vec::with_capacity(wanted.len()); + for id in &wanted { + if let Ok((version, _, _)) = + oneclient_core::game::resolve_minecraft_version(&mut metadata, &mc, id) + .await + { + resolved.push((id.clone(), version)); + } + } + resolved + }; + + let lookups = resolved.into_iter().map(|(id, version)| { + let mc = mc.clone(); + async move { + let info = oneclient_core::game::download_version_info( + &mc, + None, + &version, + oneclient_common::domain::GameLoader::Vanilla, + None, + false, + ) + .await + .ok()?; + Some((id, info.java_version?.major_version)) + } + }); + + let majors = futures_util::future::join_all(lookups) + .await + .into_iter() + .flatten() + .collect(); + + Ok(majors) + }) + .await + .map_err(|err| LauncherError::Minecraft(err.to_string()))? + } +} + +pub fn use_java_majors(versions: Vec) -> UseQuery { + use_query(Query::new(JavaMajorsKeys { versions }, JavaMajorsQuery)) +} + +pub fn java_majors(query: &UseQuery) -> HashMap { super::state::settled_or_loading(query).unwrap_or_default() } diff --git a/packages/oneclient_app/src/hooks/queries/worlds.rs b/packages/oneclient_app/src/hooks/queries/worlds.rs new file mode 100644 index 00000000..39643b65 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/worlds.rs @@ -0,0 +1,199 @@ +use std::future::Future; +use std::path::{Path, PathBuf}; + +use freya::prelude::spawn_forever; +use freya::query::{QueriesStorage, Query, QueryCapability, UseQuery, use_query}; +use notify::{Event, EventKind}; +use oneclient_core::{ClusterError, DataPackInfo, LEVEL_DAT, LauncherError, WORLD_ICON, WorldInfo}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterWorldsKeys { + pub cluster_id: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterWorldsQuery; + +impl QueryCapability for ClusterWorldsQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = ClusterWorldsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let cluster = state.clusters.get(keys.cluster_id).await?; + run_blocking(move || oneclient_core::list_cluster_worlds(&cluster)).await + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct WorldSizeKeys { + pub cluster_id: i64, + pub world: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct WorldSizeQuery(WorldSizeKeys); + +impl QueryCapability for WorldSizeQuery { + type Ok = u64; + type Err = LauncherError; + type Keys = WorldSizeKeys; + + fn matches(&self, keys: &Self::Keys) -> bool { + self.0 == *keys + } + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let cluster = state.clusters.get(keys.cluster_id).await?; + let world = keys.world.clone(); + run_blocking(move || oneclient_core::world_size(&cluster, &world)).await + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct WorldDataPacksKeys { + pub cluster_id: i64, + pub world: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct WorldDataPacksQuery; + +impl QueryCapability for WorldDataPacksQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = WorldDataPacksKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + if keys.world.is_empty() { + return Ok(Vec::new()); + } + let state = crate::launcher::state()?; + let cluster = state.clusters.get(keys.cluster_id).await?; + let world = keys.world.clone(); + Ok(tokio::spawn( + async move { oneclient_core::list_world_datapacks(&cluster, &world).await }, + ) + .await + .map_err(std::io::Error::other)??) + } +} + +pub fn use_cluster_worlds(cluster_id: i64) -> UseQuery { + use_query(Query::new( + ClusterWorldsKeys { cluster_id }, + ClusterWorldsQuery, + )) +} + +pub fn use_world_size(cluster_id: i64, world: String) -> UseQuery { + let keys = WorldSizeKeys { cluster_id, world }; + use_query(Query::new(keys.clone(), WorldSizeQuery(keys))) +} + +pub fn try_world_size(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} + +pub fn use_world_datapacks(cluster_id: i64, world: String) -> UseQuery { + use_query(Query::new( + WorldDataPacksKeys { cluster_id, world }, + WorldDataPacksQuery, + )) +} + +pub fn use_saves_folder_watch(folder: Option, query: UseQuery) { + super::folder_watch::use_folder_watch(folder, true, touches_world_list, move || { + query.invalidate() + }); +} + +fn touches_world_list(saves: &Path, event: &Event) -> bool { + if matches!(event.kind, EventKind::Access(_)) { + return false; + } + + event.paths.iter().any(|path| { + let parent = path.parent(); + parent == Some(saves) + || (parent.and_then(Path::parent) == Some(saves) + && path + .file_name() + .is_some_and(|name| name == LEVEL_DAT || name == WORLD_ICON)) + }) +} + +pub fn try_cluster_worlds(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} + +pub fn try_world_datapacks(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} + +pub fn spawn_world_task( + dispatch: crate::Actions, + failure_title: &'static str, + task: impl Future> + 'static, +) { + spawn_forever(async move { + if let Err(err) = task.await { + dispatch + .notify(failure_title) + .body(err.to_string()) + .error() + .send(); + } + }); +} + +pub async fn invalidate_world_contents(cluster_id: i64, world: String) { + QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_matching(WorldSizeKeys { cluster_id, world }) + .await; +} + +async fn run_blocking( + task: impl FnOnce() -> Result + Send + 'static, +) -> Result { + Ok(tokio::task::spawn_blocking(task) + .await + .map_err(std::io::Error::other)??) +} + +pub async fn add_world_datapacks( + cluster_id: i64, + world: String, + files: Vec, +) -> Result<(), LauncherError> { + let state = crate::launcher::state()?; + let cluster = state.clusters.get(cluster_id).await?; + let result = oneclient_core::add_world_datapacks(&cluster, &world, &files).await; + invalidate_world_contents(cluster_id, world).await; + Ok(result?) +} + +pub async fn delete_world(cluster_id: i64, world: String) -> Result<(), LauncherError> { + let state = crate::launcher::state()?; + let cluster = state.clusters.get(cluster_id).await?; + let result = run_blocking(move || oneclient_core::delete_world(&cluster, &world)).await; + QueriesStorage::::invalidate_all().await; + result +} + +pub async fn delete_world_datapack( + cluster_id: i64, + world: String, + file_name: String, +) -> Result<(), LauncherError> { + let state = crate::launcher::state()?; + let cluster = state.clusters.get(cluster_id).await?; + let target = world.clone(); + let result = + run_blocking(move || oneclient_core::delete_world_datapack(&cluster, &target, &file_name)) + .await; + invalidate_world_contents(cluster_id, world).await; + result +} diff --git a/packages/oneclient_app/src/hooks/selection.rs b/packages/oneclient_app/src/hooks/selection.rs new file mode 100644 index 00000000..9a0f3248 --- /dev/null +++ b/packages/oneclient_app/src/hooks/selection.rs @@ -0,0 +1,128 @@ +use std::collections::HashSet; +use std::hash::Hash; + +use freya::prelude::*; + +#[derive(PartialEq)] +pub struct Selection { + keys: State>, + anchor: State>, + modifiers: State, + active: State, +} + +impl Clone for Selection { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for Selection {} + +pub fn use_selection() -> Selection { + Selection { + keys: use_state(HashSet::new), + anchor: use_state(|| None), + modifiers: use_state(Modifiers::empty), + active: use_state(|| false), + } +} + +fn held_after(e: &KeyboardEventData, down: bool) -> Modifiers { + let key = match e.code { + Code::ShiftLeft | Code::ShiftRight => Modifiers::SHIFT, + Code::ControlLeft | Code::ControlRight => Modifiers::CONTROL, + Code::MetaLeft | Code::MetaRight => Modifiers::META, + _ => Modifiers::empty(), + }; + let mut held = e.modifiers; + held.set(key, down); + held +} + +impl Selection { + pub fn track_modifiers(self, el: E) -> E { + let mut down = self.modifiers; + let mut up = self.modifiers; + el.on_global_key_down(move |e: Event| { + down.set_if_modified(held_after(&e, true)); + }) + .on_global_key_up(move |e: Event| { + up.set_if_modified(held_after(&e, false)); + }) + } + + pub fn modifier_held(&self) -> bool { + self.modifiers + .read() + .intersects(Modifiers::SHIFT | Modifiers::CONTROL | Modifiers::META) + } + + pub fn is_active(&self) -> bool { + *self.active.read() + } + + pub fn enter(mut self) { + self.active.set_if_modified(true); + } + + pub fn is_selected(&self, key: &K) -> bool { + self.keys.read().contains(key) + } + + pub fn selected_in(&self, order: &[K]) -> Vec { + let keys = self.keys.read(); + order.iter().filter(|k| keys.contains(k)).cloned().collect() + } + + pub fn toggle(mut self, key: K) { + if !self.keys.write().remove(&key) { + self.keys.write().insert(key.clone()); + } + self.anchor.set(Some(key)); + self.enter(); + } + + pub fn click(mut self, key: K, order: &[K]) { + let anchor = self.anchor.peek().clone(); + let range = anchor + .filter(|_| self.modifiers.peek().contains(Modifiers::SHIFT)) + .and_then(|anchor| order.iter().position(|k| *k == anchor)) + .zip(order.iter().position(|k| *k == key)); + + let Some((from, to)) = range else { + return self.toggle(key); + }; + + let select = !self.keys.peek().contains(&key); + let mut keys = self.keys.write(); + for k in &order[from.min(to)..=from.max(to)] { + if select { + keys.insert(k.clone()); + } else { + keys.remove(k); + } + } + drop(keys); + self.enter(); + } + + pub fn toggle_all(mut self, order: &[K]) { + let all = order.iter().all(|k| self.keys.peek().contains(k)); + if all { + self.clear(); + } else { + self.keys.write().extend(order.iter().cloned()); + } + } + + pub fn clear(mut self) { + self.keys.write().clear(); + self.anchor.set(None); + } + + pub fn exit(mut self) { + self.clear(); + self.active.set_if_modified(false); + } +} diff --git a/packages/oneclient_app/src/hooks/shortcut_actions.rs b/packages/oneclient_app/src/hooks/shortcut_actions.rs index ca0e3094..9a61b691 100644 --- a/packages/oneclient_app/src/hooks/shortcut_actions.rs +++ b/packages/oneclient_app/src/hooks/shortcut_actions.rs @@ -26,12 +26,14 @@ impl Actions { .take() } + /// `shared` is every other cluster in the shared game dir when this one + /// launches there too see [`crate::state::GameState::launch_block`] #[must_use] - pub fn launch_block(&self, cluster_id: ClusterId) -> Option { + pub fn launch_block(&self, cluster_id: ClusterId, shared: &[ClusterId]) -> Option { let station = self.station(); let snapshot = station.peek(); let parallel = snapshot.settings.settings.allow_parallel_running_clusters; - snapshot.game.launch_block(cluster_id, parallel) + snapshot.game.launch_block(cluster_id, parallel, shared) } pub fn report_missing_shortcut_target(&self, folder: &str) { diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs index dbf4f783..7dfaf6e9 100644 --- a/packages/oneclient_app/src/install.rs +++ b/packages/oneclient_app/src/install.rs @@ -46,14 +46,16 @@ pub fn item_from_bundle_file(file: &oneclient_core::BundleFile) -> ClusterUpdate .. } => ClusterUpdateItem { provider: *provider, + github_hosted: false, project_id: Some(project_id.clone()), fallback: file.display_name(), offer: None, status: None, }, - oneclient_core::BundleFileKind::External(_) => ClusterUpdateItem { + oneclient_core::BundleFileKind::External { .. } => ClusterUpdateItem { provider: oneclient_content::packages::ProviderId::Local, - project_id: None, + github_hosted: file.is_github_hosted(), + project_id: Some(file.kind.metadata_id()), fallback: file.display_name(), offer: None, status: None, @@ -90,6 +92,7 @@ async fn cluster_update_summary( provider: r .provider .unwrap_or(oneclient_content::packages::ProviderId::Local), + github_hosted: false, project_id: r.project_id.clone(), fallback: r .display_name @@ -165,6 +168,7 @@ pub async fn cluster_update_notification( label: "View changes".to_string(), kind: NotificationActionKind::OpenClusterUpdate(vec![summary]), }], + toast_only: false, }) } @@ -203,6 +207,13 @@ pub struct PackageInstall { pub live_deferred: bool, } +pub struct FlaggedInstall { + pub name: String, + pub mc_version: String, + pub explanation: Option, + pub alternatives: Vec, +} + pub fn install_body( name: &str, dependencies: &[String], @@ -243,13 +254,47 @@ impl PackageInstall { } } +async fn flagged_install( + name: String, + entry: &oneclient_content::packages::BadMod, + cluster_id: i64, + content: &oneclient_content::ContentCtx, +) -> FlaggedInstall { + let resolve = async { + match PackageStore::get_cluster(cluster_id, content).await { + Ok(cluster) => { + let alternatives = + oneclient_content::packages::resolve_alternatives(entry, &cluster, content) + .await; + (cluster.mc_version, alternatives) + } + Err(err) => { + tracing::warn!(%err, cluster_id, "cannot resolve alternatives without the cluster"); + (String::new(), Vec::new()) + } + } + }; + let ((mc_version, alternatives), explanation) = tokio::join!( + resolve, + oneclient_content::packages::fetch_explanation(entry, content), + ); + FlaggedInstall { + name, + mc_version, + explanation, + alternatives, + } +} + pub async fn install_package( state: &Arc, provider: oneclient_content::packages::ProviderId, project_id: &str, version_id: &str, cluster_id: i64, -) -> PackageInstall { + world: Option, + allow_flagged: bool, +) -> Result { let lookup = async { let provider_impl = state.services.packages.get(provider)?; let project = provider_impl @@ -264,9 +309,31 @@ pub async fn install_package( let (project, version) = match lookup { Ok(found) => found, - Err(err) => return PackageInstall::failed(err), + Err(err) => return Ok(PackageInstall::failed(err)), }; + if let Some(world) = world { + let project = oneclient_content::packages::types::ProjectDetail { + content_type: oneclient_content::packages::ContentType::DataPack, + ..project + }; + return Ok(install_datapack(state, provider, &project, &version, cluster_id, world).await); + } + + let content = state.services.content(); + let bad_mods = oneclient_content::packages::load_bad_mods(&content).await; + let screened = !allow_flagged + && !state + .clusters + .get(cluster_id) + .await + .is_ok_and(|cluster| cluster.user_created); + + if screened && let Some(entry) = bad_mods.check(&project, &version) { + tracing::warn!(project = %project.name, version = %version.version_number, "refusing to install flagged mod"); + return Err(flagged_install(project.name, entry, cluster_id, &content).await); + } + // Resolved before the session starts so its children can be announced up front let mut resolution = oneclient_content::packages::DependencyResolution::default(); if oneclient_content::packages::resolves_dependencies(project.content_type) { @@ -285,6 +352,18 @@ pub async fn install_package( } } + if screened + && let Some((dependency, entry)) = resolution.install.iter().find_map(|dependency| { + bad_mods + .check(&dependency.project, &dependency.version) + .map(|entry| (dependency, entry)) + }) + { + tracing::warn!(dependency = %dependency.project.name, project = %project.name, "refusing to install flagged dependency"); + let name = format!("{} (required by {})", dependency.project.name, project.name); + return Err(flagged_install(name, entry, cluster_id, &content).await); + } + let session = oneclient_events::GroupedProgressSession::start( &state.services.events, format!("Installing {}", project.name), @@ -376,12 +455,68 @@ pub async fn install_package( mark_new(cluster_id, &artifact.hash, state).await; } - PackageInstall { + Ok(PackageInstall { session_id: Some(session.detach()), result: result.map(|_| project.name).map_err(anyhow::Error::from), dependencies: installed_dependencies, missing_dependencies, live_deferred, + }) +} + +async fn install_datapack( + state: &Arc, + provider: oneclient_content::packages::ProviderId, + project: &oneclient_content::packages::types::ProjectDetail, + version: &oneclient_content::packages::types::VersionDetail, + cluster_id: i64, + world: String, +) -> PackageInstall { + let Some(file) = version.primary_file() else { + return PackageInstall::failed(anyhow::anyhow!("This version has no file to download")); + }; + if !file.file_name.to_lowercase().ends_with(".zip") { + return PackageInstall::failed(anyhow::anyhow!( + "This version is a mod, not a data pack. Pick a .zip version instead" + )); + } + + let session = oneclient_events::GroupedProgressSession::start( + &state.services.events, + format!("Installing {}", project.name), + ); + session.expect(oneclient_events::TaskCategory::Packages, 1, file.size); + let child = session.child( + project.name.clone(), + file.size, + oneclient_events::TaskCategory::Packages, + ); + + let result = async { + let artifact = PackageStore::download_and_cache( + provider, + project, + version, + false, + Some(&child), + &state.services.content(), + ) + .await?; + let path = oneclient_content::packages::store::artifact_absolute_path(&artifact.path)?; + let cluster = state.clusters.get(cluster_id).await?; + oneclient_core::add_world_datapacks(&cluster, &world, &[path]).await?; + anyhow::Ok(()) + } + .await; + + child.finish(); + + PackageInstall { + session_id: Some(session.detach()), + result: result.map(|()| format!("{} to {world}", project.name)), + dependencies: Vec::new(), + missing_dependencies: Vec::new(), + live_deferred: false, } } diff --git a/packages/oneclient_app/src/ipc.rs b/packages/oneclient_app/src/ipc.rs index 015d8f39..c59bf3e7 100644 --- a/packages/oneclient_app/src/ipc.rs +++ b/packages/oneclient_app/src/ipc.rs @@ -1,6 +1,8 @@ +use std::sync::{Mutex, OnceLock}; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}; use crate::cli::Cli; @@ -12,6 +14,10 @@ const REPLY_OK: &str = "OK"; pub enum IpcCommand { Launch(String), Focus, + Close, + Logs, + Stop, + Quit, } pub enum Claim { @@ -25,6 +31,10 @@ impl IpcCommand { match self { Self::Launch(folder) => format!("LAUNCH {folder}"), Self::Focus => "FOCUS".to_string(), + Self::Close => "CLOSE".to_string(), + Self::Logs => "LOGS".to_string(), + Self::Stop => "STOP".to_string(), + Self::Quit => "QUIT".to_string(), } } @@ -34,7 +44,14 @@ impl IpcCommand { let folder = folder.trim(); return (!folder.is_empty()).then(|| Self::Launch(folder.to_string())); } - (line == "FOCUS").then_some(Self::Focus) + match line { + "FOCUS" => Some(Self::Focus), + "CLOSE" => Some(Self::Close), + "LOGS" => Some(Self::Logs), + "STOP" => Some(Self::Stop), + "QUIT" => Some(Self::Quit), + _ => None, + } } } @@ -68,15 +85,31 @@ async fn forward(request: &IpcCommand) -> bool { imp::send(&request.encode()).await } -/// The accept loop runs off the caller's thread so a stalled client cannot hold -/// up the next shortcut launch; `on_command` still runs here -pub async fn serve(listener: Listener, on_command: impl Fn(IpcCommand)) { - let (commands, mut incoming) = tokio::sync::mpsc::unbounded_channel(); - tokio::spawn(imp::serve(listener, commands)); +static QUEUE: OnceLock> = OnceLock::new(); +static INBOX: Mutex>> = Mutex::new(None); - while let Some(command) = incoming.recv().await { - on_command(command); - } +fn queue() -> &'static UnboundedSender { + QUEUE.get_or_init(|| { + let (commands, incoming) = unbounded_channel(); + *INBOX.lock().unwrap() = Some(incoming); + commands + }) +} + +pub fn send(command: IpcCommand) { + let _ = queue().send(command); +} + +/// The accept loop runs off the UI thread so a stalled client cannot hold up +/// the next shortcut launch +pub fn listen(listener: Listener) { + tokio::spawn(imp::serve(listener, queue().clone())); +} + +#[must_use] +pub fn take_inbox() -> Option> { + queue(); + INBOX.lock().unwrap().take() } enum BindError { @@ -87,7 +120,7 @@ enum BindError { Io(std::io::Error), } -async fn handle(stream: S, commands: &tokio::sync::mpsc::UnboundedSender) +async fn handle(stream: S, commands: &UnboundedSender) where S: AsyncRead + AsyncWrite + Unpin, { @@ -155,7 +188,10 @@ mod imp { } pub async fn bind() -> Result { - match ServerOptions::new().first_pipe_instance(true).create(ENDPOINT) { + match ServerOptions::new() + .first_pipe_instance(true) + .create(ENDPOINT) + { Ok(server) => Ok(Listener { server }), Err(err) if err.raw_os_error() == Some(ERROR_ACCESS_DENIED) => Err(BindError::Taken), Err(err) => Err(BindError::Io(err)), @@ -244,7 +280,10 @@ mod imp { let listener = UnixListener::bind(path)?; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; - Ok(Listener { listener, path: path.to_path_buf() }) + Ok(Listener { + listener, + path: path.to_path_buf(), + }) } pub async fn send(request: &str) -> bool { @@ -276,15 +315,12 @@ pub use imp::Listener; mod tests { use super::*; - #[test] - fn a_launch_request_survives_the_wire() { - let command = IpcCommand::Launch("fabric-1-20".into()); - assert_eq!(IpcCommand::decode(&command.encode()), Some(command)); - } - #[test] fn focus_survives_the_wire() { - assert_eq!(IpcCommand::decode(&IpcCommand::Focus.encode()), Some(IpcCommand::Focus)); + assert_eq!( + IpcCommand::decode(&IpcCommand::Focus.encode()), + Some(IpcCommand::Focus) + ); } #[test] @@ -306,14 +342,16 @@ mod tests { assert_eq!(IpcCommand::decode(""), None); assert_eq!(IpcCommand::decode("LAUNCH"), None); assert_eq!(IpcCommand::decode("LAUNCH "), None); - assert_eq!(IpcCommand::decode("QUIT"), None); + assert_eq!(IpcCommand::decode("RESTART"), None); } #[test] fn a_bare_start_asks_only_for_the_window() { assert_eq!(request_for(&Cli::default()), IpcCommand::Focus); assert_eq!( - request_for(&Cli { launch: Some("pack".into()) }), + request_for(&Cli { + launch: Some("pack".into()) + }), IpcCommand::Launch("pack".into()), ); } diff --git a/packages/oneclient_app/src/launcher.rs b/packages/oneclient_app/src/launcher.rs index a8c7db22..98cd8915 100644 --- a/packages/oneclient_app/src/launcher.rs +++ b/packages/oneclient_app/src/launcher.rs @@ -18,8 +18,19 @@ pub fn install(state: Arc) -> Arc { /// Errors with [`LauncherError::NotInitialized`] before startup installs the handle pub fn state() -> LauncherResult> { - LAUNCHER - .get() - .cloned() - .ok_or(LauncherError::NotInitialized) + LAUNCHER.get().cloned().ok_or(LauncherError::NotInitialized) +} + +pub async fn off_ui(work: impl Future + Send + 'static) -> T { + match tokio::spawn(work).await { + Ok(value) => value, + Err(err) => std::panic::resume_unwind(err.into_panic()), + } +} + +pub async fn off_ui_blocking(work: impl FnOnce() -> T + Send + 'static) -> T { + match tokio::task::spawn_blocking(work).await { + Ok(value) => value, + Err(err) => std::panic::resume_unwind(err.into_panic()), + } } diff --git a/packages/oneclient_app/src/layout/animated_outlet.rs b/packages/oneclient_app/src/layout/animated_outlet.rs index 5938c37a..c835b0f7 100644 --- a/packages/oneclient_app/src/layout/animated_outlet.rs +++ b/packages/oneclient_app/src/layout/animated_outlet.rs @@ -43,6 +43,8 @@ fn is_cluster_route(route: &Route) -> bool { | Route::ClusterMods { .. } | Route::ClusterShaders { .. } | Route::ClusterTextures { .. } + | Route::ClusterWorlds { .. } + | Route::ClusterDataPacks { .. } | Route::ClusterSettings { .. } ) } @@ -252,7 +254,7 @@ impl Component for AnimatedAppOutlet { .height(Size::fill()) .overflow(Overflow::Clip) .on_global_key_down(on_global_key) - .on_global_pointer_press(on_global_pointer) + .on_global_pointer_up(on_global_pointer) .maybe_child(overlay) .child(column) } diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index f1ed300f..b6953f76 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -10,22 +10,23 @@ use skia_safe::sampling_options::CubicResampler; use crate::Route; use crate::components::Button; use crate::components::{ - ART_PREVIEW_EDGE, AppNavbar, DynamicArt, FileDropOverlay, Icon, IconType, OverlayPopup, - ScrollArea, + ART_PREVIEW_EDGE, AppNavbar, CodeBlock, DynamicArt, FileDropOverlay, Icon, IconType, + OverlayPopup, accept_drop, copy_button, }; use crate::layout::AnimatedAppOutlet; -use crate::theme; use crate::use_settings_snapshot; use oneclient_core::clusters::Cluster; +use oneclient_core::images::BACKGROUND_IMAGE_EDGE; use oneclient_db::models::ClusterId; use crate::hooks::{ - ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserTypeState, - use_active_cluster_id, use_clusters, use_game_snapshot, use_launcher, - use_provide_active_cluster, use_provide_browser_compat, use_provide_browser_state, - use_provide_browser_type, use_splash, + ActiveClusterState, BROWSER_COMPAT_DEFAULT, BrowserCompatState, BrowserStateStore, + BrowserTypeState, DataPackWorldState, use_active_cluster_id, use_clusters, use_game_snapshot, + use_launcher, use_provide_active_cluster, use_provide_browser_compat, + use_provide_browser_state, use_provide_browser_type, use_provide_datapack_world, use_splash, }; use crate::theme::colors; +use crate::utils::home_cluster; use oneclient_events::LaunchStage; use std::collections::HashMap; use std::path::PathBuf; @@ -38,7 +39,7 @@ impl Component for AppShell { let active_cluster = use_state(|| None::); use_provide_active_cluster(ActiveClusterState(active_cluster)); - let browser_compat = use_state(|| true); + let browser_compat = use_state(|| BROWSER_COMPAT_DEFAULT); use_provide_browser_compat(BrowserCompatState(browser_compat)); let browser_state = use_state(HashMap::new); @@ -47,9 +48,13 @@ impl Component for AppShell { let browser_type = use_state(|| "mod".to_string()); use_provide_browser_type(BrowserTypeState(browser_type)); + let datapack_world = use_state(HashMap::new); + use_provide_datapack_world(DataPackWorldState(datapack_world)); + // `FileDrop` bubbles so anything a drop zone doesn't `stop_propagation()` lands here let mut drop_hovering = use_state(|| false); - let mut drop_pending = use_state(Vec::::new); + let drop_pending = use_state(Vec::::new); + let dropped_modpacks = use_state(Vec::::new); let game = use_game_snapshot(); @@ -77,7 +82,7 @@ impl Component for AppShell { .on_global_file_hover_cancelled(move |_| drop_hovering.set(false)) .on_file_drop(move |e: Event| { drop_hovering.set(false); - drop_pending.write().extend_from_slice(&e.file_paths); + accept_drop(&e.file_paths, drop_pending, dropped_modpacks); }) .child(AppNavbar) .child(AppHomeBackground) @@ -90,6 +95,7 @@ impl Component for AppShell { .child(FileDropOverlay { hovering: drop_hovering, pending: drop_pending, + modpacks: dropped_modpacks, }) .maybe_child( game.error @@ -114,20 +120,6 @@ struct LaunchErrorDialog { impl Component for LaunchErrorDialog { fn render(&self) -> impl IntoElement { let dispatch = crate::hooks::use_dispatch(); - let lines: Vec = self - .message - .lines() - .map(|line| { - label() - .text(line.to_string()) - .font_family(theme::MONO_FONT) - .font_size(12.) - .width(Size::fill()) - .color(colors::fg_primary()) - .into_element() - }) - .collect(); - let close = dispatch.clone(); let outside_close = dispatch.clone(); let copy_dispatch = dispatch.clone(); @@ -174,19 +166,9 @@ impl Component for LaunchErrorDialog { .color(colors::fg_secondary()), ) .child( - rect() - .width(Size::fill()) - .height(Size::px(260.)) + CodeBlock::new(self.message.clone()) .background(CODE_BG) - .corner_radius(CornerRadius::new_all(8.)) - .padding(Gaps::new_all(12.)) - .overflow(Overflow::Clip) - .child( - ScrollArea::new() - .width(Size::fill()) - .height(Size::fill()) - .children(lines), - ), + .height(260.), ) .child( rect() @@ -194,7 +176,11 @@ impl Component for LaunchErrorDialog { .width(Size::fill()) .main_align(Alignment::End) .spacing(10.) - .child(copy_error_button(&self.message, copy_dispatch)) + .child(copy_button( + self.message.clone(), + "Error message copied to your clipboard.", + copy_dispatch, + )) .child( Button::new() .primary() @@ -207,32 +193,6 @@ impl Component for LaunchErrorDialog { } } -fn copy_error_button(message: &str, dispatch: crate::Actions) -> impl IntoElement { - let message = message.to_string(); - Button::new() - .secondary() - .on_press(move |_| { - if let Err(err) = freya::text_edit::Clipboard::set(message.clone()) { - tracing::warn!("clipboard copy failed: {err:?}"); - dispatch - .notify("Copy failed") - .body("Could not copy the error to the clipboard.") - .error() - .send(); - } else { - dispatch - .notify("Copied to clipboard") - .body("Error message copied to your clipboard.") - .info() - .icon(IconType::ClipboardCheck) - .send(); - } - }) - .child(Icon::new(IconType::Copy01).size(14.)) - .text("Copy") - .into_element() -} - pub(crate) fn appshell_overlay(alpha: f32) -> Rect { let alpha = alpha.clamp(0., 1.); let scale = |a: u8| (f32::from(a) * alpha).round() as u8; @@ -263,15 +223,10 @@ pub(crate) fn appshell_overlay(alpha: f32) -> Rect { pub const HOME_BACKGROUND_ASSET: &str = "backgrounds/CavesAndCliffs.jpg"; -fn home_cluster(clusters: &[Cluster], active: Option) -> Option<&Cluster> { - active - .and_then(|id| clusters.iter().find(|c| c.id == id)) - .or_else(|| clusters.first()) -} - fn home_art(cluster: Option<&Cluster>) -> DynamicArt { cluster .map_or_else(DynamicArt::fallback, DynamicArt::for_cluster) + .max_edge(BACKGROUND_IMAGE_EDGE) .preview_edge(ART_PREVIEW_EDGE) } @@ -426,7 +381,7 @@ impl Component for ParallaxArt { let parallax_enabled = self.parallax_enabled; let fade_opacity = self.fade_opacity; - let render_cb = RenderCallback::new(move |ctx: &mut CanvasContext| { + let render_cb = RenderCallback::new(move |ctx: &mut FillRenderContext| { let Some(image) = &image else { return; }; @@ -524,10 +479,11 @@ impl Component for ParallaxArt { .layer(Layer::Relative(0)) .opacity(fade_opacity) .child( - canvas(render_cb) + rect() .key(src_ptr as u64) .width(Size::fill()) - .height(Size::fill()), + .height(Size::fill()) + .background(render_cb), ), ) } @@ -573,7 +529,7 @@ half4 main(float2 fragCoord) { } "#; -pub(crate) fn gradient_overlay_radial() -> impl IntoElement { +fn gradient_overlay_radial() -> impl IntoElement { let effect = use_hook(|| { freya::engine::prelude::RuntimeEffect::make_for_shader(VIGNETTE_SPOTLIGHT_SHADER, None) .expect("Failed to compile vignette shader") diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index 8f619cff..722bc036 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -4,14 +4,16 @@ use freya::router::*; use oneclient_common::parse_mc_version; use crate::components::{Button, Icon, IconType, TabBar, TabItem}; -use crate::hooks::{use_cluster, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata}; +use crate::hooks::{ + use_cluster, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata, +}; use crate::routes::Route; use crate::theme::colors; use crate::ui::entrance_motion_layer; -use crate::view::app::launch_button_state; +use crate::view::app::{launch_button_state, launch_syncing, use_shared_dir_taken}; const HEADER_HEIGHT: f32 = 64.; -const TABS_HEIGHT: f32 = 44.; +const TABS_HEIGHT: f32 = 38.; const BAR_SPACING: f32 = 16.; #[derive(PartialEq, Clone, Copy)] @@ -23,6 +25,8 @@ pub enum ClusterViewShellTab { Mods, Shaders, Textures, + Worlds, + DataPacks, Settings, } @@ -36,6 +40,8 @@ impl ClusterViewShellTab { Self::Mods => "Mods", Self::Shaders => "Shaders", Self::Textures => "Textures", + Self::Worlds => "Worlds", + Self::DataPacks => "Data packs", Self::Settings => "Settings", } } @@ -49,6 +55,8 @@ impl ClusterViewShellTab { Self::Mods => Some(Route::ClusterMods { cluster_id }), Self::Shaders => Some(Route::ClusterShaders { cluster_id }), Self::Textures => Some(Route::ClusterTextures { cluster_id }), + Self::Worlds => Some(Route::ClusterWorlds { cluster_id }), + Self::DataPacks => Some(Route::ClusterDataPacks { cluster_id }), Self::Settings => Some(Route::ClusterSettings { cluster_id }), } } @@ -61,8 +69,10 @@ impl ClusterViewShellTab { Self::Mods => 3, Self::Shaders => 4, Self::Textures => 5, - Self::Settings => 6, - Self::GameLog => 7, + Self::Worlds => 6, + Self::DataPacks => 7, + Self::Settings => 8, + Self::GameLog => 9, } } } @@ -82,6 +92,8 @@ fn route_cluster(route: &Route) -> Option<(i64, ClusterViewShellTab)> { Route::ClusterMods { cluster_id } => (*cluster_id, ClusterViewShellTab::Mods), Route::ClusterShaders { cluster_id } => (*cluster_id, ClusterViewShellTab::Shaders), Route::ClusterTextures { cluster_id } => (*cluster_id, ClusterViewShellTab::Textures), + Route::ClusterWorlds { cluster_id } => (*cluster_id, ClusterViewShellTab::Worlds), + Route::ClusterDataPacks { cluster_id } => (*cluster_id, ClusterViewShellTab::DataPacks), Route::ClusterSettings { cluster_id } => (*cluster_id, ClusterViewShellTab::Settings), _ => return None, }) @@ -99,15 +111,36 @@ impl Component for ClusterShell { let dispatch = use_dispatch(); let game = use_game_snapshot(); let launcher = use_launcher(); - let syncing = launcher.fetching || launcher.syncing_bundles; let cluster = use_cluster(cluster_id); + let syncing = launch_syncing(&launcher, cluster.as_ref().is_none_or(|c| c.uses_bundles())); + let shared_dir_taken = use_shared_dir_taken(cluster_id); let show_game_log = game.is_active(cluster_id); - let launch_state = launch_button_state(&game, cluster_id, syncing); + let show_datapacks = cluster + .as_ref() + .is_none_or(|c| crate::view::app::cluster::supports_datapacks(&c.mc_version)); + let show_mod_tabs = cluster.as_ref().is_none_or(|c| !c.lacks_mod_loader()); + let launch_state = launch_button_state(&game, cluster_id, syncing, shared_dir_taken); + + let hidden_tab = !show_mod_tabs + && matches!( + active_tab, + ClusterViewShellTab::Mods | ClusterViewShellTab::Shaders + ); + use_side_effect_with_deps( + &hidden_tab.then_some(cluster_id), + move |redirect: &Option| { + if let Some(cluster_id) = *redirect { + let _ = RouterContext::get().replace(Route::ClusterOverview { cluster_id }); + } + }, + ); // Queried unconditionally the shell can mount before the cluster list settles // and a conditional hook would change this component's hook count mid-life - let parsed = cluster.as_ref().and_then(|c| parse_mc_version(&c.mc_version)); + let parsed = cluster + .as_ref() + .and_then(|c| parse_mc_version(&c.mc_version)); let metadata = use_version_metadata( parsed.as_ref().map(|p| p.major), parsed.and_then(|p| p.key()), @@ -115,10 +148,20 @@ impl Component for ClusterShell { ); let header = cluster.as_ref().map(|cluster| { - let title = format!("{} {}", cluster.mc_loader, cluster.mc_version); - let subtitle = metadata - .and_then(|m| m.long_description) - .unwrap_or_else(|| cluster.name.clone()); + let loader_version = format!("{} {}", cluster.mc_loader, cluster.mc_version); + let (title, subtitle) = if cluster.user_created { + let subtitle = cluster + .description + .clone() + .filter(|d| !d.trim().is_empty()) + .unwrap_or(loader_version); + (cluster.name.clone(), subtitle) + } else { + let subtitle = metadata + .and_then(|m| m.long_description) + .unwrap_or_else(|| cluster.name.clone()); + (loader_version, subtitle) + }; cluster_header( title, subtitle, @@ -148,7 +191,13 @@ impl Component for ClusterShell { )) .spacing(BAR_SPACING) .maybe_child(header) - .child(cluster_tabs(active_tab, cluster_id, show_game_log)), + .child(cluster_tabs( + active_tab, + cluster_id, + show_game_log, + show_datapacks, + show_mod_tabs, + )), ) .child( rect() @@ -298,14 +347,18 @@ fn cluster_tabs( active: ClusterViewShellTab, cluster_id: i64, show_game_log: bool, + show_datapacks: bool, + show_mod_tabs: bool, ) -> impl IntoElement { let tabs = [ Some(ClusterViewShellTab::Overview), Some(ClusterViewShellTab::Logs), Some(ClusterViewShellTab::Screenshots), - Some(ClusterViewShellTab::Mods), - Some(ClusterViewShellTab::Shaders), + show_mod_tabs.then_some(ClusterViewShellTab::Mods), + show_mod_tabs.then_some(ClusterViewShellTab::Shaders), Some(ClusterViewShellTab::Textures), + Some(ClusterViewShellTab::Worlds), + show_datapacks.then_some(ClusterViewShellTab::DataPacks), Some(ClusterViewShellTab::Settings), show_game_log.then_some(ClusterViewShellTab::GameLog), ]; @@ -326,15 +379,22 @@ fn cluster_tabs( .width(Size::fill()) .height(Size::px(TABS_HEIGHT)) .cross_align(Alignment::Center) - .padding(Gaps::new(0., 24., 0., 24.)) - .background(colors::page_elevated()) - .corner_radius(CornerRadius::new_all(12.)) + .border( + Border::new() + .fill(colors::component_border()) + .width(BorderWidth { + top: 0., + right: 0., + bottom: 1., + left: 0., + }), + ) .child( TabBar::new() - .width(Size::fill()) + .width(Size::auto()) .height(Size::fill()) - .spacing(24.) - .font_size(12.) + .spacing(22.) + .font_size(13.) .tabs(tab_items), ) } diff --git a/packages/oneclient_app/src/layout/ipc_commands.rs b/packages/oneclient_app/src/layout/ipc_commands.rs new file mode 100644 index 00000000..bcf3672d --- /dev/null +++ b/packages/oneclient_app/src/layout/ipc_commands.rs @@ -0,0 +1,46 @@ +use freya::prelude::*; +use freya::router::*; + +use crate::hooks::use_dispatch; +use crate::ipc::{self, IpcCommand}; +use crate::platform; +use crate::routes::Route; + +pub fn use_ipc_commands() { + let dispatch = use_dispatch(); + + use_hook(move || { + let Some(mut inbox) = ipc::take_inbox() else { + return; + }; + let router = RouterContext::get(); + + spawn_forever(async move { + while let Some(command) = inbox.recv().await { + match command { + IpcCommand::Focus => platform::focus_window(), + IpcCommand::Launch(folder) => { + platform::focus_window(); + dispatch.request_launch_by_folder(folder); + } + IpcCommand::Logs => { + platform::focus_window(); + let running = dispatch.station().peek().game.running_clusters().min(); + if let Some(cluster_id) = running { + let _ = router.push(Route::ProcessLogs { cluster_id }); + } + } + IpcCommand::Stop => { + let running: Vec = + dispatch.station().peek().game.running_clusters().collect(); + for cluster_id in running { + dispatch.kill_cluster(cluster_id); + } + } + IpcCommand::Close => platform::close(), + IpcCommand::Quit => platform::quit(), + } + } + }); + }); +} diff --git a/packages/oneclient_app/src/layout/mod.rs b/packages/oneclient_app/src/layout/mod.rs index 7f2cb598..8fba9730 100644 --- a/packages/oneclient_app/src/layout/mod.rs +++ b/packages/oneclient_app/src/layout/mod.rs @@ -1,15 +1,16 @@ mod animated_outlet; mod app_shell; mod cluster_shell; +mod ipc_commands; mod onboarding_shell; mod pending_launch; mod root_layout; mod settings_shell; pub use animated_outlet::AnimatedAppOutlet; -pub(crate) use app_shell::gradient_overlay_radial; pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET, HomeArtPrefetch}; pub use cluster_shell::{ClusterShell, cluster_content}; +pub use ipc_commands::use_ipc_commands; pub use onboarding_shell::OnboardingShell; pub use pending_launch::PendingLaunchDriver; pub use root_layout::RootLayout; diff --git a/packages/oneclient_app/src/layout/onboarding_shell.rs b/packages/oneclient_app/src/layout/onboarding_shell.rs index ed701bd2..8c14ef6d 100644 --- a/packages/oneclient_app/src/layout/onboarding_shell.rs +++ b/packages/oneclient_app/src/layout/onboarding_shell.rs @@ -10,9 +10,7 @@ use crate::hooks::{ use_migration, use_onboarding_bundles, use_provide_onboarding_selection, use_settings_snapshot, }; use crate::theme::colors; -use crate::view::onboarding::{ - LoadingBackdrop, default_selection, onboarding_step_index, onboarding_total, -}; +use crate::view::onboarding::{default_selection, onboarding_step_index, onboarding_total}; #[derive(PartialEq)] pub struct OnboardingShell; @@ -24,8 +22,6 @@ impl Component for OnboardingShell { let migrated_categories = use_state(|| None::>); let language = use_state(|| "English".to_string()); let reduce_motion = use_state(|| false); - let predownload = use_state(|| true); - let setup_started = use_state(|| false); let import_folder = use_state(|| None::); let import_dedicated = use_state(|| false); let picks_location = use_state({ @@ -38,8 +34,6 @@ impl Component for OnboardingShell { migrated_categories, language, reduce_motion, - predownload, - setup_started, import_folder, import_dedicated, picks_location, @@ -70,14 +64,6 @@ impl Component for OnboardingShell { selected.set(defaults); } }); - let show_backdrop = - matches!(&route, Route::OnboardingDownloading {}) && *setup_started.read(); - let backdrop = show_backdrop.then(|| { - let clusters = onboarding_bundles_items(&bundles) - .map(|items| items.into_iter().map(|cb| cb.cluster).collect()) - .unwrap_or_default(); - LoadingBackdrop { clusters }.into_element() - }); rect() .vertical() @@ -86,7 +72,6 @@ impl Component for OnboardingShell { .background(colors::page()) .color(colors::fg_primary()) .overflow(Overflow::Clip) - .maybe_child(backdrop) .child( rect() .vertical() diff --git a/packages/oneclient_app/src/layout/pending_launch.rs b/packages/oneclient_app/src/layout/pending_launch.rs index db28ab66..b193645e 100644 --- a/packages/oneclient_app/src/layout/pending_launch.rs +++ b/packages/oneclient_app/src/layout/pending_launch.rs @@ -1,6 +1,7 @@ use freya::prelude::*; use freya::router::*; use oneclient_cluster::Cluster; +use oneclient_core::LauncherState; use crate::Actions; use crate::components::IconType; @@ -65,7 +66,8 @@ async fn launch_shortcut(actions: Actions, router: RouterContext, folder: String cluster_id: cluster.id, }); - let Some(block) = actions.launch_block(cluster.id) else { + let shared = shared_dir_neighbours(&state, &cluster).await; + let Some(block) = actions.launch_block(cluster.id, &shared) else { actions.launch_cluster(cluster.id); return; }; @@ -74,6 +76,27 @@ async fn launch_shortcut(actions: Actions, router: RouterContext, folder: String report_already_open(&actions, &cluster, block, blocker.as_deref()); } +/// The other clusters that launch into the shared game dir empty when this +/// cluster has a dedicated one +async fn shared_dir_neighbours(state: &LauncherState, cluster: &Cluster) -> Vec { + if cluster.uses_dedicated_dir() { + return Vec::new(); + } + + match state.clusters.list().await { + Ok(clusters) => clusters + .into_iter() + .filter(|other| other.id != cluster.id && !other.uses_dedicated_dir()) + .map(|other| other.id) + .collect(), + Err(err) => { + // Core refuses a busy shared dir anyway this only words the notice + tracing::warn!(cluster_id = cluster.id, "cluster list failed: {err:#}"); + Vec::new() + } + } +} + async fn blocker_name(cluster: &Cluster, block: LaunchBlock) -> Option { if block.cluster_id() == cluster.id { return None; @@ -117,7 +140,10 @@ fn report_already_open( (None, true) => format!("{} is open already.", cluster.name), (None, false) => format!("{} is on its way up.", cluster.name), (Some(other), true) => { - format!("{other} is open. Close it before launching {}.", cluster.name) + format!( + "{other} is open. Close it before launching {}.", + cluster.name + ) } (Some(other), false) => format!( "{other} is on its way up. Wait for it before launching {}.", diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 7aa172cc..ffafd1e5 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -2,14 +2,14 @@ use freya::prelude::*; use freya::router::*; use crate::components::{ - AccountSwitcher, ClusterUpdatePopup, ControlCenter, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, MicrosoftJavaPromptOverlay, - NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, TooltipHost, - UpdatePromptOverlay, use_provide_tooltips, + AccountSwitcher, BlockedDownloadsPopup, BundleChoicesPopup, ClusterUpdatePopup, ControlCenter, + FlaggedInstallPopup, GenericPromptOverlay, JavaPromptOverlay, MicrosoftJavaPromptOverlay, + ModpackConfirmPopup, NotificationCenter, OptionalModsPopup, PackageUpdatePopup, + ReleaseMigrationPopup, SplashCurtain, StatusBar, Toasts, TooltipHost, UpdatePromptOverlay, + use_provide_tooltips, }; use crate::hooks::{SplashState, use_provide_overlay_claims, use_provide_splash}; -#[cfg(not(target_os = "macos"))] -use crate::hooks::use_start_maximized; -use crate::layout::{HomeArtPrefetch, PendingLaunchDriver}; +use crate::layout::{HomeArtPrefetch, PendingLaunchDriver, use_ipc_commands}; use crate::motion::AnimationClockDriver; use crate::routes::Route; use crate::theme; @@ -25,38 +25,7 @@ impl Component for RootLayout { use_provide_splash(SplashState { active, home_ready }); use_provide_overlay_claims(); use_provide_tooltips(); - - // macOS rounds the window natively so Freya must not round on top of it - #[cfg(target_os = "macos")] - let _corner = 0.; - - // Elsewhere the window is borderless and squared when maximized - // No reactive maximized signal exists so root_size changes stand in as the trigger - #[cfg(not(target_os = "macos"))] - let _corner = { - let root_size = Platform::get().root_size; - // Seeded because the query below only answers after the window is visible - // which would round the corners of the first frames of a maximized launch - let start_maximized = use_start_maximized(); - let mut maximized = use_state(move || start_maximized); - let size = *root_size.read(); - let dep = (size.width as i32, size.height as i32); - use_side_effect_with_deps(&dep, move |_| { - spawn(async move { - let is_max = Platform::get() - .post_callback(|id, ctx| { - ctx.windows.get(&id).map(|w| w.window().is_maximized()) - }) - .await; - if let Ok(Some(is_max)) = is_max - && *maximized.peek() != is_max - { - maximized.set(is_max); - } - }); - }); - if *maximized.read() { 0. } else { 12. } - }; + use_ipc_commands(); rect() .width(Size::fill()) @@ -64,7 +33,6 @@ impl Component for RootLayout { .background(colors::page()) .color(colors::fg_primary()) .font_family(theme::DEFAULT_FONT) - //.corner_radius(CornerRadius::new_all(corner)) .overflow(Overflow::Clip) .child( rect() @@ -83,7 +51,12 @@ impl Component for RootLayout { .child(GenericPromptOverlay) .child(ClusterUpdatePopup) .child(OptionalModsPopup) + .child(BundleChoicesPopup) + .child(BlockedDownloadsPopup) + .child(ModpackConfirmPopup) .child(PackageUpdatePopup) + .child(FlaggedInstallPopup) + .child(ReleaseMigrationPopup) .child(StatusBar) .child(TooltipHost) .child(SplashCurtain) diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs index 31b2ed33..319c0bb8 100644 --- a/packages/oneclient_app/src/layout/settings_shell.rs +++ b/packages/oneclient_app/src/layout/settings_shell.rs @@ -11,7 +11,9 @@ use sysinfo::MemoryRefreshKind; use sysinfo::RefreshKind; use sysinfo::System; -use crate::components::{Icon, IconType, ScrollArea, ScrollAreaCtx, TextInput}; +use crate::components::{ + Icon, IconType, ScrollArea, ScrollAreaCtx, TextInput, TooltipPlacement, use_tooltip_anchor, +}; use crate::hooks::{latest_changelog_version, use_changelog, use_settings_snapshot}; use crate::routes::Route; use crate::theme::colors; @@ -68,8 +70,16 @@ const SEARCH_INDEX: &[SearchItem] = &[ title: "Launcher Folder", description: "Open the launcher data directory, or move it to another drive.", keywords: &[ - "data dir", "directory", "folder", "open", "move", "change", "relocate", "location", - "drive", "disk", + "data dir", + "directory", + "folder", + "open", + "move", + "change", + "relocate", + "location", + "drive", + "disk", ], route: Route::SettingsLauncher {}, }, @@ -457,11 +467,7 @@ impl Component for SettingsShell { } } -fn content_header( - title: String, - search: State, - mut width: State, -) -> impl IntoElement { +fn content_header(title: String, search: State, mut width: State) -> impl IntoElement { let search_width = search_width_for(*width.read()); rect() @@ -726,6 +732,10 @@ impl SidebarInfo { impl Component for SidebarInfo { fn render(&self) -> impl IntoElement { let dispatch = use_dispatch(); + let tooltip = use_tooltip_anchor( + Some("Click to copy to clipboard".into()), + TooltipPlacement::Top, + ); let items: [Cow<'static, str>; 4] = [ Cow::Borrowed(concat!( @@ -754,6 +764,7 @@ impl Component for SidebarInfo { .notify("Copy failed") .body("Could not copy system information to the clipboard.") .error() + .toast_only() .send(); } else { dispatch @@ -761,26 +772,27 @@ impl Component for SidebarInfo { .body("System information copied to your clipboard.") .info() .icon(IconType::ClipboardCheck) + .toast_only() .send(); } }; - rect() - .vertical() - .width(Size::fill()) - .spacing(4.) - .font_size(12.) - .padding(Gaps::new( - 15.,0.,0.,0. - )) - .color(colors::fg_secondary()) - .children( - items - .into_iter() - .map(|item| label().text(item).into_element()), - ) - .cursor(CursorIcon::Pointer) - .on_press(copy_to_clipboard) + tooltip.attach( + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .font_size(12.) + .padding(Gaps::new(15., 0., 0., 0.)) + .color(colors::fg_secondary()) + .children( + items + .into_iter() + .map(|item| label().text(item).into_element()), + ) + .cursor(CursorIcon::Pointer) + .on_press(copy_to_clipboard), + ) } } diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs index 7db65b8e..4535c13a 100644 --- a/packages/oneclient_app/src/lib.rs +++ b/packages/oneclient_app/src/lib.rs @@ -6,14 +6,13 @@ mod assets; pub mod cli; mod components; -pub mod hooks; +pub mod essential; pub mod events; pub(crate) mod file_content; +pub mod hooks; mod install; pub mod ipc; mod launcher; -pub mod state; -mod transfer; mod layout; pub mod microsoft_java; mod motion; @@ -23,7 +22,9 @@ pub mod protocol; pub mod recovery; mod routes; pub mod shortcut; +pub mod state; pub mod theme; +mod transfer; mod ui; pub mod updater; pub(crate) mod utils; @@ -32,8 +33,8 @@ mod view; pub mod constants; pub use assets::AppAssets; +pub use components::{ConfirmLinkOverlay, EssentialConfirmOverlay}; pub use events::EventPump; -pub use state::{AppChannel, AppState}; -pub use components::ConfirmLinkOverlay; pub use hooks::*; pub use routes::{Route, router}; +pub use state::{AppChannel, AppState}; diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs index 4fcdc615..1fbf71b2 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -9,9 +9,10 @@ use freya::radio::use_init_radio_station; use oneclient_app::ipc::{self, Claim}; use oneclient_app::state::{AppChannel, AppState, LauncherInit}; use oneclient_app::{ - Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, StartMaximizedState, cli, constants, - events, platform, router, theme, use_provide_actions, use_provide_link_confirm, - use_provide_start_maximized, microsoft_java, + Actions, ConfirmLinkOverlay, EssentialConfirmOverlay, EssentialGuardState, EventPump, + LinkConfirmState, StartMaximizedState, cli, constants, events, microsoft_java, platform, + router, theme, use_provide_actions, use_provide_essential_guard, use_provide_link_confirm, + use_provide_start_maximized, }; use std::cell::Cell; use tokio::runtime::Builder; @@ -20,7 +21,6 @@ struct OneClientApp { needs_location: bool, start_maximized: bool, boot_launch: Cell>, - ipc: Cell>, } impl App for OneClientApp { @@ -37,7 +37,6 @@ impl App for OneClientApp { }); let boot_launch = self.boot_launch.take(); - let ipc_listener = self.ipc.take(); let actions = use_hook(move || { let (signals_tx, signals_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -62,6 +61,7 @@ impl App for OneClientApp { // it early leaves `syncing_bundles` stuck disabling every launch button Ok(()) => { startup.sync_bundles(); + Actions::check_sessions(); microsoft_java::spawn_auto_install(); } Err(err) => { @@ -76,17 +76,6 @@ impl App for OneClientApp { actions.request_launch_by_folder(folder); } - if let Some(listener) = ipc_listener { - let served = actions.clone(); - spawn_forever(ipc::serve(listener, move |command| match command { - ipc::IpcCommand::Launch(folder) => { - platform::focus_window(); - served.request_launch_by_folder(folder); - } - ipc::IpcCommand::Focus => platform::focus_window(), - })); - } - actions }); @@ -95,17 +84,26 @@ impl App for OneClientApp { let link_confirm = use_state(|| None::); use_provide_link_confirm(LinkConfirmState(link_confirm)); + let essential_guard = use_state(|| None); + use_provide_essential_guard(EssentialGuardState(essential_guard)); + use_provide_start_maximized(StartMaximizedState(self.start_maximized)); rect() .width(Size::fill()) .height(Size::fill()) .child(ConfirmLinkOverlay) + .child(EssentialConfirmOverlay) .child(router()) } } fn main() { + #[cfg(target_os = "linux")] + unsafe { + gtk_sys::gtk_disable_setlocale(); + } + let cli = cli::parse(); let mut builder = Builder::new_multi_thread(); @@ -118,6 +116,8 @@ fn main() { let rt = builder.build().unwrap(); let _tokio_guard = rt.enter(); + let adopted = rt.block_on(oneclient_core::relocate::adopt_legacy_dir()); + // no settings file is the sign of a fresh install, but not proof of one let never_set_up = oneclient_common::paths::settings_file() .map(|path| !path.exists()) @@ -131,14 +131,11 @@ fn main() { let needs_location = never_set_up && !has_database && !was_damaged; let mut unprotected = None; - let ipc = match rt.block_on(ipc::claim(&cli)) { + match rt.block_on(ipc::claim(&cli)) { Claim::Forwarded => return, - Claim::Primary(listener) => Some(listener), - Claim::Solo(reason) => { - unprotected = Some(reason); - None - } - }; + Claim::Primary(listener) => ipc::listen(listener), + Claim::Solo(reason) => unprotected = Some(reason), + } let settings = rt.block_on(oneclient_core::settings::store::load_settings(None)); @@ -162,6 +159,14 @@ fn main() { } .expect("Failed to initialize logger"); + match adopted { + Ok(Some(from)) => { + tracing::info!(from = %from.display(), "moved out of the old launcher folder") + } + Ok(None) => {} + Err(err) => tracing::error!("{err}"), + } + if let Some(reason) = unprotected { tracing::warn!("no single-instance endpoint, a second launcher can start: {reason}"); } @@ -189,46 +194,47 @@ fn main() { oneclient_app::platform::macos::loop_memory_collector(); let start_maximized = settings.start_maximized; + let show_tray_icon = settings.show_tray_icon; let window_config = WindowConfig::new_app(OneClientApp { needs_location, start_maximized, boot_launch: Cell::new(cli.launch), - ipc: Cell::new(ipc), }) - .with_title(constants::WINDOW_TITLE) - .with_app_id(constants::WINDOW_APP_ID) - .with_icon(LaunchConfig::window_icon(include_bytes!( - "../icons/128x128.png" - ))) - .with_size(1200., 800.) - .with_min_size(800., 600.) - .with_transparency(false) - .with_background(Color::TRANSPARENT) - // A half-copied data folder is unrecoverable, so the window refuses to - // close while one is being moved; the move screen says as much - .with_on_close(|_, _| { - if oneclient_core::relocate::in_progress() { - tracing::warn!("close request ignored, the data folder is still being moved"); - CloseDecision::KeepOpen - } else { - CloseDecision::Close - } - }); + .with_title(constants::WINDOW_TITLE) + .with_app_id(constants::WINDOW_APP_ID) + .with_icon(LaunchConfig::window_icon(include_bytes!( + "../icons/128x128.png" + ))) + .with_size(1200., 800.) + .with_min_size(800., 600.) + .with_transparency(false) + .with_background(Color::TRANSPARENT) + // A half-copied data folder is unrecoverable, so the window refuses to + // close while one is being moved; the move screen says as much + .with_on_close(|_, _| { + if oneclient_core::relocate::in_progress() { + tracing::warn!("close request ignored, the data folder is still being moved"); + } else { + ipc::send(ipc::IpcCommand::Close); + } + CloseDecision::KeepOpen + }); #[cfg(target_os = "macos")] - let window_config = window_config - .with_decorations(true) - .with_window_attributes(move |attrs, _| { - use freya::winit::platform::macos::WindowAttributesExtMacOS; - attrs - .with_title_hidden(true) - .with_titlebar_transparent(true) - .with_titlebar_buttons_hidden(true) - .with_fullsize_content_view(true) - .with_has_shadow(true) - .with_maximized(start_maximized) - }); + let window_config = + window_config + .with_decorations(true) + .with_window_attributes(move |attrs, _| { + use freya::winit::platform::macos::WindowAttributesExtMacOS; + attrs + .with_title_hidden(true) + .with_titlebar_transparent(true) + .with_titlebar_buttons_hidden(true) + .with_fullsize_content_view(true) + .with_has_shadow(true) + .with_maximized(start_maximized) + }); #[cfg(not(target_os = "macos"))] let window_config = window_config @@ -243,8 +249,14 @@ fn main() { .and_then(|v| v.parse::().ok()) .unwrap_or(96 * 1024 * 1024), ) + .with_plugin(freya::borderless::BorderlessPlugin::new()) + .with_plugin(freya::metrics::MetricsPlugin::default()) .with_default_font(theme::DEFAULT_FONT); + if show_tray_icon && platform::tray::available() { + launch_config = launch_config.with_tray(platform::tray::build, platform::tray::handle); + } + for (font, bytes) in theme::load_fonts() { launch_config = launch_config.with_font(font, bytes); } diff --git a/packages/oneclient_app/src/microsoft_java.rs b/packages/oneclient_app/src/microsoft_java.rs index 18451889..2b0142c2 100644 --- a/packages/oneclient_app/src/microsoft_java.rs +++ b/packages/oneclient_app/src/microsoft_java.rs @@ -2,16 +2,17 @@ use std::collections::BTreeSet; use std::sync::{Arc, Mutex}; use freya::prelude::spawn_forever; -use tokio::sync::mpsc::UnboundedSender; use oneclient_common::Patch; use oneclient_core::settings::store::save_settings_and_apply; use oneclient_core::{LauncherState, ProfileUpdate}; use oneclient_db::models::ClusterId; use oneclient_events::{Choice, Prompt, Signal}; use oneclient_java::{JavaRuntime, JavaVendor}; +use tokio::sync::mpsc::UnboundedSender; use crate::hooks::Actions; use crate::launcher; +use crate::launcher::off_ui; /// Front-ends match on these pub const MICROSOFT_JAVA_CHOICE_INSTALL: &str = "java.microsoft.install"; @@ -59,7 +60,10 @@ async fn auto_install(refresh: UnboundedSender<()>) { return; } Err(err) => { - tracing::warn!(cluster_id, "could not read the cluster's Java version: {err:#}"); + tracing::warn!( + cluster_id, + "could not read the cluster's Java version: {err:#}" + ); return; } }; @@ -94,46 +98,58 @@ pub async fn offer_for_pinned_cluster(actions: &Actions, cluster_id: ClusterId) return; }; - if opted_out(&state) { - return; - } + let offer = off_ui({ + let state = state.clone(); + async move { + if opted_out(&state) { + return None; + } - let Some(pinned) = pinned_runtime(&state, cluster_id).await else { + let pinned = pinned_runtime(&state, cluster_id).await?; + + if pinned.vendor == JavaVendor::Microsoft { + return None; + } + + let major = match oneclient_core::required_java_major(&state, cluster_id).await { + Ok(Some(major)) => major, + Ok(None) => { + tracing::info!(cluster_id, "the cluster's manifest names no Java version"); + return None; + } + Err(err) => { + tracing::warn!( + cluster_id, + "could not read the cluster's Java version: {err:#}" + ); + return None; + } + }; + + let installed = match state + .java + .has_vendor_runtime(&JavaVendor::Microsoft, Some(major)) + .await + { + Ok(installed) => installed, + Err(err) => { + tracing::warn!("could not check for a Microsoft Java runtime: {err:#}"); + false + } + }; + + if !installed && !publishes(&state, major).await { + return None; + } + + Some((major, installed)) + } + }) + .await; + let Some((major, installed)) = offer else { return; }; - if pinned.vendor == JavaVendor::Microsoft { - return; - } - - let major = match oneclient_core::required_java_major(&state, cluster_id).await { - Ok(Some(major)) => major, - Ok(None) => { - tracing::info!(cluster_id, "the cluster's manifest names no Java version"); - return; - } - Err(err) => { - tracing::warn!(cluster_id, "could not read the cluster's Java version: {err:#}"); - return; - } - }; - - let installed = match state - .java - .has_vendor_runtime(&JavaVendor::Microsoft, Some(major)) - .await - { - Ok(installed) => installed, - Err(err) => { - tracing::warn!("could not check for a Microsoft Java runtime: {err:#}"); - false - } - }; - - if !installed && !publishes(&state, major).await { - return; - } - if !claim_ask(cluster_id) { return; } diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index bcd34267..bd439d50 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -1,12 +1,13 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; -use oneclient_events::{ - Answer, Choice, Event, GroupedProgressEvent, Level, Notification, ProgressEvent, TaskCategory, -}; +use oneclient_content::modpacks::{BlockedFile, ModpackSummary}; use oneclient_content::packages::ProviderId; use oneclient_core::BrowserPackageUpdate; use oneclient_db::models::{ClusterId, OptionalModStatus}; +use oneclient_events::{ + Answer, Choice, Event, GroupedProgressEvent, Level, Notification, ProgressEvent, TaskCategory, +}; use tokio::sync::oneshot; use uuid::Uuid; @@ -38,9 +39,16 @@ pub struct PackageUpdateGroup { pub type OptionalModRef = (String, String); +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OptionalModsOutcome { + Launch, + Cancel, +} + #[derive(Clone, Debug, PartialEq)] pub struct ClusterUpdateItem { pub provider: ProviderId, + pub github_hosted: bool, pub project_id: Option, /// Used when the meta cache has no entry (file name / package id) pub fallback: String, @@ -52,6 +60,7 @@ impl ClusterUpdateItem { pub fn from_name(name: impl Into) -> Self { Self { provider: ProviderId::Local, + github_hosted: false, project_id: None, fallback: name.into(), offer: None, @@ -60,6 +69,47 @@ impl ClusterUpdateItem { } } +#[derive(Clone, Debug, PartialEq)] +pub struct ModpackConfirm { + pub pack_name: String, + pub version: String, + pub instance_name: String, + pub mc_version: String, + pub loader: String, + pub source: String, + pub summary: ModpackSummary, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BlockedDownloads { + pub cluster_id: ClusterId, + pub cluster_name: String, + pub files: Vec, + pub added: HashSet, + pub open_when_done: bool, +} + +impl BlockedDownloads { + pub fn is_added(&self, file: &BlockedFile) -> bool { + self.added.contains(&file.sha1) + } + + pub fn remaining(&self) -> Vec { + self.files + .iter() + .filter(|file| !self.is_added(file)) + .cloned() + .collect() + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BundleChoices { + pub cluster_name: String, + pub bundles: Vec<(String, String)>, + pub held: HashSet, +} + #[derive(Clone, Debug, PartialEq)] pub struct OptionalModsGroup { pub cluster_id: ClusterId, @@ -80,9 +130,11 @@ impl OptionalModsGroup { } pub fn offers(&self) -> impl Iterator + '_ { - self.mods - .iter() - .filter_map(move |item| item.offer.clone().map(|reference| (self.cluster_id, reference))) + self.mods.iter().filter_map(move |item| { + item.offer + .clone() + .map(|reference| (self.cluster_id, reference)) + }) } } @@ -117,6 +169,10 @@ pub struct InboxEntry { pub tasks: Vec, /// Bytes/sec and seconds remaining for grouped downloads pub transfer: Option, + /// Toast-only notices still render as a toast but are never kept in the + /// notification center: they are dropped from the inbox when dismissed or + /// when their toast expires + pub toast_only: bool, } /// One aggregate row per [`TaskCategory`] (all libraries collapse into one "Libraries" row) @@ -138,6 +194,7 @@ pub struct NotificationSpec { pub icon: Option, pub progress: Option<(u64, u64)>, pub actions: Vec, + pub toast_only: bool, } impl InboxEntry { @@ -201,7 +258,11 @@ pub struct NotificationSnapshot { pub pending_prompt: Option, pub cluster_update: Option>, pub optional_mods: Option>, + pub bundle_choices: Option, pub package_updates: Option>, + pub blocked_downloads: Option, + pub open_cluster: Option, + pub modpack_confirm: Option, pub active_toast_entry_ids: Vec, } @@ -221,12 +282,17 @@ pub struct NotificationState { pending_timers: Vec, cluster_update: Option>, optional_mods: Option>, + bundle_choices: Option, package_updates: Option>, + blocked_downloads: Option, + open_cluster: Option, + modpack_confirm: Option, /// Resumes the launch that opened the update modal /// Held here not by the launch task /// because every way the modal can end goes through this state package_updates_done: Option>>, - optional_mods_done: Option>, + optional_mods_done: Option>, + bundle_choices_done: Option>>>, } const CATEGORY_ORDER: [TaskCategory; 7] = [ @@ -274,8 +340,11 @@ impl GroupedTasks { let done_count = self.done_count.get(&cat).copied().unwrap_or(0); let reserved_units = self.reserved_units.get(&cat).copied().unwrap_or(0); - let live: Vec<&ChildRec> = - self.children.values().filter(|c| c.category == cat).collect(); + let live: Vec<&ChildRec> = self + .children + .values() + .filter(|c| c.category == cat) + .collect(); let live_current: u64 = live.iter().map(|c| c.current.min(c.total)).sum(); let live_total: u64 = live.iter().map(|c| c.total).sum(); @@ -331,7 +400,11 @@ impl NotificationState { pending_prompt, cluster_update: self.cluster_update.clone(), optional_mods: self.optional_mods.clone(), + bundle_choices: self.bundle_choices.clone(), package_updates: self.package_updates.clone(), + blocked_downloads: self.blocked_downloads.clone(), + open_cluster: self.open_cluster, + modpack_confirm: self.modpack_confirm.clone(), active_toast_entry_ids: self.active_toasts.iter().map(|t| t.entry_id).collect(), } } @@ -355,7 +428,7 @@ impl NotificationState { pub fn open_optional_mods( &mut self, groups: Vec, - done: Option>, + done: Option>, ) { let groups: Vec = groups .into_iter() @@ -364,24 +437,75 @@ impl NotificationState { if groups.is_empty() { if let Some(done) = done { - let _ = done.send(()); + let _ = done.send(OptionalModsOutcome::Launch); } return; } - self.finish_optional_mods(); + self.finish_optional_mods(OptionalModsOutcome::Launch); self.optional_mods = Some(groups); self.optional_mods_done = done; } + pub fn open_blocked_downloads(&mut self, blocked: BlockedDownloads) { + self.blocked_downloads = (!blocked.files.is_empty()).then_some(blocked); + } + + pub fn open_modpack_confirm(&mut self, confirm: ModpackConfirm) { + self.modpack_confirm = Some(confirm); + } + + pub fn close_modpack_confirm(&mut self) { + self.modpack_confirm = None; + } + + pub fn request_open_cluster(&mut self, cluster_id: ClusterId) { + self.open_cluster = Some(cluster_id); + } + + pub fn take_open_cluster(&mut self) -> Option { + self.open_cluster.take() + } + + pub fn take_blocked_downloads(&mut self) -> Option { + self.blocked_downloads.take() + } + + pub fn resolve_blocked_downloads(&mut self, cluster_id: ClusterId, sha1s: &[String]) { + if let Some(blocked) = self + .blocked_downloads + .as_mut() + .filter(|blocked| blocked.cluster_id == cluster_id) + { + blocked.added.extend(sha1s.iter().cloned()); + } + } + + pub fn open_bundle_choices( + &mut self, + choices: BundleChoices, + done: oneshot::Sender>>, + ) { + self.finish_bundle_choices(None); + self.bundle_choices = Some(choices); + self.bundle_choices_done = Some(done); + } + + pub fn finish_bundle_choices(&mut self, chosen: Option>) { + self.bundle_choices = None; + if let Some(done) = self.bundle_choices_done.take() { + let _ = done.send(chosen); + } + } + pub fn hide_optional_mods(&mut self) { self.optional_mods = None; } - pub fn finish_optional_mods(&mut self) { + pub fn finish_optional_mods(&mut self, outcome: OptionalModsOutcome) { self.optional_mods = None; if let Some(done) = self.optional_mods_done.take() { - let _ = done.send(()); + let _ = done.send(outcome); } } @@ -446,7 +570,10 @@ impl NotificationState { } pub fn unread_count(inbox: &[InboxEntry]) -> usize { - inbox.iter().filter(|entry| !entry.read).count() + inbox + .iter() + .filter(|entry| !entry.read && !entry.toast_only) + .count() } /// Deliberately does not snapshot this runs tens of thousands of times per download @@ -471,7 +598,7 @@ impl NotificationState { ); self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); } - + Event::Progress(ProgressEvent::Update { id, .. }) if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => {} Event::Progress(ProgressEvent::Update { id, .. }) @@ -509,21 +636,32 @@ impl NotificationState { (timers, None) } - pub fn toggle_center(&mut self, _inbox: &mut [InboxEntry], center_open: bool) -> bool { + pub fn toggle_center(&mut self, inbox: &mut Vec, center_open: bool) -> bool { let next = !center_open; if next { + let ids: Vec = inbox + .iter() + .filter(|e| e.toast_only && e.dismissable()) + .map(|e| e.id) + .collect(); + for id in ids { + self.forget_entry(inbox, id); + } self.active_toasts.clear(); self.pending_timers.clear(); } next } - pub fn clear_inbox(&mut self) { - self.progress_entries.clear(); - self.grouped_entries.clear(); - self.grouped_tasks.clear(); - self.active_toasts.clear(); - self.pending_timers.clear(); + pub fn clear_inbox(&mut self, inbox: &mut Vec) { + let ids: Vec = inbox + .iter() + .filter(|e| e.dismissable()) + .map(|e| e.id) + .collect(); + for id in ids { + self.forget_entry(inbox, id); + } } pub fn dismiss_toast(&mut self, inbox: &mut Vec, entry_id: u64) { @@ -540,7 +678,7 @@ impl NotificationState { } let has_progress = entry.is_some_and(|e| e.progress.is_some()); self.active_toasts.remove(pos); - if !has_progress { + if !has_progress || entry.is_some_and(|e| e.toast_only) { self.forget_entry(inbox, entry_id); } } @@ -551,9 +689,16 @@ impl NotificationState { .retain(|toast| toast.toast_id != toast_id); } - pub fn expire_toast(&mut self, _inbox: &[InboxEntry], entry_id: u64) { + pub fn expire_toast(&mut self, inbox: &mut Vec, entry_id: u64) { self.active_toasts .retain(|toast| toast.entry_id != entry_id); + if inbox + .iter() + .find(|e| e.id == entry_id) + .is_some_and(|e| e.toast_only) + { + self.forget_entry(inbox, entry_id); + } } pub fn mark_read(&mut self, inbox: &mut [InboxEntry], entry_id: u64) { @@ -562,6 +707,12 @@ impl NotificationState { } } + fn mark_unread(&self, inbox: &mut [InboxEntry], entry_id: u64) { + if let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) { + entry.read = false; + } + } + pub fn dismiss_notification(&mut self, inbox: &mut Vec, entry_id: u64) { if inbox .iter() @@ -605,6 +756,7 @@ impl NotificationState { actions: Vec::new(), tasks: Vec::new(), transfer: None, + toast_only: false, }, ); id @@ -618,6 +770,7 @@ impl NotificationState { icon, progress, actions, + toast_only, } = spec; let is_loading = progress.is_some_and(|(current, total)| total == 0 || current < total); @@ -639,6 +792,7 @@ impl NotificationState { actions, tasks: Vec::new(), transfer: None, + toast_only, }, ); @@ -665,7 +819,6 @@ impl NotificationState { entry.body = body.into(); entry.progress = progress; entry.is_loading = is_loading; - entry.read = false; } } @@ -739,10 +892,11 @@ impl NotificationState { ) { if let Some(entry_id) = self.progress_entries.remove(&id) { self.update_inbox_entry(inbox, entry_id, title, body, None, false); + // Progress ticks leave a hovered card read but its outcome is news + self.mark_unread(inbox, entry_id); self.ensure_progress_toast(entry_id); } else { - let entry_id = - self.push_inbox(inbox, title, body, Level::Info, None, false); + let entry_id = self.push_inbox(inbox, title, body, Level::Info, None, false); self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); } } @@ -861,6 +1015,7 @@ impl NotificationState { entry.body = "Complete".to_string(); entry.progress = Some((1, 1)); entry.is_loading = false; + entry.read = false; entry.tasks = Vec::new(); entry.transfer = None; } @@ -895,6 +1050,7 @@ impl NotificationState { icon, progress: _, actions, + toast_only, } = spec; match entry_id.and_then(|id| inbox.iter_mut().find(|e| e.id == id)) { @@ -910,6 +1066,7 @@ impl NotificationState { entry.actions = actions; entry.tasks = Vec::new(); entry.transfer = None; + entry.toast_only = toast_only; // Keeping it in `active_toasts` lets the loop arm a dismiss timer // now that the entry is no longer loading self.ensure_progress_toast(entry.id); @@ -924,6 +1081,7 @@ impl NotificationState { icon, progress: None, actions, + toast_only, }, ); } @@ -961,7 +1119,6 @@ impl NotificationState { entry.body = body; entry.progress = Some((completed, total)); entry.is_loading = true; - entry.read = false; entry.tasks = tasks; entry.transfer = transfer; } @@ -1017,7 +1174,10 @@ mod package_update_tests { ); state.resolve_package_update(1, "b"); - assert!(wait.try_recv().is_ok(), "the last answer releases the launch"); + assert!( + wait.try_recv().is_ok(), + "the last answer releases the launch" + ); assert!(state.package_updates.is_none()); } @@ -1042,7 +1202,10 @@ mod package_update_tests { state.open_package_updates(vec![group(&[])], Some(done)); - assert!(wait.try_recv().is_ok(), "a modal that never opens cannot be answered"); + assert!( + wait.try_recv().is_ok(), + "a modal that never opens cannot be answered" + ); assert!(state.package_updates.is_none()); } @@ -1055,7 +1218,10 @@ mod package_update_tests { state.open_package_updates(vec![group(&["a"])], Some(first)); state.open_package_updates(vec![group(&["b"])], Some(second)); - assert!(first_wait.try_recv().is_ok(), "the replaced launch is let go"); + assert!( + first_wait.try_recv().is_ok(), + "the replaced launch is let go" + ); assert!(second_wait.try_recv().is_err(), "the new one still waits"); } @@ -1071,4 +1237,132 @@ mod package_update_tests { assert!(wait.try_recv().is_err()); assert!(state.package_updates.is_some()); } + + fn blocked_file(sha1: &str) -> BlockedFile { + BlockedFile { + project_id: "1".into(), + version_id: "2".into(), + project_name: sha1.into(), + file_name: format!("{sha1}.jar"), + path: format!("mods/{sha1}.jar"), + sha1: sha1.into(), + size: 1, + content_type: oneclient_content::packages::ContentType::Mod, + page_url: None, + } + } + + #[test] + fn a_found_manual_download_stays_listed_as_added() { + let mut state = NotificationState::default(); + state.open_blocked_downloads(BlockedDownloads { + cluster_id: 7, + cluster_name: "Pack".into(), + files: vec![blocked_file("aa"), blocked_file("bb")], + added: HashSet::new(), + open_when_done: true, + }); + + state.resolve_blocked_downloads(8, &["aa".to_string()]); + state.resolve_blocked_downloads(7, &["aa".to_string()]); + + let blocked = state.take_blocked_downloads().unwrap(); + assert_eq!(blocked.files.len(), 2); + assert!(blocked.is_added(&blocked.files[0])); + assert!(!blocked.is_added(&blocked.files[1])); + assert_eq!( + blocked + .remaining() + .iter() + .map(|file| file.sha1.as_str()) + .collect::>(), + vec!["bb"] + ); + } + + fn spec(title: &str) -> NotificationSpec { + NotificationSpec { + title: title.to_string(), + body: String::new(), + level: Level::Info, + icon: None, + progress: None, + actions: Vec::new(), + toast_only: false, + } + } + + fn toast_only(title: &str) -> NotificationSpec { + NotificationSpec { + toast_only: true, + ..spec(title) + } + } + + #[test] + fn a_hovered_download_stays_read_until_it_finishes() { + let mut state = NotificationState::default(); + let mut inbox = Vec::new(); + let id = Uuid::new_v4(); + + state.handle_progress(&mut inbox, id, "Downloading".into(), 1, 10); + let entry_id = inbox[0].id; + state.mark_read(&mut inbox, entry_id); + + state.handle_progress(&mut inbox, id, "Downloading".into(), 5, 10); + assert_eq!( + NotificationState::unread_count(&inbox), + 0, + "progress ticks must not undo the hover" + ); + + state.handle_progress_complete(&mut inbox, id, "Done".into(), String::new()); + assert_eq!( + NotificationState::unread_count(&inbox), + 1, + "the finished result is new again" + ); + } + + #[test] + fn a_normal_notice_survives_its_toast_expiring() { + let mut state = NotificationState::default(); + let mut inbox = Vec::new(); + + let id = state.push_custom(&mut inbox, spec("kept")); + state.expire_toast(&mut inbox, id); + + assert!( + inbox.iter().any(|e| e.id == id), + "the inbox entry must stay" + ); + } + + #[test] + fn a_toast_only_notice_is_dropped_when_its_toast_expires() { + let mut state = NotificationState::default(); + let mut inbox = Vec::new(); + + let id = state.push_custom(&mut inbox, toast_only("gone")); + state.expire_toast(&mut inbox, id); + + assert!( + inbox.iter().all(|e| e.id != id), + "a toast-only notice must not linger in the inbox" + ); + } + + #[test] + fn a_toast_only_notice_is_dropped_when_its_toast_is_dismissed() { + let mut state = NotificationState::default(); + let mut inbox = Vec::new(); + + let id = state.push_custom(&mut inbox, toast_only("gone")); + state.dismiss_toast(&mut inbox, id); + + assert!( + inbox.iter().all(|e| e.id != id), + "a toast-only notice must not linger in the inbox" + ); + } } diff --git a/packages/oneclient_app/src/platform.rs b/packages/oneclient_app/src/platform.rs index f36267dc..3b544672 100644 --- a/packages/oneclient_app/src/platform.rs +++ b/packages/oneclient_app/src/platform.rs @@ -1,14 +1,89 @@ +use std::collections::HashSet; + +use freya::prelude::{Platform, WinitPlatformExt, spawn_forever}; +use freya::radio::RadioStation; +use oneclient_core::settings::LaunchBehaviour; + +use crate::state::{AppChannel, AppState}; + const ALLOWED_URL_SCHEMES: [&str; 3] = ["http", "https", "mailto"]; pub fn focus_window() { - use freya::prelude::{Platform, WinitPlatformExt}; - Platform::get().with_window(Platform::window_id(), |win| { + #[cfg(target_os = "macos")] + macos::set_menu_bar_only(false); + + win.set_visible(true); win.set_minimized(false); win.focus_window(); }); } +pub fn hide_window() { + Platform::get().with_window(Platform::window_id(), |win| { + #[cfg(target_os = "macos")] + if !tray::is_active() { + win.set_minimized(true); + return; + } + + win.set_visible(false); + + #[cfg(target_os = "macos")] + macos::set_menu_bar_only(true); + }); +} + +pub fn close() { + let background = + crate::launcher::state().is_ok_and(|state| state.settings.read().run_in_background); + if background { hide_window() } else { quit() } +} + +pub fn quit() { + let platform = Platform::get(); + spawn_forever(async move { + if let Ok(state) = crate::launcher::state() { + oneclient_core::shutdown(&state).await; + } + drop(platform.post_callback(|_, ctx| ctx.exit())); + }); +} + +pub fn follow_game( + station: &RadioStation, + launched: &HashSet, + previous: Option, +) -> Option { + let (current, any, behaviour) = { + let state = station.peek(); + ( + state + .game + .running_clusters() + .filter(|id| launched.contains(id)) + .min(), + state.game.running_clusters().next().is_some(), + state.settings.settings.launch_behaviour, + ) + }; + + tray::set_game_running(any); + + if current == previous { + return previous; + } + + match (behaviour, current.is_some()) { + (LaunchBehaviour::HideWhilePlaying, true) => hide_window(), + (LaunchBehaviour::HideWhilePlaying, false) => focus_window(), + (LaunchBehaviour::CloseLauncher, true) => quit(), + _ => {} + } + + current +} + pub fn open_url(url: &str) { if !has_allowed_scheme(url) { tracing::warn!("refused to open url with a disallowed scheme: {url}"); @@ -33,12 +108,37 @@ fn has_allowed_scheme(url: &str) -> bool { .any(|allowed| scheme.eq_ignore_ascii_case(allowed)) } +#[cfg(target_os = "linux")] fn open_target(target: &str) { - if let Err(err) = open::that_detached(target) { - tracing::warn!("failed to open {target}: {err}"); + use std::process::Stdio; + + let spawned = open::commands(target).into_iter().find_map(|mut command| { + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok() + }); + + match spawned { + Some(mut child) => { + std::thread::spawn(move || child.wait()); + } + None => tracing::warn!("failed to open {target}"), } } +#[cfg(not(target_os = "linux"))] +fn open_target(target: &str) { + let target = target.to_owned(); + std::thread::spawn(move || { + if let Err(err) = open::that_detached(&target) { + tracing::warn!("failed to open {target}: {err}"); + } + }); +} + pub fn copy_image_to_clipboard(path: std::path::PathBuf) { std::thread::spawn(move || { let img = match image::open(&path) { @@ -65,6 +165,131 @@ pub fn copy_image_to_clipboard(path: std::path::PathBuf) { }); } +pub mod tray { + use std::cell::RefCell; + use std::sync::atomic::{AtomicBool, Ordering}; + + use freya::prelude::{LaunchConfig, RendererContext}; + use freya::tray::menu::{Menu, MenuItem, PredefinedMenuItem}; + use freya::tray::{TrayEvent, TrayIcon, TrayIconBuilder}; + + use crate::constants; + use crate::ipc::{self, IpcCommand}; + + const OPEN_ID: &str = "oneclient.open"; + const PLAY_ID: &str = "oneclient.play"; + const STOP_ID: &str = "oneclient.stop"; + const LOGS_ID: &str = "oneclient.logs"; + const QUIT_ID: &str = "oneclient.quit"; + + #[cfg(target_os = "macos")] + const ICON: &[u8] = include_bytes!("../icons/tray.png"); + #[cfg(not(target_os = "macos"))] + const ICON: &[u8] = include_bytes!("../icons/64x64.png"); + + static ACTIVE: AtomicBool = AtomicBool::new(false); + + thread_local! { + static GAME_ITEMS: RefCell> = const { RefCell::new(Vec::new()) }; + } + + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + pub(super) fn is_active() -> bool { + ACTIVE.load(Ordering::Relaxed) + } + + pub(super) fn set_game_running(running: bool) { + GAME_ITEMS.with_borrow(|items| { + for item in items { + item.set_enabled(running); + } + }); + } + + #[cfg(target_os = "linux")] + pub fn available() -> bool { + let loaded = ["libayatana-appindicator3.so.1", "libappindicator3.so.1"] + .into_iter() + .any(|name| unsafe { libloading::Library::new(name) }.is_ok()); + if !loaded { + tracing::warn!("tray icon disabled, appindicator failed to load"); + } + loaded + } + + #[cfg(not(target_os = "linux"))] + pub fn available() -> bool { + true + } + + pub fn build() -> TrayIcon { + let live_on_linux = cfg!(target_os = "linux"); + let stop = MenuItem::with_id(STOP_ID, "Stop game", live_on_linux, None); + let logs = MenuItem::with_id(LOGS_ID, "Show logs", live_on_linux, None); + + let menu = Menu::with_items(&[ + &MenuItem::with_id(OPEN_ID, "Open OneClient", true, None), + &MenuItem::with_id(PLAY_ID, "Play last version", true, None), + &PredefinedMenuItem::separator(), + &stop, + &logs, + &PredefinedMenuItem::separator(), + &MenuItem::with_id(QUIT_ID, "Quit OneClient", true, None), + ]) + .expect("failed to build the tray menu"); + + GAME_ITEMS.with_borrow_mut(|items| *items = vec![stop, logs]); + ACTIVE.store(true, Ordering::Relaxed); + + TrayIconBuilder::new() + .with_menu(Box::new(menu)) + .with_tooltip(constants::WINDOW_TITLE) + .with_icon(LaunchConfig::tray_icon(ICON)) + .with_icon_as_template(cfg!(target_os = "macos")) + .with_menu_on_left_click(!cfg!(target_os = "windows")) + .build() + .expect("failed to create the tray icon") + } + + pub fn handle(event: TrayEvent, _ctx: RendererContext<'_>) { + let command = match event { + TrayEvent::Menu(menu) => match menu.id.0.as_str() { + OPEN_ID => IpcCommand::Focus, + PLAY_ID => return play_last(), + STOP_ID => IpcCommand::Stop, + LOGS_ID => IpcCommand::Logs, + QUIT_ID => IpcCommand::Quit, + _ => return, + }, + #[cfg(target_os = "windows")] + TrayEvent::Icon(freya::tray::TrayIconEvent::Click { + button: freya::tray::MouseButton::Left, + button_state: freya::tray::MouseButtonState::Up, + .. + }) => IpcCommand::Focus, + TrayEvent::Icon(_) => return, + }; + ipc::send(command); + } + + fn play_last() { + tokio::spawn(async { + let Ok(state) = crate::launcher::state() else { + return; + }; + let Ok(clusters) = state.clusters.list().await else { + return; + }; + if let Some(cluster) = crate::utils::sort_clusters_for_home(clusters) + .into_iter() + .next() + { + ipc::send(IpcCommand::Launch(cluster.folder_name)); + } + }); + } +} + #[cfg(target_os = "macos")] pub mod macos { use std::time::Duration; @@ -88,4 +313,39 @@ pub mod macos { malloc_zone_pressure_relief(core::ptr::null_mut(), 0); } } + + pub fn set_menu_bar_only(menu_bar_only: bool) { + const REGULAR: isize = 0; + const ACCESSORY: isize = 1; + + type Id = *mut core::ffi::c_void; + + unsafe { + unsafe extern "C" { + fn objc_getClass(name: *const core::ffi::c_char) -> Id; + fn sel_registerName(name: *const core::ffi::c_char) -> Id; + fn objc_msgSend(); + } + + let class = objc_getClass(c"NSApplication".as_ptr()); + if class.is_null() { + return; + } + + let send: unsafe extern "C" fn() = objc_msgSend; + let shared: unsafe extern "C" fn(Id, Id) -> Id = core::mem::transmute(send); + let app = shared(class, sel_registerName(c"sharedApplication".as_ptr())); + if app.is_null() { + return; + } + + let set_policy: unsafe extern "C" fn(Id, Id, isize) -> bool = + core::mem::transmute(send); + set_policy( + app, + sel_registerName(c"setActivationPolicy:".as_ptr()), + if menu_bar_only { ACCESSORY } else { REGULAR }, + ); + } + } } diff --git a/packages/oneclient_app/src/protocol/mod.rs b/packages/oneclient_app/src/protocol/mod.rs index a95d0698..af366e1a 100644 --- a/packages/oneclient_app/src/protocol/mod.rs +++ b/packages/oneclient_app/src/protocol/mod.rs @@ -69,12 +69,6 @@ pub fn is_registered() -> bool { mod tests { use super::*; - #[test] - fn a_folder_survives_the_round_trip() { - let folder = "26.1.2 Fabric"; - assert_eq!(parse_launch_url(&launch_url(folder)).as_deref(), Some(folder)); - } - #[test] fn a_space_is_encoded_rather_than_left_to_split_the_url() { assert!(launch_url("My Pack").ends_with("/My%20Pack")); diff --git a/packages/oneclient_app/src/protocol/registry.rs b/packages/oneclient_app/src/protocol/registry.rs index a5920116..299d17e5 100644 --- a/packages/oneclient_app/src/protocol/registry.rs +++ b/packages/oneclient_app/src/protocol/registry.rs @@ -44,8 +44,7 @@ pub fn write_string(subkey: &str, name: Option<&str>, value: &str) -> Result<()> let bytes = unsafe { std::slice::from_raw_parts(wide.as_ptr().cast::(), wide.len() * 2) }; let name = name.map(HSTRING::from); - let status = - unsafe { RegSetValueExW(key, name_ptr(name.as_ref()), None, REG_SZ, Some(bytes)) }; + let status = unsafe { RegSetValueExW(key, name_ptr(name.as_ref()), None, REG_SZ, Some(bytes)) }; close(key); check(status, "writing the registry value") diff --git a/packages/oneclient_app/src/recovery.rs b/packages/oneclient_app/src/recovery.rs index a5fc246c..b977dcdf 100644 --- a/packages/oneclient_app/src/recovery.rs +++ b/packages/oneclient_app/src/recovery.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use freya::prelude::spawn_forever; use crate::hooks::Actions; +use crate::launcher::off_ui_blocking; pub fn database_path() -> Option { oneclient_common::paths::database_file().ok() @@ -29,19 +30,25 @@ pub fn restore_latest(actions: &Actions) { return; }; - match oneclient_db::backup::restore(&path, &snapshot) { - Ok(_) => retry(actions), - Err(err) => set_error(actions, format!("Couldn't restore the snapshot: {err}")), - } + let actions = actions.clone(); + spawn_forever(async move { + match off_ui_blocking(move || oneclient_db::backup::restore(&path, &snapshot)).await { + Ok(_) => retry(&actions), + Err(err) => set_error(&actions, format!("Couldn't restore the snapshot: {err}")), + } + }); } pub fn reset(actions: &Actions) { let Some(path) = database_path() else { return }; - match oneclient_db::backup::reset(&path) { - Ok(_) => retry(actions), - Err(err) => set_error(actions, format!("Couldn't reset the database: {err}")), - } + let actions = actions.clone(); + spawn_forever(async move { + match off_ui_blocking(move || oneclient_db::backup::reset(&path)).await { + Ok(_) => retry(&actions), + Err(err) => set_error(&actions, format!("Couldn't reset the database: {err}")), + } + }); } fn retry(actions: &Actions) { @@ -49,7 +56,9 @@ fn retry(actions: &Actions) { let events = actions.events(); { - let mut guard = station.clone().write_channel(crate::state::AppChannel::Launcher); + let mut guard = station + .clone() + .write_channel(crate::state::AppChannel::Launcher); guard.launcher.error = None; guard.launcher.snapshots = snapshots().len(); } diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs index e27f4011..e77225ce 100644 --- a/packages/oneclient_app/src/routes.rs +++ b/packages/oneclient_app/src/routes.rs @@ -13,8 +13,8 @@ use crate::view::{ AccountSkins, Clusters, Debug, Home, Stats, browser::{Browser, BrowserPackage}, cluster::{ - ClusterLogs, ClusterMods, ClusterOverview, ClusterScreenshots, ClusterSettings, - ClusterShaders, ClusterTextures, ProcessLogs, + ClusterDataPacks, ClusterLogs, ClusterMods, ClusterOverview, ClusterScreenshots, + ClusterSettings, ClusterShaders, ClusterTextures, ClusterWorlds, ProcessLogs, }, settings::{ SettingsAccounts, SettingsApis, SettingsAppearance, SettingsChangelog, @@ -23,8 +23,8 @@ use crate::view::{ }, }, onboarding::{ - OnboardingAccount, OnboardingBundles, OnboardingDownloading, OnboardingLanguage, - OnboardingLocation, OnboardingMigration, OnboardingPreferences, OnboardingTerms, + OnboardingAccount, OnboardingBundles, OnboardingLanguage, OnboardingLocation, + OnboardingMigration, OnboardingPreferences, OnboardingSummary, OnboardingTerms, OnboardingWelcome, }, }; @@ -55,8 +55,8 @@ pub enum Route { OnboardingPreferences {}, #[route("/onboarding/bundles")] OnboardingBundles {}, - #[route("/onboarding/downloading")] - OnboardingDownloading {}, + #[route("/onboarding/summary")] + OnboardingSummary {}, #[end_layout] #[layout(AppShell)] @@ -80,6 +80,10 @@ pub enum Route { ClusterShaders { cluster_id: i64 }, #[route("/app/clusters/:cluster_id/textures")] ClusterTextures { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/worlds")] + ClusterWorlds { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/datapacks")] + ClusterDataPacks { cluster_id: i64 }, #[route("/app/clusters/:cluster_id/settings")] ClusterSettings { cluster_id: i64 }, #[end_layout] @@ -138,7 +142,7 @@ impl Route { Route::OnboardingAccount { .. } => "Account".to_string(), Route::OnboardingPreferences { .. } => "Accessibility".to_string(), Route::OnboardingBundles { .. } => "Mods".to_string(), - Route::OnboardingDownloading { .. } => "Finishing".to_string(), + Route::OnboardingSummary { .. } => "Summary".to_string(), Route::Home { .. } => "Home".to_string(), Route::Clusters { .. } => "Versions".to_string(), Route::ClusterOverview { .. } => "Cluster".to_string(), @@ -148,6 +152,8 @@ impl Route { Route::ClusterMods { .. } => "Cluster Mods".to_string(), Route::ClusterShaders { .. } => "Cluster Shaders".to_string(), Route::ClusterTextures { .. } => "Cluster Textures".to_string(), + Route::ClusterWorlds { .. } => "Cluster Worlds".to_string(), + Route::ClusterDataPacks { .. } => "Cluster Data Packs".to_string(), Route::ClusterSettings { .. } => "Cluster Settings".to_string(), Route::Browser { .. } => "Browser".to_string(), Route::BrowserPackage { .. } => "Browser".to_string(), diff --git a/packages/oneclient_app/src/shortcut/macos.rs b/packages/oneclient_app/src/shortcut/macos.rs index c022126b..c1774c0b 100644 --- a/packages/oneclient_app/src/shortcut/macos.rs +++ b/packages/oneclient_app/src/shortcut/macos.rs @@ -79,7 +79,10 @@ mod tests { #[test] fn a_bare_binary_has_no_bundle() { - assert_eq!(bundle_root(&PathBuf::from("/usr/local/bin/oneclient_app")), None); + assert_eq!( + bundle_root(&PathBuf::from("/usr/local/bin/oneclient_app")), + None + ); assert_eq!( bundle_root(&PathBuf::from("/tmp/target/debug/oneclient_app")), None, diff --git a/packages/oneclient_app/src/shortcut/mod.rs b/packages/oneclient_app/src/shortcut/mod.rs index cbbb1b0e..4ddc0d13 100644 --- a/packages/oneclient_app/src/shortcut/mod.rs +++ b/packages/oneclient_app/src/shortcut/mod.rs @@ -38,7 +38,11 @@ pub fn create(request: &ShortcutRequest) -> Result { std::fs::create_dir_all(&request.dir) .with_context(|| format!("couldn't open {}", request.dir.display()))?; - let path = unique_path(&request.dir, &file_stem(&request.cluster_name), imp::EXTENSION)?; + let path = unique_path( + &request.dir, + &file_stem(&request.cluster_name), + imp::EXTENSION, + )?; imp::write(request, &exe, &path) .with_context(|| format!("couldn't write {}", path.display()))?; diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index dc04a493..6d2cfe37 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -6,6 +6,8 @@ use std::sync::Arc; use freya::radio::RadioChannel; use oneclient_common::domain::ProviderId; +use oneclient_content::packages::release_migration::ReleaseMigrationPlan; +use oneclient_core::clusters::Cluster; use oneclient_core::relocate::{RelocationOutcome, RelocationPlan}; use oneclient_core::settings::LauncherSettings; use oneclient_events::LaunchStage; @@ -26,6 +28,7 @@ pub enum AppChannel { StorageScan, Relocation, PendingLaunch, + ReleaseMigration, } impl RadioChannel for AppChannel {} @@ -49,6 +52,55 @@ pub struct AppState { pub storage_scan: Option, pub relocation: RelocationState, pub pending_launch: Option, + pub release_migration: Option, + pub release_migration_checking: HashSet, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PromptOrigin { + NewRelease, + Manual, + Simulated, + Fake, +} + +#[derive(Clone, Debug)] +pub enum ReleasePlanState { + Loading, + Ready(ReleaseMigrationPlan), + Failed, +} + +#[derive(Clone, Debug)] +pub struct ReleaseMigrationPrompt { + pub key: String, + pub target: Cluster, + pub target_dedicated: bool, + pub java_major: Option, + pub sources: Vec, + pub selected: i64, + pub plans: HashMap, + pub origin: PromptOrigin, +} + +impl ReleaseMigrationPrompt { + #[must_use] + pub fn source(&self) -> Option<&Cluster> { + self.sources + .iter() + .find(|cluster| cluster.id == self.selected) + } + + #[must_use] + pub fn plan(&self) -> Option<&ReleasePlanState> { + self.plans.get(&self.selected) + } + + #[must_use] + pub fn is_cross_loader(&self) -> bool { + self.source() + .is_some_and(|source| source.mc_loader != self.target.mc_loader) + } } /// A move of the data folder owns the whole window while it runs: the router @@ -69,6 +121,22 @@ pub struct RelocationState { #[derive(Clone, Debug, Default, PartialEq)] pub struct InstallState { pending: HashSet<(i64, ProviderId, String)>, + pub flagged: Option, + pub modpack_busy: bool, + pub modpack_project: Option<(ProviderId, String)>, + pub modpack_cluster: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FlaggedInstallPrompt { + pub cluster_id: i64, + pub provider: ProviderId, + pub project_id: String, + pub version_id: String, + pub name: String, + pub mc_version: String, + pub explanation: Option, + pub alternatives: Vec, } impl InstallState { @@ -81,6 +149,31 @@ impl InstallState { .remove(&(cluster_id, provider, project_id.to_string())); } + #[must_use] + pub fn is_modpack_job(&self, cluster_id: i64) -> bool { + self.modpack_cluster == Some(cluster_id) + } + + #[must_use] + pub fn package_busy( + &self, + is_modpack: bool, + cluster_id: i64, + provider: ProviderId, + project_id: &str, + ) -> (bool, bool) { + if !is_modpack { + return (self.is_installing(cluster_id, provider, project_id), false); + } + let installing = + self.modpack_project + .as_ref() + .is_some_and(|(busy_provider, busy_project)| { + *busy_provider == provider && busy_project == project_id + }); + (installing, self.modpack_busy) + } + #[must_use] pub fn is_installing(&self, cluster_id: i64, provider: ProviderId, project_id: &str) -> bool { self.pending @@ -182,21 +275,25 @@ impl GameState { } } + /// `shared` lists the other clusters that would share the launch's game dir + /// empty for a dedicated cluster with parallel clusters allowed only those + /// can be in the way #[must_use] - pub fn launch_block(&self, cluster_id: i64, parallel: bool) -> Option { + pub fn launch_block( + &self, + cluster_id: i64, + parallel: bool, + shared: &[i64], + ) -> Option { if let Some(block) = self.block_for(cluster_id) { return Some(block); } - if parallel { - return None; - } - self.stages .keys() .chain(self.pending.iter()) .copied() - .filter(|id| *id != cluster_id) + .filter(|id| *id != cluster_id && (!parallel || shared.contains(id))) .find_map(|id| self.block_for(id)) } @@ -214,6 +311,13 @@ impl GameState { self.stage(cluster_id).is_some_and(LaunchStage::is_busy) } + pub fn running_clusters(&self) -> impl Iterator + '_ { + self.stages + .iter() + .filter(|(_, stage)| **stage == LaunchStage::Running) + .map(|(id, _)| *id) + } + #[must_use] pub fn is_running(&self, cluster_id: i64) -> bool { self.stage(cluster_id) == Some(LaunchStage::Running) @@ -266,9 +370,32 @@ mod tests { let mut game = GameState::default(); game.stages.insert(1, LaunchStage::Running); - assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Running(1))); - assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Running(1))); - assert_eq!(game.launch_block(2, true), None); + assert_eq!( + game.launch_block(1, false, &[]), + Some(LaunchBlock::Running(1)) + ); + assert_eq!( + game.launch_block(2, false, &[]), + Some(LaunchBlock::Running(1)) + ); + assert_eq!(game.launch_block(2, true, &[]), None); + } + + #[test] + fn parallel_clusters_still_hold_one_shared_game() { + let mut game = GameState::default(); + game.stages.insert(1, LaunchStage::Running); + + // 2 shares the game dir with 1 and 3 has its own + assert_eq!( + game.launch_block(2, true, &[1]), + Some(LaunchBlock::Running(1)) + ); + assert_eq!(game.launch_block(3, true, &[]), None); + assert_eq!( + game.launch_block(1, true, &[]), + Some(LaunchBlock::Running(1)) + ); } #[test] @@ -276,8 +403,14 @@ mod tests { let mut game = GameState::default(); game.stages.insert(1, LaunchStage::Downloading); - assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Starting(1))); - assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Starting(1))); + assert_eq!( + game.launch_block(1, false, &[]), + Some(LaunchBlock::Starting(1)) + ); + assert_eq!( + game.launch_block(2, false, &[]), + Some(LaunchBlock::Starting(1)) + ); } #[test] @@ -285,8 +418,14 @@ mod tests { let mut game = GameState::default(); game.begin_launch(1); - assert_eq!(game.launch_block(1, false), Some(LaunchBlock::Starting(1))); - assert_eq!(game.launch_block(2, false), Some(LaunchBlock::Starting(1))); + assert_eq!( + game.launch_block(1, false, &[]), + Some(LaunchBlock::Starting(1)) + ); + assert_eq!( + game.launch_block(2, false, &[]), + Some(LaunchBlock::Starting(1)) + ); } #[test] @@ -294,23 +433,62 @@ mod tests { let mut game = GameState::default(); game.stages.insert(1, LaunchStage::Exited); - assert_eq!(game.launch_block(2, false), None); - assert_eq!(game.launch_block(1, false), None); + assert_eq!(game.launch_block(2, false, &[]), None); + assert_eq!(game.launch_block(1, false, &[]), None); } #[test] fn the_button_disables_on_the_claim_alone() { let mut game = GameState::default(); - assert_eq!(launch_button_state(&game, 1, false), ("Launch", true)); + assert_eq!( + launch_button_state(&game, 1, false, false), + ("Launch", true) + ); game.begin_launch(1); - assert_eq!(launch_button_state(&game, 1, false), ("Launching", false)); + assert_eq!( + launch_button_state(&game, 1, false, false), + ("Launching", false) + ); // Held past a failure which parks the stage at `Exited` game.stages.insert(1, LaunchStage::Exited); - assert_eq!(launch_button_state(&game, 1, false), ("Launching", false)); + assert_eq!( + launch_button_state(&game, 1, false, false), + ("Launching", false) + ); game.finish_launch(1); - assert_eq!(launch_button_state(&game, 1, false), ("Launch", true)); + assert_eq!( + launch_button_state(&game, 1, false, false), + ("Launch", true) + ); + } + + #[test] + fn a_queued_launch_says_it_is_waiting() { + let mut game = GameState::default(); + game.stages.insert(1, LaunchStage::Waiting); + + assert_eq!( + launch_button_state(&game, 1, false, false), + ("Waiting", false) + ); + assert!(game.is_busy(1)); + } + + #[test] + fn a_taken_shared_dir_blocks_only_an_idle_cluster() { + let mut game = GameState::default(); + assert_eq!( + launch_button_state(&game, 1, false, true), + ("Folder in use", false) + ); + + game.stages.insert(1, LaunchStage::Running); + assert_eq!( + launch_button_state(&game, 1, false, true), + ("Running", false) + ); } } diff --git a/packages/oneclient_app/src/theme/colors.rs b/packages/oneclient_app/src/theme/colors.rs index 0db8798d..65bd72a5 100644 --- a/packages/oneclient_app/src/theme/colors.rs +++ b/packages/oneclient_app/src/theme/colors.rs @@ -11,10 +11,6 @@ pub fn page_elevated() -> Color { Color::from_rgb(21, 28, 34) } -pub fn page_overlay() -> Color { - Color::from_argb(140, 17, 23, 28) -} - pub fn fg_primary() -> Color { Color::from_rgb(213, 219, 255) } diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs index 84eb9ba5..e49634d8 100644 --- a/packages/oneclient_app/src/ui.rs +++ b/packages/oneclient_app/src/ui.rs @@ -92,6 +92,21 @@ pub fn relative_time(created_at: Instant) -> String { } } +pub fn last_played_label(ts: Option>) -> String { + let Some(ts) = ts else { + return "Never".to_string(); + }; + match (chrono::Utc::now() - ts).num_days() { + ..=0 => "Today".to_string(), + 1 => "Yesterday".to_string(), + d @ 2..=6 => format!("{d} days ago"), + d @ 7..=13 => format!("{} week ago", d / 7), + d @ 14..=29 => format!("{} weeks ago", d / 7), + d @ 30..=59 => format!("{} month ago", d / 30), + d => format!("{} months ago", d / 30), + } +} + /// Gap the clamped menu keeps from the window edges pub const EDGE_MARGIN: f32 = 8.; @@ -154,6 +169,37 @@ pub fn columns_for(width: f32, min_cell: f32, max: usize, gap: f32) -> usize { (((width + gap) / (min_cell + gap)).floor() as usize).clamp(1, max) } +pub fn fixed_grid(cards: Vec, columns: usize, card_height: f32, gap: f32) -> Element { + let columns = columns.max(1); + let mut root = rect().vertical().width(Size::fill()).spacing(gap); + + for (index, chunk) in cards.chunks(columns).enumerate() { + let mut row = rect() + .key(index) + .horizontal() + .width(Size::fill()) + .height(Size::px(card_height)) + .content(Content::Flex) + .spacing(gap); + + for card in chunk { + row = row.child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .child(card.clone()), + ); + } + for _ in chunk.len()..columns { + row = row.child(rect().width(Size::flex(1.0)).height(Size::fill())); + } + + root = root.child(row.into_element()); + } + + root.into_element() +} + /// Short final rows are padded with empty flex cells so tiles keep the column width pub fn flow_grid(items: Vec, cols: usize, mut width: State, gap: f32) -> Element { let cols = cols.max(1); diff --git a/packages/oneclient_app/src/updater.rs b/packages/oneclient_app/src/updater.rs index 46d8bc5b..7cf998a9 100644 --- a/packages/oneclient_app/src/updater.rs +++ b/packages/oneclient_app/src/updater.rs @@ -1,3 +1,4 @@ +#[cfg(target_os = "linux")] use std::path::PathBuf; use oneclient_events::{Choice, EventBus, Prompt}; @@ -279,7 +280,7 @@ async fn download_and_install_forgejo(release: ForgejoRelease, events: EventBus) .user_agent("OneLauncher-Cracked-Updater") .build()?; - let mut resp = client.get(&asset.browser_download_url).send().await?; + let resp = client.get(&asset.browser_download_url).send().await?; if !resp.status().is_success() { anyhow::bail!("Failed to download update asset: HTTP {}", resp.status()); } @@ -305,7 +306,11 @@ async fn download_and_install_forgejo(release: ForgejoRelease, events: EventBus) let mut downloaded = 0u64; let mut last_sent = 0u64; - while let Some(chunk) = resp.chunk().await? { + use futures_util::StreamExt; + let mut stream = resp.bytes_stream(); + + while let Some(chunk_res) = stream.next().await { + let chunk = chunk_res?; tokio::io::AsyncWriteExt::write_all(&mut file, &chunk).await?; downloaded += chunk.len() as u64; diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs index 15ac17cb..894e8422 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -2,9 +2,10 @@ use std::cmp::Ordering; use std::collections::BTreeMap; use chrono::{Datelike, NaiveDate}; -use oneclient_core::clusters::Cluster; use oneclient_common::domain::GameLoader; use oneclient_common::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version}; +use oneclient_core::BundleArchive; +use oneclient_core::clusters::Cluster; use oneclient_common::MEMORY_HEADROOM_GB; pub use oneclient_common::total_ram_mb; @@ -12,11 +13,12 @@ pub use oneclient_common::total_ram_mb; pub type ClusterGroups = BTreeMap>; /// The modern scheme puts a full release in the first two components (`26.1`) so each -/// minor is its own line legacy `1.x` versions keep the whole major (`1.21`) as one line +/// minor is its own line. `1.21.x` is split the same way (`1.21.5`) since it spans many +/// content drops; other legacy `1.x` versions keep the whole major (`1.20`) as one line #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct ReleaseLine { pub major: u32, - /// `Some` only for the modern scheme where the minor is part of the line + /// `Some` for the modern scheme and `1.21.x`, where the minor is part of the line pub minor: Option, } @@ -24,6 +26,8 @@ impl ReleaseLine { fn from_parsed(parsed: &ParsedMcVersion) -> Option { let minor = if parsed.major >= 26 { Some(parsed.minor?) + } else if parsed.major == 21 { + parsed.minor } else { None }; @@ -41,10 +45,10 @@ impl ReleaseLine { Self::from_version(&cluster.mc_version) } - /// The generic name of the line `26.1` `1.21` + /// The generic name of the line `26.1` `1.21.5` `1.20` pub fn pretty_name(&self) -> String { match self.minor { - Some(minor) => format!("{}.{minor}", self.major), + Some(minor) => format_mc_version(self.major, minor, None), None => format!("1.{}", self.major), } } @@ -77,7 +81,25 @@ pub fn line_art_key(line: ReleaseLine, clusters: &[Cluster]) -> Option ClusterGroups { +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum GridSelection { + Line(ReleaseLine), + Instance(i64), +} + +pub fn split_clusters(clusters: &[Cluster]) -> (ClusterGroups, Vec) { + let (instances, oneclient): (Vec<&Cluster>, Vec<&Cluster>) = + clusters.iter().partition(|cluster| cluster.is_isolated()); + + ( + group_clusters_by_release(oneclient), + sort_clusters_for_home(instances.into_iter().cloned().collect()), + ) +} + +pub fn group_clusters_by_release<'a>( + clusters: impl IntoIterator, +) -> ClusterGroups { let mut groups: ClusterGroups = BTreeMap::new(); for cluster in clusters { @@ -94,20 +116,6 @@ pub fn group_clusters_by_release(clusters: &[Cluster]) -> ClusterGroups { groups } -pub fn loader_tags(clusters: &[Cluster]) -> Vec { - let mut tags = Vec::new(); - for cluster in clusters { - if cluster.mc_loader.is_modded() { - let label = cluster.mc_loader.to_string(); - if !tags.iter().any(|t| t == &label) { - tags.push(label); - } - } - } - tags.sort(); - tags -} - pub fn version_keys(clusters: &[Cluster]) -> Vec { let mut keys = Vec::new(); for cluster in clusters { @@ -207,7 +215,7 @@ pub fn format_res((w, h): (u32, u32)) -> String { format!("{w}×{h}") } -/// Prevents user from choosing his max amount of ram preset (e.g Someone has 16GB of RAM, +/// Prevents user from choosing his max amount of ram preset (e.g Someone has 16GB of RAM, /// so the max preset is 16GB - 2GB = 14GB) const MEMORY_PRESETS_GB: [u32; 11] = [2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64]; @@ -320,24 +328,49 @@ pub fn format_day(date: NaiveDate) -> String { format!("{} {}", MONTHS[date.month0() as usize], date.day()) } +pub fn bundle_display_name(archive: &BundleArchive) -> String { + let category = archive.manifest.category.trim(); + if category.is_empty() { + archive.manifest.name.clone() + } else { + category.to_string() + } +} + +pub fn default_cluster(clusters: Vec, active: Option) -> Option { + home_cluster(&clusters, active).cloned() +} + +pub fn home_cluster(clusters: &[Cluster], active: Option) -> Option<&Cluster> { + active + .and_then(|id| clusters.iter().find(|cluster| cluster.id == id)) + // `min_by` keeps the first of equals, matching the stable sort in `sort_clusters_for_home` + .or_else(|| clusters.iter().min_by(|a, b| compare_recent_activity(a, b))) +} + pub fn sort_clusters_for_home(mut clusters: Vec) -> Vec { - clusters.sort_by(compare_last_played); + clusters.sort_by(compare_recent_activity); clusters } -fn compare_last_played(a: &Cluster, b: &Cluster) -> Ordering { - match (a.last_played, b.last_played) { +fn recent_activity(cluster: &Cluster) -> Option> { + let created = cluster.created_at.filter(|_| cluster.user_created); + cluster.last_played.max(created) +} + +fn compare_recent_activity(a: &Cluster, b: &Cluster) -> Ordering { + match (recent_activity(a), recent_activity(b)) { // Most recently played first (Some(a), Some(b)) => b.cmp(&a), (Some(_), None) => Ordering::Less, (None, Some(_)) => Ordering::Greater, // Never played latest version first (major then minor) - (None, None) => version_sort_key(b).cmp(&version_sort_key(a)), + (None, None) => version_sort_key(&b.mc_version).cmp(&version_sort_key(&a.mc_version)), } } -fn version_sort_key(cluster: &Cluster) -> (u32, u32, u32) { - parse_mc_version(&cluster.mc_version) +pub fn version_sort_key(version: &str) -> (u32, u32, u32) { + parse_mc_version(version) .map(|v| (v.major, v.minor.unwrap_or(0), v.patch.unwrap_or(0))) .unwrap_or((0, 0, 0)) } @@ -374,6 +407,87 @@ mod tests { } } + #[test] + fn user_created_oneclient_clusters_join_their_release_line() { + let provisioned = versioned(1, "26.1"); + let mine = Cluster { + user_created: true, + ..versioned(2, "26.1") + }; + let vanilla = Cluster { + user_created: true, + kind: oneclient_db::models::ClusterKind::Vanilla, + ..versioned(3, "26.1") + }; + + let (groups, instances) = split_clusters(&[provisioned, mine, vanilla]); + let line = ReleaseLine::from_version("26.1").unwrap(); + let grouped: Vec = groups[&line].iter().map(|c| c.id).collect(); + assert_eq!(grouped, vec![1, 2]); + assert_eq!(instances.iter().map(|c| c.id).collect::>(), vec![3]); + } + + #[test] + fn a_newly_created_instance_comes_first_on_home() { + let now = chrono::Utc::now(); + let played = Cluster { + last_played: Some(now - chrono::Duration::hours(2)), + ..versioned(1, "1.21.1") + }; + let created = Cluster { + user_created: true, + created_at: Some(now), + ..versioned(2, "1.20.1") + }; + let provisioned = Cluster { + user_created: false, + created_at: Some(now), + ..versioned(3, "26.3") + }; + + let order: Vec = sort_clusters_for_home(vec![played, provisioned, created]) + .into_iter() + .map(|cluster| cluster.id) + .collect(); + assert_eq!(order, vec![2, 1, 3]); + } + + #[test] + fn playing_an_older_instance_moves_it_back_in_front() { + let now = chrono::Utc::now(); + let created = Cluster { + user_created: true, + created_at: Some(now - chrono::Duration::hours(1)), + ..versioned(1, "1.20.1") + }; + let played = Cluster { + last_played: Some(now), + ..versioned(2, "1.21.1") + }; + + let order: Vec = sort_clusters_for_home(vec![created, played]) + .into_iter() + .map(|cluster| cluster.id) + .collect(); + assert_eq!(order, vec![2, 1]); + } + + #[test] + fn home_background_matches_the_first_recents_card() { + let clusters = vec![ + versioned(1, "1.8.9"), + versioned(2, "26.2"), + versioned(3, "1.21.1"), + ]; + + let first_card = sort_clusters_for_home(clusters.clone())[0].id; + assert_eq!( + home_cluster(&clusters, None).map(|c| c.id), + Some(first_card) + ); + assert_eq!(home_cluster(&clusters, Some(3)).map(|c| c.id), Some(3)); + } + fn line(major: u32, minor: Option) -> ReleaseLine { ReleaseLine { major, minor } } @@ -400,10 +514,23 @@ mod tests { } #[test] - fn legacy_minors_share_one_line() { + fn one_twenty_one_minors_are_separate_lines() { let clusters = [versioned(1, "1.21.5"), versioned(2, "1.21.11")]; - assert_eq!(lines_of(&clusters), vec![line(21, None)]); + assert_eq!( + lines_of(&clusters), + vec![line(21, Some(5)), line(21, Some(11))] + ); + assert_eq!(line_title(line(21, Some(5)), &clusters[..1]), "1.21.5"); + } + + #[test] + fn other_legacy_minors_share_one_line() { + let clusters = [versioned(1, "1.20.1"), versioned(2, "1.20.4")]; + + assert_eq!(lines_of(&clusters), vec![line(20, None)]); + assert_eq!(line_title(line(20, None), &clusters), "1.20"); + assert_eq!(line_art_key(line(20, None), &clusters), None); } #[test] @@ -430,14 +557,6 @@ mod tests { #[test] fn several_versions_fall_back_to_the_generic_name() { - let legacy = [ - versioned(1, "1.21.1"), - versioned(2, "1.21.10"), - versioned(3, "1.21.11"), - ]; - assert_eq!(line_title(line(21, None), &legacy), "1.21"); - assert_eq!(line_art_key(line(21, None), &legacy), None); - let modern = [versioned(4, "26.1"), versioned(5, "26.1.2")]; assert_eq!(line_title(line(26, Some(1)), &modern), "26.1"); assert_eq!(line_art_key(line(26, Some(1)), &modern), Some((1, None))); diff --git a/packages/oneclient_app/src/view/app/analytics/charts.rs b/packages/oneclient_app/src/view/app/analytics/charts.rs index 8acaae64..e81b92a3 100644 --- a/packages/oneclient_app/src/view/app/analytics/charts.rs +++ b/packages/oneclient_app/src/view/app/analytics/charts.rs @@ -82,9 +82,7 @@ impl Component for PlaytimeChart { "Playtime by day", format!("Every {day} you have played, added up"), BarChart::new(self.per_weekday.clone(), weekday_labels()) - .readout_labels( - WEEKDAY_FULL.iter().map(|d| format!("All {d}s")).collect(), - ) + .readout_labels(WEEKDAY_FULL.iter().map(|d| format!("All {d}s")).collect()) .highlight(self.peak_weekday) .unit(ValueUnit::Duration) .gap(6.) @@ -241,15 +239,15 @@ impl Component for DailyChart { .child(DateRangePicker::new(custom, (first_day, last_day)).height(30.)) .into_element(); - chart_card( - "Daily playtime", - subtitle, - Some(nav), - BarChart::new(values, labels) - .unit(ValueUnit::Duration) - .gap(3.) - .into_element(), - ) + chart_card( + "Daily playtime", + subtitle, + Some(nav), + BarChart::new(values, labels) + .unit(ValueUnit::Duration) + .gap(3.) + .into_element(), + ) } } @@ -293,10 +291,8 @@ fn monthly_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> { *totals.entry(month).or_insert(0) += day.secs; } - let (Some(first), Some(last)) = ( - totals.keys().min().copied(), - totals.keys().max().copied(), - ) else { + let (Some(first), Some(last)) = (totals.keys().min().copied(), totals.keys().max().copied()) + else { return Vec::new(); }; diff --git a/packages/oneclient_app/src/view/app/analytics/mod.rs b/packages/oneclient_app/src/view/app/analytics/mod.rs index 3ffb5255..b1c8c4fd 100644 --- a/packages/oneclient_app/src/view/app/analytics/mod.rs +++ b/packages/oneclient_app/src/view/app/analytics/mod.rs @@ -47,63 +47,35 @@ fn analytics_body_inner(analytics: &Analytics, force_all: bool) -> Element { } pub fn analytics_placeholder(note: &str) -> Element { - AnalyticsPlaceholder { - note: note.to_string(), - } - .into_element() -} - -#[derive(PartialEq)] -struct AnalyticsPlaceholder { - note: String, -} - -impl Component for AnalyticsPlaceholder { - fn render(&self) -> impl IntoElement { - let mut body_height = use_state(|| 0f32); - let scrim_height = *body_height.read(); - - rect() - .width(Size::fill()) - .child( - rect() - .width(Size::fill()) - .on_sized(move |e: Event| { - let height = e.area.height(); - if (*body_height.read() - height).abs() > 0.5 { - body_height.set(height); - } - }) - .child(analytics_body_inner(&empty_analytics(), true)), - ) - .child( - rect() - .position(Position::new_absolute()) - .width(Size::fill()) - .height(Size::px(scrim_height)) - .layer(Layer::Relative(1)) - .center() - .padding(40.) - .background(colors::page_overlay()) - .on_press(|_| {}) - .child( - rect() - .max_width(Size::px(360.)) - .padding(Gaps::new_symmetric(16., 24.)) - .corner_radius(CornerRadius::new_all(12.)) - .background(colors::page_elevated()) - .border(border_all_color(1., colors::component_border())) - .center() - .child( - label() - .text(self.note.clone()) - .font_size(14.) - .text_align(TextAlign::Center) - .color(colors::fg_secondary()), - ), - ), - ) - } + rect() + .vertical() + .width(Size::fill()) + .spacing(24.) + .child( + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(16., 24.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .center() + .child( + label() + .text(note.to_string()) + .width(Size::fill()) + .font_size(14.) + .text_align(TextAlign::Center) + .color(colors::fg_secondary()), + ), + ) + .child( + rect() + .width(Size::fill()) + .opacity(0.4) + .interactive(false) + .child(analytics_body_inner(&empty_analytics(), true)), + ) + .into_element() } /// A run of zero-second days ending today so the timeline still renders columns for each range diff --git a/packages/oneclient_app/src/view/app/analytics/servers.rs b/packages/oneclient_app/src/view/app/analytics/servers.rs index 71f5ceb6..eb6716d8 100644 --- a/packages/oneclient_app/src/view/app/analytics/servers.rs +++ b/packages/oneclient_app/src/view/app/analytics/servers.rs @@ -122,12 +122,12 @@ impl Component for ServersSection { .unit(m.unit()) .hovered(hovered), ) - .child(rect().width(Size::flex(1.0)).child(legend( - &values, - &labels, - *hovered.read(), - m.unit(), - ))), + .child( + rect() + .width(Size::flex(1.0)) + .max_width(Size::px(420.)) + .child(legend(&values, &labels, *hovered.read(), m.unit())), + ), ) .maybe_child(show_all.read().then(|| { OverlayPopup::new() diff --git a/packages/oneclient_app/src/view/app/analytics/tiles.rs b/packages/oneclient_app/src/view/app/analytics/tiles.rs index 97541f48..99bd10d0 100644 --- a/packages/oneclient_app/src/view/app/analytics/tiles.rs +++ b/packages/oneclient_app/src/view/app/analytics/tiles.rs @@ -28,7 +28,11 @@ struct TilesRow { impl Component for TilesRow { fn render(&self) -> impl IntoElement { let width = use_state(|| 0f32); - let cols = columns_for(*width.read(), TILE_MIN_W, 4, TILE_GAP); + let w = *width.read(); + let cols = match columns_for(w, TILE_MIN_W, 4, TILE_GAP) { + 3 => 2, + n => n, + }; let stats = &self.analytics.playtime; let avg_session = if stats.session_count > 0 { @@ -38,7 +42,6 @@ impl Component for TilesRow { }; let hero = hero_tile( - IconType::ClockRewind, "Total playtime", format_duration(stats.total_secs), format!( @@ -49,50 +52,34 @@ impl Component for TilesRow { ); let mut rest = vec![ - stat_tile(IconType::Play, "Sessions", stats.session_count.to_string()), + stat_tile("Sessions", stats.session_count.to_string()), + stat_tile("Avg / session", format_duration(avg_session)), stat_tile( - IconType::Sliders04, - "Avg / session", - format_duration(avg_session), - ), - stat_tile( - IconType::Maximize01, "Longest session", format_duration(stats.longest_session_secs), ), - stat_tile( - IconType::Rocket02, - "Day streak", - streak_value(stats.current_streak), - ), - stat_tile( - IconType::CheckCircle, - "Best streak", - streak_value(stats.longest_streak), - ), - stat_tile( - IconType::Calendar, - "Days played", - stats.active_days.to_string(), - ), + stat_tile("Day streak", streak_value(stats.current_streak)), + stat_tile("Best streak", streak_value(stats.longest_streak)), + stat_tile("Days played", stats.active_days.to_string()), ]; let mut root = rect().vertical().width(Size::fill()).spacing(TILE_GAP); if cols >= 3 { let alongside: Vec = rest.drain(..(cols - 2).min(rest.len())).collect(); + let col_w = (w - TILE_GAP * (cols - 1) as f32) / cols as f32; + let hero_w = if col_w > 0. { + Size::px(col_w * 2. + TILE_GAP) + } else { + Size::flex(2.0) + }; let mut lead = rect() .horizontal() .content(Content::Flex) .width(Size::fill()) .height(Size::px(TILE_H)) .spacing(TILE_GAP) - .child( - rect() - .width(Size::flex(2.0)) - .height(Size::fill()) - .child(hero), - ); + .child(rect().width(hero_w).height(Size::fill()).child(hero)); for tile in alongside { lead = lead.child( rect() @@ -130,38 +117,19 @@ fn streak_value(days: usize) -> String { format!("{days}d") } -fn icon_chip(icon: IconType, size: f32, tint: Color) -> Element { - rect() - .width(Size::px(size)) - .height(Size::px(size)) - .corner_radius(CornerRadius::new_all(size * 0.5)) - .background(tint.with_a(38)) - .center() - .child(Icon::new(icon).size(size * 0.52).color(tint)) - .into_element() -} - -fn hero_tile(icon: IconType, caption: &str, value: String, note: String) -> Element { +fn hero_tile(caption: &str, value: String, note: String) -> Element { card() .width(Size::fill()) .height(Size::fill()) - .padding(Gaps::new_symmetric(16., 20.)) - .spacing(10.) + .main_align(Alignment::SpaceBetween) .background(colors::brand().with_a(18)) .border(border_all_color(1., colors::brand().with_a(90))) .child( - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(8.) - .child(icon_chip(icon, 24., colors::brand())) - .child( - label() - .text(caption.to_string()) - .font_size(12.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_secondary()), - ), + label() + .text(caption.to_string()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), ) .child( rect() @@ -189,25 +157,18 @@ fn hero_tile(icon: IconType, caption: &str, value: String, note: String) -> Elem .into_element() } -fn stat_tile(icon: IconType, caption: &str, value: String) -> Element { +fn stat_tile(caption: &str, value: String) -> Element { card() .width(Size::fill()) .height(Size::fill()) - .spacing(10.) + .main_align(Alignment::SpaceBetween) .child( - rect() - .horizontal() - .cross_align(Alignment::Center) - .spacing(8.) - .child(icon_chip(icon, 24., colors::fg_secondary())) - .child( - label() - .text(caption.to_string()) - .font_size(12.) - .max_lines(1) - .width(Size::fill()) - .color(colors::fg_secondary()), - ), + label() + .text(caption.to_string()) + .font_size(12.) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_secondary()), ) .child( label() @@ -278,4 +239,3 @@ fn persona_icon(persona: Persona) -> IconType { Persona::Sprinter => IconType::Play, } } - diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index 47641eb0..e01c0a2b 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -5,11 +5,12 @@ use std::collections::HashMap; use freya::router::RouterContext; use oneclient_content::packages::types::ProjectSummary; use oneclient_content::packages::{ContentType, ProviderId}; +use oneclient_core::clusters::ModpackSource; use crate::components::{Button, Icon, IconType}; use crate::hooks::{ - content_type_for_slug, use_browser_compat, use_cluster, use_dispatch, use_installs_snapshot, - use_package_versions_when, version_list, + ALL_VERSIONS, ClusterAction, VERSIONS_PAGE_SIZE, content_type_for_slug, use_browser_compat, + use_cluster, use_dispatch, use_installs_snapshot, use_package_versions_when, version_list, }; use crate::routes::Route; use crate::theme::colors; @@ -17,13 +18,28 @@ use crate::ui::border_all_color; type InstalledMap = HashMap<(ProviderId, String), Installed>; -/// Diameter of the round install button -const INSTALL_BUTTON: f32 = 30.; +/// Height of the install control, matched by the installed pill that replaces it +const INSTALL_BUTTON_H: f32 = 28.; -fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option { +#[derive(Clone, PartialEq)] +struct CardInstalled { + source: InstallSource, + enable: Option, +} + +fn installed_for( + installed: &InstalledMap, + item: &ProjectSummary, + cluster_id: i64, +) -> Option { installed .get(&(item.provider, item.id.clone())) - .map(|installed| installed.source) + .map(|installed| CardInstalled { + source: installed.source, + enable: installed + .disabled_bundled() + .and_then(|version| version.enable_action(cluster_id)), + }) } pub(super) fn grid_row( @@ -35,9 +51,9 @@ pub(super) fn grid_row( ) -> impl IntoElement { let package_type = package_type.to_string(); let fill = cols.saturating_sub(row.len()); - let installed: Vec> = row + let installed: Vec> = row .iter() - .map(|item| installed_for(installed, item)) + .map(|item| installed_for(installed, item, cluster_id)) .collect(); rect() @@ -76,7 +92,7 @@ struct PackageCard { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, } impl PackageCard { @@ -84,7 +100,7 @@ impl PackageCard { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, ) -> Self { Self { item, @@ -101,6 +117,10 @@ impl Component for PackageCard { let provider = self.item.provider; let package_type = self.package_type.clone(); let icon_url = self.item.icon_url.clone(); + let banner = match self.item.banner_url.clone() { + Some(url) => PackageBanner::new(Some(url), BANNER_H).sharp(), + None => PackageBanner::new(icon_url.clone(), BANNER_H), + }; let cluster_id = self.cluster_id; let a11y_id = use_a11y(); @@ -111,8 +131,8 @@ impl Component for PackageCard { .vertical() .width(Size::flex(1.0)) .height(Size::px(CARD_H)) - .corner_radius(CornerRadius::new_all(10.)) - .background(CARD_BG) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) .border(border_all_color( 1., if focused { @@ -129,37 +149,28 @@ impl Component for PackageCard { .on_press(move |_| open_package(cluster_id, &package_type, provider, &id)) .child( rect() - .margin(Gaps::new_all(1.)) - .corner_radius(CornerRadius::new(10., 10., 0., 0.)) + .width(Size::fill()) + .height(Size::px(BANNER_H)) .overflow(Overflow::Clip) - .child(PackageBanner::new(icon_url, BANNER_H)) - // Top-left of the banner the first thing worth knowing about a result and the opposite corner is the install button's - .maybe_child(self.installed.map(|installed| { - rect() - .position(Position::new_absolute().top(8.).left(8.)) - .layer(Layer::Relative(7)) - .child(installed_badge_overlay(installed)) - .into_element() - })) - .maybe_child(self.installed.is_none().then(|| { - rect() - .position(Position::new_absolute().top(8.).right(8.)) - .layer(Layer::Relative(7)) - .child(InstallButton::new( - &self.item, - self.cluster_id, - &self.package_type, - false, - )) - .into_element() - })), + // No installed badge, the control at the foot of the card already states it + .child(banner.backdrop_only()), + ) + .child( + rect() + .position( + Position::new_absolute() + .top(BANNER_H - CARD_ICON + CARD_ICON_OVERHANG) + .left(14.), + ) + .layer(Layer::Relative(7)) + .child(Thumbnail::new(icon_url, CARD_ICON).radius(6.)), ) .child( rect() .vertical() .width(Size::fill()) .height(Size::flex(1.0)) - .padding(Gaps::new_all(12.)) + .padding(Gaps::new(CARD_ICON_OVERHANG + 6., 14., 12., 14.)) .main_align(Alignment::SpaceBetween) .child( rect() @@ -169,35 +180,45 @@ impl Component for PackageCard { .child( label() .text(self.item.name.clone()) - .font_size(16.) - .font_weight(FontWeight::MEDIUM) + .font_size(14.) + .font_weight(FontWeight::SEMI_BOLD) .max_lines(1) - .color(CARD_NAME), + .color(colors::fg_primary()), ) .child( rect() .horizontal() .cross_align(Alignment::Center) - .spacing(4.) + .spacing(5.) .child( label() - .text(format!("by {}", self.item.author)) + .text(format!( + "by {} · {} downloads", + self.item.author, + abbreviate_number(self.item.downloads) + )) .font_size(10.) .max_lines(1) - .color(colors::fg_secondary()), + .color(colors::fg_primary().with_a(140)), ) - .child(Icon::new(self.item.provider).size(12.)), + .child(Icon::new(self.item.provider).size(11.)), ) .child( label() .text(self.item.summary.clone()) .font_size(11.) + .line_height(1.45) .max_lines(2) .width(Size::fill()) - .color(colors::fg_secondary()), + .color(colors::fg_primary().with_a(184)), ), ) - .child(downloads_row(self.item.downloads)), + .child(InstallButton::new( + &self.item, + self.cluster_id, + &self.package_type, + self.installed.clone(), + )), ) } } @@ -209,7 +230,7 @@ pub(super) fn list_row( installed: &InstalledMap, ) -> impl IntoElement { ListRow { - installed: installed_for(installed, &item), + installed: installed_for(installed, &item, cluster_id), item, cluster_id, package_type: package_type.to_string(), @@ -221,7 +242,7 @@ struct ListRow { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, } impl Component for ListRow { @@ -243,8 +264,8 @@ impl Component for ListRow { .cross_align(Alignment::Center) .spacing(12.) .padding(Gaps::new_all(16.)) - .corner_radius(CornerRadius::new_all(10.)) - .background(CARD_BG) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) .border(border_all_color( 1., if focused { @@ -260,7 +281,7 @@ impl Component for ListRow { .a11y_role(AccessibilityRole::Button) .cursor(CursorIcon::Pointer) .on_press(move |_| open_package(cluster_id, &package_type, provider, &id)) - .child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.)) + .child(Thumbnail::new(item.icon_url.clone(), 48.).radius(6.)) .child( rect() .vertical() @@ -275,24 +296,18 @@ impl Component for ListRow { label() .text(item.name.clone()) .font_size(15.) - .font_weight(FontWeight::MEDIUM) + .font_weight(FontWeight::SEMI_BOLD) .max_lines(1) - .color(CARD_NAME), + .color(colors::fg_primary()), ) .child( label() .text(format!("by {}", item.author)) .font_size(10.) .max_lines(1) - .color(colors::fg_secondary()), + .color(colors::fg_primary().with_a(140)), ) - .child(Icon::new(item.provider).size(12.)) - // Beside the attribution rather than far right where it drifted from the package and crowded the install button - .maybe_child( - self.installed.map(|installed| { - installed_badge(installed, 10.).into_element() - }), - ), + .child(Icon::new(item.provider).size(12.)), ) .child( label() @@ -300,7 +315,7 @@ impl Component for ListRow { .font_size(11.) .max_lines(2) .width(Size::fill()) - .color(colors::fg_secondary()), + .color(colors::fg_primary().with_a(184)), ), ) .child(downloads_row(item.downloads)) @@ -308,7 +323,7 @@ impl Component for ListRow { &self.item, self.cluster_id, &self.package_type, - self.installed.is_some(), + self.installed.clone(), )) } } @@ -319,12 +334,17 @@ struct InstallButton { project_id: String, cluster_id: i64, content_type: ContentType, - /// Cluster already has this one callers that hide the button entirely pass `false` - installed: bool, + /// Cluster already has this one, so the control becomes a static pill + installed: Option, } impl InstallButton { - fn new(item: &ProjectSummary, cluster_id: i64, package_type: &str, installed: bool) -> Self { + fn new( + item: &ProjectSummary, + cluster_id: i64, + package_type: &str, + installed: Option, + ) -> Self { Self { provider: item.provider, project_id: item.id.clone(), @@ -346,7 +366,8 @@ impl Component for InstallButton { let cluster = use_cluster(cluster_id); // The same narrowing the package page does so both agree on what "latest" installs - let (game_version, loader) = match (compat, &cluster) { + let is_modpack = self.content_type == ContentType::Modpack; + let (game_version, loader) = match (compat && !is_modpack, &cluster) { (true, Some(c)) => ( Some(c.mc_version.clone()), (self.content_type == ContentType::Mod).then_some(c.mc_loader), @@ -354,20 +375,61 @@ impl Component for InstallButton { _ => (None, None), }; - let installed = self.installed; - let versions = version_list(&use_package_versions_when( - !installed, + self.installed.is_none(), provider, project_id.clone(), game_version, loader, 0, + if is_modpack { + ALL_VERSIONS + } else { + VERSIONS_PAGE_SIZE + }, )); - let latest = versions.first().map(|v| v.version_id.clone()); + let latest = preferred_version(&versions, self.content_type).map(|v| v.version_id.clone()); + let is_datapack = self.content_type == ContentType::DataPack; + let mut world_prompt = use_state(|| None::); + let mut modpack_prompt = use_state(|| false); + let mut choices = use_state(Vec::new); // Nothing to start twice while an install is running or before versions arrive - let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id); + let (installing, waiting) = + use_installs_snapshot().package_busy(is_modpack, cluster_id, provider, &project_id); + + if let Some(installed) = &self.installed { + if let Some(action) = installed.enable.clone() { + return EnableButton { + action, + variant: EnableVariant::Card { + height: INSTALL_BUTTON_H, + }, + } + .into_element(); + } + let installed = installed.source; + let color = installed.color(); + return rect() + .horizontal() + .cross_align(Alignment::Center) + .height(Size::px(INSTALL_BUTTON_H)) + .spacing(6.) + .padding(Gaps::new_symmetric(0., 10.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child(Icon::new(IconType::CheckCircle).size(12.).color(color)) + .child( + label() + .text(installed.label()) + .font_size(11.) + .font_weight(FontWeight::NORMAL) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .into_element(); + } rect() // The card behind is one big press target stop here so the package page doesn't open on top of the install @@ -375,38 +437,72 @@ impl Component for InstallButton { .child( Button::new() .primary() - .width(Size::px(INSTALL_BUTTON)) - .height(Size::px(INSTALL_BUTTON)) - .padding(Gaps::new_all(0.)) - .corner_radius(CornerRadius::new_all(INSTALL_BUTTON / 2.)) - .alt(if installed { - "Already installed" - } else if installing { - "Installing" - } else { - "Install" - }) - .enabled(!installed && latest.is_some() && !installing) - .on_press(move |_| { - if let Some(version_id) = latest.clone() { - dispatch.install_package( - cluster_id, - provider, - project_id.clone(), - version_id, - ); + .small() + .height(Size::px(INSTALL_BUTTON_H)) + .padding(Gaps::new_symmetric(0., 11.)) + .enabled(latest.is_some() && !installing && !waiting) + .on_press({ + let project_id = project_id.clone(); + move |_| { + let Some(version_id) = latest.clone() else { + return; + }; + if is_datapack { + world_prompt.set(Some(version_id)); + } else if is_modpack { + let picks = minecraft_choices(&versions); + if picks.len() > 1 { + choices.set(picks); + modpack_prompt.set(true); + } else { + dispatch.install_modpack(ModpackSource::Provider { + provider, + project_id: project_id.clone(), + version_id, + }); + } + } else { + dispatch.install_package( + cluster_id, + provider, + project_id.clone(), + version_id, + None, + ); + } } }) .child( Icon::new(if installing { IconType::Loading02 } else { - IconType::Download01 + IconType::Plus }) - .size(15.) + .size(12.) .color(colors::fg_primary()), + ) + .child( + label() + .text(if installing { "Installing" } else { "Install" }) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), ), ) + .maybe_child(world_prompt.read().is_some().then_some(WorldInstallPrompt { + cluster_id, + provider, + project_id: project_id.clone(), + pending: world_prompt, + })) + .maybe_child(modpack_prompt.read().then(|| ModpackVersionPrompt { + provider, + project_id, + choices: choices.read().clone(), + open: modpack_prompt, + })) + .into_element() } } @@ -418,13 +514,13 @@ fn downloads_row(downloads: u64) -> impl IntoElement { .child( Icon::new(IconType::Download01) .size(12.) - .color(colors::fg_secondary()), + .color(colors::fg_primary().with_a(140)), ) .child( label() .text(abbreviate_number(downloads)) .font_size(11.) - .color(colors::fg_secondary()), + .color(colors::fg_primary().with_a(140)), ) } diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index 07ecb13f..84bc687d 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -5,6 +5,7 @@ use freya::animation::{ }; use freya::prelude::*; use freya::router::RouterContext; +use oneclient_common::domain::GameLoader; use oneclient_common::parse_mc_version; use oneclient_common::search::normalize_query; use oneclient_content::packages::types::ProjectSummary; @@ -19,19 +20,21 @@ use crate::components::{ }; use crate::hooks::use_cluster; use crate::hooks::{ - BROWSE_PAGE_SIZE, BrowserUiState, bundles_with_status_items, category_list, - cluster_content_items, content_type_for_slug, pick_version_metadata, search_items, - search_pending, search_total, settled_or_loading, use_browser_compat, use_browser_state_store, - use_browser_type, use_bundles_with_status, use_cluster_content, use_clusters, use_debounced, - use_package_categories, use_package_search, use_versions, use_view_state, versions_metadata, + BROWSE_PAGE_SIZE, BrowserUiState, bundle_overrides_map, bundles_with_status_items, + category_list, cluster_content_items, content_type_for_slug, pick_version_metadata, + search_items, search_pending, search_total, settled_or_loading, use_browser_compat, + use_browser_state_store, use_browser_type, use_bundle_overrides, use_bundles_with_status, + use_cluster_content, use_clusters, use_debounced, use_package_categories, use_package_search, + use_versions, use_view_state, versions_metadata, }; use crate::routes::Route; use crate::theme::colors; use crate::ui::grid_columns_for_width; +use crate::view::app::cluster::supports_datapacks; use super::{ - InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_badge_overlay, - installed_map, + EnableButton, EnableVariant, InstallSource, Installed, ModpackVersionPrompt, PackageBanner, + Thumbnail, WorldInstallPrompt, installed_map, minecraft_choices, preferred_version, }; use crate::utils::{abbreviate_number, sort_clusters_for_home}; @@ -42,28 +45,67 @@ use cards::{empty_state, grid_row, list_row}; use sidebar::CategorySidebar; use skeletons::{SkeletonListRow, skeleton_grid_row}; -const CARD_BG: Color = Color::from_rgb(26, 34, 41); -const CARD_NAME: Color = Color::from_rgb(213, 219, 255); const SCROLLBAR_GUTTER: f32 = 18.; -const CATEGORY_SIDEBAR_W: f32 = 190.; +const CATEGORY_SIDEBAR_W: f32 = 196.; const PROVIDER_LABELS_W: f32 = 900.; const WIDE_SEARCH_W: f32 = 780.; const SEARCH_W: f32 = 260.; const SEARCH_COMPACT_W: f32 = 170.; -const CARD_H: f32 = 240.; -const BANNER_H: f32 = 100.; -const MAX_CARD_W: f32 = 400.; +const CARD_H: f32 = 226.; +const BANNER_H: f32 = 74.; +const CARD_ICON: f32 = 46.; +/// How far the package icon drops past the banner into the card body +const CARD_ICON_OVERHANG: f32 = 14.; +const MAX_CARD_W: f32 = 300.; const LIST_ROW_H: f32 = 78.; -const GRID_SPACING: f32 = 12.; +const GRID_SPACING: f32 = 16.; const LIST_SPACING: f32 = 8.; const SEARCH_DEBOUNCE_MS: u64 = 250; -const BROWSE_TYPES: [(&str, &str); 3] = [ +const SORTS: [(SearchSort, &str); 4] = [ + (SearchSort::Relevance, "Relevance"), + (SearchSort::Downloads, "Downloads"), + (SearchSort::Newest, "Newest"), + (SearchSort::Updated, "Updated"), +]; + +const LOADERS: [(Option, &str); 4] = [ + (None, "Any loader"), + (Some(GameLoader::Fabric), "Fabric"), + (Some(GameLoader::Forge), "Forge"), + (Some(GameLoader::NeoForge), "NeoForge"), +]; + +const BROWSE_TYPES: [(&str, &str); 5] = [ ("mod", "Mods"), ("texture", "Textures"), ("shader", "Shaders"), + ("datapack", "Data packs"), + ("modpack", "Modpacks"), ]; +const DATAPACK_SLUG: &str = "datapack"; +const TEXTURE_SLUG: &str = "texture"; +const LOADER_SLUGS: [&str; 2] = ["mod", "shader"]; + +pub(crate) fn browsable_type(package_type: &str, cluster: &Cluster) -> String { + if cluster.lacks_mod_loader() { + let unsupported = LOADER_SLUGS.contains(&package_type) + || (package_type == DATAPACK_SLUG && !supports_datapacks(&cluster.mc_version)); + return if unsupported { + TEXTURE_SLUG + } else { + package_type + } + .to_string(); + } + if package_type == DATAPACK_SLUG && !supports_datapacks(&cluster.mc_version) { + BROWSE_TYPES[0].0.to_string() + } else { + package_type.to_string() + } +} + fn type_title(package_type: &str) -> &'static str { BROWSE_TYPES .iter() @@ -71,7 +113,7 @@ fn type_title(package_type: &str) -> &'static str { .map_or("Mods", |(_, title)| *title) } -fn encode_package_id(provider: ProviderId, id: &str) -> String { +pub(crate) fn encode_package_id(provider: ProviderId, id: &str) -> String { format!("{}:{}", provider as u8, id) } @@ -90,6 +132,21 @@ impl Component for Browser { last_type.set_if_modified(package_type.clone()); }); + let redirect = use_cluster(self.cluster_id) + .filter(Cluster::lacks_mod_loader) + .map(|c| browsable_type(&self.package_type, &c)) + .filter(|package_type| *package_type != self.package_type) + .map(|package_type| Route::Browser { + cluster_id: self.cluster_id, + package_type, + pick_cluster: self.pick_cluster, + }); + use_side_effect_with_deps(&redirect, move |redirect: &Option| { + if let Some(route) = redirect.clone() { + let _ = RouterContext::get().replace(route); + } + }); + BrowserBody { cluster_id: self.cluster_id, package_type: self.package_type.clone(), @@ -121,7 +178,11 @@ impl Component for BrowserBody { let content_type = content_type_for_slug(&package_type); let store = use_browser_state_store(); - let state_key = format!("{cluster_id}:{package_type}"); + let state_key = if content_type == ContentType::Modpack { + package_type.clone() + } else { + format!("{cluster_id}:{package_type}") + }; let saved = store.peek().get(&state_key).cloned().unwrap_or_default(); let query = use_state(|| saved.query.clone()); @@ -129,6 +190,8 @@ impl Component for BrowserBody { let view_mode = use_view_state(&format!("browser.{package_type}")).layout; let compatible_only = use_browser_compat(); let selected_categories = use_state(|| saved.categories.clone()); + let sort = use_state(|| saved.sort); + let loader = use_state(|| saved.loader); let page = use_state(|| saved.page); { @@ -139,6 +202,8 @@ impl Component for BrowserBody { query: query.read().clone(), provider: *provider.read(), categories: selected_categories.read().clone(), + sort: *sort.read(), + loader: *loader.read(), page: *page.read(), }; store.write().insert(key.clone(), snapshot); @@ -156,22 +221,25 @@ impl Component for BrowserBody { let compat = *compatible_only.read(); let cats = selected_categories.read().clone(); - let (game_versions, loaders) = match (compat, &cluster) { - (true, Some(c)) => { - let loaders = if content_type == ContentType::Mod { - vec![c.mc_loader] - } else { - Vec::new() - }; - (vec![c.mc_version.clone()], loaders) - } - _ => (Vec::new(), Vec::new()), + let targets_cluster = content_type != ContentType::Modpack; + let compat_cluster = cluster.as_ref().filter(|_| compat && targets_cluster); + let game_versions = compat_cluster + .map(|c| vec![c.mc_version.clone()]) + .unwrap_or_default(); + let loader_fixed = content_type == ContentType::Mod && compat_cluster.is_some(); + let loader_filterable = + matches!(content_type, ContentType::Mod | ContentType::Modpack) && !loader_fixed; + let picked_loader = (*loader.read()).filter(|_| loader_filterable); + let loaders = match (loader_fixed, compat_cluster) { + (true, Some(c)) => vec![c.mc_loader], + _ => picked_loader.into_iter().collect(), }; let mut page_state = page; // Normalised like the search key so a query differing only by a space does not reset the page + let sort_by = *sort.read(); let signature = format!( - "{provider_id:?}|{}|{compat}|{}", + "{provider_id:?}|{}|{compat}|{}|{sort_by:?}|{picked_loader:?}", normalize_query(&debounced_query.read()).to_lowercase(), cats.join(",") ); @@ -190,7 +258,7 @@ impl Component for BrowserBody { game_versions, loaders, cats.clone(), - SearchSort::Relevance, + sort_by, *page.read(), ); let categories_query = use_package_categories(provider_id, content_type); @@ -199,7 +267,13 @@ impl Component for BrowserBody { let installed = installed_map( cluster_content_items(&use_cluster_content(cluster_id, content_type)), &bundles_with_status_items(&use_bundles_with_status(cluster_id)), + &bundle_overrides_map(&use_bundle_overrides(cluster_id)), ); + let installed = if matches!(content_type, ContentType::DataPack | ContentType::Modpack) { + Default::default() + } else { + installed + }; let packages = search_items(&search); let total = search_total(&search); @@ -281,24 +355,19 @@ impl Component for BrowserBody { .height(Size::fill()) .overflow(Overflow::Clip) .padding(Gaps::new(0., 40., 40., 40.)) - .spacing(16.) - .child(page_title( + .spacing(18.) + .child(header( &package_type, - cluster.as_ref().map(|c| c.name.clone()), - self.pick_cluster.then(|| ClusterPicker { + cluster + .as_ref() + .filter(|_| targets_cluster) + .map(|c| c.name.clone()), + (self.pick_cluster && targets_cluster).then(|| ClusterPicker { cluster_id, package_type: package_type.clone(), }), - )) - .child(controls( - TypePicker { - cluster_id, - package_type: package_type.clone(), - pick_cluster: self.pick_cluster, - }, provider, query, - view_mode, controls_width, )) .child( @@ -307,12 +376,24 @@ impl Component for BrowserBody { .width(Size::fill()) .height(Size::flex(1.0)) .spacing(24.) - .maybe(!all_categories.is_empty(), |el| { - el.child(CategorySidebar { - categories: all_categories, - selected: selected_categories, - }) - }) + .child( + rect() + .vertical() + .width(Size::px(CATEGORY_SIDEBAR_W)) + .height(Size::fill()) + .spacing(14.) + .child(TypePicker { + cluster_id, + package_type: package_type.clone(), + pick_cluster: self.pick_cluster, + }) + .maybe(!all_categories.is_empty(), |el| { + el.child(CategorySidebar { + categories: all_categories, + selected: selected_categories, + }) + }), + ) .child( rect() .vertical() @@ -320,6 +401,15 @@ impl Component for BrowserBody { .width(Size::flex(1.0)) .height(Size::fill()) .spacing(12.) + .child(results_toolbar( + total, + current_page, + pages, + pending, + sort, + loader_filterable.then_some(loader), + view_mode, + )) .child( rect() .width(Size::fill()) @@ -347,36 +437,211 @@ impl Component for BrowserBody { } } -fn page_title( +fn header( package_type: &str, cluster_name: Option, picker: Option, + provider: State, + query: State, + mut header_width: State, ) -> impl IntoElement { + let measured = *header_width.read(); + let show_provider_labels = measured == 0. || measured >= PROVIDER_LABELS_W; + let search_width = if measured == 0. || measured >= WIDE_SEARCH_W { + SEARCH_W + } else { + SEARCH_COMPACT_W + }; + rect() - .vertical() - .spacing(2.) + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::End) + .spacing(12.) + .content(Content::Flex) + .on_sized(move |event: Event| { + let w = event.data().area.width(); + if (w - *header_width.peek()).abs() > 0.5 { + *header_width.write() = w; + } + }) + .child( + rect() + .vertical() + .spacing(10.) + .child( + label() + .text(format!("Browse {}", type_title(package_type))) + .font_size(32.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .maybe_child(match picker { + Some(picker) => Some(picker.into_element()), + // Without the picker the cluster is fixed so the same pill is read-only + None => cluster_name.map(|name| target_pill(&name).into_element()), + }), + ) + .child(rect().width(Size::flex(1.0))) + .child( + SegmentedControl::new(provider) + .no_tint() + .segments(ProviderId::REMOTE_PROVIDERS.iter().map(move |provider| { + let segment = Segment::new(*provider).icon(IconType::from(*provider)); + if show_provider_labels { + segment.label(provider.to_string()) + } else { + segment + } + })) + .into_element(), + ) + .child( + TextInput::new(query) + .width(Size::px(search_width)) + .placeholder("Search for content") + .leading( + Icon::new(IconType::SearchMd) + .size(14.) + .color(colors::fg_secondary()) + .into_element(), + ), + ) +} + +/// Reads like the cluster dropdown next to it so a fixed target doesn't look like a missing control +fn target_pill(name: &str) -> impl IntoElement { + rect() + .horizontal() + .cross_align(Alignment::Center) + .height(Size::px(30.)) + .spacing(6.) + .padding(Gaps::new_symmetric(0., 10.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::ghost_overlay()) .child( label() - .text(format!("Browse {}", type_title(package_type))) - .font_size(36.) - .font_weight(FontWeight::BOLD) + .text("Installing to") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(name.to_string()) + .font_size(12.) + .max_lines(1) .color(colors::fg_primary()), ) - .maybe_child(match picker { - Some(picker) => Some(picker.into_element()), - // Without the picker the cluster is fixed so it's just a subtitle - None => cluster_name.map(|name| { - label() - .text(name) - .font_size(14.) - .color(colors::fg_secondary()) - .into_element() - }), - }) +} + +fn results_toolbar( + total: usize, + current_page: usize, + pages: Option, + pending: bool, + sort: State, + loader: Option>>, + view_mode: State, +) -> impl IntoElement { + // Blank rather than a stale count while a page is in flight + let summary = match (pending, pages) { + (false, Some(pages)) => format!( + "{} results · page {} of {}", + abbreviate_number(total as u64), + current_page + 1, + pages + ), + _ => String::new(), + }; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .content(Content::Flex) + .child( + label() + .text(summary) + .font_size(12.) + .color(colors::fg_primary().with_a(140)), + ) + .child(rect().width(Size::flex(1.0))) + .maybe_child(loader.map(|loader| LoaderPicker { loader })) + .child(SortPicker { sort }) + .child( + SegmentedControl::new(view_mode) + .equal_width(30.) + .icon_size(15.) + .segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)) + .segment(Segment::new(ViewLayout::List).icon(IconType::LayoutTop)), + ) +} + +#[derive(PartialEq)] +struct SortPicker { + sort: State, +} + +impl Component for SortPicker { + fn render(&self) -> impl IntoElement { + let mut sort = self.sort; + let current = *sort.read(); + + let labels: Vec = SORTS.iter().map(|(_, l)| (*l).to_string()).collect(); + let selected = SORTS + .iter() + .find(|(s, _)| *s == current) + .map_or("Relevance", |(_, l)| *l); + + Dropdown::new(selected, labels) + .width(Size::px(132.)) + .height(Size::px(30.)) + .leading( + Icon::new(IconType::Sliders04) + .size(13.) + .color(colors::fg_secondary()), + ) + .on_select(move |idx: usize| { + if let Some((picked, _)) = SORTS.get(idx) { + sort.set(*picked); + } + }) + } +} + +#[derive(PartialEq)] +struct LoaderPicker { + loader: State>, +} + +impl Component for LoaderPicker { + fn render(&self) -> impl IntoElement { + let mut loader = self.loader; + let current = *loader.read(); + + let labels: Vec = LOADERS.iter().map(|(_, l)| (*l).to_string()).collect(); + let selected = LOADERS + .iter() + .find(|(l, _)| *l == current) + .map_or("Any loader", |(_, l)| *l); + + Dropdown::new(selected, labels) + .width(Size::px(132.)) + .height(Size::px(30.)) + .on_select(move |idx: usize| { + if let Some((picked, _)) = LOADERS.get(idx) { + loader.set(*picked); + } + }) + } } /// Falls back to the bare version while the manifest hasn't arrived or doesn't cover it fn version_name(metadata: &[VersionMetadata], cluster: &Cluster) -> String { + if cluster.user_created { + return format!("{} {}", cluster.mc_version, cluster.mc_loader); + } parse_mc_version(&cluster.mc_version) .and_then(|parsed| { pick_version_metadata( @@ -402,8 +667,18 @@ impl Component for TypePicker { let cluster_id = self.cluster_id; let pick_cluster = self.pick_cluster; let current = self.package_type.clone(); + let cluster = use_cluster(cluster_id); + let datapacks = cluster + .as_ref() + .is_none_or(|c| supports_datapacks(&c.mc_version)); + let mod_loader = cluster.as_ref().is_none_or(|c| !c.lacks_mod_loader()); - let labels: Vec = BROWSE_TYPES + let types: Vec<(&str, &str)> = BROWSE_TYPES + .into_iter() + .filter(|(slug, _)| datapacks || *slug != DATAPACK_SLUG) + .filter(|(slug, _)| mod_loader || !LOADER_SLUGS.contains(slug)) + .collect(); + let labels: Vec = types .iter() .map(|(_, title)| (*title).to_string()) .collect(); @@ -413,7 +688,7 @@ impl Component for TypePicker { .width(Size::px(CATEGORY_SIDEBAR_W)) .height(Size::px(30.)) .on_select(move |idx: usize| { - let picked = BROWSE_TYPES.get(idx).filter(|(slug, _)| *slug != current); + let picked = types.get(idx).filter(|(slug, _)| *slug != current); if let Some((slug, _)) = picked { let _ = RouterContext::get().push(Route::Browser { cluster_id, @@ -462,10 +737,12 @@ impl Component for ClusterPicker { .width(Size::px(240.)) .height(Size::px(28.)) .on_select(move |idx: usize| { - if let Some(cluster_id) = ids.get(idx).copied() { + if let (Some(cluster_id), Some(cluster)) = + (ids.get(idx).copied(), clusters.get(idx)) + { let _ = RouterContext::get().replace(Route::Browser { cluster_id, - package_type: package_type.clone(), + package_type: browsable_type(&package_type, cluster), pick_cluster: true, }); } @@ -473,65 +750,3 @@ impl Component for ClusterPicker { ) } } - -fn controls( - type_picker: TypePicker, - provider: State, - query: State, - view_mode: State, - mut controls_width: State, -) -> impl IntoElement { - let measured = *controls_width.read(); - let show_provider_labels = measured == 0. || measured >= PROVIDER_LABELS_W; - let search_width = if measured == 0. || measured >= WIDE_SEARCH_W { - SEARCH_W - } else { - SEARCH_COMPACT_W - }; - - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(12.) - .content(Content::Flex) - .on_sized(move |event: Event| { - let w = event.data().area.width(); - if (w - *controls_width.peek()).abs() > 0.5 { - *controls_width.write() = w; - } - }) - .child(type_picker) - .child(rect().width(Size::flex(1.0))) - .child( - SegmentedControl::new(view_mode) - .equal_width(30.) - .icon_size(16.) - .segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)) - .segment(Segment::new(ViewLayout::List).icon(IconType::LayoutTop)), - ) - .child( - SegmentedControl::new(provider) - .no_tint() - .segments(ProviderId::REMOTE_PROVIDERS.iter().map(move |provider| { - let segment = Segment::new(*provider).icon(IconType::from(*provider)); - if show_provider_labels { - segment.label(provider.to_string()) - } else { - segment - } - })) - .into_element(), - ) - .child( - TextInput::new(query) - .width(Size::px(search_width)) - .placeholder("Search for content") - .leading( - Icon::new(IconType::SearchMd) - .size(14.) - .color(colors::fg_secondary()) - .into_element(), - ), - ) -} diff --git a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs index f7e25dbb..3d6ac841 100644 --- a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs @@ -2,6 +2,7 @@ use super::*; use crate::components::ScrollArea; use crate::theme::colors; +use crate::ui::border_all_color; use crate::utils::capitalize; #[derive(PartialEq)] @@ -13,7 +14,7 @@ pub(super) struct CategorySidebar { impl Component for CategorySidebar { fn render(&self) -> impl IntoElement { let selected = self.selected; - let list = rect().vertical().width(Size::fill()).spacing(2.).children( + let list = rect().vertical().width(Size::fill()).spacing(1.).children( self.categories.clone().into_iter().map(move |cat| { let is_selected = selected.read().contains(&cat); let mut selected = selected; @@ -41,14 +42,15 @@ impl Component for CategorySidebar { rect() .vertical() - .width(Size::px(CATEGORY_SIDEBAR_W)) - .height(Size::fill()) + .width(Size::fill()) + .height(Size::flex(1.0)) .spacing(8.) .child( label() .text("CATEGORIES") .font_size(11.) - .font_weight(FontWeight::LIGHT) + .font_weight(FontWeight::MEDIUM) + .letter_spacing(1.4) .color(colors::fg_secondary()), ) .child( @@ -92,8 +94,19 @@ impl Component for CategoryRow { }; rect() + .horizontal() .width(Size::fill()) - .padding(Gaps::new_symmetric(3., 0.)) + .height(Size::px(29.)) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(0., 10.)) + .corner_radius(CornerRadius::new_all(8.)) + .maybe(selected, |el| { + el.background(colors::brand().with_a(40)) + .border(border_all_color(1., colors::brand().with_a(115))) + }) + .maybe(!selected && hovered, |el| { + el.background(colors::ghost_overlay()) + }) .a11y_id(a11y_id) .a11y_focusable(true) .a11y_role(AccessibilityRole::Button) @@ -104,10 +117,10 @@ impl Component for CategoryRow { .child( label() .text(capitalize(&self.name)) - .font_size(13.) + .font_size(12.) .max_lines(1) .font_weight(if selected { - FontWeight::SEMI_BOLD + FontWeight::MEDIUM } else { FontWeight::NORMAL }) diff --git a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs index 77f8ae4f..01caa442 100644 --- a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs +++ b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs @@ -36,25 +36,35 @@ impl Component for SkeletonCard { .vertical() .width(Size::flex(1.0)) .height(Size::px(CARD_H)) - .corner_radius(CornerRadius::new_all(10.)) - .background(CARD_BG) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) .border(border_all_color(1., colors::component_border())) .overflow(Overflow::Clip) .opacity(pulse) .child(skeleton_block(Size::fill(), BANNER_H)) + .child( + rect() + .position( + Position::new_absolute() + .top(BANNER_H - CARD_ICON + CARD_ICON_OVERHANG) + .left(14.), + ) + .layer(Layer::Relative(7)) + .child(skeleton_block(Size::px(CARD_ICON), CARD_ICON)), + ) .child( rect() .vertical() .width(Size::fill()) .height(Size::flex(1.0)) - .padding(Gaps::new_all(12.)) - .spacing(8.) - .child(skeleton_block(Size::percent(70.), 16.)) - .child(skeleton_block(Size::percent(40.), 10.)) - .child(skeleton_block(Size::fill(), 10.)) + .padding(Gaps::new(CARD_ICON_OVERHANG + 6., 14., 12., 14.)) + .spacing(6.) + .child(skeleton_block(Size::percent(60.), 14.)) .child(skeleton_block(Size::percent(85.), 10.)) + .child(skeleton_block(Size::fill(), 10.)) + .child(skeleton_block(Size::percent(70.), 10.)) .child(rect().width(Size::fill()).height(Size::flex(1.0))) - .child(skeleton_block(Size::percent(30.), 10.)), + .child(skeleton_block(Size::px(76.), 28.)), ) } } @@ -72,8 +82,8 @@ impl Component for SkeletonListRow { .cross_align(Alignment::Center) .spacing(12.) .padding(Gaps::new_all(10.)) - .corner_radius(CornerRadius::new_all(10.)) - .background(CARD_BG) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) .border(border_all_color(1., colors::component_border())) .opacity(pulse) .child(skeleton_block(Size::px(48.), 48.)) diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index 5d6f4510..4d33353d 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -2,6 +2,27 @@ mod index; mod package; pub use index::Browser; +pub(crate) use index::{browsable_type, encode_package_id}; + +mod modpack_prompt; +mod world_prompt; +use modpack_prompt::{ModpackVersionPrompt, minecraft_choices}; +use world_prompt::WorldInstallPrompt; + +/// Projects shipping both a mod and a data pack tag the mod files with a loader +fn preferred_version( + versions: &[oneclient_content::packages::types::VersionSummary], + content_type: oneclient_content::packages::ContentType, +) -> Option<&oneclient_content::packages::types::VersionSummary> { + if content_type == oneclient_content::packages::ContentType::DataPack { + versions + .iter() + .find(|v| v.loaders.is_empty()) + .or_else(|| versions.first()) + } else { + versions.first() + } +} pub use package::BrowserPackage; use std::collections::HashMap; @@ -9,9 +30,12 @@ use std::collections::HashMap; use freya::prelude::*; use oneclient_content::packages::ProviderId; use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; +use oneclient_db::models::OverrideType; -use crate::components::{Icon, IconType}; -use crate::hooks::{loaded_image, use_cached_image}; +use crate::components::{Button, Icon, IconType, set_enabled_action}; +use crate::hooks::{ + ClusterAction, loaded_image, mutation_is_running, use_cached_image, use_cluster_mutation, +}; use crate::theme::colors; use crate::ui::{ImageFallbackExt, border_all_color}; @@ -40,15 +64,36 @@ impl InstallSource { } } +#[derive(Clone, PartialEq)] +pub(crate) struct BundlePin { + pub bundle_name: String, + pub package_id: String, + pub manifest_default: bool, +} + #[derive(Clone, PartialEq)] pub(crate) struct InstalledVersion { pub version_id: String, /// The artifact to remove /// `None` when a bundle names this version but nothing is linked pub hash: Option, - /// Only meaningful alongside a `hash` a bundle pin with nothing linked is neither on nor off pub enabled: bool, pub source: InstallSource, + pub bundle: Option, +} + +impl InstalledVersion { + pub fn enable_action(&self, cluster_id: i64) -> Option { + let pin = self.bundle.as_ref()?; + set_enabled_action( + cluster_id, + self.hash.as_deref(), + Some(&pin.bundle_name), + &pin.package_id, + pin.manifest_default, + true, + ) + } } /// Holds every version found rather than an arbitrary winner a cluster can end up with more than one @@ -72,12 +117,20 @@ impl Installed { pub fn is_duplicated(&self) -> bool { self.versions.iter().filter(|v| v.hash.is_some()).count() > 1 } + + pub fn disabled_bundled(&self) -> Option<&InstalledVersion> { + if self.versions.iter().any(|v| v.enabled) { + return None; + } + self.versions.iter().find(|v| v.bundle.is_some()) + } } /// Local files and a bundle's external files are left out they have no project id to match against pub(crate) fn installed_map( content: Vec, bundles: &[BundleWithUpdateStatus], + overrides: &HashMap<(String, String), String>, ) -> HashMap<(ProviderId, String), Installed> { let mut map: HashMap<(ProviderId, String), Installed> = HashMap::new(); @@ -98,6 +151,7 @@ pub(crate) fn installed_map( hash: Some(item.hash), enabled: item.enabled, source: InstallSource::Manual, + bundle: None, }); } } @@ -105,6 +159,7 @@ pub(crate) fn installed_map( // Bundle membership wins a bundle's files would otherwise read as hand-installed // The manifest pin is only a fallback for a missing linked version for bundle in bundles { + let bundle_name = &bundle.archive.manifest.name; for (file, _status) in &bundle.files { if let BundleFileKind::Managed { provider, @@ -113,18 +168,34 @@ pub(crate) fn installed_map( .. } = &file.kind { + let pin = BundlePin { + bundle_name: bundle_name.clone(), + package_id: file.kind.package_id(), + manifest_default: file.enabled, + }; match map.get_mut(&(*provider, project_id.clone())) { Some(installed) => { installed.source = InstallSource::Bundled; - if let Some(version) = installed + let only_copy = installed.versions.len() == 1; + match installed .versions .iter_mut() .find(|v| &v.version_id == version_id) { - version.source = InstallSource::Bundled; + Some(version) => { + version.source = InstallSource::Bundled; + version.bundle.get_or_insert(pin); + } + None if only_copy => { + installed.versions[0].bundle.get_or_insert(pin); + } + None => {} } } None => { + let user_override = overrides + .get(&(bundle_name.clone(), pin.package_id.clone())) + .and_then(|o| OverrideType::parse(o)); map.insert( (*provider, project_id.clone()), Installed { @@ -132,8 +203,9 @@ pub(crate) fn installed_map( versions: vec![InstalledVersion { version_id: version_id.clone(), hash: None, - enabled: false, + enabled: oneclient_core::effective_enabled(file, user_override), source: InstallSource::Bundled, + bundle: Some(pin), }], }, ); @@ -146,18 +218,70 @@ pub(crate) fn installed_map( map } -pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl IntoElement { - badge(installed, font_size, installed.color().with_a(38), None) +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum EnableVariant { + Sidebar, + Card { height: f32 }, + VersionRow, } -/// Brings its own backdrop and outline so it stays legible over card artwork -pub(crate) fn installed_badge_overlay(installed: InstallSource) -> impl IntoElement { - badge( - installed, - 10., - BANNER_BG.with_a(225), - Some(installed.color().with_a(110)), - ) +#[derive(PartialEq)] +pub(crate) struct EnableButton { + pub action: ClusterAction, + pub variant: EnableVariant, +} + +impl Component for EnableButton { + fn render(&self) -> impl IntoElement { + let mutation = use_cluster_mutation(); + let running = mutation_is_running(&mutation); + let action = self.action.clone(); + + let button = Button::new() + .enabled(!running) + .on_press(move |_| mutation.mutate(action.clone())); + + match self.variant { + EnableVariant::Sidebar => button + .primary() + .width(Size::fill()) + .child(Icon::new(IconType::CheckCircle).size(14.)) + .text(if running { "Enabling..." } else { "Enable" }) + .into_element(), + EnableVariant::Card { height } => rect() + .on_press(|e: Event| e.stop_propagation()) + .child( + button + .primary() + .small() + .height(Size::px(height)) + .padding(Gaps::new_symmetric(0., 11.)) + .child( + Icon::new(if running { + IconType::Loading02 + } else { + IconType::CheckCircle + }) + .size(12.) + .color(colors::fg_primary()), + ) + .child( + label() + .text(if running { "Enabling" } else { "Enable" }) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ), + ) + .into_element(), + EnableVariant::VersionRow => button.secondary().small().text("Enable").into_element(), + } + } +} + +pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl IntoElement { + badge(installed, font_size, installed.color().with_a(38), None) } /// Which of several installed versions the game actually loads @@ -175,13 +299,7 @@ pub(crate) fn activity_badge(active: bool) -> impl IntoElement { .padding(Gaps::new_symmetric(2., 8.)) .corner_radius(CornerRadius::new_all(999.)) .background(color.with_a(38)) - .child( - label() - .text(text) - .font_size(11.) - .max_lines(1) - .color(color), - ) + .child(label().text(text).font_size(11.).max_lines(1).color(color)) } fn badge( @@ -285,6 +403,8 @@ fn thumbnail_placeholder(size: f32, radius: f32, icon_ratio: f32) -> Element { pub(crate) struct PackageBanner { icon_url: Option, height: f32, + backdrop_only: bool, + sharp: bool, key: DiffKey, } @@ -293,9 +413,22 @@ impl PackageBanner { Self { icon_url, height, + backdrop_only: false, + sharp: false, key: DiffKey::None, } } + + /// Drops the centred icon so a caller can place its own artwork over the blur + pub fn backdrop_only(mut self) -> Self { + self.backdrop_only = true; + self + } + + pub fn sharp(mut self) -> Self { + self.sharp = true; + self + } } impl KeyExt for PackageBanner { @@ -308,7 +441,7 @@ impl Component for PackageBanner { fn render(&self) -> impl IntoElement { let h = self.height; let icon = h * 0.62; - let query = use_cached_image(self.icon_url.clone(), 512); + let query = use_cached_image(self.icon_url.clone(), if self.sharp { 384 } else { 256 }); let loaded = loaded_image(self.icon_url.as_deref(), &query); let banner = rect() @@ -319,6 +452,7 @@ impl Component for PackageBanner { .background(BANNER_BG); let icon_placeholder = thumbnail_placeholder(icon, 10., 0.45); + let backdrop_only = self.backdrop_only; match loaded { Some((url, bytes)) => banner @@ -338,17 +472,18 @@ impl Component for PackageBanner { ) .layer(Layer::Relative(1)), ) - .child( + .maybe_child((!self.sharp).then(|| { rect() .position(Position::new_absolute().top(0.).left(0.)) .width(Size::fill()) .height(Size::fill()) - .blur(12.) + .backdrop_blur(12.) .background(BANNER_BG.with_a(120)) .overflow(Overflow::Clip) - .layer(Layer::Relative(3)), - ) - .child( + .layer(Layer::Relative(3)) + .into_element() + })) + .maybe_child((!backdrop_only).then(|| { rect() .width(Size::px(icon)) .height(Size::px(icon)) @@ -358,10 +493,12 @@ impl Component for PackageBanner { .height(Size::px(icon)) .aspect_ratio(AspectRatio::Min) .corner_radius(CornerRadius::new_all(10.)) - .fallback(icon_placeholder), + .fallback(icon_placeholder.clone()), ) - .layer(Layer::Relative(5)), - ), + .layer(Layer::Relative(5)) + .into_element() + })), + None if backdrop_only => banner, None => banner.child(icon_placeholder), } } @@ -406,6 +543,7 @@ mod tests { hidden: false, path: format!("mods/{project_id}.jar"), size: 1, + file_type: oneclient_core::BundleFileType::Normal, kind: BundleFileKind::Managed { provider: ProviderId::Modrinth, project_id: project_id.to_string(), @@ -424,9 +562,35 @@ mod tests { .collect(), archive: archive("performance", true, files), has_updates: false, + opted_in_types: [ContentType::Mod].into(), }] } + fn installed_map( + content: Vec, + bundles: &[BundleWithUpdateStatus], + ) -> HashMap<(ProviderId, String), Installed> { + super::installed_map(content, bundles, &HashMap::new()) + } + + fn optional(project_id: &str, version_id: &str) -> BundleFile { + BundleFile { + enabled: false, + ..managed(project_id, version_id) + } + } + + fn overridden( + project_id: &str, + override_type: OverrideType, + ) -> HashMap<(String, String), String> { + let bundle_name = archive("performance", true, Vec::new()).manifest.name; + HashMap::from([( + (bundle_name, project_id.to_string()), + override_type.as_str().to_string(), + )]) + } + fn entry(map: &HashMap<(ProviderId, String), Installed>, project: &str) -> Installed { map.get(&(ProviderId::Modrinth, project.to_string())) .expect("project missing from the map") @@ -444,7 +608,10 @@ mod tests { ); let sodium = entry(&map, "sodium"); - assert!(sodium.is_version("v1"), "the older version is still in there"); + assert!( + sodium.is_version("v1"), + "the older version is still in there" + ); assert!(sodium.is_version("v2")); assert_eq!( sodium.find_version("v2").and_then(|v| v.hash.clone()), @@ -464,7 +631,11 @@ mod tests { ); let sodium = entry(&map, "sodium"); - assert_eq!(sodium.source, InstallSource::Bundled, "the bundle owns the project"); + assert_eq!( + sodium.source, + InstallSource::Bundled, + "the bundle owns the project" + ); assert_eq!( sodium.find_version("v1").map(|v| v.source), Some(InstallSource::Bundled) @@ -548,4 +719,96 @@ mod tests { ); assert_eq!(sodium.source, InstallSource::Manual); } + + #[test] + fn an_optional_mod_never_downloaded_offers_enable_through_the_bundle() { + let map = installed_map(Vec::new(), &bundles(vec![optional("sodium", "v1")])); + + let sodium = entry(&map, "sodium"); + let disabled = sodium.disabled_bundled().expect("offers enable"); + assert!(matches!( + disabled.enable_action(7), + Some(ClusterAction::SetBundlePackageEnabled { + cluster_id: 7, + enabled: true, + manifest_default: false, + .. + }) + )); + } + + #[test] + fn an_enabled_pin_waiting_for_sync_does_not_offer_enable() { + let map = installed_map(Vec::new(), &bundles(vec![managed("sodium", "v1")])); + + assert!(entry(&map, "sodium").disabled_bundled().is_none()); + } + + #[test] + fn a_disabled_override_on_an_unlinked_pin_offers_enable() { + let map = super::installed_map( + Vec::new(), + &bundles(vec![managed("sodium", "v1")]), + &overridden("sodium", OverrideType::Disabled), + ); + + let sodium = entry(&map, "sodium"); + assert!(matches!( + sodium.disabled_bundled().and_then(|v| v.enable_action(7)), + Some(ClusterAction::SetBundlePackageEnabled { + manifest_default: true, + .. + }) + )); + } + + #[test] + fn a_disabled_linked_bundle_artifact_is_enabled_by_hash() { + let map = installed_map( + vec![disabled_linked("sodium", Some("v1"), "hash-1", false)], + &bundles(vec![managed("sodium", "v1")]), + ); + + let sodium = entry(&map, "sodium"); + assert!(matches!( + sodium.disabled_bundled().and_then(|v| v.enable_action(7)), + Some(ClusterAction::SetArtifactEnabled { ref hash, enabled: true, .. }) if hash == "hash-1" + )); + } + + #[test] + fn a_disabled_copy_from_an_older_bundle_pin_still_offers_enable() { + let map = installed_map( + vec![disabled_linked("sodium", Some("v1"), "hash-1", false)], + &bundles(vec![managed("sodium", "v2")]), + ); + + assert!(entry(&map, "sodium").disabled_bundled().is_some()); + } + + #[test] + fn another_enabled_copy_hides_enable() { + let map = installed_map( + vec![ + disabled_linked("sodium", Some("v1"), "hash-1", false), + linked("sodium", Some("v2"), "hash-2"), + ], + &bundles(vec![managed("sodium", "v1")]), + ); + + assert!( + entry(&map, "sodium").disabled_bundled().is_none(), + "enabling the bundle copy would load two" + ); + } + + #[test] + fn a_hand_installed_disabled_mod_is_not_offered_enable() { + let map = installed_map( + vec![disabled_linked("sodium", Some("v1"), "hash-1", false)], + &[], + ); + + assert!(entry(&map, "sodium").disabled_bundled().is_none()); + } } diff --git a/packages/oneclient_app/src/view/app/browser/modpack_prompt.rs b/packages/oneclient_app/src/view/app/browser/modpack_prompt.rs new file mode 100644 index 00000000..5694116a --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/modpack_prompt.rs @@ -0,0 +1,276 @@ +use std::collections::HashSet; + +use freya::prelude::*; +use oneclient_common::version::parse_mc_version; +use oneclient_content::packages::ProviderId; +use oneclient_content::packages::types::{ReleaseType, VersionSummary}; +use oneclient_core::clusters::ModpackSource; + +use crate::components::{Button, Icon, IconType, ScrollArea}; +use crate::hooks::use_dispatch; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::app::cluster::dialog; + +const ROW_H: f32 = 44.; +const ROW_GAP: f32 = 6.; +const VISIBLE_ROWS: usize = 5; + +#[derive(Clone, PartialEq)] +pub(crate) struct MinecraftChoice { + mc: String, + version_id: String, + detail: String, +} + +pub(crate) fn minecraft_choices(versions: &[VersionSummary]) -> Vec { + let mut seen = HashSet::new(); + let mut order: Vec<_> = versions + .iter() + .flat_map(|v| &v.game_versions) + .filter(|mc| seen.insert(mc.as_str())) + .filter(|mc| mc.bytes().all(|b| b.is_ascii_digit() || b == b'.')) + .filter_map(|mc| parse_mc_version(mc).map(|p| (mc.clone(), (p.major, p.minor, p.patch)))) + .collect(); + order.sort_by(|a, b| b.1.cmp(&a.1)); + + let mut choices: Vec = Vec::new(); + for (mc, _) in order { + let for_mc: Vec<_> = versions + .iter() + .filter(|v| v.game_versions.contains(&mc)) + .collect(); + let mut loader_sets: Vec<&Vec<_>> = Vec::new(); + for v in &for_mc { + if !loader_sets.contains(&&v.loaders) { + loader_sets.push(&v.loaders); + } + } + for loaders in loader_sets { + let same = || for_mc.iter().filter(|v| &v.loaders == loaders); + let Some(pick) = same() + .find(|v| matches!(v.release_type, ReleaseType::Release)) + .or_else(|| same().next()) + else { + continue; + }; + push_choice(&mut choices, &mc, pick); + } + } + choices +} + +fn push_choice(choices: &mut Vec, mc: &str, pick: &VersionSummary) { + if let Some(existing) = choices.iter_mut().find(|c| c.version_id == pick.version_id) { + existing.mc.push_str(" / "); + existing.mc.push_str(mc); + return; + } + let mut detail = vec![pick.version_number.clone()]; + detail.extend(pick.loaders.iter().map(|l| l.to_string())); + match pick.release_type { + ReleaseType::Release => {} + ReleaseType::Beta => detail.push("Beta".to_string()), + ReleaseType::Alpha => detail.push("Alpha".to_string()), + } + detail.push(pick.published.format("%Y-%m-%d").to_string()); + choices.push(MinecraftChoice { + mc: mc.to_string(), + version_id: pick.version_id.clone(), + detail: detail.join(" · "), + }); +} + +#[derive(PartialEq)] +pub(crate) struct ModpackVersionPrompt { + pub provider: ProviderId, + pub project_id: String, + pub choices: Vec, + pub open: State, +} + +impl Component for ModpackVersionPrompt { + fn render(&self) -> impl IntoElement { + let provider = self.provider; + let project_id = self.project_id.clone(); + let mut open = self.open; + let dispatch = use_dispatch(); + let mut selected = use_state(|| 0usize); + let current = (*selected.read()).min(self.choices.len().saturating_sub(1)); + + let rows: Vec = self + .choices + .iter() + .enumerate() + .map(|(i, choice)| { + choice_row(choice, i == current, move || selected.set(i)).into_element() + }) + .collect(); + let shown = self.choices.len().min(VISIBLE_ROWS) as f32; + let list_h = shown * ROW_H + (shown - 1.).max(0.) * ROW_GAP; + let control = ScrollArea::new() + .width(Size::fill()) + .height(Size::px(list_h)) + .spacing(ROW_GAP) + .children(rows) + .into_element(); + + let version_id = self.choices.get(current).map(|c| c.version_id.clone()); + let install = move |_| { + let Some(version_id) = version_id.clone() else { + return; + }; + dispatch.install_modpack(ModpackSource::Provider { + provider, + project_id: project_id.clone(), + version_id, + }); + open.set(false); + }; + + dialog( + "Which Minecraft version?".to_string(), + "This modpack is made for several Minecraft versions. Pick the one to install." + .to_string(), + Some(control), + move || open.set(false), + [ + Button::new() + .secondary() + .on_press(move |_| open.set(false)) + .text("Cancel") + .into_element(), + Button::new() + .primary() + .on_press(install) + .child(Icon::new(IconType::Download01).size(14.)) + .text("Install") + .into_element(), + ], + ) + } +} + +fn choice_row( + choice: &MinecraftChoice, + active: bool, + mut on_select: impl FnMut() + 'static, +) -> impl IntoElement { + let ring = if active { + colors::brand() + } else { + colors::component_border() + }; + let radio = rect() + .width(Size::px(16.)) + .height(Size::px(16.)) + .center() + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1.5, ring)) + .maybe_child(active.then(|| { + rect() + .width(Size::px(8.)) + .height(Size::px(8.)) + .corner_radius(CornerRadius::new_all(4.)) + .background(colors::brand()) + })); + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(ROW_H)) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .padding(Gaps::new_symmetric(0., 12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., ring)) + .on_press(move |_| on_select()) + .child(radio) + .child( + rect() + .vertical() + .width(Size::flex(1.)) + .spacing(2.) + .child( + label() + .text(format!("Minecraft {}", choice.mc)) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(choice.detail.clone()) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn version(id: &str, mc: &[&str], release_type: ReleaseType) -> VersionSummary { + VersionSummary { + version_id: id.to_string(), + game_versions: mc.iter().map(|s| s.to_string()).collect(), + project_id: String::new(), + name: String::new(), + version_number: String::new(), + published: Default::default(), + release_type, + loaders: Vec::new(), + downloads: 0, + file_size: 0, + dependencies: Vec::new(), + } + } + + #[test] + fn one_choice_per_minecraft_version_preferring_releases() { + let versions = [ + version("b3", &["26.3"], ReleaseType::Beta), + version("r3", &["26.3"], ReleaseType::Release), + version("b2", &["26.2"], ReleaseType::Beta), + version( + "r1", + &["1.21.11", "Fabric", "26.1", "1.21.5-rc1", "Client"], + ReleaseType::Release, + ), + ]; + let picks: Vec<(String, String)> = minecraft_choices(&versions) + .into_iter() + .map(|c| (c.mc, c.version_id)) + .collect(); + let expect = [("26.3", "r3"), ("26.2", "b2"), ("26.1 / 1.21.11", "r1")]; + assert_eq!( + picks, + expect.map(|(a, b)| (a.to_string(), b.to_string())).to_vec() + ); + } + + #[test] + fn one_choice_per_loader_on_the_same_minecraft_version() { + use oneclient_common::domain::GameLoader; + let with = |id, loader, release_type| VersionSummary { + loaders: vec![loader], + ..version(id, &["1.21.1"], release_type) + }; + let versions = [ + with("fabric-new", GameLoader::Fabric, ReleaseType::Release), + with("forge-beta", GameLoader::Forge, ReleaseType::Beta), + with("fabric-old", GameLoader::Fabric, ReleaseType::Release), + with("forge-rel", GameLoader::Forge, ReleaseType::Release), + ]; + let ids: Vec = minecraft_choices(&versions) + .into_iter() + .map(|c| c.version_id) + .collect(); + assert_eq!(ids, ["fabric-new", "forge-rel"]); + } +} diff --git a/packages/oneclient_app/src/view/app/browser/package/gallery.rs b/packages/oneclient_app/src/view/app/browser/package/gallery.rs index 898fdfc1..31f00dc1 100644 --- a/packages/oneclient_app/src/view/app/browser/package/gallery.rs +++ b/packages/oneclient_app/src/view/app/browser/package/gallery.rs @@ -158,13 +158,13 @@ impl Component for GalleryViewer { move || start }); + let idx = (*index.read()).min(len.saturating_sub(1)); + let query = use_cached_image(self.images.get(idx).map(|i| i.url.clone()), FULL_EDGE); + if len == 0 { return rect().into_element(); } - let idx = (*index.read()).min(len - 1); let image = self.images[idx].clone(); - - let query = use_cached_image(Some(image.url.clone()), FULL_EDGE); let loaded = loaded_image(Some(&image.url), &query); let close = self.on_close.clone(); diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs index 62212e47..d31c4990 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -1,20 +1,26 @@ use freya::prelude::*; -use oneclient_content::packages::{ContentType, ProviderId}; +use std::collections::HashMap; -use crate::components::ScrollArea; +use oneclient_content::packages::types::DependencyKind; +use oneclient_content::packages::{ContentType, ProviderId}; +use oneclient_core::clusters::ModpackSource; + +use crate::components::{ScrollArea, use_shared_delete}; +use crate::hooks::use_cluster; use crate::hooks::{ - bundles_with_status_items, cluster_content_items, content_type_for_slug, project_detail, - use_browser_compat, use_bundles_with_status, use_cluster_content, use_dispatch, - use_installs_snapshot, use_link_confirm, use_package_project, use_package_versions, - version_list, versions_total, + ALL_VERSIONS, VERSIONS_PAGE_SIZE, bundle_overrides_map, bundles_with_status_items, + cluster_content_items, content_type_for_slug, package_meta_batch, project_detail, + use_browser_compat, use_bundle_overrides, use_bundles_with_status, use_cluster_content, + use_dispatch, use_installs_snapshot, use_link_confirm, use_package_meta_batch, + use_package_project, use_package_versions_when, version_list, versions_total, }; use crate::theme::colors; use crate::ui::border_all_color; -use crate::hooks::use_cluster; use super::{ - Installed, InstalledVersion, PackageBanner, Thumbnail, activity_badge, installed_badge, - installed_badge_overlay, installed_map, + EnableButton, EnableVariant, Installed, InstalledVersion, ModpackVersionPrompt, PackageBanner, + Thumbnail, WorldInstallPrompt, activity_badge, installed_badge, installed_map, + minecraft_choices, preferred_version, }; use crate::utils::abbreviate_number; @@ -29,6 +35,47 @@ const PANEL_BG: Color = Color::from_rgb(21, 28, 34); const SIDEBAR_W: f32 = 280.; const SCROLLBAR_GUTTER: f32 = 18.; +#[derive(Clone)] +struct Installer { + dispatch: crate::Actions, + cluster_id: i64, + provider: ProviderId, + world_prompt: Option>>, + modpack: bool, + modpack_prompt: Option>, +} + +impl Installer { + fn install_latest(&self, project_id: String, version_id: String) { + match self.modpack_prompt { + Some(mut prompt) => prompt.set(true), + None => self.install(project_id, version_id), + } + } + + fn install(&self, project_id: String, version_id: String) { + if self.modpack { + self.dispatch.install_modpack(ModpackSource::Provider { + provider: self.provider, + project_id, + version_id, + }); + return; + } + + match self.world_prompt { + Some(mut prompt) => prompt.set(Some(version_id)), + None => self.dispatch.install_package( + self.cluster_id, + self.provider, + project_id, + version_id, + None, + ), + } + } +} + fn decode_package_id(package_id: &str) -> (ProviderId, String) { match package_id.split_once(':') { Some((repr, id)) => { @@ -60,7 +107,7 @@ fn pill(text: String) -> impl IntoElement { .into_element() } -fn pill_flow(items: &[String], per_row: usize, max: usize) -> impl IntoElement { +fn pill_flow(items: &[String], max: usize) -> impl IntoElement { let shown: Vec = items.iter().take(max).cloned().collect(); let overflow = items.len().saturating_sub(shown.len()); let mut all = shown; @@ -69,15 +116,11 @@ fn pill_flow(items: &[String], per_row: usize, max: usize) -> impl IntoElement { } rect() - .vertical() + .horizontal() + .width(Size::fill()) + .content(Content::wrap_spacing(4.)) .spacing(4.) - .children(all.chunks(per_row.max(1)).map(|chunk| { - rect() - .horizontal() - .spacing(4.) - .children(chunk.iter().map(|t| pill(t.clone()).into_element())) - .into_element() - })) + .children(all.into_iter().map(|t| pill(t).into_element())) .into_element() } @@ -92,7 +135,7 @@ fn pill_detail(key: &str, items: &[String]) -> Element { .font_size(12.) .color(colors::fg_secondary()), ) - .child(pill_flow(items, 4, 18)) + .child(pill_flow(items, 18)) .into_element() } @@ -114,10 +157,22 @@ impl Component for BrowserPackage { let compatible_only = use_browser_compat(); let dispatch = use_dispatch(); let confirm = use_link_confirm(); + let world_prompt = use_state(|| None::); + let modpack_prompt = use_state(|| false); + let is_datapack = content_type == ContentType::DataPack; + let mut installer = Installer { + dispatch: dispatch.clone(), + cluster_id, + provider, + world_prompt: is_datapack.then_some(world_prompt), + modpack: content_type == ContentType::Modpack, + modpack_prompt: None, + }; let cluster = use_cluster(cluster_id); let compat = *compatible_only.read(); - let (game_version, loader) = match (compat, &cluster) { + let narrows = content_type != ContentType::Modpack; + let (game_version, loader) = match (compat && narrows, &cluster) { (true, Some(c)) => ( Some(c.mc_version.clone()), (content_type == ContentType::Mod).then_some(c.mc_loader), @@ -135,26 +190,87 @@ impl Component for BrowserPackage { } let project_query = use_package_project(provider, project_id.clone()); - let versions_query = use_package_versions( + let is_modpack = content_type == ContentType::Modpack; + let versions_query = use_package_versions_when( + !is_modpack, provider, project_id.clone(), game_version, loader, *versions_page.read(), + VERSIONS_PAGE_SIZE, ); + let all_versions = version_list(&use_package_versions_when( + is_modpack, + provider, + project_id.clone(), + None, + None, + 0, + ALL_VERSIONS, + )); - let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id); + let (installing, waiting) = use_installs_snapshot().package_busy( + content_type == ContentType::Modpack, + cluster_id, + provider, + &project_id, + ); let installed = installed_map( cluster_content_items(&use_cluster_content(cluster_id, content_type)), &bundles_with_status_items(&use_bundles_with_status(cluster_id)), + &bundle_overrides_map(&use_bundle_overrides(cluster_id)), ) - .remove(&(provider, project_id.clone())); + .remove(&(provider, project_id.clone())) + .filter(|_| !is_datapack); let project = project_detail(&project_query); - let versions = version_list(&versions_query); - let total_versions = versions_total(&versions_query); - let latest_version = versions.first().map(|v| v.version_id.clone()); + + let remove_id = project.as_ref().map(|p| p.id.clone()); + let remove_dispatch = dispatch.clone(); + let (on_remove, remove_dialog) = use_shared_delete(cluster_id, move |(name, hash)| { + if let Some(project_id) = &remove_id { + remove_dispatch.remove_package_version( + cluster_id, + provider, + project_id.clone(), + hash, + name, + ); + } + }); + + let (versions, total_versions) = if is_modpack { + let page = all_versions + .iter() + .skip(versions_page.read().saturating_mul(VERSIONS_PAGE_SIZE)) + .take(VERSIONS_PAGE_SIZE) + .cloned() + .collect(); + (page, all_versions.len()) + } else { + ( + version_list(&versions_query), + versions_total(&versions_query), + ) + }; + let dependency_ids: Vec = versions + .iter() + .flat_map(|v| &v.dependencies) + .filter(|d| d.kind == DependencyKind::Required) + .filter_map(|d| d.project_id.clone()) + .collect(); + let dependency_names: HashMap = + package_meta_batch(&use_package_meta_batch(provider, dependency_ids)) + .into_iter() + .map(|(id, meta)| (id, meta.name)) + .collect(); + let latest_source = if is_modpack { &all_versions } else { &versions }; + let latest_version = + preferred_version(latest_source, content_type).map(|v| v.version_id.clone()); + let choices = minecraft_choices(&all_versions); + installer.modpack_prompt = (choices.len() > 1).then_some(modpack_prompt); let gallery = project .as_ref() @@ -171,12 +287,12 @@ impl Component for BrowserPackage { versions, total_versions, versions_page, - provider, + dependency_names, project.id.clone(), - cluster_id, - dispatch.clone(), + installer.clone(), + on_remove, installed.clone(), - installing, + installing || waiting, ) .into_element(), (Some(_), _) => gallery_panel(gallery).into_element(), @@ -191,12 +307,11 @@ impl Component for BrowserPackage { .child(sidebar( project, latest_version, - provider, - cluster_id, - dispatch, + installer, confirm, installed, installing, + waiting, )) .child( rect() @@ -221,6 +336,19 @@ impl Component for BrowserPackage { .padding(Gaps::new(0., SCROLLBAR_GUTTER, 0., 0.)) .children([row]), ) + .maybe_child(remove_dialog) + .maybe_child(world_prompt.read().is_some().then(|| WorldInstallPrompt { + cluster_id, + provider, + project_id: project_id.clone(), + pending: world_prompt, + })) + .maybe_child(modpack_prompt.read().then(|| ModpackVersionPrompt { + provider, + project_id: project_id.clone(), + choices, + open: modpack_prompt, + })) .into_element() } } diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs index 7ae602be..95984ef4 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -1,12 +1,14 @@ use super::*; -use oneclient_content::packages::ProviderId; use oneclient_content::packages::markdown::normalize_markdown; -use oneclient_content::packages::types::{PackageBody, ProjectDetail, ReleaseType, VersionSummary}; +use oneclient_content::packages::types::{ + DependencyKind, PackageBody, ProjectDetail, ReleaseType, VersionSummary, +}; -use crate::Actions; -use crate::components::{Button, Icon, IconType, Markdown, MarkdownStyle, Segment, SegmentedControl}; -use crate::hooks::VERSIONS_PAGE_SIZE; +use crate::components::{ + Button, Icon, IconType, Markdown, MarkdownStyle, Segment, SegmentedControl, +}; +use crate::hooks::{ClusterAction, VERSIONS_PAGE_SIZE}; use crate::theme::colors; use crate::ui::border_all_color; use crate::utils::format_size; @@ -105,10 +107,10 @@ pub(super) fn versions_panel( versions: Vec, total_versions: usize, versions_page: State, - provider: ProviderId, + dependency_names: HashMap, project_id: String, - cluster_id: i64, - dispatch: Actions, + installer: Installer, + on_remove: EventHandler<(String, String)>, installed: Option, installing: bool, ) -> impl IntoElement { @@ -140,15 +142,21 @@ pub(super) fn versions_panel( let duplicated = installed .as_ref() .is_some_and(|installed| installed.is_duplicated()); + let enable = installed + .as_ref() + .and_then(Installed::disabled_bundled) + .filter(|disabled| disabled.version_id == v.version_id) + .and_then(|disabled| disabled.enable_action(installer.cluster_id)); version_row( v, - provider, + &dependency_names, project_id.clone(), - cluster_id, - dispatch.clone(), + installer.clone(), + on_remove.clone(), tag, duplicated, installing, + enable, ) .into_element() })) @@ -216,14 +224,15 @@ fn version_pager(current: usize, total_pages: usize, page: State) -> impl #[allow(clippy::too_many_arguments)] fn version_row( v: VersionSummary, - provider: ProviderId, + dependency_names: &HashMap, project_id: String, - cluster_id: i64, - dispatch: Actions, + installer: Installer, + on_remove: EventHandler<(String, String)>, installed: Option, // Saying which version is live only tells the user anything when there are several duplicated: bool, installing: bool, + enable: Option, ) -> impl IntoElement { let version_id = v.version_id.clone(); let mut chips: Vec = v.loaders.iter().map(|l| l.to_string()).collect(); @@ -237,6 +246,13 @@ fn version_row( parts.join(" · ") }; let has_chips = !chips.is_empty(); + let requires: Vec<&str> = v + .dependencies + .iter() + .filter(|d| d.kind == DependencyKind::Required) + .filter_map(|d| d.project_id.as_deref()) + .map(|id| dependency_names.get(id).map_or(id, String::as_str)) + .collect(); rect() .horizontal() @@ -262,7 +278,16 @@ fn version_row( .max_lines(1) .color(colors::fg_primary()), ) - .maybe(has_chips, |el| el.child(pill_flow(&chips, 8, 10))) + .maybe(has_chips, |el| el.child(pill_flow(&chips, 10))) + .maybe(!requires.is_empty(), |el| { + el.child( + label() + .text(format!("Requires {}", requires.join(", "))) + .font_size(11.) + .max_lines(2) + .color(colors::fg_secondary()), + ) + }) .child( label() .text(stats) @@ -282,28 +307,27 @@ fn version_row( .filter(|_| duplicated) .map(|installed| activity_badge(installed.enabled).into_element()), ) - .child(version_button( - installed, - v.name, - provider, - project_id, - version_id, - cluster_id, - dispatch, - installing, - )) + .child(match enable { + Some(action) => EnableButton { + action, + variant: EnableVariant::VersionRow, + } + .into_element(), + None => version_button( + installed, v.name, project_id, version_id, installer, on_remove, installing, + ) + .into_element(), + }) } /// A bundle pin with nothing linked leaves nothing to press no artifact to remove and installing by hand would duplicate it -#[allow(clippy::too_many_arguments)] fn version_button( installed: Option, version_name: String, - provider: ProviderId, project_id: String, version_id: String, - cluster_id: i64, - dispatch: Actions, + installer: Installer, + on_remove: EventHandler<(String, String)>, busy: bool, ) -> impl IntoElement { let Some(installed) = installed else { @@ -311,34 +335,23 @@ fn version_button( .secondary() .small() .enabled(!busy) - .on_press(move |_| { - dispatch.install_package( - cluster_id, - provider, - project_id.clone(), - version_id.clone(), - ); - }) + .on_press(move |_| installer.install(project_id.clone(), version_id.clone())) .text("Install"); }; let Some(hash) = installed.hash else { - return Button::new().secondary().small().enabled(false).text("Install"); + return Button::new() + .secondary() + .small() + .enabled(false) + .text("Install"); }; Button::new() .danger() .small() .enabled(!busy) - .on_press(move |_| { - dispatch.remove_package_version( - cluster_id, - provider, - project_id.clone(), - hash.clone(), - version_name.clone(), - ); - }) + .on_press(move |_| on_remove.call((version_name.clone(), hash.clone()))) .text("Remove") } diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs index 628e8659..45df607d 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -3,21 +3,18 @@ use super::*; use oneclient_content::packages::ProviderId; use oneclient_content::packages::types::{ProjectDetail, ProjectMember}; -use crate::Actions; use crate::components::{Button, Icon, IconType}; use crate::theme::colors; use crate::ui::border_all_color; -#[allow(clippy::too_many_arguments)] pub(super) fn sidebar( project: Option, latest_version: Option, - provider: ProviderId, - cluster_id: i64, - dispatch: Actions, + installer: Installer, confirm: State>, installed: Option, installing: bool, + waiting: bool, ) -> impl IntoElement { let Some(project) = project else { return rect() @@ -32,7 +29,11 @@ pub(super) fn sidebar( (Some(installed), Some(latest)) => installed.is_version(latest), _ => false, }; - let can_install = latest_version.is_some() && !have_latest && !installing; + let can_install = latest_version.is_some() && !have_latest && !installing && !waiting; + let enable = installed + .as_ref() + .and_then(Installed::disabled_bundled) + .and_then(|version| version.enable_action(installer.cluster_id)); rect() .vertical() @@ -51,14 +52,7 @@ pub(super) fn sidebar( rect() .width(Size::fill()) .overflow(Overflow::Clip) - .child(PackageBanner::new(project.icon_url.clone(), 110.)) - .maybe_child(installed.as_ref().map(|installed| { - rect() - .position(Position::new_absolute().top(8.).left(8.)) - .layer(Layer::Relative(7)) - .child(installed_badge_overlay(installed.source)) - .into_element() - })), + .child(PackageBanner::new(project.icon_url.clone(), 110.)), ) .child( rect() @@ -111,19 +105,19 @@ pub(super) fn sidebar( ), ), ) - .child( - Button::new() + .child(match enable { + Some(action) => EnableButton { + action, + variant: EnableVariant::Sidebar, + } + .into_element(), + None => Button::new() .primary() .width(Size::fill()) .enabled(can_install) .on_press(move |_| { if let Some(version_id) = latest_version.clone() { - dispatch.install_package( - cluster_id, - provider, - project_id.clone(), - version_id, - ); + installer.install_latest(project_id.clone(), version_id); } }) .child(Icon::new(IconType::Download01).size(14.)) @@ -133,8 +127,9 @@ pub(super) fn sidebar( "Latest installed" } else { "Install latest" - }), - ) + }) + .into_element(), + }) .maybe( !project.members.is_empty() || !project.author.is_empty(), |el| el.child(authors_card(&project, confirm)), diff --git a/packages/oneclient_app/src/view/app/browser/world_prompt.rs b/packages/oneclient_app/src/view/app/browser/world_prompt.rs new file mode 100644 index 00000000..50261502 --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/world_prompt.rs @@ -0,0 +1,109 @@ +use freya::prelude::*; +use oneclient_content::packages::ProviderId; + +use crate::components::{Button, Dropdown, Icon, IconType}; +use crate::hooks::{ + query_is_loading, try_cluster_worlds, use_cluster_worlds, use_datapack_world, use_dispatch, +}; +use crate::theme::colors; +use crate::view::app::cluster::dialog; + +#[derive(PartialEq)] +pub(crate) struct WorldInstallPrompt { + pub cluster_id: i64, + pub provider: ProviderId, + pub project_id: String, + pub pending: State>, +} + +impl Component for WorldInstallPrompt { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let provider = self.provider; + let project_id = self.project_id.clone(); + let mut pending = self.pending; + + let dispatch = use_dispatch(); + let mut remembered = use_datapack_world(); + let worlds_query = use_cluster_worlds(cluster_id); + let names: Vec = try_cluster_worlds(&worlds_query) + .unwrap_or_default() + .into_iter() + .map(|w| w.folder_name) + .collect(); + + let world = remembered + .read() + .get(&cluster_id) + .filter(|name| names.contains(name)) + .cloned() + .or_else(|| names.first().cloned()); + + let control = match &world { + Some(current) => { + let options = names.clone(); + Dropdown::new(current.clone(), names.clone()) + .width(Size::fill()) + .height(Size::px(32.)) + .leading( + Icon::new(IconType::Globe01) + .size(14.) + .color(colors::fg_secondary()), + ) + .on_select(move |idx: usize| { + if let Some(name) = options.get(idx) { + remembered.write().insert(cluster_id, name.clone()); + } + }) + .into_element() + } + None => label() + .text(if query_is_loading(&worlds_query) { + "Loading worlds..." + } else { + "This version has no worlds yet. Create one in game first." + }) + .font_size(12.) + .color(colors::fg_secondary()) + .into_element(), + }; + + let install_world = world.clone(); + let install = move |_| { + let (Some(world), Some(version_id)) = (install_world.clone(), pending.read().clone()) + else { + return; + }; + remembered.write().insert(cluster_id, world.clone()); + dispatch.install_package( + cluster_id, + provider, + project_id.clone(), + version_id, + Some(world), + ); + pending.set(None); + }; + + dialog( + "Install into which world?".to_string(), + "Data packs only apply to the world they are added to.".to_string(), + Some(control), + move || pending.set(None), + [ + Button::new() + .secondary() + .on_press(move |_| pending.set(None)) + .text("Cancel") + .into_element(), + Button::new() + .primary() + .enabled(world.is_some()) + .on_press(install) + .child(Icon::new(IconType::Download01).size(14.)) + .text("Install") + .into_element(), + ], + ) + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index 78fe3fe3..6302c257 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -1,10 +1,10 @@ +use std::sync::Arc; + use freya::prelude::*; use oneclient_common::Patch; -use oneclient_java::JavaRuntime; use oneclient_common::domain::GameLoader; -use oneclient_core::settings::{ - GameSettingsProfile, PackageUpdateMode, ProfileUpdate, Resolution, -}; +use oneclient_core::settings::{GameSettingsProfile, PackageUpdateMode, ProfileUpdate, Resolution}; +use oneclient_java::JavaRuntime; use crate::components::{ Button, Dropdown, Icon, IconType, ScrollArea, TextInput, memory_field, toggle, @@ -12,15 +12,19 @@ use crate::components::{ }; use crate::hooks::{ ClusterAction, java_runtimes, loader_versions, mutation_is_running, query_error, - try_game_profile, use_cluster_mutation, use_dispatch, use_game_profile, use_java_runtimes, - use_loader_versions, use_settings_snapshot, + settled_or_loading, try_game_profile, use_cluster_mutation, use_clusters, use_dispatch, + use_game_profile, use_game_snapshot, use_java_runtimes, use_loader_versions, + use_migratable_routes, use_release_migration_checking, use_settings_snapshot, }; use crate::layout::cluster_content; use crate::theme::colors; use crate::ui::centered_note; -use crate::view::app::settings::{section_header, settings_row}; +use crate::view::app::clusters::{DeleteInstanceModal, EditInstanceModal, InstanceFacts}; +use crate::view::app::settings::{section_header, settings_row, settings_row_disabled}; +use oneclient_core::clusters::{can_migrate_manually, rank_migration_sources}; use super::cluster_not_found; +use super::modpack_settings::{ModpackRepairRow, ModpackUpdateRow}; use crate::hooks::use_cluster; #[derive(PartialEq)] @@ -64,6 +68,55 @@ impl Component for ClusterSettings { let versions = loader_versions(&versions_query); let runtimes = java_runtimes(&runtimes_query); + let instance_row = { + InstanceRow { + editable: cluster.user_created, + facts: InstanceFacts { + cluster_id, + name: cluster.name.clone(), + description: cluster.description.clone(), + tags: cluster.tags.clone(), + cover: cluster.cover_file(), + mc_version: cluster.mc_version.clone(), + mc_loader: cluster.mc_loader, + kind: cluster.kind, + modpack: cluster.linked_modpack_hash.is_some(), + }, + } + .into_element() + }; + + let mod_loader = !cluster.lacks_mod_loader(); + let loader_section: Vec = if mod_loader { + vec![ + section_header("LOADER").into_element(), + LoaderRow { + cluster_id, + loader, + selected: cluster.mc_loader_version.clone(), + versions, + } + .into_element(), + ] + } else { + Vec::new() + }; + let migrate_row: Vec = if mod_loader { + vec![MigrateFromRow { cluster_id }.into_element()] + } else { + Vec::new() + }; + + let modpack_section: Vec = if cluster.linked_modpack_hash.is_some() { + vec![ + section_header("MODPACK").into_element(), + ModpackUpdateRow { cluster_id }.into_element(), + ModpackRepairRow { cluster_id }.into_element(), + ] + } else { + Vec::new() + }; + cluster_content() .child( ScrollArea::new() @@ -71,6 +124,16 @@ impl Component for ClusterSettings { .height(Size::fill()) .scrollbar_gutter(true) .spacing(4.) + .child(section_header("INSTANCE")) + .child(instance_row) + .child( + DedicatedDirRow { + cluster_id, + dedicated: cluster.uses_dedicated_dir(), + locked: cluster.is_isolated(), + } + .into_element(), + ) .child(section_header("GAME")) .child( ToggleRow { @@ -98,24 +161,7 @@ impl Component for ClusterSettings { } .into_element(), ) - .child(section_header("LOADER")) - .child( - LoaderRow { - cluster_id, - loader, - selected: cluster.mc_loader_version.clone(), - versions, - } - .into_element(), - ) - .child(section_header("DIRECTORY")) - .child( - DedicatedDirRow { - cluster_id, - dedicated: cluster.uses_dedicated_dir(), - } - .into_element(), - ) + .append_children(loader_section) .child(section_header("SHORTCUT")) .child(ShortcutRow { cluster_id }.into_element()) .child(section_header("JAVA")) @@ -129,6 +175,7 @@ impl Component for ClusterSettings { .into_element(), ) .child(text_row(cluster_id, TextField::JvmArgs, &profile, &global)) + .child(text_row(cluster_id, TextField::GameArgs, &profile, &global)) .child(section_header("PROCESS")) .child(text_row(cluster_id, TextField::Pre, &profile, &global)) .child(text_row(cluster_id, TextField::Wrapper, &profile, &global)) @@ -142,6 +189,8 @@ impl Component for ClusterSettings { } .into_element(), ) + .append_children(migrate_row) + .append_children(modpack_section) .child(section_header("REPAIR")) .child(VerifyFilesRow { cluster_id }.into_element()), ) @@ -262,6 +311,65 @@ impl Component for ToggleRow { } } +#[derive(PartialEq)] +struct InstanceRow { + editable: bool, + facts: InstanceFacts, +} + +impl Component for InstanceRow { + fn render(&self) -> impl IntoElement { + let mut editing = use_state(|| false); + let mut deleting = use_state(|| false); + let cluster_id = self.facts.cluster_id; + let name = self.facts.name.clone(); + let facts = self.facts.clone(); + + let editable = self.editable; + let description = if editable { + "Change this instance's name, description, tags and cover image, or remove it from your list." + } else { + "Remove this version from your list." + }; + + let buttons = rect() + .horizontal() + .spacing(8.) + .maybe_child(editable.then(|| { + Button::new() + .small() + .secondary() + .on_press(move |_| editing.set(true)) + .text("Edit") + .into_element() + })) + .child( + Button::new() + .small() + .danger() + .on_press(move |_| deleting.set(true)) + .text("Delete"), + ); + + rect() + .vertical() + .width(Size::fill()) + .child(settings_row( + IconType::Pencil01, + "Instance Details", + description, + buttons, + )) + .maybe_child(editing.read().then(|| { + EditInstanceModal::new(facts.clone(), move |()| editing.set(false)).into_element() + })) + .maybe_child(deleting.read().then(|| { + DeleteInstanceModal::new(cluster_id, name.clone(), move |()| deleting.set(false)) + .into_element() + })) + } +} + #[derive(PartialEq)] struct ShortcutRow { cluster_id: i64, @@ -309,7 +417,11 @@ impl Component for VerifyFilesRow { .secondary() .enabled(!running) .maybe(!running, |el| el.on_press(on_press)) - .text(if running { "Verifying..." } else { "Verify Files" }); + .text(if running { + "Verifying..." + } else { + "Verify Files" + }); settings_row( IconType::ClipboardCheck, @@ -325,6 +437,7 @@ impl Component for VerifyFilesRow { struct DedicatedDirRow { cluster_id: i64, dedicated: bool, + locked: bool, } impl Component for DedicatedDirRow { @@ -333,6 +446,16 @@ impl Component for DedicatedDirRow { let dedicated = self.dedicated; let mutation = use_cluster_mutation(); + if self.locked { + return settings_row_disabled( + IconType::Folder, + "Dedicated Directory", + "Vanilla and modded instances always run in their own folder, so their worlds, settings and packs stay separate.", + toggle_controlled(true, (|()| {}).into()), + ) + .into_element(); + } + let on_toggle: EventHandler<()> = (move |()| { mutation.mutate(ClusterAction::SetDedicatedDir { cluster_id, @@ -347,6 +470,7 @@ impl Component for DedicatedDirRow { "Run this cluster in its own .minecraft instead of the shared one.", toggle_controlled(dedicated, on_toggle), ) + .into_element() } } @@ -506,6 +630,7 @@ impl Component for ResolutionRow { #[derive(Clone, Copy, PartialEq)] enum TextField { JvmArgs, + GameArgs, Pre, Wrapper, Post, @@ -520,6 +645,12 @@ impl TextField { "Extra arguments passed to Java. Separate them with spaces; quote values containing spaces.", "-XX:+UseG1GC", ), + Self::GameArgs => ( + IconType::Play, + "Game Arguments", + "Extra arguments passed to Minecraft. Separate them with spaces; quote values containing spaces.", + "--tracy --tracyNoImages", + ), Self::Pre => ( IconType::FilePlus02, "Pre-Launch Command", @@ -544,6 +675,7 @@ impl TextField { fn value(self, profile: &GameSettingsProfile) -> Option { match self { Self::JvmArgs => profile.launch_args.clone(), + Self::GameArgs => profile.game_args.clone(), Self::Pre => profile.hook_pre.clone(), Self::Wrapper => profile.hook_wrapper.clone(), Self::Post => profile.hook_post.clone(), @@ -554,6 +686,7 @@ impl TextField { let mut u = ProfileUpdate::default(); match self { Self::JvmArgs => u.launch_args = value, + Self::GameArgs => u.game_args = value, Self::Pre => u.hook_pre = value, Self::Wrapper => u.hook_wrapper = value, Self::Post => u.hook_post = value, @@ -625,6 +758,7 @@ impl Component for TextRow { let control = TextInput::new(text) .placeholder(placeholder) + .expandable(true) .width(Size::px(220.)); settings_row( @@ -756,12 +890,116 @@ impl Component for BrowserUpdateModeRow { } } +#[derive(PartialEq)] +struct MigrateFromRow { + cluster_id: i64, +} + +impl Component for MigrateFromRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let dispatch = use_dispatch(); + let checking = use_release_migration_checking(cluster_id); + let game = use_game_snapshot(); + let clusters = settled_or_loading(&use_clusters()).unwrap_or_default(); + let mut picked = use_state(|| None::<(i64, i64)>); + + let mut routes: Vec<(String, i64, i64)> = Vec::new(); + if let Some(this) = clusters.iter().find(|cluster| cluster.id == cluster_id) { + routes.extend( + rank_migration_sources(this, &clusters) + .into_iter() + .map(|source| (format!("From {}", source.name), cluster_id, source.id)), + ); + routes.extend( + clusters + .iter() + .filter(|other| { + other.id != cluster_id + && can_migrate_manually(this.mc_loader, other.mc_loader) + }) + .map(|target| (format!("To {}", target.name), target.id, cluster_id)), + ); + } + let has_partners = !routes.is_empty(); + let migratable = + use_migratable_routes(routes.iter().map(|route| (route.1, route.2)).collect()); + routes.retain(|route| migratable.contains(&(route.1, route.2))); + + let selected = picked + .read() + .and_then(|pair| routes.iter().find(|route| (route.1, route.2) == pair)) + .or_else(|| routes.first()) + .cloned(); + let selected_pair = selected.as_ref().map(|route| (route.1, route.2)); + let running = selected_pair + .is_some_and(|(target, source)| game.is_running(target) || game.is_running(source)); + + let mut button = Button::new() + .secondary() + .enabled(selected_pair.is_some() && !checking && !running) + .on_press(move |_| { + if let Some((target, source)) = selected_pair { + dispatch.open_manual_migration(target, source); + } + }); + button = if checking { + button + .child(Icon::new(IconType::Loading02).size(14.)) + .text("Checking…") + } else { + button + .child(Icon::new(IconType::Copy01).size(14.)) + .text("Migrate...") + }; + if running { + button = button.tooltip("Close the game before migrating"); + } + + let picker: Element = if routes.is_empty() { + label() + .text(if has_partners { + "Nothing installed from the browser left to copy" + } else { + "No other clusters with this loader" + }) + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + } else { + let pairs: Vec<(i64, i64)> = routes.iter().map(|route| (route.1, route.2)).collect(); + let names: Vec = routes.iter().map(|route| route.0.clone()).collect(); + Dropdown::new(selected.map(|route| route.0).unwrap_or_default(), names) + .width(Size::px(220.)) + .height(Size::px(34.)) + .on_select(move |index: usize| { + if let Some(pair) = pairs.get(index) { + picked.set(Some(*pair)); + } + }) + .into_element() + }; + + settings_row( + IconType::Copy01, + "Migrate", + "Copy mods, resource packs and shaders you installed from the browser to or from another cluster.", + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child(picker) + .child(button), + ) + } +} + #[derive(PartialEq)] struct LoaderRow { cluster_id: i64, loader: GameLoader, selected: Option, - versions: Vec, + versions: Arc<[String]>, } impl Component for LoaderRow { @@ -781,7 +1019,7 @@ impl Component for LoaderRow { .color(colors::fg_secondary()) .into_element() } else { - let options = versions.clone(); + let options = versions.to_vec(); Dropdown::new(selected, options.clone()) .width(Size::px(220.)) .height(Size::px(34.)) @@ -802,5 +1040,6 @@ impl Component for LoaderRow { ), control, ) + .into_element() } } diff --git a/packages/oneclient_app/src/view/app/cluster/datapacks.rs b/packages/oneclient_app/src/view/app/cluster/datapacks.rs new file mode 100644 index 00000000..e93777df --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/datapacks.rs @@ -0,0 +1,312 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::{DataPackInfo, PackIcon}; + +use crate::components::{Button, CardLayout, ContextMenu, Dropdown, Icon, IconType}; +use crate::hooks::{ + add_world_datapacks, delete_world_datapack, query_is_loading, spawn_world_task, + try_cluster_worlds, try_world_datapacks, use_cluster, use_cluster_worlds, use_datapack_world, + use_dispatch, use_view_state, use_world_datapacks, +}; +use crate::layout::cluster_content; +use crate::theme::colors; +use crate::{Actions, Route}; + +use super::cluster_not_found; +use super::folder_list::{ + CardIcon, FolderCard, PACKAGE_ROWS, confirm_dialog, content_box, folder_button, layout_toggle, + matches_search, notify_in_use, search_input, toolbar_action, toolbar_panel, + use_game_folder_in_use, +}; +use super::package_manager::{empty_hint, empty_shell, empty_title}; + +const WORLD_PICKER_W: f32 = 220.; + +#[derive(PartialEq)] +pub struct ClusterDataPacks { + pub cluster_id: i64, +} + +fn pick_and_add(cluster_id: i64, world: String, dispatch: Actions) { + spawn(async move { + let Some(handles) = rfd::AsyncFileDialog::new() + .set_title("Select data packs to add") + .add_filter("Data pack", &["zip"]) + .pick_files() + .await + else { + return; + }; + let files = handles.iter().map(|h| h.path().to_path_buf()).collect(); + spawn_world_task( + dispatch, + "Couldn't add data packs", + add_world_datapacks(cluster_id, world, files), + ); + }); +} + +fn browse(cluster_id: i64, world: String, mut remembered: State>) { + remembered.write().insert(cluster_id, world); + let _ = RouterContext::get().push(Route::Browser { + cluster_id, + package_type: "datapack".to_string(), + pick_cluster: false, + }); +} + +impl Component for ClusterDataPacks { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let cluster = use_cluster(cluster_id); + + let worlds_query = use_cluster_worlds(cluster_id); + let worlds = try_cluster_worlds(&worlds_query).unwrap_or_default(); + let names: Vec = worlds.iter().map(|w| w.folder_name.clone()).collect(); + + let mut remembered = use_datapack_world(); + let current = remembered + .read() + .get(&cluster_id) + .filter(|name| names.contains(name)) + .cloned() + .or_else(|| names.first().cloned()); + + let packs_query = use_world_datapacks(cluster_id, current.clone().unwrap_or_default()); + let dispatch = use_dispatch(); + let in_use = use_game_folder_in_use(cluster.as_ref()); + let search = use_state(String::new); + let layout = use_view_state("cluster.datapacks").layout; + let mut menu = use_state(|| None::<(f32, f32, DataPackInfo)>); + let mut pending_delete = use_state(|| None::); + + let Some(cluster) = cluster else { + return cluster_not_found(); + }; + let card_layout = CardLayout::from(*layout.read()); + + let Some(world) = current else { + let empty = (!query_is_loading(&worlds_query)).then(|| { + empty_shell(IconType::Globe01) + .child(empty_title("No worlds yet.")) + .child(empty_hint( + "Data packs live inside worlds, so create a world in game first.", + )) + .into_element() + }); + return cluster_content() + .child(toolbar_panel(None, vec![layout_toggle(layout)])) + .child(content_box( + 0, + card_layout, + PACKAGE_ROWS, + |_| rect().into_element(), + empty, + Vec::new(), + )) + .into_element(); + }; + + let folder: Option = cluster + .game_dir() + .ok() + .map(|d| d.join("saves").join(&world).join("datapacks")); + + let all = try_world_datapacks(&packs_query).unwrap_or_default(); + let needle = search.read().trim().to_lowercase(); + let packs: Vec = all + .iter() + .filter(|p| { + matches_search( + &needle, + &[&p.file_name, p.description.as_deref().unwrap_or_default()], + ) + }) + .cloned() + .collect(); + + let row = { + let packs = packs.clone(); + move |i: usize| { + let info = packs[i].clone(); + let menu_info = info.clone(); + FolderCard { + icon: match info.icon.clone() { + Some(PackIcon::File(path)) => CardIcon::Image(path), + Some(PackIcon::Cached(url)) => CardIcon::Cached(url, IconType::Database01), + None => CardIcon::Symbol(IconType::Database01), + }, + title: info.file_name.clone(), + badge: if info.is_dir { + (IconType::Folder, "Folder".to_string()) + } else { + (IconType::File02, "Zip".to_string()) + }, + description: info.description.clone(), + size: info.size_bytes, + layout: card_layout, + on_context: (move |(x, y)| menu.set(Some((x, y, menu_info.clone())))).into(), + } + .into_element() + } + }; + + let empty = if query_is_loading(&packs_query) { + None + } else if all.is_empty() { + let add_world = world.clone(); + let add_dispatch = dispatch.clone(); + Some( + empty_shell(IconType::FilePlus02) + .child(empty_title("No data packs in this world yet.")) + .child(empty_hint( + "Data packs only apply to the world they are added to.", + )) + .child(rect().height(Size::px(6.))) + .child( + rect() + .horizontal() + .spacing(8.) + .child({ + let browse_world = add_world.clone(); + Button::new() + .primary() + .on_press(move |_| { + browse(cluster_id, browse_world.clone(), remembered) + }) + .child(Icon::new(IconType::SearchMd).size(14.)) + .text("Browse Content") + }) + .child( + Button::new() + .secondary() + .on_press(move |_| { + pick_and_add( + cluster_id, + add_world.clone(), + add_dispatch.clone(), + ) + }) + .child(Icon::new(IconType::FilePlus02).size(14.)) + .text("Add from file"), + ), + ) + .into_element(), + ) + } else { + Some( + empty_shell(IconType::SearchMd) + .child(empty_title("No data packs match your search.")) + .child(empty_hint("Try a different term.")) + .into_element(), + ) + }; + + let picker_names = names.clone(); + let picker = Dropdown::new(world.clone(), names) + .width(Size::px(WORLD_PICKER_W)) + .height(Size::px(34.)) + .leading( + Icon::new(IconType::Globe01) + .size(14.) + .color(colors::fg_secondary()), + ) + .on_select(move |idx: usize| { + if let Some(name) = picker_names.get(idx) { + remembered.write().insert(cluster_id, name.clone()); + } + }) + .into_element(); + + let add_world = world.clone(); + let add_dispatch = dispatch.clone(); + let mut controls = vec![search_input(search)]; + controls.extend(folder.clone().map(folder_button)); + controls.push(layout_toggle(layout)); + let browse_world = world.clone(); + controls.push( + toolbar_action(IconType::SearchMd, "Browse") + .on_press(move |_| browse(cluster_id, browse_world.clone(), remembered)) + .into_element(), + ); + controls.push( + toolbar_action(IconType::Plus, "Add Data Packs") + .on_press(move |_| { + pick_and_add(cluster_id, add_world.clone(), add_dispatch.clone()) + }) + .into_element(), + ); + + let menu_overlay = menu.read().clone().map(|(x, y, info)| { + let delete_name = info.file_name.clone(); + let reveal = folder.clone(); + ContextMenu::new(x, y) + .title(info.file_name.clone()) + .action(IconType::Folder, "Open folder", move |()| { + if let Some(dir) = &reveal { + crate::platform::open_path(&dir.to_string_lossy()); + } + }) + .separator() + .danger_action(IconType::Trash01, "Delete", { + let dispatch = dispatch.clone(); + move |()| { + if in_use { + notify_in_use(&dispatch, "Data packs"); + } else { + pending_delete.set(Some(delete_name.clone())); + } + } + }) + .on_close(move |_| menu.set(None)) + .into_element() + }); + + let confirm_overlay = pending_delete.read().clone().map(|file_name| { + let delete_world = world.clone(); + confirm_dialog( + format!("Move \"{file_name}\" to trash?"), + format!( + "It is removed from \"{world}\" only and can be restored from your system trash." + ), + move || pending_delete.set(None), + { + let dispatch = dispatch.clone(); + move || { + pending_delete.set(None); + if in_use { + notify_in_use(&dispatch, "Data packs"); + return; + } + spawn_world_task( + dispatch.clone(), + "Couldn't delete data pack", + delete_world_datapack( + cluster_id, + delete_world.clone(), + file_name.clone(), + ), + ); + } + }, + ) + }); + + cluster_content() + .child(toolbar_panel(Some(picker), controls)) + .child(content_box( + packs.len(), + card_layout, + PACKAGE_ROWS, + row, + empty, + Vec::new(), + )) + .maybe_child(menu_overlay) + .maybe_child(confirm_overlay) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/folder_list.rs b/packages/oneclient_app/src/view/app/cluster/folder_list.rs new file mode 100644 index 00000000..44f221c4 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/folder_list.rs @@ -0,0 +1,501 @@ +use std::path::PathBuf; + +use freya::prelude::*; +use oneclient_common::parse_mc_version; +use oneclient_core::clusters::Cluster; +use oneclient_core::settings::ViewLayout; + +use crate::components::{ + Button, CARD_BG, CARD_GRID_H, CARD_H, CARD_NAME, CardLayout, GRID_GAP, GRID_MIN_W, Icon, + IconType, LocalImage, OverlayPopup, ScrollArea, Segment, SegmentedControl, TextInput, badge, + icon_box, kebab_button, meta_size, meta_text, on_secondary, open_folder_button, +}; +use crate::hooks::{ + loaded_image, settled_or_loading, use_cached_image, use_clusters, use_game_snapshot, +}; +use crate::theme::colors; +use crate::ui::{ImageFallbackExt, border_all_color}; + +use super::package_manager::{CARD_SPACING, GRID_MAX_COLS, notice_bar}; + +const CONTROL_H: f32 = 34.; +const SEARCH_W: f32 = 180.; +const IMAGE_EDGE: u32 = 128; +const FIRST_DATAPACK_MAJOR: u32 = 13; + +#[derive(Clone, Copy)] +pub(super) struct RowHeights { + pub list: f32, + pub grid: f32, +} + +pub(super) const PACKAGE_ROWS: RowHeights = RowHeights { + list: CARD_H, + grid: CARD_GRID_H, +}; + +pub(crate) fn supports_datapacks(mc_version: &str) -> bool { + parse_mc_version(mc_version).is_none_or(|p| p.major >= FIRST_DATAPACK_MAJOR) +} + +pub(super) fn use_game_folder_in_use(cluster: Option<&Cluster>) -> bool { + let game = use_game_snapshot(); + let clusters = settled_or_loading(&use_clusters()).unwrap_or_default(); + + cluster.is_some_and(|cluster| { + game.is_active(cluster.id) + || (!cluster.uses_dedicated_dir() + && clusters + .iter() + .any(|c| game.is_active(c.id) && !c.uses_dedicated_dir())) + }) +} + +pub(super) fn notify_in_use(dispatch: &crate::Actions, what: &str) { + dispatch + .notify("Close Minecraft first") + .body(format!( + "{what} can't be deleted while a version using this game folder is running." + )) + .error() + .send(); +} + +pub(super) fn matches_search(needle: &str, fields: &[&str]) -> bool { + needle.is_empty() || fields.iter().any(|f| f.to_lowercase().contains(needle)) +} + +pub(super) fn toolbar_panel(leading: Option, controls: Vec) -> Element { + let mut top_corners = CornerRadius::new_all(0.); + top_corners.fill_top(12.); + + rect() + .vertical() + .width(Size::fill()) + .overflow(Overflow::Clip) + .padding(Gaps::new_symmetric(8., 12.)) + .corner_radius(top_corners) + .background(colors::page_elevated()) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .content(Content::Flex) + .child( + rect() + .horizontal() + .width(Size::flex(1.0)) + .height(Size::px(CONTROL_H)) + .cross_align(Alignment::Center) + .maybe_child(leading), + ) + .children(controls), + ) + .into_element() +} + +pub(super) fn search_input(search: State) -> Element { + TextInput::new(search) + .placeholder("Search...") + .width(Size::px(SEARCH_W)) + .leading( + Icon::new(IconType::SearchMd) + .size(14.) + .color(colors::fg_secondary()) + .into_element(), + ) + .into_element() +} + +pub(super) fn folder_button(folder: PathBuf) -> Element { + open_folder_button(folder) + .width(Size::px(CONTROL_H)) + .height(Size::px(CONTROL_H)) + .into_element() +} + +pub(super) fn layout_toggle(layout: State) -> Element { + SegmentedControl::new(layout) + .height(CONTROL_H) + .icon_size(15.) + .equal_width(CONTROL_H) + .segment(Segment::new(ViewLayout::List).icon(IconType::ParagraphWrap)) + .segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)) + .into_element() +} + +pub(super) fn toolbar_action(icon: IconType, text: &str) -> Button { + Button::new() + .primary() + .height(Size::px(CONTROL_H)) + .font_size(12.) + .child(Icon::new(icon).size(15.)) + .text(text.to_string()) +} + +pub(super) fn content_box( + count: usize, + layout: CardLayout, + heights: RowHeights, + row: impl Fn(usize) -> Element + 'static, + empty: Option, + notices: Vec, +) -> Element { + let scroll = (count > 0).then(|| { + let area = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .scrollbar_gutter(true); + match layout { + CardLayout::List => area.lazy(count, heights.list, CARD_SPACING, row), + CardLayout::Grid => area.lazy_grid( + count, + heights.grid, + GRID_GAP, + GRID_MIN_W, + GRID_MAX_COLS, + row, + ), + } + .into_element() + }); + + let mut bottom_corners = CornerRadius::new_all(0.); + bottom_corners.fill_bottom(12.); + + let empty = if count == 0 { empty } else { None }; + + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new(0., 12., 12., 12.)) + .corner_radius(bottom_corners) + .background(colors::page_elevated()) + .overflow(Overflow::Clip) + .content(Content::Flex) + .children(notices.into_iter().map(notice_bar)) + .maybe_child(scroll) + .maybe_child(empty.map(|empty| { + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .child(empty) + .into_element() + })) + .into_element() +} + +#[derive(Clone, PartialEq)] +pub(super) enum CardIcon { + Image(PathBuf), + Cached(String, IconType), + Symbol(IconType), +} + +#[derive(PartialEq)] +struct CachedIcon { + url: String, + fallback: IconType, + size: f32, +} + +impl Component for CachedIcon { + fn render(&self) -> impl IntoElement { + let query = use_cached_image(Some(self.url.clone()), IMAGE_EDGE); + + match loaded_image(Some(&self.url), &query) { + Some(loaded) => ImageViewer::new(loaded) + .width(Size::px(self.size)) + .height(Size::px(self.size)) + .aspect_ratio(AspectRatio::Min) + .corner_radius(CornerRadius::new_all(8.)) + .fallback(icon_box(self.fallback, self.size)) + .into_element(), + None => icon_box(self.fallback, self.size), + } + } +} + +#[derive(PartialEq)] +pub(super) struct FolderCard { + pub icon: CardIcon, + pub title: String, + pub badge: (IconType, String), + pub description: Option, + pub size: u64, + pub layout: CardLayout, + pub on_context: EventHandler<(f32, f32)>, +} + +impl Component for FolderCard { + fn render(&self) -> impl IntoElement { + let mut hovered = use_state(|| false); + + let icon = card_icon( + &self.icon, + match self.layout { + CardLayout::List => 44., + CardLayout::Grid => 40., + }, + ); + let on_context = Some(self.on_context.clone()); + + match self.layout { + CardLayout::List => rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(CARD_H)) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_all(10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(CARD_BG) + .content(Content::Flex) + .on_secondary_down(on_secondary(on_context.clone())) + .child(self.list_info(icon)) + .child(meta_size(self.size)) + .child(kebab_button(self.on_context.clone())) + .into_element(), + CardLayout::Grid => { + let hovering = *hovered.read(); + let muted = CARD_NAME.with_a(127); + + let header = rect() + .horizontal() + .width(Size::fill()) + .spacing(11.) + .content(Content::Flex) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(self.title.clone()) + .font_size(14.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .width(Size::fill()) + .color(Color::WHITE), + ) + .child(meta_text(self.grid_meta(), muted)), + ) + .child(kebab_button(self.on_context.clone())); + + let description = self.description.clone().map(|text| { + label() + .text(text) + .font_size(11.) + .line_height(1.45) + .max_lines(2) + .text_overflow(TextOverflow::Ellipsis) + .width(Size::fill()) + .color(colors::fg_primary().with_a(183)) + .into_element() + }); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .spacing(9.) + .padding(Gaps::new_all(14.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if hovering { + colors::component_bg_hover() + } else { + colors::component_bg() + }) + .border(border_all_color( + 1., + if hovering { + colors::component_border_hover() + } else { + colors::component_border() + }, + )) + .overflow(Overflow::Clip) + .content(Content::Flex) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_secondary_down(on_secondary(on_context)) + .child(header) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .maybe_child(description), + ) + .into_element() + } + } + } +} + +impl FolderCard { + fn grid_meta(&self) -> String { + let mut parts = vec![self.badge.1.clone()]; + if self.size > 0 { + parts.push(crate::utils::format_size(self.size)); + } + parts.join(" \u{b7} ") + } + + fn list_info(&self, icon: Element) -> Element { + rect() + .horizontal() + .width(Size::flex(1.0)) + .cross_align(Alignment::Center) + .spacing(12.) + .content(Content::Flex) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .child( + label() + .text(self.title.clone()) + .font_size(15.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .max_width(Size::percent(60.)) + .color(CARD_NAME), + ) + .child(badge( + Icon::new(self.badge.0) + .size(12.) + .color(colors::fg_secondary()) + .into_element(), + self.badge.1.clone(), + )), + ) + .maybe_child(self.description.clone().map(|text| { + label() + .text(text) + .font_size(11.) + .max_lines(2) + .text_overflow(TextOverflow::Ellipsis) + .width(Size::fill()) + .color(colors::fg_secondary()) + .into_element() + })), + ) + .into_element() + } +} + +pub(super) fn card_icon(icon: &CardIcon, size: f32) -> Element { + match icon { + CardIcon::Image(path) => rect() + .width(Size::px(size)) + .height(Size::px(size)) + .corner_radius(CornerRadius::new_all(8.)) + .overflow(Overflow::Clip) + .background(colors::component_bg()) + .child(LocalImage::new(path.clone(), IMAGE_EDGE, true).skeleton(true)) + .into_element(), + CardIcon::Cached(url, fallback) => CachedIcon { + url: url.clone(), + fallback: *fallback, + size, + } + .into_element(), + CardIcon::Symbol(symbol) => icon_box(*symbol, size), + } +} + +pub(super) fn confirm_dialog( + title: String, + body: String, + on_cancel: impl FnMut() + Clone + 'static, + mut on_confirm: impl FnMut() + 'static, +) -> Element { + let mut cancel = on_cancel.clone(); + + dialog( + title, + body, + None, + on_cancel, + [ + Button::new() + .secondary() + .on_press(move |_| cancel()) + .text("Cancel") + .into_element(), + Button::new() + .danger() + .on_press(move |_| on_confirm()) + .child(Icon::new(IconType::Trash01).size(14.)) + .text("Delete") + .into_element(), + ], + ) +} + +pub(crate) fn dialog( + title: String, + body: String, + control: Option, + mut on_close: impl FnMut() + 'static, + buttons: [Element; 2], +) -> Element { + OverlayPopup::new() + .on_close(move |_| on_close()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(400.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(title) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(body) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .maybe_child(control) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .children(buttons), + ), + ), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/cluster/logs.rs b/packages/oneclient_app/src/view/app/cluster/logs.rs index b21594d7..e276306f 100644 --- a/packages/oneclient_app/src/view/app/cluster/logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/logs.rs @@ -336,9 +336,7 @@ pub fn viewer_header( ) .into_element(), ) - .child( - UploadToMclogs::new(has_log, confirm) - ); + .child(UploadToMclogs::new(has_log, confirm)); rect() .vertical() diff --git a/packages/oneclient_app/src/view/app/cluster/mod.rs b/packages/oneclient_app/src/view/app/cluster/mod.rs index 94ae1ec8..1cd50416 100644 --- a/packages/oneclient_app/src/view/app/cluster/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/mod.rs @@ -3,21 +3,28 @@ mod overview; mod package_manager; mod cluster_settings; +mod datapacks; +mod folder_list; +mod modpack_settings; mod mods; mod process_logs; mod screenshots; mod shaders; mod textures; +mod worlds; pub use logs::ClusterLogs; pub use overview::ClusterOverview; pub use cluster_settings::ClusterSettings; +pub use datapacks::ClusterDataPacks; +pub(crate) use folder_list::{dialog, supports_datapacks}; pub use mods::ClusterMods; pub use process_logs::ProcessLogs; pub use screenshots::ClusterScreenshots; pub use shaders::ClusterShaders; pub use textures::ClusterTextures; +pub use worlds::ClusterWorlds; use freya::prelude::*; diff --git a/packages/oneclient_app/src/view/app/cluster/modpack_settings.rs b/packages/oneclient_app/src/view/app/cluster/modpack_settings.rs new file mode 100644 index 00000000..2958f953 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/modpack_settings.rs @@ -0,0 +1,169 @@ +use freya::prelude::*; +use oneclient_content::modpacks::{ + ModpackRelease, ModpackUpdateStatus, check_modpack_update, cluster_modpack, +}; + +use crate::components::{Button, IconType}; +use crate::hooks::{use_dispatch, use_installs_snapshot}; +use crate::launcher::{self, off_ui}; +use crate::view::app::settings::settings_row; + +#[derive(Clone, PartialEq)] +enum UpdateCheck { + Idle, + Checking, + UpToDate, + Available { version_id: String, label: String }, + Unpublished, + Failed(String), +} + +fn load_release(cluster_id: i64, mut current: State>) { + spawn(async move { + let Ok(state) = launcher::state() else { return }; + let content = state.services.content(); + let found = off_ui(async move { cluster_modpack(cluster_id, &content).await }).await; + match found { + Ok(release) => current.set(release), + Err(err) => tracing::debug!(cluster_id, error = %err, "could not identify the modpack"), + } + }); +} + +fn run_check(cluster_id: i64, mut check: State) { + check.set(UpdateCheck::Checking); + spawn(async move { + let Ok(state) = launcher::state() else { + check.set(UpdateCheck::Idle); + return; + }; + let content = state.services.content(); + let found = off_ui(async move { check_modpack_update(cluster_id, &content).await }).await; + + check.set(match found { + Ok(ModpackUpdateStatus::Unpublished) => UpdateCheck::Unpublished, + Ok(ModpackUpdateStatus::UpToDate) => UpdateCheck::UpToDate, + Ok(ModpackUpdateStatus::Available(latest)) => UpdateCheck::Available { + version_id: latest.version_id, + label: latest.name, + }, + Err(err) => UpdateCheck::Failed(err.to_string()), + }); + }); +} + +fn update_description(current: Option<&ModpackRelease>, check: &UpdateCheck) -> String { + let installed = current.map(|release| format!("Installed: {}. ", release.name)); + let status = match check { + UpdateCheck::Idle => "Check Modrinth or CurseForge for a newer version of this pack.".to_string(), + UpdateCheck::Checking => "Checking for a newer version...".to_string(), + UpdateCheck::UpToDate => "This is the newest version for this Minecraft version.".to_string(), + UpdateCheck::Available { label, .. } => { + format!("{label} is available. Mods you removed stay removed and configs you edited are kept.") + } + UpdateCheck::Unpublished => { + "This pack is not published on Modrinth or CurseForge, so it cannot be updated from here." + .to_string() + } + UpdateCheck::Failed(err) => format!("Could not check for updates: {err}"), + }; + format!("{}{status}", installed.unwrap_or_default()) +} + +#[derive(PartialEq)] +pub struct ModpackUpdateRow { + pub cluster_id: i64, +} + +impl Component for ModpackUpdateRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let dispatch = use_dispatch(); + let check = use_state(|| UpdateCheck::Idle); + let current = use_state(|| None::); + let busy = use_installs_snapshot().is_modpack_job(cluster_id); + + use_hook(move || load_release(cluster_id, current)); + + let mut was_busy = use_state(|| busy); + if *was_busy.peek() != busy { + was_busy.set(busy); + if !busy { + let mut check = check; + check.set(UpdateCheck::Idle); + load_release(cluster_id, current); + } + } + + let state = check.read().clone(); + let description = update_description(current.read().as_ref(), &state); + + let button = match (&state, busy) { + (_, true) => Button::new() + .small() + .secondary() + .enabled(false) + .text("Working..."), + (UpdateCheck::Available { version_id, .. }, false) => { + let version_id = version_id.clone(); + Button::new() + .small() + .primary() + .on_press(move |_| dispatch.update_modpack(cluster_id, version_id.clone())) + .text("Update") + } + (UpdateCheck::Checking, false) => Button::new() + .small() + .secondary() + .enabled(false) + .text("Checking..."), + (UpdateCheck::Unpublished, false) => Button::new() + .small() + .secondary() + .enabled(false) + .text("Check for updates"), + (_, false) => Button::new() + .small() + .secondary() + .on_press(move |_| run_check(cluster_id, check)) + .text("Check for updates"), + }; + + settings_row( + IconType::DownloadCloud02, + "Modpack Updates", + description, + button, + ) + } +} + +#[derive(PartialEq)] +pub struct ModpackRepairRow { + pub cluster_id: i64, +} + +impl Component for ModpackRepairRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let dispatch = use_dispatch(); + let busy = use_installs_snapshot().is_modpack_job(cluster_id); + + let button = Button::new() + .small() + .secondary() + .enabled(!busy) + .maybe(!busy, |el| { + el.on_press(move |_| dispatch.repair_modpack(cluster_id)) + }) + .text(if busy { "Working..." } else { "Reinstall" }); + + settings_row( + IconType::RefreshCw01, + "Reinstall Missing Files", + "Download anything from the pack that is missing and reopen the manual downloads for \ + CurseForge files. Files you removed stay removed.", + button, + ) + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/mods.rs b/packages/oneclient_app/src/view/app/cluster/mods.rs index 09ab2da0..c28950cd 100644 --- a/packages/oneclient_app/src/view/app/cluster/mods.rs +++ b/packages/oneclient_app/src/view/app/cluster/mods.rs @@ -1,16 +1,20 @@ +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::PathBuf; + use freya::prelude::*; use oneclient_content::packages::ContentType; use crate::hooks::{ bundle_overrides_map, bundles_with_status_items, cluster_content_items, stale_hashes, - use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_updates, + use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_mods_folder_sync, + use_package_updates, use_shadowed_mods, }; use crate::layout::cluster_content; +use super::cluster_not_found; use super::package_manager::{ PackageManager, bundle_categories, bundle_packages, use_content_meta, }; -use super::cluster_not_found; use crate::hooks::use_cluster; #[derive(PartialEq)] @@ -24,16 +28,17 @@ impl Component for ClusterMods { let bundles = use_bundles_with_status(self.cluster_id); let overrides = use_bundle_overrides(self.cluster_id); let updates = use_package_updates(self.cluster_id); + let shadowed = use_shadowed_mods(self.cluster_id); let bundle_items = bundles_with_status_items(&bundles); let content_items = cluster_content_items(&content); let meta = use_content_meta(&content_items, &bundle_items, ContentType::Mod); - let Some(_cluster) = use_cluster(self.cluster_id) else { + let Some(cluster) = use_cluster(self.cluster_id) else { return cluster_not_found(); }; let all_categories = bundle_categories(&bundle_items); - let items = bundle_packages( + let mut items = bundle_packages( content_items, &bundle_items, &bundle_overrides_map(&overrides), @@ -41,8 +46,19 @@ impl Component for ClusterMods { &stale_hashes(&updates), ContentType::Mod, ); + for item in &mut items { + let Some(hash) = &item.hash else { + continue; + }; + item.shadowed = shadowed.shadowed.contains(hash); + item.outranked = shadowed.outranked.contains(hash); + } cluster_content() + .child(ModsFolderSync { + cluster_id: self.cluster_id, + folder: oneclient_common::paths::cluster_mods_dir(&cluster.folder_name).ok(), + }) .child( PackageManager::new( "Mods", @@ -58,3 +74,22 @@ impl Component for ClusterMods { .into_element() } } + +#[derive(PartialEq)] +struct ModsFolderSync { + cluster_id: i64, + folder: Option, +} + +impl Component for ModsFolderSync { + fn render(&self) -> impl IntoElement { + use_mods_folder_sync(self.cluster_id, self.folder.clone()); + rect() + } + + fn render_key(&self) -> DiffKey { + let mut hasher = DefaultHasher::new(); + (self.cluster_id, &self.folder).hash(&mut hasher); + DiffKey::U64(hasher.finish()) + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/overview.rs b/packages/oneclient_app/src/view/app/cluster/overview.rs index 160616e9..dfa6286c 100644 --- a/packages/oneclient_app/src/view/app/cluster/overview.rs +++ b/packages/oneclient_app/src/view/app/cluster/overview.rs @@ -4,7 +4,7 @@ use crate::components::ScrollArea; use crate::hooks::{try_cluster_analytics, use_cluster_analytics}; use crate::layout::cluster_content; use crate::theme::colors; -use crate::ui::{centered_note}; +use crate::ui::centered_note; use crate::view::app::{analytics_body, analytics_placeholder}; use super::cluster_not_found; @@ -19,11 +19,12 @@ impl Component for ClusterOverview { fn render(&self) -> impl IntoElement { let cluster_id = self.cluster_id; + let query = use_cluster_analytics(cluster_id); + let Some(cluster) = use_cluster(cluster_id) else { return cluster_content().child(cluster_not_found()).into_element(); }; - let query = use_cluster_analytics(cluster_id); let analytics = try_cluster_analytics(&query); let body: Element = match &analytics { diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs index 6119a953..b2606897 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -3,18 +3,33 @@ use std::collections::{HashMap, HashSet}; use freya::prelude::*; use oneclient_common::search::{MatchScore, SearchQuery}; use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId}; -use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; +use oneclient_core::{ + BundleFileKind, BundleFileType, BundleWithUpdateStatus, FileUpdateStatus, LinkedArtifactInfo, +}; use oneclient_db::models::OverrideType; -use crate::components::{CARD_GRID_H, CardLayout, GRID_GAP, GRID_MIN_W, PackageEntry}; -use crate::hooks::{package_meta_batch, use_game_snapshot, use_package_meta_batch, use_view_state}; +use crate::components::{ + CARD_GRID_H, CardLayout, GRID_GAP, GRID_MIN_W, PackageEntry, disable_warning_body, + toggle_action, +}; +use crate::hooks::{ + ClusterAction, EssentialGuardKind, PendingEssential, disable_warnings, package_meta_batch, + use_cluster_mutation, use_disable_warnings, use_essential_guard, use_game_snapshot, + use_package_meta_batch, use_selection, use_view_state, +}; + +use super::folder_list::confirm_dialog; mod views; -use views::{ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, toolbar_bar}; +use views::{ + AdvancedSection, Bulk, ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, + toolbar_bar, +}; +pub(super) use views::{empty_hint, empty_shell, empty_title, notice_bar}; const CARD_H: f32 = 84.; -const CARD_SPACING: f32 = 8.; -const LAZY_OVERSCAN: i64 = 2; +pub(super) const CARD_SPACING: f32 = 8.; +pub(super) const GRID_MAX_COLS: usize = 5; pub type PackageMetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; @@ -52,11 +67,22 @@ fn provider_project_ids( ids } -fn local_project_ids(content: &[LinkedArtifactInfo], content_type: ContentType) -> Vec { +fn local_project_ids( + content: &[LinkedArtifactInfo], + bundles: &[BundleWithUpdateStatus], + content_type: ContentType, +) -> Vec { + let bundled = bundles + .iter() + .flat_map(|bundle| &bundle.files) + .filter(|(file, _status)| file.content_type() == content_type) + .filter(|(file, _status)| matches!(file.kind, BundleFileKind::External { .. })) + .map(|(file, _status)| file.kind.metadata_id()); content .iter() .filter(|info| info.content_type == content_type && info.provider.is_none()) .map(|info| info.hash.clone()) + .chain(bundled) .collect() } @@ -76,7 +102,7 @@ pub fn use_content_meta( let local = use_package_meta_batch( ProviderId::Local, - local_project_ids(content, content_type), + local_project_ids(content, bundles, content_type), ); for (hash, meta) in package_meta_batch(&local) { out.insert((ProviderId::Local, hash), meta); @@ -106,21 +132,28 @@ pub fn bundle_packages( // Hidden is per-bundle so one bundle carrying a mod as a private dependency must not suppress a bundle that offers it openly let mut shown_elsewhere: HashSet = HashSet::new(); + let mut normal_elsewhere: HashSet = HashSet::new(); for bundle in bundles { for (file, _status) in &bundle.files { if !file.hidden && file.content_type() == content_type { shown_elsewhere.insert(file.kind.package_id()); + if file.file_type == BundleFileType::Normal { + normal_elsewhere.insert(file.kind.package_id()); + } } } } + let mut ordered: Vec<&BundleWithUpdateStatus> = bundles.iter().collect(); + ordered.sort_by_key(|b| !b.opted_in_types.contains(&content_type)); + let mut rows = Vec::new(); let mut seen: HashSet = HashSet::new(); - for bundle in bundles { + for bundle in ordered { let bundle_name = &bundle.archive.manifest.name; let category = bundle.archive.manifest.category.clone(); - for (file, _status) in &bundle.files { + for (file, status) in &bundle.files { if file.content_type() != content_type { continue; } @@ -133,14 +166,24 @@ pub fn bundle_packages( continue; } - let provider = match &file.kind { - BundleFileKind::Managed { provider, .. } => *provider, - BundleFileKind::External(_) => ProviderId::Local, - }; - let installed_info = by_project - .get(pid.as_str()) - .or_else(|| by_hash.get(pid.as_str())) - .copied(); + let provider = file.kind.metadata_provider(); + let installed_info = match &file.kind { + BundleFileKind::Managed { .. } => by_project + .get(pid.as_str()) + .or_else(|| by_hash.get(pid.as_str())), + BundleFileKind::External { file: ext, .. } => { + let hash = match status { + FileUpdateStatus::UpdateAvailable { + installed_version_id, + .. + } => installed_version_id, + _ => &ext.sha1, + }; + seen.insert(hash.clone()); + by_hash.get(hash.as_str()) + } + } + .copied(); let ov = overrides .get(&(bundle_name.clone(), pid.clone())) .map(String::as_str); @@ -149,13 +192,19 @@ pub fn bundle_packages( None => oneclient_core::effective_enabled(file, ov.and_then(OverrideType::parse)), }; + let advanced = content_type == ContentType::Mod + && file.file_type == BundleFileType::Advanced + && !normal_elsewhere.contains(&pid); + let categories = if category.is_empty() { Vec::new() } else { vec![category.clone()] }; - rows.push(make_row( + let opted_in = + installed_info.is_some() || bundle.opted_in_types.contains(&content_type); + let mut row = make_row( pid, Some(bundle_name.clone()), provider, @@ -163,13 +212,18 @@ pub fn bundle_packages( categories, enabled, file.enabled, + file.is_github_hosted(), installed_info, - meta, + meta.get(&(provider, file.kind.metadata_id())), file.display_name(), false, // Flagged rather than dropped `HiddenFilter` filters on the row and the seen id stops the loose-content pass resurrecting it as a local file file.hidden, - )); + opted_in, + ); + row.advanced = advanced; + row.github_url = file.github_repo_url(); + rows.push(row); } } @@ -182,6 +236,7 @@ pub fn bundle_packages( let provider = info.provider.unwrap_or(ProviderId::Local); let pid = info.project_id.clone().unwrap_or_else(|| info.hash.clone()); let outdated = stale.contains(&info.hash); + let row_meta = meta.get(&(provider, pid.clone())); rows.push(make_row( pid, None, @@ -190,13 +245,15 @@ pub fn bundle_packages( Vec::new(), info.enabled, true, + false, Some(info), - meta, + row_meta, info.display_name .clone() .unwrap_or_else(|| info.file_name.clone()), outdated, false, + true, )); } @@ -212,13 +269,14 @@ fn make_row( categories: Vec, enabled: bool, manifest_default: bool, + github_hosted: bool, installed_info: Option<&LinkedArtifactInfo>, - meta: &PackageMetaMap, + m: Option<&CachedPackageMeta>, fallback_name: String, update_available: bool, hidden: bool, + opted_in: bool, ) -> PackageEntry { - let m = meta.get(&(provider, package_id.clone())); let name = m .map(|p| p.name.clone()) .filter(|s| !s.is_empty()) @@ -230,23 +288,23 @@ fn make_row( .map(|p| p.author.clone()) .filter(|s| !s.is_empty()) .unwrap_or_default(); + let version = installed_info.and_then(|i| i.display_version.clone()); let description = m .map(|p| p.summary.clone()) .filter(|s| !s.is_empty()) - .or_else(|| { - installed_info - .and_then(|i| i.display_version.clone()) - .map(|v| format!("Version {v}")) - }) .unwrap_or_default(); PackageEntry { + essential: crate::essential::lookup(provider, &package_id), package_id, bundle_name, provider, + github_hosted, + github_url: None, name, file_name, author, + version, description, icon_url: m.and_then(|p| p.icon_url.clone()), size, @@ -257,9 +315,11 @@ fn make_row( hash: installed_info.map(|i| i.hash.clone()), update_available, hidden, - seen_status: installed_info - .map(|i| i.seen_status) - .unwrap_or_default(), + opted_in, + shadowed: false, + outranked: false, + advanced: false, + seen_status: installed_info.map(|i| i.seen_status).unwrap_or_default(), } } @@ -276,17 +336,17 @@ impl Tab { match self { Tab::All => "All".to_string(), Tab::Category(c) => c.clone(), - Tab::Browser => "Browser".to_string(), + Tab::Browser => "Online".to_string(), Tab::Local => "Local".to_string(), } } pub(super) fn matches(&self, p: &PackageEntry) -> bool { match self { - Tab::All => true, + Tab::All => p.opted_in, Tab::Category(c) => p.categories.iter().any(|pc| pc == c), - Tab::Browser => p.is_remote() && !p.in_bundle(), - Tab::Local => !p.is_remote(), + Tab::Browser => (p.is_remote() || p.github_hosted) && !p.in_bundle(), + Tab::Local => !p.is_remote() && !p.github_hosted, } } } @@ -339,7 +399,7 @@ fn build_tabs(categories: &[String], items: &[PackageEntry], hidden: HiddenFilte } } - // Category tabs are hidden when empty All + Browser + Local are always shown + // Category tabs are hidden when empty All + Online + Local are always shown let mut tabs: Vec = vec![Tab::All]; tabs.extend( cats.into_iter() @@ -405,7 +465,10 @@ impl Component for PackageManager { .await { Ok(cleared) if cleared > 0 => { - tracing::debug!(cleared, "retired package badges after the list was viewed"); + tracing::debug!( + cleared, + "retired package badges after the list was viewed" + ); } Ok(_) => {} Err(err) => tracing::warn!(%err, "failed to retire package badges"), @@ -414,12 +477,25 @@ impl Component for PackageManager { }); let session_live = use_game_snapshot().is_active(cluster_id); + let cluster = crate::hooks::use_cluster(cluster_id); + let shares_content = cluster + .as_ref() + .map(|cluster| cluster.shares_content(content_type)); + let uses_bundles = cluster + .as_ref() + .is_none_or(|cluster| cluster.uses_bundles()); let active = use_state(|| 0usize); let search = use_state(String::new); let enabled_filter = use_state(|| EnabledFilter::All); let hidden_filter = use_state(|| HiddenFilter::Hide); + let advanced_open = use_state(|| false); let toolbar_width = use_state(|| 0f32); + let selection = use_selection::(); + let mutation = use_cluster_mutation(); + let mut guard = use_essential_guard(); + let warnings_query = use_disable_warnings(); + let mut confirm_delete = use_state(|| false); let view = use_view_state("cluster.packages"); let sort = view.sort; let layout = view.layout; @@ -434,6 +510,12 @@ impl Component for PackageManager { let hidden = *hidden_filter.read(); let card_layout = CardLayout::from(*layout.read()); + let disabled_essentials: Vec<&'static str> = items + .iter() + .filter(|package| !package.enabled) + .filter_map(|package| package.essential.map(|essential| essential.name)) + .collect(); + let tabs = build_tabs(&self.categories, &items, hidden); let active_idx = (*active.read()).min(tabs.len().saturating_sub(1)); let tab_filter = tabs.get(active_idx); @@ -461,7 +543,128 @@ impl Component for PackageManager { } }; - rect() + let mut notices = Vec::new(); + if content_type.is_global() + && let Some(shares_content) = shares_content + { + notices.push(if shares_content { + views::global_notice(noun_plural) + } else { + views::instance_only_notice(noun_plural) + }); + } + if session_live { + notices.push(views::running_notice(noun_plural, content_type)); + } + if !disabled_essentials.is_empty() { + notices.push(views::essential_notice(&disabled_essentials)); + } + + let (advanced, filtered): (Vec<_>, Vec<_>) = filtered.into_iter().partition(|p| p.advanced); + + let section = AdvancedSection { + open: advanced_open, + forced: !query.is_empty(), + }; + let shown: Vec<&PackageEntry> = filtered + .iter() + .chain(advanced.iter().filter(|_| section.expanded())) + .collect(); + let order: Vec = shown.iter().map(|p| p.package_id.clone()).collect(); + let chosen: Vec = shown + .into_iter() + .filter(|p| selection.is_selected(&p.package_id)) + .cloned() + .collect(); + let deletable: Vec = chosen + .iter() + .filter(|p| p.installed && !p.in_bundle()) + .filter_map(|p| p.hash.clone()) + .collect(); + + let set_enabled: EventHandler = { + let chosen = chosen.clone(); + let warnings = disable_warnings(&warnings_query); + (move |enabled: bool| { + let targets: Vec<&PackageEntry> = + chosen.iter().filter(|p| p.enabled != enabled).collect(); + let actions: Vec = targets + .iter() + .filter_map(|p| toggle_action(p, cluster_id, enabled)) + .collect(); + if actions.is_empty() { + return; + } + let action = ClusterAction::Batch(actions); + + let warned: Vec = targets + .iter() + .filter(|_| !enabled) + .filter_map(|p| { + disable_warning_body(p, warnings.clone()) + .map(|body| format!("**{}**\n\n{body}", p.name)) + }) + .collect(); + if warned.is_empty() { + mutation.mutate(action); + return; + } + guard.set(Some(PendingEssential { + name: match targets.as_slice() { + [only] => only.name.clone(), + _ => format!("{} {noun_plural}", targets.len()), + }, + body: warned.join("\n\n"), + kind: EssentialGuardKind::Disable, + action, + })); + }) + .into() + }; + + let bulk = Bulk { + selection, + order, + count: chosen.len(), + deletable: deletable.len(), + set_enabled, + delete: (move |()| confirm_delete.set(true)).into(), + }; + + let delete_dialog = confirm_delete.read().then(|| { + let count = deletable.len(); + let noun = if count == 1 { + package_type + } else { + noun_plural + }; + let shared = if shares_content == Some(true) { + format!(" Shared {noun_plural} are deleted from every cluster that uses them.") + } else { + String::new() + }; + confirm_dialog( + format!("Delete {count} {noun}?"), + format!("This can't be undone.{shared}"), + move || confirm_delete.set(false), + move || { + mutation.mutate(ClusterAction::Batch( + deletable + .iter() + .map(|hash| ClusterAction::RemoveArtifact { + cluster_id, + hash: hash.clone(), + }) + .collect(), + )); + selection.exit(); + confirm_delete.set(false); + }, + ) + }); + + selection + .track_modifiers(rect()) .vertical() .width(Size::fill()) .height(Size::fill()) @@ -474,25 +677,92 @@ impl Component for PackageManager { sort_mode, enabled_filter, hidden_filter, + uses_bundles, layout, cluster_id, package_type, toolbar_width, + &bulk, )) - .maybe_child( - content_type - .is_global() - .then(|| views::global_notice(noun_plural)), + .child( + ContentBox::new( + filtered, + advanced, + section, + noun_plural, + package_type, + content_type, + cluster_id, + content_kind, + card_layout, + bulk, + ) + .notices(notices), ) - .maybe_child(session_live.then(|| views::running_notice(noun_plural, content_type))) - .child(ContentBox::new( - filtered, - noun_plural, - package_type, - content_type, - cluster_id, - content_kind, - card_layout, - )) + .maybe_child(delete_dialog) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::{archive, file}; + use oneclient_core::{BundleFile, FileUpdateStatus}; + + fn bundle(category: &str, opted_in: bool, files: Vec) -> BundleWithUpdateStatus { + BundleWithUpdateStatus { + files: files + .iter() + .cloned() + .map(|f| (f, FileUpdateStatus::NotInstalled)) + .collect(), + archive: archive(category, true, files), + has_updates: false, + opted_in_types: if opted_in { + [ContentType::Mod].into() + } else { + HashSet::new() + }, + } + } + + #[test] + fn all_tab_skips_bundles_not_opted_into() { + let bundles = [ + bundle( + "Declined", + false, + vec![ + file("only-declined", true, false), + file("shared", true, false), + ], + ), + bundle( + "Taken", + true, + vec![file("shared", true, false), file("only-taken", true, false)], + ), + ]; + let rows = bundle_packages( + Vec::new(), + &bundles, + &HashMap::new(), + &PackageMetaMap::new(), + &HashSet::new(), + ContentType::Mod, + ); + let all: Vec<&str> = rows + .iter() + .filter(|p| Tab::All.matches(p)) + .map(|p| p.package_id.as_str()) + .collect(); + assert_eq!(all, ["shared", "only-taken"]); + let shared = rows.iter().find(|p| p.package_id == "shared").unwrap(); + assert_eq!(shared.categories, ["Taken"]); + let declined = Tab::Category("Declined".into()); + assert!( + rows.iter() + .any(|p| p.package_id == "only-declined" && declined.matches(p)) + ); } } diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index f64c2c1f..51548420 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -1,15 +1,15 @@ use super::*; -use freya::animation::{AnimNum, Ease, OnCreation, use_animation}; use freya::router::RouterContext; use oneclient_content::packages::ContentType; use oneclient_core::settings::ViewLayout; use crate::components::{ - Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, ScrollAreaCtx, - Segment, SegmentedControl, TabBar, TabItem, TextInput, + Button, CardLayout, ChevronToggle, ContextMenu, FilterMenu, FilterOption, Icon, IconType, + LazySection, PackageEntry, PackageRow, ScrollArea, Segment, SegmentedControl, TextInput, + package_context_menu, use_shared_delete, }; -use crate::hooks::{use_dispatch, use_overlay_claim}; +use crate::hooks::{ClusterAction, Selection, use_cluster_mutation, use_dispatch}; use crate::routes::Route; use crate::theme::colors; use crate::{Actions, utils}; @@ -120,8 +120,131 @@ impl HiddenFilter { } } -const FILTER_PANEL_W: f32 = 172.; -const FILTER_BTN_W: f32 = 34.; +#[derive(Clone, PartialEq)] +pub(super) struct Bulk { + pub(super) selection: Selection, + pub(super) order: Vec, + pub(super) count: usize, + pub(super) deletable: usize, + pub(super) set_enabled: EventHandler, + pub(super) delete: EventHandler<()>, +} + +impl Bulk { + pub(super) fn active(&self) -> bool { + self.selection.is_active() + } + + fn all_selected(&self) -> bool { + self.count > 0 && self.count == self.order.len() + } + + fn toggle_all_label(&self) -> (IconType, &'static str) { + if self.all_selected() { + (IconType::XClose, "Unselect all") + } else { + (IconType::Check, "Select all") + } + } + + fn controls(&self) -> Vec { + let selection = self.selection; + let order = self.order.clone(); + let enable = self.set_enabled.clone(); + let disable = self.set_enabled.clone(); + let delete = self.delete.clone(); + let (toggle_icon, toggle_label) = self.toggle_all_label(); + let button = |icon: IconType, text: String| { + Button::new() + .secondary() + .height(Size::px(34.)) + .font_size(12.) + .child(Icon::new(icon).size(15.)) + .text(text) + }; + + vec![ + label() + .text(format!("{} selected", self.count)) + .font_size(12.) + .max_lines(1) + .color(colors::fg_secondary()) + .into_element(), + button(toggle_icon, toggle_label.to_string()) + .on_press(move |_| selection.toggle_all(&order)) + .into_element(), + button(IconType::CheckCircle, "Enable".to_string()) + .enabled(self.count > 0) + .on_press(move |_| enable.call(true)) + .into_element(), + button(IconType::Minus, "Disable".to_string()) + .enabled(self.count > 0) + .on_press(move |_| disable.call(false)) + .into_element(), + button(IconType::Trash01, format!("Delete ({})", self.deletable)) + .danger() + .enabled(self.deletable > 0) + .on_press(move |_| delete.call(())) + .into_element(), + Button::new() + .ghost() + .icon() + .height(Size::px(34.)) + .tooltip("Exit select mode") + .on_press(move |_| selection.exit()) + .child(Icon::new(IconType::XClose).size(15.)) + .into_element(), + ] + } + + fn menu(&self, x: f32, y: f32, key: String) -> ContextMenu { + let selection = self.selection; + let order = self.order.clone(); + let enable = self.set_enabled.clone(); + let disable = self.set_enabled.clone(); + let delete = self.delete.clone(); + let (toggle_icon, toggle_label) = self.toggle_all_label(); + + let (icon, text) = if selection.is_selected(&key) { + (IconType::XClose, "Unselect") + } else { + (IconType::Check, "Select") + }; + + let mut menu = ContextMenu::new(x, y) + .title(format!("{} selected", self.count)) + .action(icon, text, move |()| selection.toggle(key.clone())) + .separator(); + if self.count > 0 { + menu = menu + .action(IconType::CheckCircle, "Enable Selected", move |()| { + enable.call(true) + }) + .action(IconType::Minus, "Disable Selected", move |()| { + disable.call(false) + }) + .separator(); + } + menu = menu.action(toggle_icon, toggle_label, move |()| { + selection.toggle_all(&order) + }); + if self.count > 0 && !self.all_selected() { + menu = menu.action(IconType::XClose, "Clear selection", move |()| { + selection.clear() + }); + } + + if self.deletable == 0 { + return menu; + } + menu.separator().danger_action( + IconType::Trash01, + format!("Delete {}", self.deletable), + move |()| delete.call(()), + ) + } +} + const TOOLBAR_STACK_W: f32 = 640.; const TABS_ROW_H: f32 = 34.; @@ -144,14 +267,21 @@ pub(super) fn toolbar_bar( current_sort: SortMode, enabled_filter: State, hidden_filter: State, + uses_bundles: bool, layout: State, cluster_id: i64, package_type: &'static str, mut toolbar_width: State, + bulk: &Bulk, ) -> impl IntoElement { - let tab_items = tabs.iter().enumerate().map(|(i, tab)| { + let chips = tabs.iter().enumerate().map(|(i, tab)| { let mut active = active; - TabItem::new(tab.label(), i == active_idx).on_press(move |_| *active.write() = i) + CategoryChip { + label: tab.label(), + selected: i == active_idx, + on_press: (move |_| *active.write() = i).into(), + } + .into_element() }); let mut top_corners = CornerRadius::new_all(0.); @@ -163,29 +293,22 @@ pub(super) fn toolbar_bar( let filter_tabs = ScrollView::new() .direction(Direction::Horizontal) .invert_scroll_wheel(true) - .show_scrollbar(stacked) - .scrollbar_theme(tabs_scrollbar_theme()) + .show_scrollbar(true) + .scrollbar(|context| ScrollBar::new(context).theme(tabs_scrollbar_theme()).into()) .width(if stacked { Size::fill() } else { Size::flex(1.0) }) - .height(if stacked { - Size::fill() - } else { - Size::auto() - }) + .height(Size::px(TABS_ROW_H)) .child( - TabBar::new() + rect() + .horizontal() .width(Size::auto()) - .height(if stacked { - Size::fill() - } else { - Size::auto() - }) - .spacing(20.) - .font_size(12.) - .tabs(tab_items), + .height(Size::fill()) + .cross_align(Alignment::Center) + .spacing(6.) + .children(chips), ) .into_element(); @@ -200,12 +323,13 @@ pub(super) fn toolbar_bar( .into_element(), ) .into_element(), - FilterButton { + filter_button( sort, current_sort, enabled_filter, hidden_filter, - } + uses_bundles, + ) .into_element(), ]; @@ -228,6 +352,11 @@ pub(super) fn toolbar_bar( .text("Add Content") .into_element(), ); + let controls = if bulk.active() { + bulk.controls() + } else { + controls + }; let inner = if stacked { rect() @@ -281,8 +410,69 @@ pub(super) fn toolbar_bar( .into_element() } -pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentType) -> Element { - let text = match content_type { +#[derive(PartialEq)] +struct CategoryChip { + label: String, + selected: bool, + on_press: EventHandler>, +} + +impl Component for CategoryChip { + fn render(&self) -> impl IntoElement { + let mut hovered = use_state(|| false); + let selected = self.selected; + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let background = if selected { + colors::brand() + } else if *hovered.read() { + colors::component_bg_hover() + } else { + colors::component_bg() + }; + + rect() + .horizontal() + .height(Size::px(32.)) + .center() + .padding(Gaps::new_symmetric(0., 12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(background) + .border(crate::ui::border_all_color( + 1., + if selected || focused { + colors::brand() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Tab) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(self.on_press.clone()) + .child( + label() + .text(self.label.clone()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(if selected { + Color::WHITE + } else { + colors::fg_primary() + }), + ) + } +} + +pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentType) -> String { + match content_type { ContentType::ResourcePack => format!( "Minecraft is running. New {noun_plural} usually go in right away, open Options → Resource Packs in game to turn them on. OneClient tells you when one has to wait for the next launch." ), @@ -292,25 +482,39 @@ pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentTyp _ => format!( "Minecraft is running. Changes to your {noun_plural} are saved, and take effect the next time you launch this version." ), - }; - - notice_bar(text) + } } -pub(super) fn global_notice(noun_plural: &'static str) -> Element { - notice_bar(format!( - "These {noun_plural} are shared across all your clusters. Adding one here makes it available everywhere, and turning one off removes it everywhere." - )) +pub(super) fn global_notice(noun_plural: &'static str) -> String { + format!( + "These {noun_plural} are shared across all your OneClient clusters. Adding one here makes it available in all of them, and turning one off removes it from all of them." + ) } -fn notice_bar(text: String) -> Element { +pub(super) fn instance_only_notice(noun_plural: &'static str) -> String { + format!( + "These {noun_plural} belong to this instance alone. Adding one here does not touch your other instances." + ) +} + +pub(super) fn essential_notice(names: &[&'static str]) -> String { + match names { + [only] => format!("{only} is turned off, so its features will not work in game."), + _ => format!( + "{} are turned off, so their features will not work in game.", + names.join(", ") + ), + } +} + +pub(crate) fn notice_bar(text: String) -> Element { rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .content(Content::Flex) .spacing(10.) - .margin(Gaps::new(8., 0., 0., 0.)) + .margin(Gaps::new(0., 0., 8., 0.)) .padding(Gaps::new_symmetric(9., 12.)) .corner_radius(CornerRadius::new_all(10.)) .background(colors::brand().with_a(30)) @@ -330,272 +534,50 @@ fn notice_bar(text: String) -> Element { .into_element() } -#[derive(PartialEq)] -struct FilterButton { - sort: State>, +fn filter_button( + mut sort: State>, current_sort: SortMode, - enabled_filter: State, - hidden_filter: State, -} + mut enabled_filter: State, + mut hidden_filter: State, + uses_bundles: bool, +) -> FilterMenu { + let show = *enabled_filter.read(); + let hidden = *hidden_filter.read(); -impl Component for FilterButton { - fn render(&self) -> impl IntoElement { - let mut open = use_state(|| false); + // Hiding hidden packages is the default so it does not count as the filters being touched + let active = current_sort != SortMode::NameAsc + || show != EnabledFilter::All + || hidden != HiddenFilter::Hide; - let sort = self.sort; - let current_sort = self.current_sort; - let enabled_filter = self.enabled_filter; - let hidden_filter = self.hidden_filter; + let menu = FilterMenu::new(active) + .section( + "Sort by", + SortMode::ALL.map(|mode| { + FilterOption::new(mode.label(), mode == current_sort, move |()| { + sort.set(Some(mode.key().to_string())); + }) + }), + ) + .section( + "Show", + EnabledFilter::ALL.map(|filter| { + FilterOption::new(filter.label(), filter == show, move |()| { + enabled_filter.set(filter); + }) + }), + ); - // Hiding hidden packages is the default so it does not count as the filters being touched - let is_open = open(); - let active = current_sort != SortMode::NameAsc - || *enabled_filter.read() != EnabledFilter::All - || *hidden_filter.read() != HiddenFilter::Hide; - - let icon_color = if active { - colors::brand() - } else { - colors::fg_secondary() - }; - - rect() - .width(Size::px(FILTER_BTN_W)) - .child( - Button::new() - .secondary() - .icon() - .tooltip("Sort and filter") - .width(Size::px(FILTER_BTN_W)) - .height(Size::px(34.)) - .on_press(move |e: Event| { - e.stop_propagation(); - open.toggle(); - }) - .child(Icon::new(IconType::Sliders04).size(16.).color(icon_color)), - ) - .maybe_child(is_open.then(|| { - let on_close: EventHandler<()> = (move |()| open.set(false)).into(); - FilterPopover { - sort, - current_sort, - enabled_filter, - hidden_filter, - on_close, - } - .into_element() - })) + if !uses_bundles { + return menu; } -} - -#[derive(PartialEq)] -struct FilterPopover { - sort: State>, - current_sort: SortMode, - enabled_filter: State, - hidden_filter: State, - on_close: EventHandler<()>, -} - -impl Component for FilterPopover { - fn render(&self) -> impl IntoElement { - use_overlay_claim(); - - let mut sort = self.sort; - let current_sort = self.current_sort; - let mut enabled_filter = self.enabled_filter; - let mut hidden_filter = self.hidden_filter; - let backdrop_close = self.on_close.clone(); - let key_close = self.on_close.clone(); - - let a11y_id = use_a11y(); - - let fade = use_animation(|conf| { - conf.on_creation(OnCreation::Run); - AnimNum::new(0., 1.).time(160).ease(Ease::Out) - }); - let progress = fade.read().value(); - - let show = *enabled_filter.read(); - let hidden = *hidden_filter.read(); - - let mut panel = rect() - .vertical() - .width(Size::fill()) - .spacing(4.) - .padding(Gaps::new_all(8.)) - .opacity(progress) - .offset_y((progress - 1.0) * 6.) - .corner_radius(CornerRadius::new_all(10.)) - .background(colors::page_elevated()) - .border(crate::ui::border_all_color(1., colors::component_border())) - .shadow(Shadow::from(( - 0., - 8., - 32., - 0., - Color::from_argb(120, 0, 0, 0), - ))) - .a11y_id(a11y_id) - .a11y_role(AccessibilityRole::Dialog) - .on_global_key_down(move |e: Event| { - if e.key == Key::Named(NamedKey::Escape) { - key_close.call(()); - } - }) - .child(section_label("Sort by")); - - for mode in SortMode::ALL { - let selected = mode == current_sort; - let on_press: EventHandler> = (move |_| { - sort.set(Some(mode.key().to_string())); - }) - .into(); - panel = panel.child( - ChoiceRow { - text: mode.label(), - selected, - on_press, - key: DiffKey::None, - } - .key(mode.key()), - ); - } - - panel = panel.child(section_label("Show")); - for filter in EnabledFilter::ALL { - let selected = filter == show; - let on_press: EventHandler> = (move |_| { - enabled_filter.set(filter); - }) - .into(); - panel = panel.child( - ChoiceRow { - text: filter.label(), - selected, - on_press, - key: DiffKey::None, - } - .key(filter.label()), - ); - } - - panel = panel.child(section_label("Hidden packages")); - for filter in HiddenFilter::ALL { - let selected = filter == hidden; - let on_press: EventHandler> = (move |_| { + menu.section( + "Hidden packages", + HiddenFilter::ALL.map(|filter| { + FilterOption::new(filter.label(), filter == hidden, move |()| { hidden_filter.set(filter); }) - .into(); - panel = panel.child( - ChoiceRow { - text: filter.label(), - selected, - on_press, - key: DiffKey::None, - } - .key(filter.label()), - ); - } - - rect() - .height(Size::px(0.)) - .width(Size::px(FILTER_PANEL_W)) - .layer(Layer::Overlay) - .child( - rect() - .layer(Layer::OverlayLevel(10)) - .position(Position::new_global().top(0.).left(0.)) - .width(Size::window_percent(100.)) - .height(Size::window_percent(100.)) - .on_press(move |_| backdrop_close.call(())), - ) - .child( - rect() - .width(Size::fill()) - .layer(Layer::OverlayLevel(12)) - .margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W))) - .child(panel), - ) - } -} - -fn section_label(text: &'static str) -> impl IntoElement { - label() - .text(text) - .font_size(10.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_secondary()) -} - -#[derive(PartialEq)] -struct ChoiceRow { - text: &'static str, - selected: bool, - on_press: EventHandler>, - key: DiffKey, -} - -impl KeyExt for ChoiceRow { - fn write_key(&mut self) -> &mut DiffKey { - &mut self.key - } -} - -impl Component for ChoiceRow { - fn render(&self) -> impl IntoElement { - let text = self.text; - let selected = self.selected; - - let a11y_id = use_a11y(); - let focus = use_focus(a11y_id); - let focused = focus().is_focused(); - - let color = if selected { - colors::fg_primary() - } else { - colors::fg_secondary() - }; - - let bg = if selected { - colors::component_bg() - } else if focused { - colors::ghost_overlay_hover() - } else { - Color::TRANSPARENT - }; - - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(8.) - .padding(Gaps::new_symmetric(5., 8.)) - .corner_radius(CornerRadius::new_all(6.)) - .background(bg) - .content(Content::Flex) - .a11y_id(a11y_id) - .a11y_focusable(true) - .a11y_role(AccessibilityRole::MenuItemRadio) - .maybe(focused, |el| { - el.border(crate::ui::border_all_color(1., colors::brand())) - }) - .cursor(CursorIcon::Pointer) - .on_all_press(self.on_press.clone()) - .child( - label() - .text(text) - .font_size(12.) - .width(Size::flex(1.0)) - .color(color), - ) - .maybe_child(selected.then(|| { - Icon::new(IconType::Check) - .size(14.) - .color(colors::brand()) - .into_element() - })) - } + }), + ) } fn add_from_file_button( @@ -654,43 +636,131 @@ pub(super) enum ContentKind { }, } +const SECTION_HEADER_H: f32 = 36.; + +#[derive(Clone, Copy, PartialEq)] +pub(super) struct AdvancedSection { + pub(super) open: State, + pub(super) forced: bool, +} + +impl AdvancedSection { + pub(super) fn expanded(self) -> bool { + self.forced || *self.open.read() + } +} + +#[derive(PartialEq)] +struct SectionHeader { + label: &'static str, + count: usize, + section: AdvancedSection, +} + +impl Component for SectionHeader { + fn render(&self) -> impl IntoElement { + let mut hovered = use_state(|| false); + let section = self.section; + let mut open = section.open; + let expanded = section.expanded(); + let interactive = !section.forced; + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(0., 12.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(if interactive && *hovered.read() { + colors::ghost_overlay_hover() + } else { + Color::TRANSPARENT + }) + .maybe(interactive, |el| { + el.cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(move |_| open.toggle()) + }) + .child(ChevronToggle { expanded }) + .child( + label() + .text(self.label) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(self.count.to_string()) + .font_size(12.) + .color(colors::fg_secondary()), + ) + } +} + #[derive(PartialEq)] pub(super) struct ContentBox { items: Vec, + advanced: Vec, + section: AdvancedSection, noun_plural: &'static str, package_type: &'static str, content_type: ContentType, cluster_id: i64, kind: ContentKind, layout: CardLayout, + bulk: Bulk, + notices: Vec, } impl ContentBox { #[allow(clippy::too_many_arguments)] pub(super) fn new( items: Vec, + advanced: Vec, + section: AdvancedSection, noun_plural: &'static str, package_type: &'static str, content_type: ContentType, cluster_id: i64, kind: ContentKind, layout: CardLayout, + bulk: Bulk, ) -> Self { Self { items, + advanced, + section, noun_plural, package_type, content_type, cluster_id, kind, layout, + bulk, + notices: Vec::new(), } } + + pub(super) fn notices(mut self, notices: Vec) -> Self { + self.notices = notices; + self + } } impl Component for ContentBox { fn render(&self) -> impl IntoElement { - let items = self.items.clone(); + let section = self.section; + let expanded = section.expanded(); + let advanced_count = self.advanced.len(); + let mut items = self.items.clone(); + let normal_count = items.len(); + if expanded { + items.extend(self.advanced.iter().cloned()); + } let package_type = self.package_type; let content_type = self.content_type; let cluster_id = self.cluster_id; @@ -699,33 +769,98 @@ impl Component for ContentBox { let layout = self.layout; let dispatch = use_dispatch(); + let cluster = use_cluster_mutation(); + let mut menu = use_state(|| None::<(f32, f32, PackageEntry)>); + let (on_delete, delete_dialog) = use_shared_delete(cluster_id, move |(_, hash)| { + cluster.mutate(ClusterAction::RemoveArtifact { cluster_id, hash }); + }); - let count = items.len(); - let scroll = (count > 0).then(|| match layout { - CardLayout::List => { - let items = items.clone(); - ScrollArea::new() - .width(Size::fill()) - .height(Size::fill()) - .scrollbar_gutter(true) - .lazy(count, CARD_H, CARD_SPACING, move |i| { - let item = items[i].clone(); - let key = item.package_id.clone(); - PackageRow::new(item, cluster_id, package_type) - .layout(CardLayout::List) - .key(key) - .into_element() + let bulk = self.bulk.clone(); + let selection = bulk.selection; + let active = bulk.active(); + let selecting = active || selection.modifier_held(); + let selected: Vec = items + .iter() + .map(|p| selection.is_selected(&p.package_id)) + .collect(); + + let row = { + let items = items.clone(); + let order = bulk.order.clone(); + move |i: usize| { + let item: PackageEntry = items[i].clone(); + let key = item.package_id.clone(); + let for_menu = item.clone(); + let is_selected = selected[i]; + let order = order.clone(); + let click_key = key.clone(); + PackageRow::new(item, cluster_id, package_type) + .layout(layout) + .selection(is_selected, selecting, move |()| { + selection.click(click_key.clone(), &order) }) + .on_context(move |(x, y)| menu.set(Some((x, y, for_menu.clone())))) + .key(key) .into_element() } - CardLayout::Grid => ScrollArea::new() + }; + + let count = normal_count + advanced_count; + let scroll = (count > 0).then(|| { + let mut sections = vec![LazySection { + header: false, + count: normal_count, + }]; + if advanced_count > 0 { + sections.push(LazySection { + header: true, + count: if expanded { advanced_count } else { 0 }, + }); + } + let (item_height, gap, min_width, max_cols) = match layout { + CardLayout::List => (CARD_H, CARD_SPACING, 0., 1), + CardLayout::Grid => (CARD_GRID_H, GRID_GAP, GRID_MIN_W, GRID_MAX_COLS), + }; + ScrollArea::new() .width(Size::fill()) - .height(Size::fill()) + .height(Size::flex(1.0)) .scrollbar_gutter(true) - .content(move |ctx: ScrollAreaCtx| { - grid_content(&items, package_type, cluster_id, ctx).into_element() - }) - .into_element(), + .lazy_sections( + sections, + item_height, + gap, + min_width, + max_cols, + SECTION_HEADER_H, + row, + move |_| { + SectionHeader { + label: "Advanced", + count: advanced_count, + section, + } + .into_element() + }, + ) + .into_element() + }); + + let menu_overlay = menu.read().clone().map(|(x, y, item)| { + let menu_for = if bulk.active() { + bulk.menu(x, y, item.package_id.clone()) + } else { + let key = item.package_id.clone(); + package_context_menu( + x, + y, + &item, + cluster_id, + package_type, + on_delete, + (move |()| selection.toggle(key.clone())).into(), + ) + }; + menu_for.on_close(move |_| menu.set(None)).into_element() }); let empty = (count == 0).then(|| match kind { @@ -763,14 +898,22 @@ impl Component for ContentBox { .vertical() .width(Size::fill()) .height(Size::flex(1.0)) - .spacing(8.) - .padding(Gaps::new_all(8.)) + .padding(Gaps::new(0., 12., 12., 12.)) .corner_radius(bottom_corners) .background(colors::page_elevated()) .overflow(Overflow::Clip) + .content(Content::Flex) + .children(self.notices.iter().map(|text| notice_bar(text.clone()))) .maybe_child(header) .maybe_child(scroll) - .maybe_child(empty) + .maybe_child(empty.map(|empty| { + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .child(empty) + })) + .maybe_child(menu_overlay) + .maybe_child(delete_dialog) } } @@ -784,62 +927,7 @@ fn action_header(button: impl IntoElement) -> impl IntoElement { .child(button) } -fn grid_content( - items: &[PackageEntry], - package_type: &'static str, - cluster_id: i64, - ctx: ScrollAreaCtx, -) -> impl IntoElement { - let count = items.len(); - let cols = - (((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize).clamp(1, 5); - let rows_total = count.div_ceil(cols); - let slot = CARD_GRID_H + GRID_GAP; - - let first_row = (((-ctx.corrected_y) / slot).floor() as i64 - LAZY_OVERSCAN).max(0) as usize; - let span = ((ctx.viewport_h / slot).ceil() as i64 + 2 * LAZY_OVERSCAN).max(0) as usize; - let last_row = (first_row + span).min(rows_total); - - let top_pad = first_row as f32 * slot; - let bottom_pad = rows_total.saturating_sub(last_row) as f32 * slot; - - let mut container = rect().vertical().width(Size::fill()); - if top_pad > 0. { - container = container.child(rect().width(Size::fill()).height(Size::px(top_pad))); - } - for r in first_row..last_row { - let mut row = rect() - .key(r) - .horizontal() - .width(Size::fill()) - .height(Size::px(slot)) - .spacing(GRID_GAP) - .content(Content::Flex); - for c in 0..cols { - let idx = r * cols + c; - let cell = rect().width(Size::flex(1.0)).height(Size::px(CARD_GRID_H)); - row = row.child(if idx < count { - let item = items[idx].clone(); - let key = item.package_id.clone(); - cell.child( - PackageRow::new(item, cluster_id, package_type) - .layout(CardLayout::Grid) - .key(key) - .into_element(), - ) - } else { - cell - }); - } - container = container.child(row); - } - if bottom_pad > 0. { - container = container.child(rect().width(Size::fill()).height(Size::px(bottom_pad))); - } - container.into_element() -} - -fn empty_shell(icon: IconType) -> Rect { +pub(crate) fn empty_shell(icon: IconType) -> Rect { rect() .vertical() .width(Size::fill()) @@ -850,14 +938,14 @@ fn empty_shell(icon: IconType) -> Rect { .child(Icon::new(icon).size(28.).color(colors::fg_secondary())) } -fn empty_title(text: impl Into) -> impl IntoElement { +pub(crate) fn empty_title(text: impl Into) -> impl IntoElement { label() .text(text.into()) .font_size(14.) .color(colors::fg_secondary()) } -fn empty_hint(text: impl Into) -> impl IntoElement { +pub(crate) fn empty_hint(text: impl Into) -> impl IntoElement { label() .text(text.into()) .font_size(12.) diff --git a/packages/oneclient_app/src/view/app/cluster/process_logs.rs b/packages/oneclient_app/src/view/app/cluster/process_logs.rs index a6325538..4f8ca2cf 100644 --- a/packages/oneclient_app/src/view/app/cluster/process_logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/process_logs.rs @@ -70,7 +70,11 @@ impl Component for ProcessLogs { } } -fn filter_lines(lines: &Arc>>, search: &str, level: LevelFilter) -> Arc>> { +fn filter_lines( + lines: &Arc>>, + search: &str, + level: LevelFilter, +) -> Arc>> { let needle = (!search.trim().is_empty()).then(|| search.to_lowercase()); let wanted = level.to_level(); diff --git a/packages/oneclient_app/src/view/app/cluster/screenshots.rs b/packages/oneclient_app/src/view/app/cluster/screenshots.rs index a7a8905a..c5465063 100644 --- a/packages/oneclient_app/src/view/app/cluster/screenshots.rs +++ b/packages/oneclient_app/src/view/app/cluster/screenshots.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::path::PathBuf; use freya::prelude::*; @@ -6,10 +5,14 @@ use oneclient_core::ScreenshotInfo; use oneclient_core::settings::ViewLayout; use crate::components::{ - Button, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, Segment, - SegmentedControl, open_folder_button, screenshot_context_menu, + Button, ContextMenu, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, + Segment, SegmentedControl, open_folder_button, screenshot_context_menu, +}; +use crate::hooks::{ + ScreenshotAction, Selection, query_is_loading, try_cluster_screenshots, + use_cluster_screenshots, use_dispatch, use_screenshot_action, use_screenshot_folder_watch, + use_selection, use_view_state, }; -use crate::hooks::{ScreenshotAction, query_is_loading, try_cluster_screenshots, use_cluster_screenshots, use_dispatch, use_screenshot_action, use_screenshot_folder_watch, use_view_state}; use crate::layout::cluster_content; use crate::theme::colors; use crate::ui::{border_all_color, flow_grid, fmt_date, grid_columns_for_width}; @@ -33,10 +36,11 @@ pub struct ClusterScreenshots { impl Component for ClusterScreenshots { fn render(&self) -> impl IntoElement { - let Some(cluster) = use_cluster(self.cluster_id) else { - return cluster_not_found(); - }; - let folder = cluster.game_dir().ok().map(|d| d.join("screenshots")); + let cluster = use_cluster(self.cluster_id); + let folder = cluster + .as_ref() + .and_then(|c| c.game_dir().ok()) + .map(|d| d.join("screenshots")); let query = use_cluster_screenshots(self.cluster_id); // Otherwise a screenshot taken in game only shows up on the next visit @@ -48,40 +52,43 @@ impl Component for ClusterScreenshots { let view_mode = use_view_state("cluster.screenshots").layout; let menu_dispatch = use_dispatch(); - let mut edit_mode = use_state(|| false); - let mut selected = use_state(HashSet::::new); + let selection = use_selection::(); let mut viewing = use_state(|| None::); - let confirm_delete = use_state(|| false); + let mut confirm_delete = use_state(|| false); let grid_width = use_state(|| 0f32); let mut menu = use_state(|| None::<(f32, f32, PathBuf)>); + if cluster.is_none() { + return cluster_not_found(); + } + + let order: Vec = shots.iter().map(|s| s.path.clone()).collect(); + let selected = selection.selected_in(&order); + let editing = selection.is_active(); + let toolbar = toolbar_row( &shots, folder, view_mode, - edit_mode, - selected, + editing, + selection, + selected.len(), confirm_delete, ); let content: Element = if shots.is_empty() { - empty_state(query_is_loading(&query)) - .into_element() + empty_state(query_is_loading(&query)).into_element() } else { let mut items: Vec = Vec::new(); for (idx, info) in shots.iter().enumerate() { let info = info.clone(); - let is_selected = selected.read().contains(&info.path); - let editing = *edit_mode.read(); + let is_selected = selected.contains(&info.path); let activate_path = info.path.clone(); + let activate_order = order.clone(); let on_activate = move |_| { - if editing { - let p = activate_path.clone(); - let mut set = selected.write(); - if !set.remove(&p) { - set.insert(p); - } + if editing || selection.modifier_held() { + selection.click(activate_path.clone(), &activate_order); } else { viewing.set(Some(idx)); } @@ -135,27 +142,67 @@ impl Component for ClusterScreenshots { .children(vec![content]); let confirm_overlay = confirm_delete.read().then(|| { - let count = selected.read().len(); - confirm_panel(count, confirm_delete, move || { - let paths: Vec = selected.read().iter().cloned().collect(); - for path in paths { + let paths = selected.clone(); + confirm_panel(paths.len(), confirm_delete, move || { + for path in paths.iter().cloned() { action.mutate(ScreenshotAction::Delete { path }); } - selected.write().clear(); - edit_mode.set(false); + selection.exit(); confirm_delete.clone().set(false); }) }); let menu_overlay = menu.read().clone().map(|(x, y, path)| { - screenshot_context_menu(x, y, path, action, menu_dispatch.clone(), |()| {}) - .on_close(move |_| menu.set(None)) - .into_element() + let menu_for = if !editing { + let select_path = path.clone(); + screenshot_context_menu( + x, + y, + path, + action, + menu_dispatch.clone(), + |()| {}, + Some((move |()| selection.toggle(select_path.clone())).into()), + ) + } else { + let all = order.clone(); + let every = !selected.is_empty() && selected.len() == order.len(); + let (icon, text) = if every { + (IconType::XClose, "Unselect all") + } else { + (IconType::Check, "Select all") + }; + let own = if selected.contains(&path) { + (IconType::XClose, "Unselect") + } else { + (IconType::Check, "Select") + }; + let mut multi = ContextMenu::new(x, y) + .title(format!("{} selected", selected.len())) + .action(own.0, own.1, move |()| selection.toggle(path.clone())) + .separator() + .action(icon, text, move |()| selection.toggle_all(&all)); + if !selected.is_empty() && !every { + multi = multi.action(IconType::XClose, "Clear selection", move |()| { + selection.clear() + }); + } + if !selected.is_empty() { + multi = multi.separator().danger_action( + IconType::Trash01, + format!("Delete {}", selected.len()), + move |()| confirm_delete.set(true), + ); + } + multi + }; + menu_for.on_close(move |_| menu.set(None)).into_element() }); cluster_content() .child( - rect() + selection + .track_modifiers(rect()) .vertical() .width(Size::fill()) .height(Size::fill()) @@ -177,12 +224,11 @@ fn toolbar_row( shots: &[ScreenshotInfo], folder: Option, view_mode: State, - mut edit_mode: State, - mut selected: State>, + editing: bool, + selection: Selection, + count: usize, mut confirm_delete: State, ) -> impl IntoElement { - let editing = *edit_mode.read(); - let count = selected.read().len(); let all_paths: Vec = shots.iter().map(|s| s.path.clone()).collect(); let mut right = rect() @@ -197,7 +243,7 @@ fn toolbar_row( ); if editing { - let select_all_paths = all_paths.clone(); + let every = count > 0 && count == all_paths.len(); right = right .child( label() @@ -209,20 +255,18 @@ fn toolbar_row( Button::new() .secondary() .small() - .on_press(move |_| { - let mut set = selected.write(); - *set = select_all_paths.iter().cloned().collect(); - }) - .text("Select all"), + .on_press(move |_| selection.toggle_all(&all_paths)) + .text(if every { "Unselect all" } else { "Select all" }), ) - .child( + .maybe_child((count > 0 && !every).then(|| { Button::new() .ghost() .small() .enabled(count > 0) - .on_press(move |_| selected.write().clear()) - .text("Deselect all"), - ) + .on_press(move |_| selection.clear()) + .text("Deselect all") + .into_element() + })) .child( Button::new() .danger() @@ -236,10 +280,7 @@ fn toolbar_row( Button::new() .ghost() .small() - .on_press(move |_| { - selected.write().clear(); - edit_mode.set(false); - }) + .on_press(move |_| selection.exit()) .text("Cancel"), ); } else { @@ -255,7 +296,7 @@ fn toolbar_row( .secondary() .small() .enabled(!shots.is_empty()) - .on_press(move |_| edit_mode.set(true)) + .on_press(move |_| selection.enter()) .child(Icon::new(IconType::Pencil01).size(14.)) .text("Select"), ); diff --git a/packages/oneclient_app/src/view/app/cluster/shaders.rs b/packages/oneclient_app/src/view/app/cluster/shaders.rs index ab75017e..cea5ad8c 100644 --- a/packages/oneclient_app/src/view/app/cluster/shaders.rs +++ b/packages/oneclient_app/src/view/app/cluster/shaders.rs @@ -7,10 +7,10 @@ use crate::hooks::{ }; use crate::layout::cluster_content; +use super::cluster_not_found; use super::package_manager::{ PackageManager, bundle_categories, bundle_packages, use_content_meta, }; -use super::cluster_not_found; use crate::hooks::use_cluster; #[derive(PartialEq)] diff --git a/packages/oneclient_app/src/view/app/cluster/textures.rs b/packages/oneclient_app/src/view/app/cluster/textures.rs index ccf90c38..bfdf038a 100644 --- a/packages/oneclient_app/src/view/app/cluster/textures.rs +++ b/packages/oneclient_app/src/view/app/cluster/textures.rs @@ -7,10 +7,10 @@ use crate::hooks::{ }; use crate::layout::cluster_content; +use super::cluster_not_found; use super::package_manager::{ PackageManager, bundle_categories, bundle_packages, use_content_meta, }; -use super::cluster_not_found; use crate::hooks::use_cluster; #[derive(PartialEq)] diff --git a/packages/oneclient_app/src/view/app/cluster/worlds.rs b/packages/oneclient_app/src/view/app/cluster/worlds.rs new file mode 100644 index 00000000..2faf7973 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/worlds.rs @@ -0,0 +1,332 @@ +use std::collections::HashMap; + +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::WorldInfo; + +use crate::components::{ + CARD_BG, CARD_NAME, CardLayout, ContextMenu, IconType, kebab_button, meta_size, meta_text, + on_secondary, +}; +use crate::hooks::{ + delete_world, query_is_loading, spawn_world_task, try_cluster_worlds, try_world_size, + use_cluster, use_cluster_worlds, use_datapack_world, use_dispatch, use_saves_folder_watch, + use_view_state, use_world_size, +}; +use crate::layout::cluster_content; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::{border_all_color, fmt_date}; +use crate::utils::format_size; + +use super::cluster_not_found; +use super::folder_list::{ + CardIcon, RowHeights, card_icon, confirm_dialog, content_box, folder_button, layout_toggle, + matches_search, notify_in_use, search_input, supports_datapacks, toolbar_panel, + use_game_folder_in_use, +}; +use super::package_manager::{empty_hint, empty_shell, empty_title}; + +const LIST_ICON: f32 = 44.; +const GRID_ICON: f32 = 40.; +const LIST_PAD: f32 = 10.; +const GRID_PAD: f32 = 14.; + +const SHARED_NOTICE: &str = "This version uses the shared game folder, so these worlds also appear in every other version that uses it."; + +const WORLD_ROWS: RowHeights = RowHeights { + list: LIST_ICON + 2. * LIST_PAD, + grid: GRID_ICON + 2. * GRID_PAD, +}; + +#[derive(PartialEq)] +pub struct ClusterWorlds { + pub cluster_id: i64, +} + +fn open_datapacks(cluster_id: i64, world: String, mut remembered: State>) { + remembered.write().insert(cluster_id, world); + let _ = RouterContext::get().push(Route::ClusterDataPacks { cluster_id }); +} + +impl Component for ClusterWorlds { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let cluster = use_cluster(cluster_id); + let saves = cluster + .as_ref() + .and_then(|c| c.game_dir().ok()) + .map(|d| d.join("saves")); + + let query = use_cluster_worlds(cluster_id); + use_saves_folder_watch(saves.clone(), query); + let dispatch = use_dispatch(); + let in_use = use_game_folder_in_use(cluster.as_ref()); + let remembered = use_datapack_world(); + let search = use_state(String::new); + let layout = use_view_state("cluster.worlds").layout; + let mut menu = use_state(|| None::<(f32, f32, WorldInfo)>); + let mut pending_delete = use_state(|| None::); + + let Some(cluster) = cluster else { + return cluster_not_found(); + }; + let shared = !cluster.uses_dedicated_dir(); + let datapacks = supports_datapacks(&cluster.mc_version); + + let all = try_cluster_worlds(&query).unwrap_or_default(); + let needle = search.read().trim().to_lowercase(); + let worlds: Vec = all + .iter() + .filter(|w| matches_search(&needle, &[&w.folder_name])) + .cloned() + .collect(); + let card_layout = CardLayout::from(*layout.read()); + + let row = { + let worlds = worlds.clone(); + move |i: usize| { + let info = worlds[i].clone(); + let press_world = info.folder_name.clone(); + let menu_info = info.clone(); + WorldCard { + cluster_id, + info, + layout: card_layout, + on_press: datapacks.then(|| { + (move |()| open_datapacks(cluster_id, press_world.clone(), remembered)) + .into() + }), + on_context: (move |(x, y)| menu.set(Some((x, y, menu_info.clone())))).into(), + } + .into_element() + } + }; + + let empty = if query_is_loading(&query) { + None + } else if all.is_empty() { + Some( + empty_shell(IconType::Globe01) + .child(empty_title("No worlds yet.")) + .child(empty_hint( + "Worlds show up here once you create one in game.", + )) + .into_element(), + ) + } else { + Some( + empty_shell(IconType::SearchMd) + .child(empty_title("No worlds match your search.")) + .child(empty_hint("Try a different term.")) + .into_element(), + ) + }; + + let mut controls = vec![search_input(search)]; + controls.extend(saves.map(folder_button)); + controls.push(layout_toggle(layout)); + + let menu_overlay = menu.read().clone().map(|(x, y, info)| { + let open_world = info.folder_name.clone(); + let target_world = info.folder_name.clone(); + let path = info.path.clone(); + let mut context = ContextMenu::new(x, y).title(info.folder_name.clone()); + if datapacks { + context = context.action(IconType::Database01, "Data packs", move |()| { + open_datapacks(cluster_id, open_world.clone(), remembered) + }); + } + context + .action(IconType::Folder, "Open folder", move |()| { + crate::platform::open_path(&path.to_string_lossy()) + }) + .separator() + .danger_action(IconType::Trash01, "Delete", { + let dispatch = dispatch.clone(); + move |()| { + if in_use { + notify_in_use(&dispatch, "Worlds"); + } else { + pending_delete.set(Some(target_world.clone())); + } + } + }) + .on_close(move |_| menu.set(None)) + .into_element() + }); + + let confirm_overlay = pending_delete.read().clone().map(|world| { + let target_world = world.clone(); + let body = if shared { + "This version uses the shared game folder, so the world is removed from every version that uses it. It can be restored from your system trash." + } else { + "It can be restored from your system trash." + }; + confirm_dialog( + format!("Move \"{world}\" to trash?"), + body.to_string(), + move || pending_delete.set(None), + { + let dispatch = dispatch.clone(); + move || { + pending_delete.set(None); + if in_use { + notify_in_use(&dispatch, "Worlds"); + return; + } + spawn_world_task( + dispatch.clone(), + "Couldn't delete world", + delete_world(cluster_id, target_world.clone()), + ); + } + }, + ) + }); + + cluster_content() + .child(toolbar_panel(None, controls)) + .child(content_box( + worlds.len(), + card_layout, + WORLD_ROWS, + row, + empty, + shared + .then(|| SHARED_NOTICE.to_string()) + .into_iter() + .collect(), + )) + .maybe_child(menu_overlay) + .maybe_child(confirm_overlay) + .into_element() + } +} + +#[derive(PartialEq)] +struct WorldCard { + cluster_id: i64, + info: WorldInfo, + layout: CardLayout, + on_press: Option>, + on_context: EventHandler<(f32, f32)>, +} + +impl Component for WorldCard { + fn render(&self) -> impl IntoElement { + let mut hovered = use_state(|| false); + let info = &self.info; + let grid = self.layout == CardLayout::Grid; + let size = try_world_size(&use_world_size(self.cluster_id, info.folder_name.clone())); + + let icon = card_icon( + &info + .icon + .clone() + .map_or(CardIcon::Symbol(IconType::Globe01), CardIcon::Image), + if grid { GRID_ICON } else { LIST_ICON }, + ); + let last_played = format!("Last played {}", fmt_date(info.last_played)); + let on_press = self.on_press.clone(); + let pressable = on_press.is_some(); + + let text = if grid { + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(info.folder_name.clone()) + .font_size(14.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .width(Size::fill()) + .color(Color::WHITE), + ) + .child(meta_text( + match size { + Some(size) => format!("{last_played} \u{b7} {}", format_size(size)), + None => last_played.clone(), + }, + CARD_NAME.with_a(127), + )) + } else { + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(info.folder_name.clone()) + .font_size(15.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .width(Size::fill()) + .color(CARD_NAME), + ) + .child( + label() + .text(last_played) + .font_size(11.) + .max_lines(1) + .text_overflow(TextOverflow::Ellipsis) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + }; + + let hovering = *hovered.read(); + let card = rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .maybe(pressable, |el| el.cursor(CursorIcon::Pointer)) + .on_press(move |_| { + if let Some(handler) = &on_press { + handler.call(()); + } + }) + .on_secondary_down(on_secondary(Some(self.on_context.clone()))); + + if grid { + card.height(Size::px(WORLD_ROWS.grid)) + .spacing(11.) + .padding(Gaps::new_all(GRID_PAD)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if hovering { + colors::component_bg_hover() + } else { + colors::component_bg() + }) + .border(border_all_color( + 1., + if hovering { + colors::component_border_hover() + } else { + colors::component_border() + }, + )) + .overflow(Overflow::Clip) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .child(icon) + .child(text) + .child(kebab_button(self.on_context.clone())) + } else { + card.height(Size::px(WORLD_ROWS.list)) + .spacing(12.) + .padding(Gaps::new_all(LIST_PAD)) + .corner_radius(CornerRadius::new_all(8.)) + .background(CARD_BG) + .child(icon) + .child(text) + .child(meta_size(size.unwrap_or_default())) + .child(kebab_button(self.on_context.clone())) + } + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/delete_instance.rs b/packages/oneclient_app/src/view/app/clusters/delete_instance.rs new file mode 100644 index 00000000..855efde9 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/delete_instance.rs @@ -0,0 +1,134 @@ +use freya::prelude::*; +use freya::router::RouterContext; + +use crate::components::{Button, OverlayPopup}; +use crate::hooks::{ + ClusterAction, use_active_cluster_id, use_cluster, use_cluster_mutation, use_game_snapshot, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; + +const DIALOG_WIDTH: f32 = 420.; + +#[derive(PartialEq)] +pub struct DeleteInstanceModal { + cluster_id: i64, + name: String, + on_close: EventHandler<()>, +} + +impl DeleteInstanceModal { + pub fn new(cluster_id: i64, name: String, on_close: impl Into>) -> Self { + Self { + cluster_id, + name, + on_close: on_close.into(), + } + } +} + +impl Component for DeleteInstanceModal { + fn render(&self) -> impl IntoElement { + let mutation = use_cluster_mutation(); + let mut active_id = use_active_cluster_id(); + let game = use_game_snapshot(); + let cluster_id = self.cluster_id; + let running = game.is_running(cluster_id); + let cluster = use_cluster(cluster_id); + let dedicated = cluster.as_ref().is_none_or(|c| c.uses_dedicated_dir()); + let provisioned = cluster.as_ref().is_some_and(|c| !c.user_created); + let warning = if dedicated { + format!( + "{} and its files will be deleted, including its worlds, settings and installed content. This cannot be undone.", + self.name + ) + } else { + format!( + "{} and its files will be deleted, including its settings and installed content. Worlds and everything else in the shared game folder are kept. This cannot be undone.", + self.name + ) + }; + + let close_scrim = self.on_close.clone(); + let close_cancel = self.on_close.clone(); + let close_delete = self.on_close.clone(); + + OverlayPopup::new() + .on_close(move |()| close_scrim.call(())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(DIALOG_WIDTH)) + .max_width(Size::window_percent(92.)) + .spacing(16.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text("Delete instance") + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(warning) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .maybe_child(provisioned.then(|| { + label() + .text("It won't be added back automatically. To play this version again, create a OneClient instance for it.") + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + })) + .maybe_child(running.then(|| { + label() + .text("Close the game before deleting this instance.") + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + })) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| close_cancel.call(())) + .text("Cancel"), + ) + .child( + Button::new() + .danger() + .enabled(!running) + .on_press(move |_| { + mutation.mutate(ClusterAction::DeleteInstance { + cluster_id, + }); + if *active_id.peek() == Some(cluster_id) { + *active_id.write() = None; + } + let _ = RouterContext::get().push(Route::Home {}); + close_delete.call(()); + }) + .text("Delete"), + ), + ), + ), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/cards.rs b/packages/oneclient_app/src/view/app/clusters/instance/cards.rs new file mode 100644 index 00000000..60cc0eb8 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/cards.rs @@ -0,0 +1,374 @@ +use freya::prelude::*; + +use super::model::{LoaderChoice, VersionRow}; +use crate::AppAssets; +use crate::components::{AssetImage, Icon, IconType, pill}; +use crate::theme::colors; +use crate::ui::border_all_color; + +pub const CARD_RADIUS: f32 = 12.; +pub const VERSION_ROW_H: f32 = 48.; +pub const VERSION_ROW_SPACING: f32 = 4.; +pub const LOADER_MARK_SIZE: f32 = 22.; +pub const BUNDLE_COLUMNS: usize = 3; +pub const BUNDLE_CARD_H: f32 = 132.; +pub const LOADER_COLUMNS: usize = 2; +pub const LOADER_CARD_H: f32 = 124.; + +const MARKER_SIZE: f32 = 18.; +const MARKER_MARK: f32 = 12.; +const MARKER_DOT: f32 = 7.; +const MARKER_RADIUS: f32 = 5.; + +#[derive(PartialEq)] +pub struct SelectCard { + pub id: String, + pub selected: bool, + pub height: Option, + pub padding: Gaps, + pub content: Element, + pub on_press: EventHandler<()>, +} + +impl Component for SelectCard { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + let hovered = *hovering.read(); + let on_press = self.on_press.clone(); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let ring = if self.selected || focused { + colors::brand() + } else if hovered { + colors::component_border_hover() + } else { + colors::component_border() + }; + + rect() + .key(self.id.clone()) + .width(Size::fill()) + .height(self.height.map_or_else(Size::auto, Size::px)) + .padding(self.padding) + .corner_radius(CornerRadius::new_all(CARD_RADIUS)) + .overflow(Overflow::Clip) + .background(if hovered || focused { + colors::component_bg_hover() + } else { + colors::component_bg() + }) + .border(border_all_color(1., ring)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_press.call(())) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .child(self.content.clone()) + } +} + +pub fn loader_mark(choice: LoaderChoice, selected: bool, size: f32) -> Option { + let path = choice.mark(); + let drawn = AppAssets::get(path) + .is_some() + .then(|| AssetImage::new(path, size).into_element()); + + if let Some(drawn) = drawn { + return Some( + rect() + .width(Size::px(size)) + .height(Size::px(size)) + .corner_radius(CornerRadius::new_all(5.)) + .overflow(Overflow::Clip) + .opacity(if selected { 1.0 } else { 0.7 }) + .child(drawn) + .into_element(), + ); + } + + let color = if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + }; + + Some( + rect() + .width(Size::px(size)) + .height(Size::px(size)) + .center() + .corner_radius(CornerRadius::new_all(5.)) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(choice.short()) + .font_size(9.) + .font_weight(FontWeight::SEMI_BOLD) + .letter_spacing(0.4) + .color(color), + ) + .into_element(), + ) +} + +pub fn marker(selected: bool, checkbox: bool) -> Element { + let radius = if checkbox { MARKER_RADIUS } else { 999. }; + + rect() + .width(Size::px(MARKER_SIZE)) + .height(Size::px(MARKER_SIZE)) + .center() + .corner_radius(CornerRadius::new_all(radius)) + .background(if selected { + colors::brand() + } else { + colors::page_elevated() + }) + .border(border_all_color( + 1., + if selected { + colors::brand() + } else { + colors::component_border() + }, + )) + .maybe_child(selected.then(|| { + if checkbox { + Icon::new(IconType::Check) + .size(MARKER_MARK) + .color(Color::WHITE) + .into_element() + } else { + rect() + .width(Size::px(MARKER_DOT)) + .height(Size::px(MARKER_DOT)) + .corner_radius(CornerRadius::new_all(999.)) + .background(Color::WHITE) + .into_element() + } + })) + .into_element() +} + +pub struct WideCard { + pub icon: IconType, + pub title: String, + pub badge: Option, + pub blurb: String, + pub meta: String, + pub selected: bool, + pub on_press: EventHandler<()>, +} + +pub fn wide_card(card: WideCard) -> Element { + let WideCard { + icon, + title, + badge, + blurb, + meta, + selected, + on_press, + } = card; + let title_id = title.clone(); + + let content = rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(14.) + .child(marker(selected, false)) + .child(Icon::new(icon).size(25.).color(if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + })) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + label() + .text(title) + .font_size(15.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .maybe_child(badge.map(|badge| { + label() + .text(badge) + .font_size(12.) + .color(colors::fg_secondary()) + })), + ) + .child( + label() + .text(format!("{blurb} {meta}")) + .font_size(13.) + .line_height(1.45) + .color(colors::fg_secondary()), + ), + ) + .into_element(); + + SelectCard { + id: title_id, + selected, + height: None, + padding: Gaps::new_symmetric(16., 18.), + content, + on_press, + } + .into_element() +} + +pub struct CellCard { + pub id: String, + pub title: String, + pub blurb: String, + pub meta: Option, + pub corner: Option, + pub selected: bool, + pub checkbox: bool, + pub on_press: EventHandler<()>, +} + +pub fn cell_card(card: CellCard) -> Element { + let CellCard { + id, + title, + blurb, + meta, + corner, + selected, + checkbox, + on_press, + } = card; + + let content = rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .main_align(Alignment::SpaceBetween) + .spacing(8.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .child(marker(selected, checkbox)) + .child( + label() + .text(title) + .width(Size::flex(1.0)) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(colors::fg_primary()), + ) + .maybe_child(corner), + ) + .child( + label() + .text(blurb) + .width(Size::fill()) + .font_size(12.) + .line_height(1.4) + .max_lines(3) + .color(colors::fg_secondary()), + ), + ) + .maybe_child(meta.map(|meta| { + label() + .text(meta) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()) + .into_element() + })) + .into_element(); + + SelectCard { + id, + selected, + height: None, + padding: Gaps::new_all(14.), + content, + on_press, + } + .into_element() +} + +pub fn version_row(row: &VersionRow, selected: bool, on_press: EventHandler<()>) -> Element { + let content = rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(14.) + .child(marker(selected, false)) + .child( + label() + .text(row.id.clone()) + .width(Size::px(110.)) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(row.meta.clone()) + .width(Size::flex(1.0)) + .font_size(12.) + .max_lines(1) + .text_align(TextAlign::Right) + .color(colors::fg_secondary()), + ) + .maybe_child( + row.badge + .as_ref() + .map(|badge| pill(None, badge.clone(), colors::fg_secondary())), + ) + .maybe_child((!row.date.is_empty()).then(|| { + label() + .text(row.date.clone()) + .width(Size::px(84.)) + .font_size(12.) + .max_lines(1) + .text_align(TextAlign::Right) + .color(colors::fg_secondary()) + .into_element() + })) + .into_element(); + + SelectCard { + id: row.id.clone(), + selected, + height: Some(VERSION_ROW_H), + padding: Gaps::new_symmetric(0., 14.), + content, + on_press, + } + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/copy.rs b/packages/oneclient_app/src/view/app/clusters/instance/copy.rs new file mode 100644 index 00000000..cec8a2da --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/copy.rs @@ -0,0 +1,135 @@ +use oneclient_cluster::naming::validate_instance_name; + +use super::data::Picks; +use super::model::{ModpackOrigin, Step, TypeChoice, Wizard}; + +pub fn heading(picks: &Picks) -> (&'static str, String) { + match picks.step { + Step::Type => ( + "Choose a type", + "Three ways to start. Packages can be added to any of them later.".to_string(), + ), + Step::Loader => ( + "Choose a mod loader", + match &picks.versions.chosen { + Some(version) => format!( + "Loaders are what packages install into. Only the ones with a build for {version} can be picked." + ), + None => "Loaders are what packages install into.".to_string(), + }, + ), + Step::Version => ( + "Choose a version", + match picks.choice { + TypeChoice::OneClient => { + "Only versions OneClient ships a build for are listed.".to_string() + } + TypeChoice::Scratch | TypeChoice::Modpack => { + "Every Minecraft version. Switch the release type to reach snapshots, betas and alphas." + .to_string() + } + }, + ), + Step::Bundles => ( + "Add bundles", + "Curated sets of packages, installed and configured together. Take as many as you like." + .to_string(), + ), + Step::Customize => ( + "Name the instance", + "Everything here can be changed afterwards.".to_string(), + ), + Step::Modpack => ( + "Add a modpack", + "The pack decides the version, the loader and the mods. The instance is named after it." + .to_string(), + ), + } +} + +pub fn footer_note(picks: &Picks) -> String { + match picks.step { + Step::Type => match picks.choice { + TypeChoice::OneClient => { + "Shares its game folder, worlds and packs with your other OneClient instances." + .to_string() + } + TypeChoice::Scratch => "Keeps its own game folder, worlds and packs.".to_string(), + TypeChoice::Modpack => { + "Keeps its own game folder, worlds and packs, set up the way the pack author made it." + .to_string() + } + }, + Step::Loader => match (&picks.versions.chosen, picks.loader.chosen) { + (Some(version), None) => format!("Pick a loader with a build for {version}."), + _ => picks.loader_label(), + }, + Step::Version => match &picks.versions.chosen { + Some(version) => format!("{version} downloads the first time you launch it."), + None => "Pick a version to continue.".to_string(), + }, + Step::Bundles => { + let taken = picks.bundles.taken_count(); + let total = picks.bundles.archives.len(); + if taken == 0 { + "Nothing selected. OneConfig is installed either way.".to_string() + } else { + format!("{taken} of {total} selected. Bundles can be changed later.") + } + } + Step::Customize => "The instance is created locally. Nothing is uploaded.".to_string(), + Step::Modpack => match picks.modpack_origin { + ModpackOrigin::Browse => { + "Installing a modpack from the browser creates its instance.".to_string() + } + ModpackOrigin::File => { + "Takes .mrpack files from Modrinth and .zip files from CurseForge.".to_string() + } + }, + } +} + +pub fn step_value(wizard: Wizard, picks: &Picks, step: Step) -> String { + match step { + Step::Type => match picks.choice { + TypeChoice::OneClient => "OneClient".to_string(), + TypeChoice::Scratch => "From scratch".to_string(), + TypeChoice::Modpack => "Modpack".to_string(), + }, + Step::Loader => picks.loader_label(), + Step::Version => picks + .versions + .chosen + .clone() + .unwrap_or_else(|| "Not chosen".to_string()), + Step::Bundles => { + let taken = picks.bundles.taken_count(); + if taken == 0 { + "None".to_string() + } else { + format!("{taken} selected") + } + } + Step::Modpack => match picks.modpack_origin { + ModpackOrigin::Browse => "Browse".to_string(), + ModpackOrigin::File => "From a file".to_string(), + }, + Step::Customize => match wizard.details.tags.read().len() { + 0 => "Optional".to_string(), + 1 => "1 tag".to_string(), + many => format!("{many} tags"), + }, + } +} + +pub fn is_ready(picks: &Picks) -> bool { + match picks.step { + Step::Type | Step::Bundles | Step::Modpack => true, + Step::Loader => picks.loader.chosen.is_some(), + Step::Version => { + picks.versions.chosen.is_some() + && (picks.choice == TypeChoice::Scratch || picks.loader.chosen.is_some()) + } + Step::Customize => validate_instance_name(&picks.name).is_ok(), + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/create.rs b/packages/oneclient_app/src/view/app/clusters/instance/create.rs new file mode 100644 index 00000000..a87c2d0a --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/create.rs @@ -0,0 +1,246 @@ +use std::collections::HashSet; + +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::GameVersionKind; +use oneclient_core::clusters::{ClusterKind, ModpackSource}; + +use super::copy::{footer_note, heading, is_ready, step_value}; +use super::data::{Picks, resolve}; +use super::details::DetailsState; +use super::model::*; +use super::rail::{Rail, RowState, rail, steps_card, version_art}; +use super::shell::{Shell, shell}; +use super::steps; +use crate::Route; +use crate::hooks::{ + ClusterAction, settled_or_loading, use_active_cluster_id, use_cluster_mutation, use_clusters, + use_dispatch, +}; +use crate::utils::default_cluster; + +const MODPACK_BROWSE_TYPE: &str = "modpack"; +const MODPACK_EXTENSIONS: [&str; 2] = ["mrpack", "zip"]; + +fn create_action(wizard: Wizard, picks: &Picks) -> Option { + let mc_version = picks.versions.chosen.clone()?; + let mc_loader = picks.loader.chosen?; + + Some(ClusterAction::CreateInstance { + kind: picks.kind, + name: picks.name.trim().to_string(), + mc_version, + mc_loader, + mc_loader_version: picks.loader.version.clone(), + description: wizard.details.description_value(), + tags: wizard.details.tags.read().clone(), + cover_source: wizard.details.cover.read().clone(), + bundles: (picks.kind == ClusterKind::OneClient && picks.bundles.loaded) + .then(|| picks.bundles.taken()), + }) +} + +fn wizard_rail(wizard: Wizard, picks: &Picks) -> Element { + let description = wizard.details.description.read().trim().to_string(); + let tags = wizard.details.tags.read().clone(); + + let title = if picks.name.trim().is_empty() { + "New instance".to_string() + } else { + picks.name.clone() + }; + + let subtitle = if description.is_empty() { + match picks.choice { + TypeChoice::OneClient => match &picks.versions.chosen { + Some(version) => format!("OneClient · {version}"), + None => "OneClient".to_string(), + }, + TypeChoice::Scratch => match &picks.versions.chosen { + Some(version) => format!("{version} · {}", picks.loader_label()), + None => picks.loader_label(), + }, + TypeChoice::Modpack => "Modpack".to_string(), + } + } else { + description + }; + + let rows = picks + .steps + .iter() + .enumerate() + .map(|(index, step)| { + let state = if index < picks.index { + RowState::Done + } else if index == picks.index { + RowState::Current + } else { + RowState::Pending + }; + let value = if state == RowState::Pending { + String::new() + } else { + step_value(wizard, picks, *step) + }; + (step.label(), value, state) + }) + .collect(); + + let art = if picks.choice == TypeChoice::Modpack { + version_art(None, None) + } else { + version_art(picks.versions.chosen.as_deref(), picks.loader.chosen) + } + .picked_cover(wizard.details.cover.read().clone()); + + rail(Rail { + art, + title, + subtitle, + card: steps_card(rows), + tags: if picks.step == Step::Customize { + tags + } else { + Vec::new() + }, + }) +} + +#[derive(PartialEq)] +pub struct CreateInstanceModal { + on_close: EventHandler<()>, +} + +impl CreateInstanceModal { + pub fn new(on_close: impl Into>) -> Self { + Self { + on_close: on_close.into(), + } + } +} + +impl Component for CreateInstanceModal { + fn render(&self) -> impl IntoElement { + let mutation = use_cluster_mutation(); + let wizard = Wizard { + step: use_state(|| 0usize), + choice: use_state(|| TypeChoice::OneClient), + version: use_state(|| None::), + filter: use_state(|| kind_bit(GameVersionKind::Release)), + query: use_state(String::new), + loader: use_state(|| LoaderChoice::Fabric), + loader_version: use_state(|| None::), + declined: use_state(|| None::>), + modpack_origin: use_state(|| ModpackOrigin::Browse), + details: DetailsState::blank(), + }; + let dispatch = use_dispatch(); + let browse_cluster = browse_cluster(); + + let picks = resolve(wizard); + let action = create_action(wizard, &picks); + let (title, subtitle) = heading(&picks); + + let first = picks.index == 0; + let last = picks.step == Step::Customize; + let modpack_step = picks.step == Step::Modpack; + let origin = picks.modpack_origin; + let index = picks.index; + let mut step = wizard.step; + let mut query = wizard.query; + + let close_x = self.on_close.clone(); + let close_cancel = self.on_close.clone(); + let close_created = self.on_close.clone(); + let close_modpack = self.on_close.clone(); + + shell(Shell { + rail: wizard_rail(wizard, &picks), + eyebrow: None, + title: title.to_string(), + subtitle, + body: steps::body(wizard, &picks), + scrolls_itself: picks.step == Step::Version, + note: footer_note(&picks), + secondary_label: if first { "Cancel" } else { "Back" }.to_string(), + primary_label: match (modpack_step, origin) { + (true, ModpackOrigin::Browse) => "Browse modpacks", + (true, ModpackOrigin::File) => "Choose file", + (false, _) if last => "Create instance", + (false, _) => "Next", + } + .to_string(), + primary_enabled: is_ready(&picks), + on_close: (move |()| close_x.call(())).into(), + on_secondary: (move |()| { + if first { + close_cancel.call(()); + } else { + step.set(index.saturating_sub(1)); + } + }) + .into(), + on_primary: (move |()| { + if modpack_step { + start_modpack( + origin, + browse_cluster, + dispatch.clone(), + close_modpack.clone(), + ); + } else if last { + if let Some(action) = action.clone() { + mutation.mutate(action); + close_created.call(()); + } + } else { + query.set(String::new()); + step.set(index + 1); + } + }) + .into(), + }) + } +} + +fn browse_cluster() -> i64 { + let clusters = settled_or_loading(&use_clusters()).unwrap_or_default(); + let active = *use_active_cluster_id().read(); + + default_cluster(clusters, active) + .map(|cluster| cluster.id) + .unwrap_or_default() +} + +fn start_modpack( + origin: ModpackOrigin, + cluster_id: i64, + dispatch: crate::Actions, + on_close: EventHandler<()>, +) { + match origin { + ModpackOrigin::Browse => { + on_close.call(()); + let _ = RouterContext::get().push(Route::Browser { + cluster_id, + package_type: MODPACK_BROWSE_TYPE.to_string(), + pick_cluster: true, + }); + } + ModpackOrigin::File => { + spawn(async move { + let Some(handle) = rfd::AsyncFileDialog::new() + .set_title("Choose a modpack") + .add_filter("Modpack", &MODPACK_EXTENSIONS) + .pick_file() + .await + else { + return; + }; + dispatch.install_modpack(ModpackSource::File(handle.path().to_path_buf())); + on_close.call(()); + }); + } + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/data.rs b/packages/oneclient_app/src/view/app/clusters/instance/data.rs new file mode 100644 index 00000000..3b8191da --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/data.rs @@ -0,0 +1,395 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use oneclient_cluster::naming::{MAX_NAME_CHARS, validate_instance_name}; +use oneclient_common::domain::GameLoader; +use oneclient_content::packages::ProviderId; +use oneclient_core::clusters::ClusterKind; +use oneclient_core::{BundleArchive, GameVersionKind}; + +use super::model::*; +use super::package_names; +use crate::hooks::{ + GameVersion, available_bundles, game_versions, java_majors, loader_versions, + package_meta_batch, query_error, settled_or_loading, use_available_bundles, use_game_versions, + use_java_majors, use_loader_versions, use_package_meta_batch, use_version_loaders, + use_versions, versions_metadata, +}; +use crate::utils::version_sort_key; + +pub struct VersionPicks { + pub chosen: Option, + pub list: VersionList, + pub loaded: bool, + pub error: Option, + pub filter: u8, +} + +pub struct LoaderPicks { + pub chosen: Option, + pub available: Option>, + pub version: Option, + pub versions: Arc<[String]>, +} + +pub struct BundlePicks { + pub archives: Arc<[BundleArchive]>, + pub loaded: bool, + pub names: HashMap, + pub declined: HashSet, +} + +impl BundlePicks { + pub fn taken(&self) -> Vec { + self.names_of_taken().collect() + } + + pub fn taken_count(&self) -> usize { + self.names_of_taken().count() + } + + fn names_of_taken(&self) -> impl Iterator + '_ { + self.archives + .iter() + .map(|archive| archive.manifest.name.clone()) + .filter(|name| !self.declined.contains(name)) + } +} + +pub struct Picks { + pub steps: &'static [Step], + pub index: usize, + pub step: Step, + pub choice: TypeChoice, + pub modpack_origin: ModpackOrigin, + pub kind: ClusterKind, + pub name: String, + pub suggested: String, + pub versions: VersionPicks, + pub loader: LoaderPicks, + pub bundles: BundlePicks, +} + +impl Picks { + pub fn loader_label(&self) -> String { + match (self.loader.chosen, self.loader.version.as_deref()) { + (None | Some(GameLoader::Vanilla), _) => "Vanilla".to_string(), + (Some(loader), Some(version)) => format!("{loader} {version}"), + (Some(loader), None) => loader.to_string(), + } + } +} + +fn matches_filter(kind: GameVersionKind, filter: u8) -> bool { + filter & kind_bit(kind) != 0 +} + +struct OneClientVersion { + id: String, + loader: GameLoader, +} + +struct Catalogue { + oneclient: Vec, + oneclient_settled: bool, + vanilla: Option>, + java: HashMap, + error: Option, +} + +impl Catalogue { + fn loader_for(&self, version: &str) -> Option { + self.oneclient + .iter() + .find(|entry| entry.id == version) + .map(|entry| entry.loader) + } + + fn lists(&self, version: &str) -> bool { + self.oneclient.iter().any(|entry| entry.id == version) + } +} + +fn use_catalogue(choice: TypeChoice) -> Catalogue { + let oneclient_query = use_versions(); + let vanilla_query = use_game_versions(); + + let metadata = versions_metadata(&oneclient_query); + let mut oneclient: Vec = metadata + .clone() + .unwrap_or_default() + .into_iter() + .filter_map(|entry| { + Some(OneClientVersion { + id: oneclient_common::version::format_mc_version( + entry.major_version, + entry.minor_version?, + entry.patch_version, + ), + loader: entry.loader.as_deref()?.parse().ok()?, + }) + }) + .collect(); + oneclient.sort_by_key(|entry| std::cmp::Reverse(version_sort_key(&entry.id))); + + let java_query = use_java_majors(if choice == TypeChoice::OneClient { + oneclient.iter().map(|entry| entry.id.clone()).collect() + } else { + Vec::new() + }); + + let error = if choice == TypeChoice::OneClient { + query_error(&oneclient_query) + } else { + query_error(&vanilla_query) + }; + + Catalogue { + oneclient, + oneclient_settled: metadata.is_some(), + vanilla: game_versions(&vanilla_query), + java: java_majors(&java_query), + error, + } +} + +struct BundleContext { + archives: Arc<[BundleArchive]>, + settled: bool, + names: HashMap, +} + +fn use_bundle_context( + version: Option<&String>, + loader: Option, + takes_bundles: bool, + wants_names: bool, +) -> BundleContext { + let key = match (takes_bundles, version) { + (true, Some(version)) => version.clone(), + _ => String::new(), + }; + + let query = use_available_bundles(key, loader.unwrap_or(GameLoader::Vanilla)); + let bundles = available_bundles(&query); + let archives = bundles.clone().unwrap_or_else(|| Arc::from([])); + + let mut modrinth_ids = Vec::new(); + let mut curseforge_ids = Vec::new(); + for file in archives.iter().flat_map(|archive| &archive.manifest.files) { + match file.kind.metadata_provider() { + ProviderId::Modrinth => modrinth_ids.push(file.kind.package_id()), + ProviderId::CurseForge => curseforge_ids.push(file.kind.package_id()), + ProviderId::Local => {} + } + } + + let modrinth = use_package_meta_batch(ProviderId::Modrinth, modrinth_ids); + let curseforge = use_package_meta_batch(ProviderId::CurseForge, curseforge_ids); + + BundleContext { + names: if wants_names { + package_names( + &archives, + &package_meta_batch(&modrinth), + &package_meta_batch(&curseforge), + ) + } else { + HashMap::new() + }, + archives, + settled: bundles.is_some(), + } +} + +fn build_version_list( + choice: TypeChoice, + catalogue: &Catalogue, + filter: u8, + needle: &str, +) -> VersionList { + let matches_needle = |id: &str| needle.is_empty() || id.to_lowercase().contains(needle); + + if choice == TypeChoice::OneClient { + return VersionList::Curated( + catalogue + .oneclient + .iter() + .filter(|entry| matches_needle(&entry.id)) + .map(|entry| VersionRow { + badge: catalogue + .java + .get(&entry.id) + .map(|major| format!("Java {major}")), + id: entry.id.clone(), + meta: entry.loader.to_string(), + date: String::new(), + }) + .collect(), + ); + } + + let all = catalogue.vanilla.clone().unwrap_or_else(|| Arc::from([])); + let visible = all + .iter() + .enumerate() + .filter(|(_, entry)| matches_filter(entry.kind, filter)) + .filter(|(_, entry)| matches_needle(&entry.id)) + .map(|(index, _)| index as u32) + .collect(); + + VersionList::Catalogue { all, visible } +} + +fn default_version(choice: TypeChoice, catalogue: &Catalogue, filter: u8) -> Option { + if choice == TypeChoice::OneClient { + return catalogue.oneclient.first().map(|entry| entry.id.clone()); + } + + let all = catalogue.vanilla.as_deref().unwrap_or(&[]); + let mut scoped = all + .iter() + .filter(|entry| matches_filter(entry.kind, filter)); + let first = scoped.clone().next(); + scoped + .find(|entry| entry.kind.is_release()) + .or(first) + .map(|entry| entry.id.clone()) +} + +fn kind_for(choice: TypeChoice, loader: Option) -> ClusterKind { + match (choice, loader) { + (TypeChoice::OneClient, _) => ClusterKind::OneClient, + (TypeChoice::Scratch, Some(GameLoader::Vanilla) | None) => ClusterKind::Vanilla, + (TypeChoice::Scratch, Some(_)) | (TypeChoice::Modpack, _) => ClusterKind::Modded, + } +} + +fn suggested_name( + version: Option<&String>, + kind: ClusterKind, + loader: Option, +) -> String { + let Some(version) = version else { + return String::new(); + }; + + let full = match (kind, loader) { + (ClusterKind::Vanilla, _) => format!("{version} Vanilla"), + (_, Some(loader)) => format!("{version} {loader}"), + (_, None) => version.clone(), + }; + if validate_instance_name(&full).is_ok() { + return full; + } + + version + .chars() + .take(MAX_NAME_CHARS) + .collect::() + .trim() + .to_string() +} + +pub fn resolve(w: Wizard) -> Picks { + let choice = *w.choice.read(); + let steps = step_order(choice); + let index = (*w.step.read()).min(steps.len().saturating_sub(1)); + let step = steps[index]; + + let catalogue = use_catalogue(choice); + + let filter = *w.filter.read(); + let needle = w.query.read().trim().to_lowercase(); + let versions = build_version_list(choice, &catalogue, filter, &needle); + + let oneclient = choice == TypeChoice::OneClient; + let versions_settled = if oneclient { + catalogue.oneclient_settled + } else { + catalogue.vanilla.is_some() + }; + let versions_error = catalogue.error.clone(); + + let still_offered = |chosen: &String| { + if oneclient { + catalogue.lists(chosen) + } else { + catalogue + .vanilla + .iter() + .flat_map(|all| all.iter()) + .any(|entry: &GameVersion| { + &entry.id == chosen && matches_filter(entry.kind, filter) + }) + } + }; + let version = (w.version.read().clone()) + .filter(still_offered) + .or_else(|| default_version(choice, &catalogue, filter)); + + let available_query = use_version_loaders(version.clone().unwrap_or_default()); + let available = settled_or_loading(&available_query); + let loader = match choice { + TypeChoice::OneClient => version.as_ref().and_then(|id| catalogue.loader_for(id)), + TypeChoice::Scratch | TypeChoice::Modpack => { + w.loader.read().resolve(available.as_deref().unwrap_or(&[])) + } + }; + + let loader_versions = loader_versions(&use_loader_versions( + version.clone().unwrap_or_default(), + loader.unwrap_or(GameLoader::Vanilla), + )); + let loader_version = match (choice, loader) { + (TypeChoice::Scratch, Some(loader)) if loader != GameLoader::Vanilla => { + (w.loader_version.read().clone()) + .filter(|chosen| loader_versions.contains(chosen)) + .or_else(|| loader_versions.first().cloned()) + } + _ => None, + }; + + let bundles = use_bundle_context(version.as_ref(), loader, oneclient, step == Step::Bundles); + + let kind = kind_for(choice, loader); + let suggested = suggested_name(version.as_ref(), kind, loader); + let declined = w.declined.read().clone().unwrap_or_else(|| { + bundles + .archives + .iter() + .filter(|archive| !archive.manifest.enabled) + .map(|archive| archive.manifest.name.clone()) + .collect() + }); + + Picks { + steps, + index, + step, + choice, + modpack_origin: *w.modpack_origin.read(), + kind, + name: w.details.effective_name(&suggested), + suggested, + versions: VersionPicks { + chosen: version, + list: versions, + loaded: versions_settled, + error: versions_error, + filter, + }, + loader: LoaderPicks { + chosen: loader, + available, + version: loader_version, + versions: loader_versions, + }, + bundles: BundlePicks { + archives: bundles.archives, + loaded: bundles.settled, + names: bundles.names, + declined, + }, + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/details.rs b/packages/oneclient_app/src/view/app/clusters/instance/details.rs new file mode 100644 index 00000000..713c9017 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/details.rs @@ -0,0 +1,579 @@ +use std::path::PathBuf; + +use freya::prelude::*; +use oneclient_cluster::naming::{MAX_TAG_CHARS, NameProblem, is_allowed_name_char, validate_tag}; + +use crate::components::{ + ART_PREVIEW_EDGE, Button, DynamicArt, GALLERY_COVER_EDGE, Icon, IconType, LocalImage, + TextInput, VersionArtGallery, +}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_RADIUS: f32 = 12.; +const TAG_ROW_H: f32 = 28.; +const PREVIEW_W: f32 = 132.; +const PREVIEW_H: f32 = 74.; + +const PRESET_TAGS: [&str; 9] = [ + "PVP", + "Performance", + "QoL", + "Skyblock", + "Bedwars", + "Test", + "Fun", + "Experimental", + "Shenanigans", +]; + +#[derive(Clone, Copy)] +pub struct DetailsState { + pub name: State, + pub name_touched: State, + pub description: State, + pub tags: State>, + pub tag_draft: State, + pub tag_open: State, + pub cover: State>, + pub cover_cleared: State, + pub gallery_open: State, + pub gallery_pending: State>, + pub gallery_error: State>, +} + +impl DetailsState { + pub fn blank() -> Self { + Self::init(String::new(), String::new(), Vec::new(), false) + } + + pub fn seeded(name: &str, description: Option<&str>, tags: &[String]) -> Self { + Self::init( + name.to_string(), + description.unwrap_or_default().to_string(), + tags.to_vec(), + true, + ) + } + + fn init(name: String, description: String, tags: Vec, touched: bool) -> Self { + Self { + name: use_state(move || name.clone()), + name_touched: use_state(move || touched), + description: use_state(move || description.clone()), + tags: use_state(move || tags.clone()), + tag_draft: use_state(String::new), + tag_open: use_state(|| false), + cover: use_state(|| None::), + cover_cleared: use_state(|| false), + gallery_open: use_state(|| false), + gallery_pending: use_state(|| None::), + gallery_error: use_state(|| None::), + } + } + + pub fn effective_name(&self, suggested: &str) -> String { + let typed = self.typed_name(); + if *self.name_touched.read() && !typed.is_empty() { + typed + } else { + suggested.to_string() + } + } + + pub fn typed_name(&self) -> String { + self.name.read().trim().to_string() + } + + pub fn description_value(&self) -> Option { + let text = self.description.read().trim().to_string(); + (!text.is_empty()).then_some(text) + } + + pub fn preview_cover(&self, existing: Option) -> Option<(PathBuf, bool)> { + if let Some(picked) = self.cover.read().clone() { + return Some((picked, true)); + } + if *self.cover_cleared.read() { + return None; + } + existing.map(|path| (path, false)) + } +} + +pub fn details_body( + state: DetailsState, + placeholder: String, + existing_cover: Option, + version_art: DynamicArt, + name_problem: Option, + name_limit: Option, +) -> Element { + let mut touched = state.name_touched; + let name = state.name; + let tags = state.tags.read().clone(); + let tag_open = *state.tag_open.read(); + let preview = state.preview_cover(existing_cover); + + rect() + .vertical() + .width(Size::fill()) + .spacing(18.) + .child(field( + "Name", + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + TextInput::new(state.name) + .placeholder(placeholder) + .width(Size::fill()) + .on_validate(move |validator: InputValidator| { + touched.set(true); + let next = validator.text(); + let current = name.peek(); + validator.set_valid( + name_limit.is_none_or(|limit| within_limit(&next, ¤t, limit)) + && no_new_forbidden(&next, ¤t), + ); + }), + ) + .maybe_child(name_problem.map(|problem| { + label() + .text(problem.message()) + .font_size(12.) + .color(colors::danger()) + .into_element() + })) + .into_element(), + )) + .child(field( + "Description", + TextInput::new(state.description) + .placeholder("What is this instance for?") + .multiline(true) + .width(Size::fill()) + .height(Size::px(76.)) + .into_element(), + )) + .child(field("Tags", tag_field(state, &tags, tag_open))) + .child(field( + "Background image", + cover_field(state, preview, version_art), + )) + .into_element() +} + +fn no_new_forbidden(next: &str, current: &str) -> bool { + let forbidden = |text: &str| text.chars().filter(|c| !is_allowed_name_char(*c)).count(); + forbidden(next) <= forbidden(current) +} + +fn within_limit(next: &str, current: &str, limit: usize) -> bool { + let count = next.chars().count(); + count <= limit || count < current.chars().count() +} + +fn add_tag(state: DetailsState, tag: &str) { + let mut all_tags = state.tags; + + let tag = tag.trim(); + if validate_tag(tag).is_err() || already_taken(&all_tags.read(), tag) { + return; + } + + let mut next = all_tags.read().clone(); + next.push(tag.to_string()); + all_tags.set(next); +} + +fn already_taken(tags: &[String], candidate: &str) -> bool { + tags.iter() + .any(|tag| tag.eq_ignore_ascii_case(candidate.trim())) +} + +fn commit_tag(state: DetailsState) { + let mut tag_draft = state.tag_draft; + let mut tag_open = state.tag_open; + + let draft = tag_draft.read().clone(); + add_tag(state, &draft); + tag_draft.set(String::new()); + tag_open.set(false); +} + +fn tag_field(state: DetailsState, tags: &[String], open: bool) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child(chosen_tags(state, tags, open)) + .maybe_child(preset_tags(state, tags)) + .into_element() +} + +fn preset_tags(state: DetailsState, tags: &[String]) -> Option { + let free: Vec<&'static str> = PRESET_TAGS + .into_iter() + .filter(|preset| !already_taken(tags, preset)) + .collect(); + + if free.is_empty() { + return None; + } + + Some( + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text("Suggested") + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::wrap_spacing(6.)) + .spacing(6.) + .children(free.into_iter().map(|preset| { + PresetChip { + tags: state.tags, + preset, + } + .into_element() + })), + ) + .into_element(), + ) +} + +#[derive(PartialEq)] +struct PresetChip { + tags: State>, + preset: &'static str, +} + +impl Component for PresetChip { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + let hovered = *hovering.read(); + + let mut tags = self.tags; + let preset = self.preset; + + rect() + .key(preset) + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .height(Size::px(26.)) + .padding(Gaps::new_symmetric(0., 10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if hovered { + colors::component_bg_hover() + } else { + colors::component_bg() + }) + .border(border_all_color( + 1., + if hovered { + colors::component_border_hover() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .on_press(move |_| { + if !already_taken(&tags.read(), preset) { + let mut next = tags.read().clone(); + next.push(preset.to_string()); + tags.set(next); + } + }) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .child( + Icon::new(IconType::Plus) + .size(9.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(preset) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + } +} + +fn chosen_tags(state: DetailsState, tags: &[String], open: bool) -> Element { + let mut tag_draft = state.tag_draft; + let mut tag_open = state.tag_open; + let mut all_tags = state.tags; + let draft_valid = validate_tag(&tag_draft.read()).is_ok(); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::wrap_spacing(8.)) + .cross_align(Alignment::Center) + .spacing(8.) + .children(tags.iter().map(|tag| { + let removed = tag.clone(); + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .height(Size::px(TAG_ROW_H)) + .padding(Gaps::new_symmetric(0., 10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::brand()) + .cursor(CursorIcon::Pointer) + .on_press(move |_| { + let next: Vec = all_tags + .read() + .iter() + .filter(|tag| *tag != &removed) + .cloned() + .collect(); + all_tags.set(next); + }) + .child( + label() + .text(tag.clone()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(Color::WHITE), + ) + .child(Icon::new(IconType::XClose).size(10.).color(Color::WHITE)) + .into_element() + })) + .child(if open { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + rect().width(Size::px(132.)).child( + TextInput::new(tag_draft) + .compact() + .placeholder("Add a tag") + .auto_focus(true) + .width(Size::fill()) + .height(Size::px(TAG_ROW_H)) + .on_validate(move |validator: InputValidator| { + let fits = within_limit( + &validator.text(), + &tag_draft.peek(), + MAX_TAG_CHARS, + ); + validator.set_valid(fits); + }) + .on_submit(move |_| commit_tag(state)) + .into_element(), + ), + ) + .child( + Button::new() + .primary() + .icon() + .alt("Add this tag") + .width(Size::px(TAG_ROW_H)) + .height(Size::px(TAG_ROW_H)) + .enabled(draft_valid) + .on_press(move |_| commit_tag(state)) + .child(Icon::new(IconType::Check).size(13.)), + ) + .into_element() + } else { + Button::new() + .secondary() + .icon() + .alt("Add a tag") + .width(Size::px(TAG_ROW_H)) + .height(Size::px(TAG_ROW_H)) + .on_press(move |_| { + tag_draft.set(String::new()); + tag_open.set(true); + }) + .child(Icon::new(IconType::Plus).size(14.)) + .into_element() + }) + .into_element() +} + +fn cover_field( + state: DetailsState, + preview: Option<(PathBuf, bool)>, + version_art: DynamicArt, +) -> Element { + let mut cover = state.cover; + let mut cleared = state.cover_cleared; + + let mut gallery_open = state.gallery_open; + let mut gallery_pending = state.gallery_pending; + let mut gallery_error = state.gallery_error; + + let pick = move |_| { + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new() + .set_title("Choose a background image") + .add_filter("Image", &["png", "jpg", "jpeg", "webp", "gif"]) + .pick_file() + .await + { + cover.set(Some(handle.path().to_path_buf())); + cleared.set(false); + } + }); + }; + + let pick_from_gallery = move |url: String| { + spawn(async move { + let mut failed = |message: &str| { + gallery_pending.set(None); + gallery_error.set(Some(message.to_string())); + }; + + let state = match crate::launcher::state() { + Ok(state) => state, + Err(err) => { + tracing::warn!(error = %err, "cannot reach the launcher state to pick gallery art"); + failed("The launcher is still starting up. Try again in a moment."); + return; + } + }; + + match state + .images + .ensure_on_disk(&state.services.requester, &url, GALLERY_COVER_EDGE) + .await + { + Ok(path) => { + cover.set(Some(path)); + cleared.set(false); + gallery_pending.set(None); + gallery_error.set(None); + gallery_open.set(false); + } + Err(err) => { + tracing::warn!(error = %err, "failed to cache the chosen gallery art"); + failed("That artwork could not be downloaded. Check your connection."); + } + } + }); + }; + + let has_cover = preview.is_some(); + let gallery = (*gallery_open.read()).then(|| { + rect() + .width(Size::px(0.)) + .height(Size::px(0.)) + .child(VersionArtGallery::new( + gallery_pending, + gallery_error, + pick_from_gallery, + move |()| { + gallery_pending.set(None); + gallery_error.set(None); + gallery_open.set(false); + }, + )) + .into_element() + }); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(14.) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(CARD_RADIUS)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .width(Size::px(PREVIEW_W)) + .height(Size::px(PREVIEW_H)) + .corner_radius(CornerRadius::new_all(8.)) + .overflow(Overflow::Clip) + .border(border_all_color(1., colors::component_border())) + .center() + .child(match preview { + Some((path, picked)) => LocalImage::new(path, ART_PREVIEW_EDGE, true) + .picked(picked) + .skeleton(true) + .into_element(), + None => version_art.max_edge(ART_PREVIEW_EDGE).into_element(), + }), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(8.) + .child( + label() + .text("Shown behind the instance on the home page and on its card. PNG or JPG, 1280x720 or larger.") + .font_size(12.) + .line_height(1.4) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .spacing(8.) + .cross_align(Alignment::Center) + .child( + Button::new() + .secondary() + .small() + .on_press(pick) + .text("Choose file"), + ) + .child( + Button::new() + .secondary() + .small() + .on_press(move |_| gallery_open.set(true)) + .text("Browse gallery"), + ) + .maybe_child(has_cover.then(|| { + Button::new() + .ghost() + .small() + .on_press(move |_| { + cover.set(None); + cleared.set(true); + }) + .text("Use version art") + .into_element() + })), + ), + ) + .maybe_child(gallery) + .into_element() +} + +pub fn field(title: &'static str, control: Element) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + label() + .text(title) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child(control) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/edit.rs b/packages/oneclient_app/src/view/app/clusters/instance/edit.rs new file mode 100644 index 00000000..73a20f70 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/edit.rs @@ -0,0 +1,152 @@ +use std::path::PathBuf; + +use freya::prelude::*; +use oneclient_cluster::naming::{MAX_NAME_CHARS, validate_name}; +use oneclient_common::domain::GameLoader; +use oneclient_core::clusters::ClusterKind; + +use super::details::{DetailsState, details_body}; +use super::rail::{Rail, facts_card, rail, version_art}; +use super::shell::{Shell, shell}; +use crate::hooks::{ClusterAction, use_cluster_mutation}; + +#[derive(Clone, PartialEq)] +pub struct InstanceFacts { + pub cluster_id: i64, + pub name: String, + pub description: Option, + pub tags: Vec, + pub cover: Option, + pub mc_version: String, + pub mc_loader: GameLoader, + pub kind: ClusterKind, + pub modpack: bool, +} + +#[derive(PartialEq)] +pub struct EditInstanceModal { + facts: InstanceFacts, + on_close: EventHandler<()>, +} + +impl EditInstanceModal { + pub fn new(facts: InstanceFacts, on_close: impl Into>) -> Self { + Self { + facts, + on_close: on_close.into(), + } + } +} + +fn name_limit(modpack: bool) -> Option { + (!modpack).then_some(MAX_NAME_CHARS) +} + +fn kind_label(kind: ClusterKind) -> &'static str { + match kind { + ClusterKind::OneClient => "OneClient", + ClusterKind::Vanilla => "Vanilla", + ClusterKind::Modded => "Modded", + } +} + +impl Component for EditInstanceModal { + fn render(&self) -> impl IntoElement { + let mutation = use_cluster_mutation(); + let cluster_id = self.facts.cluster_id; + + let state = DetailsState::seeded( + &self.facts.name, + self.facts.description.as_deref(), + &self.facts.tags, + ); + + let existing = self.facts.cover.clone(); + let preview = state.preview_cover(existing.clone()); + let tags = state.tags.read().clone(); + let typed = state.typed_name(); + let name_problem = if typed == self.facts.name { + None + } else { + validate_name(&typed, name_limit(self.facts.modpack)).err() + }; + let ready = !typed.is_empty() && name_problem.is_none(); + + let description = state.description.read().trim().to_string(); + let subtitle = if description.is_empty() { + format!( + "{} · {}", + kind_label(self.facts.kind), + self.facts.mc_version + ) + } else { + description + }; + + let title = if typed.is_empty() { + self.facts.name.clone() + } else { + typed.clone() + }; + + let art = version_art(Some(&self.facts.mc_version), Some(self.facts.mc_loader)); + let art = match &preview { + Some((path, true)) => art.picked_cover(Some(path.clone())), + Some((path, false)) => art.cover(Some(path.clone())), + None => art, + }; + + let loader = self.facts.mc_loader.to_string(); + + let close_x = self.on_close.clone(); + let close_cancel = self.on_close.clone(); + let close_save = self.on_close.clone(); + + shell(Shell { + rail: rail(Rail { + art, + title, + subtitle, + card: facts_card( + vec![ + ("Type", kind_label(self.facts.kind).to_string()), + ("Version", self.facts.mc_version.clone()), + ("Loader", loader), + ], + ), + tags, + }), + eyebrow: Some("Instance details".to_string()), + title: "Edit the instance".to_string(), + subtitle: "Only how it is presented changes here. The version, loader and installed content stay as they are." + .to_string(), + body: details_body( + state, + self.facts.name.clone(), + existing, + version_art(Some(&self.facts.mc_version), Some(self.facts.mc_loader)), + name_problem, + name_limit(self.facts.modpack), + ), + scrolls_itself: false, + note: "Changes apply straight away.".to_string(), + secondary_label: "Cancel".to_string(), + primary_label: "Save changes".to_string(), + primary_enabled: ready, + on_close: (move |()| close_x.call(())).into(), + on_secondary: (move |()| close_cancel.call(())).into(), + on_primary: (move |()| { + mutation.mutate(ClusterAction::UpdateInstance { + cluster_id, + name: state.typed_name(), + description: state.description_value(), + tags: state.tags.read().clone(), + cover_source: state.cover.read().clone(), + clear_cover: *state.cover_cleared.read(), + }); + close_save.call(()); + }) + .into(), + }) + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/mod.rs b/packages/oneclient_app/src/view/app/clusters/instance/mod.rs new file mode 100644 index 00000000..c482a4e6 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/mod.rs @@ -0,0 +1,95 @@ +use std::collections::HashMap; + +use oneclient_content::packages::{CachedPackageMeta, ProviderId}; +use oneclient_core::BundleArchive; + +mod cards; +mod copy; +mod create; +mod data; +mod details; +mod edit; +mod model; +mod rail; +mod shell; +mod steps; + +pub use create::CreateInstanceModal; +pub use edit::{EditInstanceModal, InstanceFacts}; + +pub fn tidy_file_name(raw: &str) -> String { + let stem = raw + .rsplit_once('.') + .filter(|(head, ext)| !head.is_empty() && ext.len() <= 8) + .map_or(raw, |(head, _)| head); + + let bytes = stem.as_bytes(); + let cut = bytes.iter().enumerate().position(|(index, byte)| { + matches!(byte, b'-' | b'_' | b'+') && bytes.get(index + 1).is_some_and(u8::is_ascii_digit) + }); + + match cut { + Some(0) | None => stem.to_string(), + Some(cut) => stem[..cut].to_string(), + } +} + +pub fn package_names( + archives: &[BundleArchive], + modrinth: &HashMap, + curseforge: &HashMap, +) -> HashMap { + let mut names = HashMap::new(); + + for file in archives.iter().flat_map(|archive| &archive.manifest.files) { + let package_id = file.kind.package_id(); + let meta = match file.kind.metadata_provider() { + ProviderId::Modrinth => modrinth.get(&package_id), + ProviderId::CurseForge => curseforge.get(&package_id), + ProviderId::Local => None, + }; + + let name = meta + .map(|meta| meta.name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| tidy_file_name(&file.display_name())); + + names.insert(package_id, name); + } + + names +} + +#[cfg(test)] +mod tests { + use super::tidy_file_name; + + #[test] + fn a_versioned_jar_keeps_only_its_name() { + assert_eq!( + tidy_file_name("sodium-fabric-0.5.11+mc1.20.1.jar"), + "sodium-fabric" + ); + assert_eq!(tidy_file_name("EvergreenHUD-3.0.0.jar"), "EvergreenHUD"); + assert_eq!(tidy_file_name("Sodium-13.303x012"), "Sodium"); + } + + #[test] + fn a_name_without_a_version_survives_whole() { + assert_eq!(tidy_file_name("OneConfig.jar"), "OneConfig"); + assert_eq!(tidy_file_name("PolyBlur"), "PolyBlur"); + } + + #[test] + fn a_leading_separator_is_not_a_version_cut() { + assert_eq!(tidy_file_name("-1abc"), "-1abc"); + } + + #[test] + fn a_dotted_name_is_not_mistaken_for_an_extension() { + assert_eq!( + tidy_file_name("com.example.longextension"), + "com.example.longextension" + ); + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/model.rs b/packages/oneclient_app/src/view/app/clusters/instance/model.rs new file mode 100644 index 00000000..c908490b --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/model.rs @@ -0,0 +1,205 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use freya::prelude::*; +use oneclient_common::domain::GameLoader; +use oneclient_core::GameVersionKind; + +use super::details::DetailsState; +use crate::hooks::GameVersion; + +pub const VERSION_KINDS: [(&str, GameVersionKind); 4] = [ + ("Releases", GameVersionKind::Release), + ("Snapshots", GameVersionKind::Snapshot), + ("Beta", GameVersionKind::Beta), + ("Alpha", GameVersionKind::Alpha), +]; + +pub fn kind_bit(kind: GameVersionKind) -> u8 { + let index = VERSION_KINDS + .iter() + .position(|(_, k)| *k == kind) + .unwrap_or(0); + 1 << index +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum TypeChoice { + OneClient, + Scratch, + Modpack, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ModpackOrigin { + Browse, + File, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LoaderChoice { + Fabric, + Forge, + NeoForge, + Vanilla, +} + +impl LoaderChoice { + pub const ALL: [Self; 4] = [Self::Fabric, Self::Forge, Self::NeoForge, Self::Vanilla]; + + pub fn primary(self) -> GameLoader { + match self { + Self::Fabric => GameLoader::Fabric, + Self::Forge => GameLoader::Forge, + Self::NeoForge => GameLoader::NeoForge, + Self::Vanilla => GameLoader::Vanilla, + } + } + + pub fn name(self) -> String { + self.primary().to_string() + } + + pub fn mark(self) -> &'static str { + match self { + Self::Fabric => "icons/fabric.png", + Self::Forge => "icons/forge.png", + Self::NeoForge => "icons/neo-forge.png", + Self::Vanilla => "icons/vanilla.png", + } + } + + pub fn short(self) -> &'static str { + match self { + Self::Fabric => "FA", + Self::Forge => "FO", + Self::NeoForge => "NF", + Self::Vanilla => "MC", + } + } + + pub fn blurb(self) -> &'static str { + match self { + Self::Fabric => { + "Light and quick to start. The widest package selection on modern versions, and the legacy build covers 1.8.9 and older." + } + Self::Forge => "The oldest loader. Most 1.12.2 and 1.8.9 packages need it.", + Self::NeoForge => "Forge's successor, for 1.20.2 and newer.", + Self::Vanilla => { + "Minecraft exactly as Mojang ships it. Packages cannot be installed without a loader." + } + } + } + + pub fn resolve(self, available: &[GameLoader]) -> Option { + match self { + Self::Vanilla => Some(GameLoader::Vanilla), + Self::Fabric => available + .contains(&GameLoader::Fabric) + .then_some(GameLoader::Fabric) + .or_else(|| { + available + .contains(&GameLoader::Ornithe) + .then_some(GameLoader::Ornithe) + }), + other => { + let loader = other.primary(); + available.contains(&loader).then_some(loader) + } + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Step { + Type, + Loader, + Version, + Bundles, + Customize, + Modpack, +} + +impl Step { + pub fn label(self) -> &'static str { + match self { + Self::Type => "Type", + Self::Loader => "Loader", + Self::Version => "Version", + Self::Bundles => "Bundles", + Self::Customize => "Details", + Self::Modpack => "Source", + } + } +} + +const ONECLIENT_STEPS: [Step; 4] = [Step::Type, Step::Version, Step::Bundles, Step::Customize]; +const SCRATCH_STEPS: [Step; 4] = [Step::Type, Step::Version, Step::Loader, Step::Customize]; +const MODPACK_STEPS: [Step; 2] = [Step::Type, Step::Modpack]; + +pub fn step_order(choice: TypeChoice) -> &'static [Step] { + match choice { + TypeChoice::OneClient => &ONECLIENT_STEPS, + TypeChoice::Scratch => &SCRATCH_STEPS, + TypeChoice::Modpack => &MODPACK_STEPS, + } +} + +#[derive(Clone)] +pub struct VersionRow { + pub id: String, + pub meta: String, + pub badge: Option, + pub date: String, +} + +#[derive(Clone)] +pub enum VersionList { + Curated(Arc<[VersionRow]>), + Catalogue { + all: Arc<[GameVersion]>, + visible: Arc<[u32]>, + }, +} + +impl VersionList { + pub fn len(&self) -> usize { + match self { + Self::Curated(rows) => rows.len(), + Self::Catalogue { visible, .. } => visible.len(), + } + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub fn row(&self, index: usize) -> Option { + match self { + Self::Curated(rows) => rows.get(index).cloned(), + Self::Catalogue { all, visible } => { + let entry = all.get(*visible.get(index)? as usize)?; + Some(VersionRow { + id: entry.id.clone(), + meta: String::new(), + badge: (!entry.kind.is_release()).then(|| entry.kind.label().to_string()), + date: entry.released.clone(), + }) + } + } + } +} + +#[derive(Clone, Copy)] +pub struct Wizard { + pub step: State, + pub choice: State, + pub version: State>, + pub filter: State, + pub query: State, + pub loader: State, + pub loader_version: State>, + pub declined: State>>, + pub modpack_origin: State, + pub details: DetailsState, +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/rail.rs b/packages/oneclient_app/src/view/app/clusters/instance/rail.rs new file mode 100644 index 00000000..892b7992 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/rail.rs @@ -0,0 +1,198 @@ +use freya::prelude::*; +use oneclient_common::domain::GameLoader; +use oneclient_common::parse_mc_version; + +use super::shell::RAIL_WIDTH; +use crate::components::DynamicArt; + +const HAIRLINE: Color = Color::from_argb(31, 255, 255, 255); +const CHIP_BG: Color = Color::from_argb(26, 255, 255, 255); +const SUB: Color = Color::from_argb(184, 213, 219, 255); +const MUTED: Color = Color::from_argb(140, 213, 219, 255); + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum RowState { + Done, + Current, + Pending, +} + +pub struct Rail { + pub art: DynamicArt, + pub title: String, + pub subtitle: String, + pub card: Element, + pub tags: Vec, +} + +pub fn version_art(version: Option<&str>, loader: Option) -> DynamicArt { + match version.and_then(parse_mc_version) { + Some(parsed) => DynamicArt::for_version(parsed.major, parsed.key(), loader), + None => DynamicArt::fallback(), + } +} + +pub fn steps_card(rows: Vec<(&'static str, String, RowState)>) -> Element { + rows_list( + rows.into_iter() + .map(|(name, value, state)| { + let name_color = match state { + RowState::Current => Color::WHITE, + RowState::Done => SUB, + RowState::Pending => MUTED, + }; + row(name, &value, name_color) + }) + .collect(), + ) +} + +pub fn facts_card(rows: Vec<(&'static str, String)>) -> Element { + rows_list( + rows.into_iter() + .map(|(name, value)| row(name, &value, SUB)) + .collect(), + ) +} + +fn rows_list(rows: Vec) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .children(rows) + .into_element() +} + +fn row(name: &'static str, value: &str, name_color: Color) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + label() + .text(name) + .width(Size::px(76.)) + .font_size(13.) + .max_lines(1) + .color(name_color), + ) + .child( + label() + .text(value.to_string()) + .width(Size::flex(1.0)) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .text_align(TextAlign::Right) + .color(Color::WHITE), + ) + .into_element() +} + +pub fn rail(parts: Rail) -> Element { + let Rail { + art, + title, + subtitle, + card, + tags, + } = parts; + + rect() + .width(Size::px(RAIL_WIDTH)) + .height(Size::fill()) + .overflow(Overflow::Clip) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .child(art), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .layer(Layer::Relative(3)) + .main_align(Alignment::End) + .padding(Gaps::new_all(24.)) + .spacing(18.) + .background( + LinearGradient::new() + .angle(0.) + .stop((Color::from_argb(77, 11, 16, 19), 0.)) + .stop((Color::from_argb(26, 11, 16, 19), 20.)) + .stop((Color::from_argb(204, 11, 16, 19), 56.)) + .stop((Color::from_argb(247, 11, 16, 19), 100.)), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(7.) + .child( + label() + .text(title) + .font_size(30.) + .font_weight(FontWeight::SEMI_BOLD) + .line_height(1.06) + .max_lines(2) + .color(Color::WHITE), + ) + .child( + label() + .text(subtitle) + .font_size(13.) + .line_height(1.45) + .max_lines(3) + .color(SUB), + ), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(14.) + .child(card) + .maybe_child((!tags.is_empty()).then(|| { + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .background(HAIRLINE), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::wrap_spacing(6.)) + .spacing(6.) + .children(tags.iter().map(|tag| { + rect() + .padding(Gaps::new_symmetric(3., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(CHIP_BG) + .child( + label() + .text(tag.clone()) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(Color::WHITE), + ) + .into_element() + })), + ) + .into_element() + })), + ), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/shell.rs b/packages/oneclient_app/src/view/app/clusters/instance/shell.rs new file mode 100644 index 00000000..de78930d --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/shell.rs @@ -0,0 +1,214 @@ +use freya::prelude::*; + +use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::theme::colors; +use crate::ui::border_all_color; + +pub const DIALOG_WIDTH: f32 = 1020.; +pub const DIALOG_HEIGHT: f32 = 660.; +pub const RAIL_WIDTH: f32 = 368.; +pub const PANE_PADDING: f32 = 24.; + +pub struct Shell { + pub rail: Element, + pub eyebrow: Option, + pub title: String, + pub subtitle: String, + pub body: Element, + pub scrolls_itself: bool, + pub note: String, + pub secondary_label: String, + pub primary_label: String, + pub primary_enabled: bool, + pub on_close: EventHandler<()>, + pub on_secondary: EventHandler<()>, + pub on_primary: EventHandler<()>, +} + +pub fn shell(parts: Shell) -> Element { + let Shell { + rail, + eyebrow, + title, + subtitle, + body, + scrolls_itself, + note, + secondary_label, + primary_label, + primary_enabled, + on_close, + on_secondary, + on_primary, + } = parts; + + let close_scrim = on_close.clone(); + + OverlayPopup::new() + .on_close(move |()| close_scrim.call(())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .horizontal() + .width(Size::px(DIALOG_WIDTH)) + .height(Size::px(DIALOG_HEIGHT)) + .max_width(Size::window_percent(94.)) + .max_height(Size::window_percent(92.)) + .content(Content::Flex) + .corner_radius(CornerRadius::new_all(16.)) + .overflow(Overflow::Clip) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child(rail) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .content(Content::Flex) + .child(header(eyebrow, title, subtitle, on_close)) + .child(if scrolls_itself { + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new(0., PANE_PADDING, 8., PANE_PADDING)) + .child(body) + .into_element() + } else { + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new(0., PANE_PADDING, 8., PANE_PADDING)) + .scrollbar_gutter(true) + .child(body) + .into_element() + }) + .child(footer( + note, + secondary_label, + primary_label, + primary_enabled, + on_secondary, + on_primary, + )), + ), + ), + ) + .into_element() +} + +fn header( + eyebrow: Option, + title: String, + subtitle: String, + on_close: EventHandler<()>, +) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Start) + .spacing(16.) + .padding(Gaps::new(22., PANE_PADDING, 16., PANE_PADDING)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(5.) + .maybe_child(eyebrow.map(|eyebrow| { + label() + .text(eyebrow) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .letter_spacing(1.6) + .color(colors::fg_secondary()) + })) + .child( + label() + .text(title) + .font_size(20.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(13.) + .line_height(1.35) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .ghost() + .icon() + .alt("Close") + .on_press(move |_| on_close.call(())) + .child(Icon::new(IconType::XClose).size(16.)), + ) + .into_element() +} + +fn footer( + note: String, + secondary_label: String, + primary_label: String, + primary_enabled: bool, + on_secondary: EventHandler<()>, + on_primary: EventHandler<()>, +) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(20.) + .padding(Gaps::new(16., PANE_PADDING, 16., PANE_PADDING)) + .border( + Border::new() + .fill(colors::component_border()) + .width(BorderWidth { + top: 1., + right: 0., + bottom: 0., + left: 0., + }), + ) + .child( + label() + .text(note) + .width(Size::flex(1.0)) + .font_size(12.) + .line_height(1.35) + .max_lines(2) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .spacing(10.) + .cross_align(Alignment::Center) + .child( + Button::new() + .ghost() + .on_press(move |_| on_secondary.call(())) + .text(secondary_label), + ) + .child( + Button::new() + .primary() + .enabled(primary_enabled) + .on_press(move |_| { + if primary_enabled { + on_primary.call(()); + } + }) + .text(primary_label), + ), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/clusters/instance/steps.rs b/packages/oneclient_app/src/view/app/clusters/instance/steps.rs new file mode 100644 index 00000000..95612996 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/instance/steps.rs @@ -0,0 +1,486 @@ +use std::collections::HashMap; + +use freya::prelude::*; +use oneclient_cluster::naming::{MAX_NAME_CHARS, validate_instance_name}; +use oneclient_core::BundleArchive; + +use super::cards::{ + BUNDLE_CARD_H, BUNDLE_COLUMNS, CARD_RADIUS, CellCard, LOADER_CARD_H, LOADER_COLUMNS, + LOADER_MARK_SIZE, VERSION_ROW_H, VERSION_ROW_SPACING, WideCard, cell_card, loader_mark, + version_row, wide_card, +}; +use super::data::Picks; +use super::details::details_body; +use super::model::{LoaderChoice, ModpackOrigin, Step, TypeChoice, Wizard}; +use super::model::{VERSION_KINDS, kind_bit}; +use super::rail::version_art; +use crate::components::{ + Dropdown, GRID_GAP, Icon, IconType, ScrollArea, TextInput, centered_spinner, +}; +use crate::theme::colors; +use crate::ui::{border_all_color, centered_note, fixed_grid, note}; +use crate::utils::bundle_display_name; + +pub fn body(wizard: Wizard, picks: &Picks) -> Element { + let (key, inner) = match picks.step { + Step::Type => ("step-type", type_step(wizard, picks)), + Step::Loader => ("step-loader", loader_step(wizard, picks)), + Step::Version => ("step-version", version_step(wizard, picks)), + Step::Bundles => ("step-bundles", bundles_step(wizard, picks)), + Step::Modpack => ("step-modpack", modpack_step(wizard, picks)), + Step::Customize => ( + "step-details", + details_body( + wizard.details, + picks.suggested.clone(), + None, + version_art(picks.versions.chosen.as_deref(), picks.loader.chosen), + validate_instance_name(&picks.name).err(), + Some(MAX_NAME_CHARS), + ), + ), + }; + + rect() + .key(key) + .width(Size::fill()) + .height(if picks.step == Step::Version { + Size::fill() + } else { + Size::auto() + }) + .child(inner) + .into_element() +} + +fn type_step(mut wizard: Wizard, picks: &Picks) -> Element { + let cards = [ + ( + TypeChoice::OneClient, + IconType::IconLogo, + "OneClient", + Some("Recommended"), + "The most bleeding edge performance, QoL mods and world hosting in one instance.", + "Shares configs, worlds, and packs with your other OneClient instances.", + ), + ( + TypeChoice::Scratch, + IconType::Sliders04, + "Start from scratch", + None, + "Pick a loader and version, then add mods yourself.", + "Has its own game folder.", + ), + ( + TypeChoice::Modpack, + IconType::DownloadCloud02, + "Modpack", + None, + "Install a pack from Modrinth or CurseForge, or a file you downloaded.", + "Has its own game folder.", + ), + ]; + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .children( + cards + .into_iter() + .map(|(choice, icon, title, badge, blurb, meta)| { + wide_card(WideCard { + icon, + title: title.to_string(), + badge: badge.map(str::to_string), + blurb: blurb.to_string(), + meta: meta.to_string(), + selected: picks.choice == choice, + on_press: (move |()| { + wizard.choice.set(choice); + wizard.version.set(None); + wizard.declined.set(None); + }) + .into(), + }) + }), + ) + .into_element() +} + +fn modpack_step(wizard: Wizard, picks: &Picks) -> Element { + let cards = [ + ( + ModpackOrigin::Browse, + IconType::Globe01, + "Browse modpacks", + "Search Modrinth and CurseForge. Installing a pack from there creates its instance.", + "Opens the browser on the Modpacks tab.", + ), + ( + ModpackOrigin::File, + IconType::File02, + "Import a file", + "Use a modpack you already downloaded or got from someone else.", + "Modrinth .mrpack or CurseForge .zip.", + ), + ]; + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .children(cards.into_iter().map(|(origin, icon, title, blurb, meta)| { + let mut chosen = wizard.modpack_origin; + wide_card(WideCard { + icon, + title: title.to_string(), + badge: None, + blurb: blurb.to_string(), + meta: meta.to_string(), + selected: picks.modpack_origin == origin, + on_press: (move |()| chosen.set(origin)).into(), + }) + })) + .into_element() +} + +fn select_loader(mut wizard: Wizard, choice: LoaderChoice) { + wizard.loader.set(choice); + wizard.loader_version.set(None); +} + +fn loader_step(wizard: Wizard, picks: &Picks) -> Element { + let chosen = *wizard.loader.read(); + + let cards: Vec = LoaderChoice::ALL + .into_iter() + .map(|choice| { + let offered = picks + .loader + .available + .as_ref() + .is_none_or(|available| choice.resolve(available).is_some()); + cell_card(CellCard { + id: choice.name(), + title: choice.name(), + blurb: choice.blurb().to_string(), + meta: (!offered).then(|| { + let version = picks.versions.chosen.as_deref(); + format!("No build for {}", version.unwrap_or("this version")) + }), + corner: loader_mark(choice, chosen == choice, LOADER_MARK_SIZE), + selected: offered && chosen == choice, + checkbox: false, + on_press: (move |()| { + if offered { + select_loader(wizard, choice); + } + }) + .into(), + }) + }) + .collect(); + + let shows_versions = chosen != LoaderChoice::Vanilla && !picks.loader.versions.is_empty(); + + rect() + .vertical() + .width(Size::fill()) + .spacing(GRID_GAP) + .child( + rect() + .key("loader-grid") + .width(Size::fill()) + .child(fixed_grid(cards, LOADER_COLUMNS, LOADER_CARD_H, GRID_GAP)), + ) + .maybe_child(shows_versions.then(|| { + rect() + .key("loader-version") + .width(Size::fill()) + .child(loader_version_card(wizard, picks, chosen)) + .into_element() + })) + .into_element() +} + +fn loader_version_card(mut wizard: Wizard, picks: &Picks, chosen: LoaderChoice) -> Element { + let options = picks.loader.versions.to_vec(); + let selected = picks.loader.version.clone().unwrap_or_default(); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_all(16.)) + .corner_radius(CornerRadius::new_all(CARD_RADIUS)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(4.) + .child( + label() + .text(format!("{} version", chosen.name())) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text("The newest build is picked for you. Change it if a package needs an older one.") + .font_size(12.) + .line_height(1.4) + .color(colors::fg_secondary()), + ), + ) + .child( + Dropdown::new(selected, options.clone()) + .width(Size::px(200.)) + .height(Size::px(32.)) + .on_select(move |index: usize| { + if let Some(chosen) = options.get(index) { + wizard.loader_version.set(Some(chosen.clone())); + } + }), + ) + .into_element() +} + +fn version_step(wizard: Wizard, picks: &Picks) -> Element { + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .spacing(12.) + .child( + rect() + .key("version-controls") + .width(Size::fill()) + .child(version_controls(wizard, picks)), + ) + .child( + rect() + .key("version-list") + .width(Size::fill()) + .height(Size::flex(1.0)) + .child(version_list(wizard, picks)), + ) + .into_element() +} + +fn version_controls(mut wizard: Wizard, picks: &Picks) -> Element { + let filter = *wizard.filter.read(); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(wizard.query) + .placeholder("Search versions") + .width(Size::fill()) + .leading(Icon::new(IconType::SearchMd).size(14.)), + ), + ) + .maybe_child((picks.choice != TypeChoice::OneClient).then(|| { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + label() + .text("Show") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + Dropdown::new( + filter_summary(filter), + VERSION_KINDS + .iter() + .map(|(name, _)| (*name).to_string()) + .collect(), + ) + .checked( + VERSION_KINDS + .iter() + .map(|(_, kind)| filter & kind_bit(*kind) != 0) + .collect(), + ) + .width(Size::px(168.)) + .height(Size::px(32.)) + .on_select(move |index: usize| { + if let Some((_, kind)) = VERSION_KINDS.get(index) { + let mask = *wizard.filter.peek(); + wizard.filter.set(mask ^ kind_bit(*kind)); + wizard.version.set(None); + } + }), + ) + .into_element() + })) + .into_element() +} + +fn filter_summary(filter: u8) -> String { + let picked: Vec<&str> = VERSION_KINDS + .iter() + .filter(|(_, kind)| filter & kind_bit(*kind) != 0) + .map(|(name, _)| *name) + .collect(); + match picked.as_slice() { + [] => "None".to_string(), + [only] => (*only).to_string(), + all if all.len() == VERSION_KINDS.len() => "All versions".to_string(), + [first, rest @ ..] => format!("{first} +{}", rest.len()), + } +} + +fn version_list(mut wizard: Wizard, picks: &Picks) -> Element { + if let Some(error) = &picks.versions.error { + return centered_failure(error); + } + if !picks.versions.loaded { + return centered_spinner("Loading versions..."); + } + if picks.versions.list.is_empty() { + return centered_note("No versions match that search."); + } + + let versions = picks.versions.list.clone(); + let count = versions.len(); + let selected = picks.versions.chosen.clone(); + + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .scrollbar_gutter(true) + .reset_key(list_reset_key(picks, &wizard.query.read())) + .lazy(count, VERSION_ROW_H, VERSION_ROW_SPACING, move |index| { + let Some(row) = versions.row(index) else { + return rect().into_element(); + }; + let id = row.id.clone(); + let is_selected = selected.as_deref() == Some(row.id.as_str()); + + version_row( + &row, + is_selected, + (move |()| wizard.version.set(Some(id.clone()))).into(), + ) + }) + .into_element() +} + +fn centered_failure(message: &str) -> Element { + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .padding(Gaps::new_symmetric(0., 16.)) + .child(note( + format!("Could not load the version list. {message}"), + colors::danger(), + )) + .into_element() +} + +fn list_reset_key(picks: &Picks, needle: &str) -> u64 { + use std::hash::{Hash, Hasher}; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + matches!(picks.choice, TypeChoice::OneClient).hash(&mut hasher); + picks.versions.filter.hash(&mut hasher); + needle.hash(&mut hasher); + hasher.finish() +} + +fn bundle_blurb(archive: &BundleArchive, names: &HashMap) -> String { + let mut listed: Vec = archive + .manifest + .files + .iter() + .filter(|file| file.enabled && !file.hidden) + .map(|file| { + let package_id = file.kind.package_id(); + names + .get(&package_id) + .cloned() + .unwrap_or_else(|| super::tidy_file_name(&file.display_name())) + }) + .collect(); + + let total = listed.len(); + listed.truncate(3); + let head = listed.join(", "); + + match total.saturating_sub(3) { + 0 if head.is_empty() => "Configuration only.".to_string(), + 0 => head, + rest => format!("{head} and {rest} more"), + } +} + +fn bundles_step(mut wizard: Wizard, picks: &Picks) -> Element { + if picks.bundles.archives.is_empty() { + return centered_note(if picks.bundles.loaded { + "No bundles ship for this version yet." + } else { + "Loading bundles..." + }); + } + + let cards: Vec = picks + .bundles + .archives + .iter() + .map(|archive| { + let name = archive.manifest.name.clone(); + let count = archive + .manifest + .files + .iter() + .filter(|file| file.enabled && !file.hidden) + .count(); + + cell_card(CellCard { + id: name.clone(), + title: bundle_display_name(archive), + blurb: bundle_blurb(archive, &picks.bundles.names), + meta: Some(match count { + 1 => "1 package".to_string(), + many => format!("{many} packages"), + }), + corner: None, + selected: !picks.bundles.declined.contains(&name), + checkbox: true, + on_press: { + let defaults = picks.bundles.declined.clone(); + (move |()| { + let mut next = wizard + .declined + .read() + .clone() + .unwrap_or_else(|| defaults.clone()); + if !next.remove(&name) { + next.insert(name.clone()); + } + wizard.declined.set(Some(next)); + }) + .into() + }, + }) + }) + .collect(); + + fixed_grid(cards, BUNDLE_COLUMNS, BUNDLE_CARD_H, GRID_GAP) +} diff --git a/packages/oneclient_app/src/view/app/clusters/mod.rs b/packages/oneclient_app/src/view/app/clusters/mod.rs index 64e98e08..01f88510 100644 --- a/packages/oneclient_app/src/view/app/clusters/mod.rs +++ b/packages/oneclient_app/src/view/app/clusters/mod.rs @@ -1,3 +1,7 @@ +mod delete_instance; +mod instance; mod page; +pub use delete_instance::DeleteInstanceModal; +pub use instance::{CreateInstanceModal, EditInstanceModal, InstanceFacts}; pub use page::Clusters; diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs index f1306920..aa383145 100644 --- a/packages/oneclient_app/src/view/app/clusters/page.rs +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -1,29 +1,89 @@ +use std::path::PathBuf; + use freya::prelude::*; use freya::router::RouterContext; -use oneclient_common::VersionKey; -use oneclient_core::clusters::Cluster; use oneclient_common::domain::GameLoader; +use oneclient_common::{VersionKey, parse_mc_version}; +use oneclient_core::clusters::Cluster; +use oneclient_core::settings::ViewLayout; use crate::components::{ - Button, ClusterLandscapeArt, Dropdown, Icon, IconType, ScrollArea, VersionCard, + ART_PREVIEW_EDGE, Button, Dropdown, DynamicArt, FilterMenu, FilterOption, Icon, IconType, + InstanceRow, ScrollArea, Segment, SegmentedControl, TabBar, TabItem, TextInput, VersionCard, + open_folder_button, +}; +use crate::hooks::{ + settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, + use_launcher, use_version_metadata, use_view_state, }; -use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata}; use crate::routes::Route; use crate::theme::colors; -use crate::ui::border_all_color; -use crate::utils::{ - ReleaseLine, default_line, default_loader, default_version_key, group_clusters_by_release, - line_title, loaders_for_line, resolve_cluster, version_keys, version_label, +use crate::ui::{ + border_all_color, fixed_grid, grid_columns_for_width, last_played_label, window_logical_size, }; -use crate::view::app::launch_button_state; +use crate::utils::{ + GridSelection, ReleaseLine, default_line, default_loader, default_version_key, format_duration, + line_title, resolve_cluster, split_clusters, +}; +use crate::view::app::clusters::CreateInstanceModal; +use crate::view::app::{launch_button_state, launch_syncing, use_shared_dir_taken}; -const GRID_GAP_PX: f32 = 12.; -const MIN_CARD_WIDTH_PX: f32 = 280.; -const SIDEBAR_WIDTH_PX: f32 = 300.; -const CARD_HEIGHT_PX: f32 = 146.; -const PLACEHOLDER_VERSION_INFO: &str = "Placeholder version info"; -const ART_MAX_HEIGHT_PX: f32 = 140.; -const ART_MIN_SIDEBAR_PX: f32 = 260.; +const PAGE_PADDING: Gaps = Gaps::new(12., 28., 28., 28.); +const COLUMN_GAP_PX: f32 = 24.; +const CARD_GAP_PX: f32 = 10.; +const ROW_GAP_PX: f32 = 8.; +const CARD_HEIGHT_PX: f32 = 150.; +const ROW_HEIGHT_PX: f32 = 56.; +const MAX_CARD_WIDTH_PX: f32 = 300.; +const MAX_ROW_WIDTH_PX: f32 = 480.; +const SIDEBAR_WIDTH_PX: f32 = 360.; +const COMPACT_SIDEBAR_WIDTH_PX: f32 = 300.; +const COMPACT_BELOW_PX: f32 = 1000.; +const INLINE_SEARCH_MIN_PX: f32 = 700.; +const SEARCH_WIDTH_PX: f32 = 220.; +const TOOLBAR_HEIGHT_PX: f32 = 44.; +const ART_MAX_HEIGHT_PX: f32 = 200.; +const ART_MIN_SIDEBAR_PX: f32 = 420.; + +#[derive(Clone, Copy, PartialEq)] +enum Filter { + All, + OneClient, + Custom, + Modpacks, +} + +impl Filter { + fn shows(self, section: Filter) -> bool { + self == Filter::All || self == section + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Sort { + RecentFirst, + OldestFirst, + NameAsc, + NameDesc, +} + +impl Sort { + const ALL: [Sort; 4] = [ + Sort::RecentFirst, + Sort::OldestFirst, + Sort::NameAsc, + Sort::NameDesc, + ]; + + fn label(self) -> &'static str { + match self { + Sort::RecentFirst => "Recently played", + Sort::OldestFirst => "Least recently played", + Sort::NameAsc => "Name (A-Z)", + Sort::NameDesc => "Name (Z-A)", + } + } +} #[derive(PartialEq)] pub struct Clusters; @@ -32,26 +92,32 @@ impl Component for Clusters { fn render(&self) -> impl IntoElement { let clusters_query = use_clusters(); let active_id = use_active_cluster_id(); - let mut selected_line = use_state(|| None::); - let mut selected_version = use_state(|| None::); - let mut selected_loader = use_state(|| None::); - let mut grid_columns = use_state(|| 2_usize); + let show_create = use_state(|| false); + let mut selected = use_state(|| None::); + let mut selected_cluster = use_state(|| None::); + let mut filter = use_state(|| Filter::All); + let query = use_state(String::new); + let loaders = use_state(Vec::::new); + let sort = use_state(|| Sort::RecentFirst); + let layout = use_view_state("clusters").layout; + let mut body_width = + use_state(|| window_logical_size().width - PAGE_PADDING.left() - PAGE_PADDING.right()); let clusters = settled_or_loading(&clusters_query).unwrap_or_default(); - let groups = group_clusters_by_release(&clusters); + let (groups, instances) = split_clusters(&clusters); // Newest version first let lines: Vec = groups.keys().rev().copied().collect(); - if lines.is_empty() { + if lines.is_empty() && instances.is_empty() { return rect() .vertical() .width(Size::fill()) .height(Size::fill()) .overflow(Overflow::Clip) - .padding(40.) + .padding(PAGE_PADDING) .spacing(24.) - .child(page_header()) + .child(page_header(show_create)) .child( rect() .vertical() @@ -63,70 +129,214 @@ impl Component for Clusters { .font_size(16.) .color(colors::fg_secondary()), ), - ); + ) + .maybe_child(create_modal(show_create)); } let active_cluster = active_id .read() .and_then(|id| clusters.iter().find(|c| c.id == id).cloned()); - if selected_line.read().is_none() { - *selected_line.write() = default_line(&groups, active_cluster.clone()); + let fallback = || { + lines + .first() + .copied() + .map(GridSelection::Line) + .or_else(|| instances.first().map(|c| GridSelection::Instance(c.id))) + }; + + if selected.read().is_none() { + *selected.write() = match active_cluster.as_ref() { + Some(cluster) if cluster.is_isolated() => Some(GridSelection::Instance(cluster.id)), + other => default_line(&groups, other.cloned()) + .map(GridSelection::Line) + .or_else(fallback), + }; } - let line = (*selected_line.read()) - .filter(|l| groups.contains_key(l)) - .unwrap_or(lines[0]); + let current = (*selected.read()) + .filter(|sel| match sel { + GridSelection::Line(line) => groups.contains_key(line), + GridSelection::Instance(id) => instances.iter().any(|c| c.id == *id), + }) + .or_else(fallback); - if *selected_line.read() != Some(line) { - *selected_line.write() = Some(line); + if *selected.read() != current { + *selected.write() = current; } - let clusters_for_line = groups.get(&line).cloned().unwrap_or_default(); + let line = match current { + Some(GridSelection::Line(line)) => Some(line), + _ => None, + }; - if selected_version.read().is_none() { - let preferred = active_cluster - .as_ref() - .and_then(|c| oneclient_common::parse_mc_version(&c.mc_version)) - .and_then(|p| p.key()); - *selected_version.write() = default_version_key(&clusters_for_line, preferred); + let clusters_for_line = line + .and_then(|line| groups.get(&line).cloned()) + .unwrap_or_default(); + + if line.is_some() && selected_cluster.read().is_none() { + *selected_cluster.write() = + default_line_cluster(&clusters_for_line, active_cluster.as_ref()); } - if selected_loader.read().is_none() { - let preferred = active_cluster.as_ref().map(|c| c.mc_loader); - *selected_loader.write() = default_loader(&clusters_for_line, preferred); - } + let cluster = selected_cluster + .read() + .and_then(|id| clusters_for_line.iter().find(|c| c.id == id).cloned()) + .or_else(|| clusters_for_line.first().cloned()); - let cluster = resolve_cluster( - &clusters_for_line, - *selected_version.read(), - *selected_loader.read(), - ) - .or_else(|| clusters_for_line.first().cloned()); + let instance = match current { + Some(GridSelection::Instance(id)) => instances.iter().find(|c| c.id == id).cloned(), + _ => None, + }; - let columns = *grid_columns.read(); - let grid_rows = chunk_lines(&lines, columns); + let (packs, custom): (Vec<&Cluster>, Vec<&Cluster>) = instances + .iter() + .partition(|c| c.linked_modpack_hash.is_some()); + + let body_w = *body_width.read(); + let sidebar_w = if body_w < COMPACT_BELOW_PX { + COMPACT_SIDEBAR_WIDTH_PX + } else { + SIDEBAR_WIDTH_PX + }; + let main_w = body_w - sidebar_w - COLUMN_GAP_PX; + let grid = *layout.read() == ViewLayout::Grid; + let (columns, item_height, gap) = if grid { + ( + grid_columns_for_width(main_w, MAX_CARD_WIDTH_PX, CARD_GAP_PX), + CARD_HEIGHT_PX, + CARD_GAP_PX, + ) + } else { + ( + grid_columns_for_width(main_w, MAX_ROW_WIDTH_PX, ROW_GAP_PX), + ROW_HEIGHT_PX, + ROW_GAP_PX, + ) + }; + let inline_search = main_w >= INLINE_SEARCH_MIN_PX; + + let active_filter = *filter.read(); + let needle = query.read().trim().to_lowercase(); + let picked_loaders = loaders.read().clone(); + let matches = |c: &Cluster| { + (picked_loaders.is_empty() || picked_loaders.contains(&c.mc_loader.to_string())) + && (needle.is_empty() + || format!("{} {} {}", c.name, c.mc_version, c.mc_loader) + .to_lowercase() + .contains(&needle)) + }; + let sort_by = *sort.read(); + + let shown_lines: Vec = lines + .iter() + .copied() + .filter(|l| active_filter.shows(Filter::OneClient) && groups[l].iter().any(matches)) + .collect(); + let shown_custom = shown(&custom, sort_by, |c| { + active_filter.shows(Filter::Custom) && matches(c) + }); + let shown_packs = shown(&packs, sort_by, |c| { + active_filter.shows(Filter::Modpacks) && matches(c) + }); + + let mut available_loaders: Vec = + clusters.iter().map(|c| c.mc_loader.to_string()).collect(); + available_loaders.sort(); + available_loaders.dedup(); + let filters = rect() + .horizontal() + .spacing(8.) + .child(filter_menu(&available_loaders, loaders, sort)) + .child(layout_toggle(layout)) + .into_element(); + let nothing_shown = + shown_lines.is_empty() && shown_custom.is_empty() && shown_packs.is_empty(); + + let tabs = [ + (Filter::All, "All"), + (Filter::OneClient, "OneClient"), + (Filter::Custom, "Custom"), + (Filter::Modpacks, "Modpacks"), + ] + .map(|(value, name)| { + TabItem::new(name, active_filter == value).on_press(move |_| filter.set(value)) + }); + + let line_cards: Vec = shown_lines + .iter() + .map(|&line| { + let list = &groups[&line]; + let item = GridSelection::Line(line); + let is_selected = current == Some(item); + let caption = match (is_selected, &cluster, list.as_slice()) { + (true, Some(c), _) | (_, _, [c]) => cluster_caption(c), + _ => format!("{} instances", list.len()), + }; + let on_press = move |_| { + selected.set(Some(item)); + selected_cluster.set(None); + }; + if grid { + VersionCard::new(line, list, caption, is_selected, on_press).into_element() + } else { + InstanceRow::for_line(line, list, caption, is_selected, on_press).into_element() + } + }) + .collect(); + + let instance_rows = |list: &[&Cluster]| -> Vec { + list.iter() + .map(|c| { + let item = GridSelection::Instance(c.id); + let is_selected = current == Some(item); + let on_press = move |_| selected.set(Some(item)); + if grid { + VersionCard::for_instance(c, is_selected, on_press).into_element() + } else { + InstanceRow::new(c, is_selected, on_press).into_element() + } + }) + .collect() + }; + + let sidebar = match (instance, line, cluster) { + (Some(instance), _, _) => Sidebar::for_instance(&instance, sidebar_w).into_element(), + (None, Some(line), Some(cluster)) => { + let parsed = parse_mc_version(&cluster.mc_version); + LineSidebar { + major: line.major, + key: parsed.and_then(|p| p.key()), + loader: cluster.mc_loader, + user_created: cluster.user_created, + base: Sidebar { + picker: line_picker(line, &cluster, &clusters_for_line, selected_cluster), + description: cluster.description.clone(), + ..Sidebar::base(&cluster, sidebar_w) + }, + } + .into_element() + } + _ => sidebar_error(sidebar_w), + }; rect() .vertical() .width(Size::fill()) .height(Size::fill()) .overflow(Overflow::Clip) - .padding(Gaps::new(0., 40., 40., 40.)) - .spacing(16.) - .child(page_header()) + .padding(PAGE_PADDING) .child( rect() .horizontal() .width(Size::fill()) - .height(Size::flex(1.0)) + .height(Size::fill()) .content(Content::Flex) - .spacing(GRID_GAP_PX) + .spacing(COLUMN_GAP_PX) .on_sized(move |event: Event| { let width = event.data().area.width(); - let next = grid_columns_for_width(width); - if next != *grid_columns.peek() { - *grid_columns.write() = next; + if (width - *body_width.peek()).abs() > 0.5 { + *body_width.write() = width; } }) .child( @@ -134,110 +344,396 @@ impl Component for Clusters { .vertical() .width(Size::flex(1.0)) .height(Size::fill()) - .overflow(Overflow::Clip) + .content(Content::Flex) + .spacing(18.) + .child(page_header(show_create)) + .child(toolbar(tabs, query, inline_search.then(|| filters.clone()))) + .maybe_child((!inline_search).then(|| { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .spacing(8.) + .child(search_input(query, Size::flex(1.))) + .child(filters) + .into_element() + })) .child( ScrollArea::new() .width(Size::fill()) - .height(Size::fill()) - .spacing(GRID_GAP_PX) - .children(grid_rows.into_iter().map(|row| { - let row_len = row.len(); - rect() - .horizontal() - .width(Size::fill()) - .content(Content::Flex) - .spacing(GRID_GAP_PX) - .children(row.into_iter().map(|line| { - let list = - groups.get(&line).cloned().unwrap_or_default(); - let selected = *selected_line.read() == Some(line); - let mut selected_line = selected_line; - let mut selected_version = selected_version; - let mut selected_loader = selected_loader; - - VersionCard::new(line, &list, selected, move |_| { - *selected_line.write() = Some(line); - *selected_version.write() = None; - *selected_loader.write() = None; - }) - .into_element() - })) - .children((row_len..columns).map(|_| { - rect() - .width(Size::flex(1.0)) - .height(Size::px(CARD_HEIGHT_PX)) - .into_element() - })) + .height(Size::flex(1.0)) + .scrollbar_gutter(true) + .spacing(22.) + .append_children((!shown_lines.is_empty()).then(|| { + section( + "OneClient", + "Grouped by Minecraft version", + fixed_grid(line_cards, columns, item_height, gap), + ) + })) + .append_children( + [ + ( + "Custom", + "Your own instances, modded or vanilla", + &shown_custom, + ), + ("Modpacks", "Installed from Browse", &shown_packs), + ] + .into_iter() + .filter(|(_, _, list)| !list.is_empty()) + .map( + |(title, caption, list)| { + section( + title, + caption, + fixed_grid( + instance_rows(list), + columns, + item_height, + gap, + ), + ) + }, + ), + ) + .append_children(nothing_shown.then(|| { + label() + .text("No instances match your search or filters.") + .font_size(13.) + .color(colors::fg_secondary()) .into_element() })), ), ) - .child(match cluster { - Some(cluster) => DetailSidebar { - line, - cluster_id: cluster.id, - clusters_for_line, - selected_version, - selected_loader, + .child(sidebar), + ) + .maybe_child(create_modal(show_create)) + } +} + +fn create_modal(mut show_create: State) -> Option { + show_create + .read() + .then(|| CreateInstanceModal::new(move |()| show_create.set(false)).into_element()) +} + +fn shown<'a>( + list: &[&'a Cluster], + sort: Sort, + keep: impl Fn(&Cluster) -> bool, +) -> Vec<&'a Cluster> { + let mut list: Vec<&Cluster> = list.iter().copied().filter(|c| keep(c)).collect(); + match sort { + Sort::RecentFirst => {} + Sort::OldestFirst => list.reverse(), + Sort::NameAsc => list.sort_by_key(|c| c.name.to_lowercase()), + Sort::NameDesc => list.sort_by_key(|c| std::cmp::Reverse(c.name.to_lowercase())), + } + list +} + +fn cluster_caption(cluster: &Cluster) -> String { + format!("{} \u{b7} {}", cluster.mc_version, cluster.mc_loader) +} + +fn picker_caption(cluster: &Cluster) -> String { + if cluster.user_created { + format!("{} \u{b7} {}", cluster.name, cluster_caption(cluster)) + } else { + cluster_caption(cluster) + } +} + +fn default_line_cluster(clusters: &[Cluster], active: Option<&Cluster>) -> Option { + if let Some(active) = active.filter(|a| clusters.iter().any(|c| c.id == a.id)) { + return Some(active.id); + } + let preferred_key = active + .and_then(|c| parse_mc_version(&c.mc_version)) + .and_then(|p| p.key()); + resolve_cluster( + clusters, + default_version_key(clusters, preferred_key), + default_loader(clusters, active.map(|c| c.mc_loader)), + ) + .map(|c| c.id) +} + +fn bottom_border() -> Border { + Border::new() + .fill(colors::component_border()) + .width(BorderWidth { + top: 0., + right: 0., + bottom: 1., + left: 0., + }) +} + +fn section(title: &str, caption: &str, grid: Element) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + rect() + .horizontal() + .cross_align(Alignment::End) + .spacing(10.) + .child( + label() + .text(title.to_string()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text(caption.to_string()) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child(grid) + .into_element() +} + +fn toolbar( + tabs: impl IntoIterator, + query: State, + inline_filters: Option, +) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(TOOLBAR_HEIGHT_PX)) + .cross_align(Alignment::Center) + .content(Content::Flex) + .border(bottom_border()) + .child( + TabBar::new() + .width(Size::auto()) + .height(Size::fill()) + .spacing(24.) + .font_size(14.) + .tabs(tabs), + ) + .child(rect().width(Size::flex(1.0))) + .maybe_child(inline_filters.map(|filters| { + rect() + .horizontal() + .spacing(8.) + .child(search_input(query, Size::px(SEARCH_WIDTH_PX))) + .child(filters) + .into_element() + })) +} + +fn filter_menu( + available: &[String], + mut loaders: State>, + mut sort: State, +) -> Element { + let picked = loaders.read().clone(); + let sort_by = *sort.read(); + + FilterMenu::new(!picked.is_empty() || sort_by != Sort::RecentFirst) + .section( + "Sort by", + Sort::ALL.map(|option| { + FilterOption::new(option.label(), sort_by == option, move |()| { + sort.set(option) + }) + }), + ) + .section( + "Loader", + available.iter().map(|loader| { + let name = loader.clone(); + FilterOption::new(loader.clone(), picked.contains(loader), move |()| { + let mut next = loaders.peek().clone(); + if next.contains(&name) { + next.retain(|l| *l != name); + } else { + next.push(name.clone()); + } + loaders.set(next); + }) + }), + ) + .into_element() +} + +fn layout_toggle(layout: State) -> Element { + SegmentedControl::new(layout) + .height(34.) + .icon_size(15.) + .equal_width(34.) + .segment(Segment::new(ViewLayout::List).icon(IconType::ParagraphWrap)) + .segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)) + .into_element() +} + +fn search_input(query: State, width: Size) -> Element { + TextInput::new(query) + .width(width) + .height(Size::px(34.)) + .placeholder("Search instances") + .leading( + Icon::new(IconType::SearchMd) + .size(14.) + .color(colors::fg_secondary()) + .into_element(), + ) + .into_element() +} + +fn line_picker( + line: ReleaseLine, + cluster: &Cluster, + siblings: &[Cluster], + mut selected_cluster: State>, +) -> Option { + if siblings.len() <= 1 { + return None; + } + + let options: Vec = siblings.iter().map(picker_caption).collect(); + let current = siblings + .iter() + .position(|c| c.id == cluster.id) + .unwrap_or(0); + let ids: Vec = siblings.iter().map(|c| c.id).collect(); + + Some( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + label() + .text(format!( + "{} instances on {}", + siblings.len(), + line_title(line, siblings) + )) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child( + Dropdown::new(options[current].clone(), options) + .outlined() + .width(Size::fill()) + .height(Size::px(40.)) + .on_select(move |idx: usize| { + if let Some(id) = ids.get(idx).copied() { + selected_cluster.set(Some(id)); } - .into_element(), - None => sidebar_error(), }), ) + .into_element(), + ) +} + +#[derive(Clone, PartialEq)] +struct Sidebar { + width: f32, + cluster_id: i64, + uses_bundles: bool, + art: DynamicArt, + type_label: &'static str, + title: String, + subtitle: String, + description: Option, + meta: Vec<(&'static str, String)>, + folder: Option, + picker: Option, +} + +impl Sidebar { + fn base(cluster: &Cluster, width: f32) -> Self { + Self { + width, + cluster_id: cluster.id, + uses_bundles: cluster.uses_bundles(), + art: DynamicArt::for_cluster(cluster).max_edge(ART_PREVIEW_EDGE), + type_label: "OneClient", + title: cluster.name.clone(), + subtitle: cluster_caption(cluster), + description: None, + meta: vec![ + ("Minecraft", cluster.mc_version.clone()), + ("Loader", cluster.mc_loader.to_string()), + ("Last played", last_played_label(cluster.last_played)), + ], + folder: cluster.dir().ok(), + picker: None, + } + } + + fn for_instance(cluster: &Cluster, width: f32) -> Self { + let mut sidebar = Self::base(cluster, width); + sidebar.type_label = if cluster.linked_modpack_hash.is_some() { + "Modpack" + } else { + "Custom" + }; + sidebar.description = cluster.description.clone(); + sidebar.meta.push(( + "Playtime", + format_duration(i64::try_from(cluster.overall_played.as_secs()).unwrap_or(i64::MAX)), + )); + sidebar } } #[derive(PartialEq)] -struct DetailSidebar { - line: ReleaseLine, - cluster_id: i64, - clusters_for_line: Vec, - selected_version: State>, - selected_loader: State>, +struct LineSidebar { + major: u32, + key: Option, + loader: GameLoader, + user_created: bool, + base: Sidebar, } -impl Component for DetailSidebar { +impl Component for LineSidebar { + fn render(&self) -> impl IntoElement { + let metadata = use_version_metadata(Some(self.major), self.key, Some(self.loader)); + + let mut sidebar = self.base.clone(); + if let Some(metadata) = metadata { + if !self.user_created { + sidebar.title = metadata.name; + } + sidebar.description = sidebar.description.or(metadata.long_description); + } + sidebar + } +} + +impl Component for Sidebar { fn render(&self) -> impl IntoElement { let mut sidebar_height = use_state(|| 0f32); - let active_id = use_active_cluster_id(); + let mut active_id = use_active_cluster_id(); let dispatch = use_dispatch(); let game = use_game_snapshot(); let launcher = use_launcher(); - let clusters_for_line = self.clusters_for_line.as_slice(); - let syncing = launcher.fetching || launcher.syncing_bundles; - let keys = version_keys(clusters_for_line); - let loaders = loaders_for_line(clusters_for_line); - let version_title = line_title(self.line, clusters_for_line); - let version_value = (self.selected_version)(); - let loader_value = (self.selected_loader)(); - - let metadata = use_version_metadata(Some(self.line.major), version_value, loader_value); - - let heading = metadata - .as_ref() - .map(|m| m.name.clone()) - .unwrap_or_else(|| format!("Version {version_title}")); - let description = metadata - .as_ref() - .and_then(|m| m.long_description.clone()) - .unwrap_or_else(|| PLACEHOLDER_VERSION_INFO.to_string()); - let tags = metadata - .as_ref() - .map(|m| m.tags.clone()) - .unwrap_or_default(); - + let cluster_id = self.cluster_id; + let syncing = launch_syncing(&launcher, self.uses_bundles); + let shared_dir_taken = use_shared_dir_taken(cluster_id); let art_height = art_height_for(*sidebar_height.read()); + let top_padding = if art_height.is_some() { 4. } else { 22. }; rect() - .width(Size::px(SIDEBAR_WIDTH_PX)) - .min_width(Size::px(SIDEBAR_WIDTH_PX)) + .width(Size::px(self.width)) + .min_width(Size::px(self.width)) .height(Size::fill()) .vertical() - .spacing(8.) - .padding(8.) .content(Content::Flex) - .corner_radius(CornerRadius::new_all(12.)) + .corner_radius(CornerRadius::new_all(20.)) .background(colors::page_elevated()) .border(border_all_color(1., colors::component_border())) .overflow(Overflow::Clip) @@ -247,75 +743,152 @@ impl Component for DetailSidebar { *sidebar_height.write() = height; } }) - .maybe_child(art_height.map(|max| { - rect() - .width(Size::fill()) - .max_height(Size::px(max)) - .child(ClusterLandscapeArt::for_version( - self.line.major, - version_value, - loader_value, - false, - )) - .into_element() - })) + .maybe_child( + art_height.map(|height| sidebar_art(self.art.clone(), self.type_label, height)), + ) .child( rect() .vertical() .width(Size::fill()) .height(Size::flex(1.0)) .content(Content::Flex) - .padding(Gaps::new_all(8.)) - .spacing(8.) + .padding(Gaps::new(top_padding, 22., 22., 22.)) + .spacing(18.) .child( - rect() - .vertical() + ScrollArea::new() .width(Size::fill()) .height(Size::flex(1.0)) - .content(Content::Flex) - .spacing(4.) + .scrollbar_gutter(true) + .spacing(18.) .child( - ScrollArea::new() + rect() + .vertical() .width(Size::fill()) - .height(Size::flex(1.0)) .spacing(4.) .child( label() - .text(heading) + .text(self.title.clone()) .font_size(24.) .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), + .color(Color::WHITE), ) - .children(tags_row(&tags)) .child( label() - .text(description) - .font_size(12.) + .text(self.subtitle.clone()) + .font_size(13.) .color(colors::fg_secondary()), ), ) - .children(version_rows(self.line, &keys, version_value, self.selected_version)) - .children(loader_rows(&loaders, loader_value, self.selected_loader)), + .append_children(self.picker.clone()) + .child(meta_list(&self.meta)) + .append_children(self.description.clone().map(|text| { + label() + .text(text) + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + })), ) .child( rect() .horizontal() .width(Size::fill()) .content(Content::Flex) - .main_align(Alignment::Center) .spacing(8.) .child(play_button( - self.cluster_id, + cluster_id, dispatch, - launch_button_state(&game, self.cluster_id, syncing), + launch_button_state(&game, cluster_id, syncing, shared_dir_taken), )) - .child(view_button(self.cluster_id, active_id)), + .child( + Button::new() + .secondary() + .icon() + .tooltip("Instance settings") + .on_press(move |_| { + *active_id.write() = Some(cluster_id); + let _ = RouterContext::get() + .push(Route::ClusterSettings { cluster_id }); + }) + .child(Icon::new(IconType::Settings01).size(16.)), + ) + .maybe_child(self.folder.clone().map(open_folder_button)), ), ) - .into_element() } } +fn sidebar_art(art: DynamicArt, type_label: &str, height: f32) -> Element { + rect() + .width(Size::fill()) + .height(Size::px(height)) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .child(art), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .layer(Layer::Relative(3)) + .background( + LinearGradient::new() + .angle(0.) + .stop((colors::page_elevated().with_a(0), 40.)) + .stop((colors::page_elevated().with_a(242), 100.)), + ), + ) + .child( + rect() + .position(Position::new_absolute().top(14.).left(14.)) + .layer(Layer::Relative(4)) + .padding(Gaps::new_symmetric(5., 10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(Color::from_af32rgb(0.66, 11, 16, 19)) + .child( + label() + .text(type_label.to_string()) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ), + ) + .into_element() +} + +fn meta_list(meta: &[(&'static str, String)]) -> Element { + rect() + .vertical() + .width(Size::fill()) + .children(meta.iter().map(|(key, value)| { + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::SpaceBetween) + .padding(Gaps::new_symmetric(9., 0.)) + .border(bottom_border()) + .child( + label() + .text(key.to_string()) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(value.clone()) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .into_element() + })) + .into_element() +} + fn art_height_for(sidebar_height: f32) -> Option { if sidebar_height <= 0. { return Some(ART_MAX_HEIGHT_PX); @@ -326,109 +899,6 @@ fn art_height_for(sidebar_height: f32) -> Option { Some((sidebar_height * 0.32).min(ART_MAX_HEIGHT_PX)) } -fn tags_row(tags: &[String]) -> Option { - if tags.is_empty() { - return None; - } - - Some( - rect() - .horizontal() - .width(Size::fill()) - .spacing(6.) - .children(tags.iter().map(|tag| { - rect() - .padding(Gaps::new_symmetric(3., 8.)) - .corner_radius(CornerRadius::new_all(999.)) - .background(colors::component_bg()) - .child( - label() - .text(tag.clone()) - .font_size(11.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_secondary()), - ) - .into_element() - })) - .into_element(), - ) -} - -/// A single-version line already names it on the card and in the heading -fn version_rows( - line: ReleaseLine, - keys: &[VersionKey], - selected: Option, - mut selected_version: State>, -) -> Option { - if keys.len() <= 1 { - return None; - } - - let options: Vec = keys.iter().map(|k| version_label(line.major, *k)).collect(); - let current = selected - .and_then(|s| keys.iter().position(|k| *k == s)) - .unwrap_or(0); - let keys = keys.to_vec(); - - Some(info_row( - "Version", - Dropdown::new(options[current].clone(), options) - .on_select(move |idx: usize| { - if let Some(key) = keys.get(idx).copied() { - *selected_version.write() = Some(key); - } - }) - .into_element(), - )) -} - -fn loader_rows( - loaders: &[GameLoader], - selected: Option, - mut selected_loader: State>, -) -> Option { - if loaders.len() <= 1 { - return None; - } - - let options: Vec = loaders.iter().map(|l| l.to_string()).collect(); - let current = selected - .and_then(|s| loaders.iter().position(|l| *l == s)) - .unwrap_or(0); - let loaders = loaders.to_vec(); - - Some(info_row( - "Mod Loader", - Dropdown::new(options[current].clone(), options) - .on_select(move |idx: usize| { - if let Some(loader) = loaders.get(idx).copied() { - *selected_loader.write() = Some(loader); - } - }) - .into_element(), - )) -} - -fn info_row(label_text: impl Into, control: impl IntoElement) -> Element { - let label_text = label_text.into(); - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .main_align(Alignment::SpaceBetween) - .padding(Gaps::new_symmetric(4.0, 0.0)) - .child( - label() - .text(label_text) - .font_size(12.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_primary()), - ) - .child(control) - .into_element() -} - fn play_button( cluster_id: i64, dispatch: crate::Actions, @@ -447,50 +917,49 @@ fn play_button( .text(label) } -fn view_button(cluster_id: i64, mut active_id: State>) -> impl IntoElement { - Button::new() - .secondary() - .width(Size::flex(1.)) - .on_press(move |_| { - *active_id.write() = Some(cluster_id); - let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); - }) - .text("View") - .child(Icon::new(IconType::ArrowRight).size(14.)) -} - -fn page_header() -> impl IntoElement { +fn page_header(mut show_create: State) -> impl IntoElement { rect() - .vertical() - .spacing(6.) + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::End) + .content(Content::Flex) + .spacing(16.) .child( - label() - .text("Versions") - .font_size(36.) - .font_weight(FontWeight::BOLD) - .color(colors::fg_primary()), - ) - .child( - label() - .text( - "Something something in corporate style fashion about picking your \ - preferred gamemodes and versions and optionally loader so that oneclient \ - can pick something for them", + rect() + .vertical() + .width(Size::flex(1.)) + .spacing(6.) + .child( + label() + .text("Versions") + .font_size(32.) + .font_weight(FontWeight::SEMI_BOLD) + .color(Color::WHITE), ) - .font_size(12.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_secondary()), + .child( + label() + .text("Pick a OneClient version, or launch one of your own instances.") + .font_size(13.) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .primary() + .on_press(move |_| show_create.set(true)) + .child(Icon::new(IconType::Plus).size(16.)) + .text("New instance"), ) } -fn sidebar_error() -> Element { +fn sidebar_error(width: f32) -> Element { rect() - .width(Size::px(SIDEBAR_WIDTH_PX)) + .width(Size::px(width)) .height(Size::fill()) .vertical() .padding(16.) .spacing(8.) - .corner_radius(CornerRadius::new_all(16.)) + .corner_radius(CornerRadius::new_all(20.)) .border(border_all_color(1., colors::component_border())) .background(colors::component_bg()) .child( @@ -507,20 +976,3 @@ fn sidebar_error() -> Element { ) .into_element() } - -fn chunk_lines(lines: &[ReleaseLine], columns: usize) -> Vec> { - let columns = columns.max(1); - lines.chunks(columns).map(|chunk| chunk.to_vec()).collect() -} - -fn grid_columns_for_width(available_width_px: f32) -> usize { - const GAP: f32 = 16.; - - let grid_width = available_width_px - SIDEBAR_WIDTH_PX - GAP; - if grid_width < MIN_CARD_WIDTH_PX { - return 1; - } - - let cols = (grid_width / (MIN_CARD_WIDTH_PX + GRID_GAP_PX)).floor() as usize; - cols.clamp(1, 3) -} diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 0c1c13e4..0cc91e84 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -9,13 +9,16 @@ use oneclient_net::status::{self, ServiceStatus}; use crate::Actions; use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialog, toggle}; -use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch}; +use crate::hooks::{ + invalidate_auth_queries, settled_or_loading, try_default_account, use_active_cluster_id, + use_clusters, use_current_account, use_dispatch, +}; use crate::notifications::{ ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind, OptionalModsGroup, }; use crate::routes::Route; -use crate::theme::colors; +use crate::theme::{self, colors}; use crate::ui::border_all_color; type SqlResult = Option>; @@ -25,7 +28,7 @@ pub struct Debug; impl Component for Debug { fn render(&self) -> impl IntoElement { - let dispatch = use_dispatch(); + let dispatch = use_dispatch(); let log_debug_info = use_state(|| false); let show_dev_stuff = use_state(|| false); let seen_onboarding = use_state(|| true); @@ -77,6 +80,11 @@ impl Component for Debug { vec![ClusterUpdateSimulator.into_element()], )) .child(divider()) + .child(section( + "Release Migration", + vec![ReleaseMigrationSimulator.into_element()], + )) + .child(divider()) .child(section( "Launcher Auto Update", vec![LauncherUpdateSimulator.into_element()], @@ -92,6 +100,11 @@ impl Component for Debug { vec![AuthGuidancePreview.into_element()], )) .child(divider()) + .child(section( + "Session Expiry", + vec![SessionExpirySimulator.into_element()], + )) + .child(divider()) .child(section( "Corruption Simulator", vec![CorruptionSimulator.into_element()], @@ -103,12 +116,15 @@ impl Component for Debug { .child(divider()) .child(section( "Other", - vec![action_row(&dispatch, vec![ - ("Open Dev Tools", IconType::CodeSnippet02), - ("Open Onboarding", IconType::Rocket02), - ("Open Launcher Data", IconType::Folder), - ("Log Running Processes", IconType::Terminal), - ])], + vec![action_row( + &dispatch, + vec![ + ("Open Dev Tools", IconType::CodeSnippet02), + ("Open Onboarding", IconType::Rocket02), + ("Open Launcher Data", IconType::Folder), + ("Log Running Processes", IconType::Terminal), + ], + )], )), ) } @@ -213,6 +229,7 @@ impl Component for ToastController { let info = dispatch.clone(); let error = dispatch.clone(); let prog = dispatch.clone(); + let truncated = dispatch.clone(); let reset = dispatch; rect() @@ -265,6 +282,15 @@ impl Component for ToastController { .send(); }), ) + .child( + Button::new() + .secondary() + .child(Icon::new(IconType::AlertTriangle).size(16.)) + .text("mclo.gs Shortened") + .on_press(move |_| { + crate::components::upload_mclogs::notify_log_truncated(&truncated); + }), + ) .child( Button::new() .secondary() @@ -432,52 +458,50 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 7] = [ &[], ), preset_summary(2, "Skyblock", &[], &[], &["OptiFine", "Skytils"], &[]), - preset_summary(3, "Vanilla+", &[], &["Sodium", "Iris", "FerriteCore"], &[], &[]), + preset_summary( + 3, + "Vanilla+", + &[], + &["Sodium", "Iris", "FerriteCore"], + &[], + &[], + ), ] }), ("2 clusters · removals only", IconType::Trash01, || { vec![ preset_summary(1, "PolyBlock", &[], &[], &["OptiFine"], &[]), - preset_summary(2, "Skyblock", &[], &[], &["Skytils", "NotEnoughUpdates"], &[]), + preset_summary( + 2, + "Skyblock", + &[], + &[], + &["Skytils", "NotEnoughUpdates"], + &[], + ), ] }), ("6 clusters · long names", IconType::Database01, || { (1..=6) - .map(|i| { - ClusterUpdateSummary { - cluster_id: i, - cluster_name: format!( - "Cluster {i} with a deliberately overlong name that has to truncate" - ), - updated: cluster_update_items(&[ - format!("Sodium 0.{i} → 0.{}", i + 1), - format!("Iris 1.{i} → 1.{}", i + 1), - ]), - added: cluster_update_items(&[format!("Lithium {i}")]), - removed: Vec::new(), - optional: Vec::new(), - } + .map(|i| ClusterUpdateSummary { + cluster_id: i, + cluster_name: format!( + "Cluster {i} with a deliberately overlong name that has to truncate" + ), + updated: cluster_update_items(&[ + format!("Sodium 0.{i} → 0.{}", i + 1), + format!("Iris 1.{i} → 1.{}", i + 1), + ]), + added: cluster_update_items(&[format!("Lithium {i}")]), + removed: Vec::new(), + optional: Vec::new(), }) .collect() }), ("2 clusters · offers only", IconType::Plus, || { vec![ - preset_summary( - 1, - "PolyBlock", - &[], - &[], - &[], - &["Lithium", "FerriteCore"], - ), - preset_summary( - 2, - "Skyblock", - &[], - &["Skytils"], - &[], - &["Skytils"], - ), + preset_summary(1, "PolyBlock", &[], &[], &[], &["Lithium", "FerriteCore"]), + preset_summary(2, "Skyblock", &[], &["Skytils"], &[], &["Skytils"]), ] }), ]; @@ -519,7 +543,7 @@ fn preset_summary( updated: cluster_update_items(updated), added: cluster_update_items(added), removed: cluster_update_items(removed), - optional: cluster_update_items(optional) + optional: cluster_update_items(optional), } } @@ -579,6 +603,124 @@ fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec impl IntoElement { + let dispatch = use_dispatch(); + let clusters_query = use_clusters(); + let mut target = use_state(|| None::); + + let mut clusters = settled_or_loading(&clusters_query).unwrap_or_default(); + newest_first(&mut clusters); + + let selected = target + .read() + .and_then(|id| clusters.iter().find(|cluster| cluster.id == id)) + .or_else(|| clusters.first()) + .cloned(); + + let ids: Vec = clusters.iter().map(|cluster| cluster.id).collect(); + let options: Vec = clusters + .iter() + .map(|cluster| cluster.name.clone()) + .collect(); + let selected_name = selected + .as_ref() + .map(|cluster| cluster.name.clone()) + .unwrap_or_else(|| "No clusters".to_string()); + let selected_id = selected.as_ref().map(|cluster| cluster.id); + + let simulate = dispatch.clone(); + let fake = dispatch.clone(); + let queue = dispatch.clone(); + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("\"Simulate\" opens the real migration modal with the chosen cluster as the new release and the real compatibility check against every other cluster with the same loader; closing it changes nothing. \"Fake Plan\" skips the check and fills the same modal with made-up packages, so the UI can be reviewed when no cluster has anything to migrate. \"Queue as New Version\" adds the chosen version to the pending list exactly as a new manifest entry would, then runs the startup check, so the real one-time flow can be tested.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + label() + .text("Target") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + Dropdown::new(selected_name, options) + .width(Size::px(220.)) + .height(Size::px(32.)) + .on_select(move |index: usize| { + if let Some(id) = ids.get(index) { + target.set(Some(*id)); + } + }), + ) + .child( + Button::new() + .primary() + .enabled(selected_id.is_some()) + .child(Icon::new(IconType::RefreshCw01).size(16.)) + .text("Simulate Release Migration") + .on_press(move |_| { + if let Some(id) = selected_id { + simulate.simulate_release_migration(id); + } + }), + ) + .child( + Button::new() + .secondary() + .enabled(selected_id.is_some()) + .text("Fake Plan") + .on_press(move |_| { + if let Some(id) = selected_id { + fake.simulate_fake_release_migration(id); + } + }), + ) + .child( + Button::new() + .secondary() + .enabled(selected_id.is_some()) + .text("Queue as New Version") + .on_press(move |_| { + if let Some(id) = selected_id { + queue.queue_release_migration(id); + } + }), + ), + ) + .into_element() + } +} + #[derive(PartialEq)] struct LauncherUpdateSimulator; @@ -802,6 +944,121 @@ impl Component for AuthGuidancePreview { } } +#[derive(PartialEq)] +struct SessionExpirySimulator; + +#[derive(Clone, Copy)] +enum ExpiryKind { + /// Skips the network and flips the account straight to signed out + SignedOut, + /// Leaves detection to Microsoft on the next launch + RejectedToken, +} + +impl Component for SessionExpirySimulator { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let current = use_current_account(); + + let account = try_default_account(¤t).filter(|a| a.is_microsoft()); + let target = account.as_ref().map(|a| (a.id, a.username.clone())); + let usable = account.as_ref().is_some_and(|a| !a.needs_sign_in()); + + let description = match &account { + Some(a) if a.needs_sign_in() => format!("{} is already signed out.", a.username), + Some(a) => format!("Acts on the active account, {}.", a.username), + None => "The active account must be a Microsoft account.".to_string(), + }; + + let button = |text: &'static str, icon: IconType, kind: ExpiryKind| { + let dispatch = dispatch.clone(); + let target = target.clone(); + Button::new() + .danger() + .enabled(usable) + .child(Icon::new(icon).size(16.)) + .text(text) + .on_press(move |_| { + if let Some((id, username)) = target.clone() { + run_expiry(&dispatch, kind, id, username); + } + }) + }; + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text(description) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(8.) + .child(button( + "Force signed out", + IconType::AlertTriangle, + ExpiryKind::SignedOut, + )) + .child(button( + "Invalidate refresh token", + IconType::RefreshCw01, + ExpiryKind::RejectedToken, + )), + ) + .child( + label() + .text("Invalidate keeps the account looking fine until the next launch, where Microsoft answers invalid_grant.") + .font_size(11.) + .color(colors::fg_secondary()), + ) + .into_element() + } +} + +fn run_expiry(dispatch: &crate::Actions, kind: ExpiryKind, id: uuid::Uuid, username: String) { + let dispatch = dispatch.clone(); + spawn(async move { + let result = match crate::launcher::state() { + Ok(state) => match kind { + ExpiryKind::SignedOut => state.auth.mark_signed_out(id).await, + ExpiryKind::RejectedToken => state.auth.corrupt_refresh_token(id).await, + } + .map_err(|err| err.to_string()), + Err(err) => Err(err.to_string()), + }; + invalidate_auth_queries(Some(id)).await; + + match result { + Ok(()) => { + let body = match kind { + ExpiryKind::SignedOut => format!("{username} is now signed out"), + ExpiryKind::RejectedToken => { + format!("{username} will be rejected on the next launch") + } + }; + dispatch + .notify("Session expiry simulated") + .body(body) + .icon(IconType::AlertTriangle) + .send(); + } + Err(err) => { + dispatch + .notify("Simulation failed") + .body(err) + .error() + .send(); + } + } + }); +} + /// Presets are grouped by which layer should catch them same-length corruption only the hashing pass finds truncation the launch-time size check #[derive(PartialEq)] struct CorruptionSimulator; @@ -1036,9 +1293,11 @@ fn run_damage(dispatch: &crate::Actions, kind: DamageKind, cluster_id: i64) { let dispatch = dispatch.clone(); spawn(async move { let result = match kind { - DamageKind::Assets(count, damage) => oneclient_core::simulate::damage_assets(count, damage) - .await - .map(|report| (report, damage.verb())), + DamageKind::Assets(count, damage) => { + oneclient_core::simulate::damage_assets(count, damage) + .await + .map(|report| (report, damage.verb())) + } DamageKind::Libraries(count, damage) => { oneclient_core::simulate::damage_libraries(count, damage) .await @@ -1090,25 +1349,37 @@ impl Component for SqlConsole { .vertical() .width(Size::fill()) .spacing(10.) + .child( + TextInput::new(query) + .multiline(true) + .placeholder("SELECT * FROM …") + .font_family(theme::MONO_FONT) + .width(Size::fill()) + .height(Size::px(140.)) + .on_submit(move |_| run_sql(query, result, running)), + ) .child( rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .spacing(12.) - .child( - rect().width(Size::flex(1.0)).child( - TextInput::new(query) - .placeholder("SELECT * FROM …") - .on_submit(move |_| run_sql(query, result, running)), - ), - ) .child( Button::new() .primary() .child(Icon::new(IconType::Terminal).size(16.)) .text(if *running.read() { "Running…" } else { "Run" }) .on_press(move |_| run_sql(query, result, running)), + ) + .child( + label() + .text(if cfg!(target_os = "macos") { + "⌘ Enter" + } else { + "Ctrl+Enter" + }) + .font_size(12.) + .color(colors::fg_secondary()), ), ) .child(sql_result(&result.read())) @@ -1300,9 +1571,9 @@ fn action_row(dispatch: &Actions, buttons: Vec<(&'static str, IconType)>) -> Ele .text(text); if text == "Open Onboarding" { - let dispatch = dispatch.clone(); - button = button.on_press(move |_| { - dispatch.reset_onboarding(); + let dispatch = dispatch.clone(); + button = button.on_press(move |_| { + dispatch.reset_onboarding(); let _ = RouterContext::get().replace(Route::OnboardingWelcome {}); }); } diff --git a/packages/oneclient_app/src/view/app/mod.rs b/packages/oneclient_app/src/view/app/mod.rs index 2f95edd3..319bfa26 100644 --- a/packages/oneclient_app/src/view/app/mod.rs +++ b/packages/oneclient_app/src/view/app/mod.rs @@ -9,21 +9,27 @@ pub mod browser; pub mod cluster; pub mod settings; -use crate::state::GameState as GameSnapshot; +use crate::hooks::{settled_or_loading, use_clusters, use_game_snapshot}; +use crate::state::{GameState as GameSnapshot, LauncherInit}; +use oneclient_core::clusters::Cluster; use oneclient_events::LaunchStage; pub fn launch_button_state( game: &GameSnapshot, cluster_id: i64, syncing: bool, + shared_dir_taken: bool, ) -> (&'static str, bool) { let state = match game.stage(cluster_id) { Some(LaunchStage::Checking) => ("Checking", false), + Some(LaunchStage::Waiting) => ("Waiting", false), Some(LaunchStage::Downloading) => ("Downloading", false), Some(LaunchStage::Launching) => ("Launching", false), Some(LaunchStage::Running) => ("Running", false), // The click's own flag held for a beat after a failure so a burst of clicks starts one game _ if game.is_launch_pending(cluster_id) => ("Launching", false), + // Core refuses it anyway the shared game dir holds one game at a time + _ if shared_dir_taken => ("Folder in use", false), _ => ("Launch", true), }; // Block launching while the startup bundle download is still running @@ -33,6 +39,33 @@ pub fn launch_button_state( state } +/// Whether another shared cluster is starting or running in the shared game dir +/// dedicated clusters never collide so any number of them may run alongside +pub fn shared_dir_taken(game: &GameSnapshot, clusters: &[Cluster], cluster_id: i64) -> bool { + // Active games are few so filter on them before touching the marker files + let mut others = clusters + .iter() + .filter(|c| c.id != cluster_id && (game.is_active(c.id) || game.is_launch_pending(c.id))) + .peekable(); + + others.peek().is_some() + && clusters + .iter() + .find(|c| c.id == cluster_id) + .is_some_and(|c| !c.uses_dedicated_dir()) + && others.any(|c| !c.uses_dedicated_dir()) +} + +pub fn use_shared_dir_taken(cluster_id: i64) -> bool { + let game = use_game_snapshot(); + let clusters = settled_or_loading(&use_clusters()).unwrap_or_default(); + shared_dir_taken(&game, &clusters, cluster_id) +} + +pub fn launch_syncing(launcher: &LauncherInit, uses_bundles: bool) -> bool { + launcher.fetching || (uses_bundles && launcher.syncing_bundles) +} + pub(crate) use analytics::{analytics_body, analytics_placeholder}; pub use clusters::Clusters; pub use debug::Debug; diff --git a/packages/oneclient_app/src/view/app/settings/accounts.rs b/packages/oneclient_app/src/view/app/settings/accounts.rs index 9065601d..332b6c64 100644 --- a/packages/oneclient_app/src/view/app/settings/accounts.rs +++ b/packages/oneclient_app/src/view/app/settings/accounts.rs @@ -7,7 +7,8 @@ use uuid::Uuid; use super::{section_header, settings_page}; use crate::components::{ - Avatar, Button, Icon, IconType, OverlayPopup, PlayerModel, TextInput, use_microsoft_login, + Avatar, Button, Icon, IconType, MicrosoftLogin, OverlayPopup, PlayerModel, TextInput, + use_microsoft_login, }; use crate::hooks::{ AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys, @@ -89,10 +90,12 @@ impl Component for SettingsAccounts { username: account.username.clone(), kind: account.kind, expires: account.expires, + signed_out: account.needs_sign_in(), is_default: Some(account.id) == default_id, set_default, remove, refresh, + sign_in: msa.clone(), edit_account, edit_name, } @@ -212,6 +215,10 @@ fn hero( on_add_microsoft: impl FnMut(Event) + 'static, ) -> impl IntoElement { let (name, subtitle) = match &account { + Some(account) if account.needs_sign_in() => ( + account.username.clone(), + "Signed out. Sign in again to play", + ), Some(account) => (account.username.clone(), kind_label(account.kind)), None => ( "No active account".to_string(), @@ -481,15 +488,20 @@ const REFRESH_SPIN_TIME: u64 = 800; const REFRESHING_OPACITY: f32 = 0.7; +/// Only the identity half fades the sign-in and remove buttons stay crisp +const SIGNED_OUT_OPACITY: f32 = 0.45; + struct AccountRow { id: Uuid, username: String, kind: AccountKind, expires: DateTime, + signed_out: bool, is_default: bool, set_default: crate::hooks::UseSetDefaultAccount, remove: crate::hooks::UseRemoveAccount, refresh: crate::hooks::UseRefreshAccount, + sign_in: MicrosoftLogin, edit_account: State>, edit_name: State, } @@ -500,7 +512,9 @@ impl PartialEq for AccountRow { && self.username == other.username && self.kind == other.kind && self.expires == other.expires + && self.signed_out == other.signed_out && self.is_default == other.is_default + && self.sign_in.pending == other.sign_in.pending && self.edit_account == other.edit_account && self.edit_name == other.edit_name } @@ -519,7 +533,11 @@ impl Component for AccountRow { let refresh = self.refresh; let is_microsoft = self.kind == AccountKind::Microsoft; - let expired = is_microsoft && self.expires <= Utc::now(); + let signed_out = is_microsoft && self.signed_out; + // A signed-out entry has no token left to call expired + let expired = is_microsoft && !signed_out && self.expires <= Utc::now(); + let identity_opacity = if signed_out { SIGNED_OUT_OPACITY } else { 1. }; + let sign_in = self.sign_in.clone(); let mut refreshing = use_state(|| false); let is_refreshing = *refreshing.read(); @@ -546,7 +564,9 @@ impl Component for AccountRow { 0. }; - let border_color = if expired { + let border_color = if signed_out { + colors::component_border() + } else if expired { colors::danger() } else if is_default { colors::brand() @@ -570,19 +590,22 @@ impl Component for AccountRow { 1. }) .a11y_role(AccessibilityRole::Button) - .maybe(!is_default, |el| { + .maybe(!is_default && !signed_out, |el| { el.on_press(move |_| set_default.mutate(SetDefaultAccountKeys { id: Some(id) })) }) .child( - Avatar::new(id.to_string()) - .width(Size::px(AVATAR_SIZE_PX)) - .height(Size::px(AVATAR_SIZE_PX)), + rect().opacity(identity_opacity).child( + Avatar::new(id.to_string()) + .width(Size::px(AVATAR_SIZE_PX)) + .height(Size::px(AVATAR_SIZE_PX)), + ), ) .child( rect() .vertical() .width(Size::flex(1.0)) .spacing(4.) + .opacity(identity_opacity) .child( rect() .horizontal() @@ -597,18 +620,37 @@ impl Component for AccountRow { .color(colors::fg_primary()), ) .maybe_child(is_default.then(default_badge)) - .maybe_child(expired.then(expired_badge)), + .maybe_child(expired.then(expired_badge)) + .maybe_child(signed_out.then(signed_out_badge)), ) // The kind rides with the id a third badge and a full uuid do not both fit .child( label() - .text(format!("{} · {id}", kind_label(self.kind))) + .text(if signed_out { + "Session expired. Sign in again to use this account".to_string() + } else { + format!("{} · {id}", kind_label(self.kind)) + }) .font_size(11.) .max_lines(1) .color(colors::fg_secondary()), ), ) - .maybe_child(is_microsoft.then(|| { + .maybe_child(signed_out.then(|| { + let pending = sign_in.pending; + Button::new() + .secondary() + .enabled(!pending) + .tooltip("Sign in to this Microsoft account again") + .on_press(move |e: Event| { + e.stop_propagation(); + sign_in.start(); + }) + .child(Icon::new(IconType::Globe01).size(16.)) + .text(if pending { "Signing in..." } else { "Sign in" }) + .into_element() + })) + .maybe_child((is_microsoft && !signed_out).then(|| { Button::new() .ghost() .icon() @@ -728,6 +770,18 @@ fn expired_badge() -> impl IntoElement { ) } +fn signed_out_badge() -> impl IntoElement { + badge( + Icon::new(IconType::AlertTriangle) + .size(12.) + .color(colors::fg_secondary()) + .into_element(), + "Signed out".to_string(), + colors::component_border(), + colors::fg_secondary(), + ) +} + fn badge(icon: impl IntoElement, text: String, border: Color, fg: Color) -> impl IntoElement { rect() .horizontal() diff --git a/packages/oneclient_app/src/view/app/settings/apis.rs b/packages/oneclient_app/src/view/app/settings/apis.rs index 13dd6729..d90128af 100644 --- a/packages/oneclient_app/src/view/app/settings/apis.rs +++ b/packages/oneclient_app/src/view/app/settings/apis.rs @@ -3,7 +3,7 @@ use freya::prelude::*; use super::settings_page; use crate::components::{IconType, TextInput}; use crate::hooks::{use_dispatch, use_settings_snapshot}; -use crate::view::app::settings::{section_header, settings_row}; +use crate::view::app::settings::{resettable, section_header, settings_row}; fn normalize(value: &str) -> Option { let trimmed = value.trim(); @@ -49,12 +49,12 @@ impl Component for SettingsApis { first.set(false); return; } - let mut next = settings.clone(); - next.modrinth_api_key = normalize(&modrinth); - next.curseforge_api_key = normalize(&curseforge); - next.custom_api_endpoint = normalize(&endpoint); - next.custom_meta_url_base = normalize(&meta_url_base); - dispatch.set_settings(next); + dispatch.edit_settings(|settings| { + settings.modrinth_api_key = normalize(&modrinth); + settings.curseforge_api_key = normalize(&curseforge); + settings.custom_api_endpoint = normalize(&endpoint); + settings.custom_meta_url_base = normalize(&meta_url_base); + }); }); settings_page() @@ -63,34 +63,50 @@ impl Component for SettingsApis { IconType::Key01, "Modrinth", "Personal access token used for Modrinth requests.", - TextInput::new(modrinth_key) - .placeholder("Optional") - .width(Size::px(220.)), + resettable( + TextInput::new(modrinth_key) + .placeholder("Optional") + .width(Size::px(220.)), + modrinth_key, + String::new(), + ), )) .child(settings_row( IconType::Key01, "CurseForge", "API key used for CurseForge requests.", - TextInput::new(curseforge_key) - .placeholder("Default") - .width(Size::px(220.)), + resettable( + TextInput::new(curseforge_key) + .placeholder("Default") + .width(Size::px(220.)), + curseforge_key, + String::new(), + ), )) .child(section_header("ADVANCED")) .child(settings_row( IconType::Globe01, "Custom API Endpoint", "Override the default OneClient backend endpoint.", - TextInput::new(custom_api_endpoint) - .placeholder("Default") - .width(Size::px(220.)), + resettable( + TextInput::new(custom_api_endpoint) + .placeholder("Default") + .width(Size::px(220.)), + custom_api_endpoint, + String::new(), + ), )) .child(settings_row( IconType::Globe01, "Custom Meta URL Base", "Override the default bundles and versions data host.", - TextInput::new(custom_meta_url_base) - .placeholder("Default") - .width(Size::px(220.)), + resettable( + TextInput::new(custom_meta_url_base) + .placeholder("Default") + .width(Size::px(220.)), + custom_meta_url_base, + String::new(), + ), )) .into_element() } diff --git a/packages/oneclient_app/src/view/app/settings/appearance.rs b/packages/oneclient_app/src/view/app/settings/appearance.rs index accfc8b6..342a2da8 100644 --- a/packages/oneclient_app/src/view/app/settings/appearance.rs +++ b/packages/oneclient_app/src/view/app/settings/appearance.rs @@ -1,11 +1,12 @@ use freya::prelude::*; +use oneclient_core::settings::LauncherSettings; use super::settings_page; use crate::components::{Icon, IconType, toggle}; use crate::hooks::{use_dispatch, use_settings_snapshot}; use crate::theme::colors; use crate::ui::border_all_color; -use crate::view::app::settings::{settings_row, settings_row_disabled}; +use crate::view::app::settings::{resettable, settings_row, settings_row_disabled}; #[derive(Clone, Copy)] struct ThemePreview { @@ -52,6 +53,7 @@ impl Component for SettingsAppearance { let selected_theme = use_state(|| 0usize); let settings = use_settings_snapshot().settings; + let defaults = LauncherSettings::default(); let dispatch = use_dispatch(); let dynamic_bg = use_state({ @@ -72,10 +74,10 @@ impl Component for SettingsAppearance { first.set(false); return; } - let mut next = settings.clone(); - next.dynamic_background_enabled = parallax; - next.animations_enabled = animations; - dispatch.set_settings(next); + dispatch.edit_settings(|settings| { + settings.dynamic_background_enabled = parallax; + settings.animations_enabled = animations; + }); }); settings_page() @@ -88,8 +90,11 @@ impl Component for SettingsAppearance { .padding(Gaps::new(8., 0., 0., 0.)) .child(accent_color_row()) .child(custom_theme_row()) - .child(dynamic_background_row(dynamic_bg)) - .child(animations_row(animations_on)), + .child(dynamic_background_row( + dynamic_bg, + defaults.dynamic_background_enabled, + )) + .child(animations_row(animations_on, defaults.animations_enabled)), ) .child( rect().padding(Gaps::new(8., 0., 0., 0.)).child( @@ -245,20 +250,20 @@ fn custom_theme_row() -> impl IntoElement { ) } -fn dynamic_background_row(enabled: State) -> impl IntoElement { +fn dynamic_background_row(enabled: State, default: bool) -> impl IntoElement { settings_row( IconType::Eye, "Parallax background", "Make the home screen background drift with your cursor. Turning it off keeps the background but disables the motion.", - toggle(enabled), + resettable(toggle(enabled), enabled, default), ) } -fn animations_row(animations_on: State) -> impl IntoElement { +fn animations_row(animations_on: State, default: bool) -> impl IntoElement { settings_row( IconType::Play, "Animations", - "Disable all launcher animations and transitions.", - toggle(animations_on), + "Whether all launcher animations and transitions should be enabled.", + resettable(toggle(animations_on), animations_on, default), ) } diff --git a/packages/oneclient_app/src/view/app/settings/changelog.rs b/packages/oneclient_app/src/view/app/settings/changelog.rs index 3c78dfa3..ce21b702 100644 --- a/packages/oneclient_app/src/view/app/settings/changelog.rs +++ b/packages/oneclient_app/src/view/app/settings/changelog.rs @@ -4,9 +4,9 @@ use freya::animation::{ use freya::prelude::*; use super::settings_page; -use crate::components::{Icon, IconType}; +use crate::components::{Icon, IconType, Markdown, MarkdownStyle}; use crate::hooks::{ - changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, + changelog_entries, changelog_error, changelog_is_loading, latest_changelog_version, use_changelog, use_dispatch, use_settings_snapshot, }; use crate::theme::colors; @@ -55,15 +55,15 @@ impl Component for SettingsChangelog { .into_element(); } - let groups = changelog_groups(&query).unwrap_or_default(); + let entries = changelog_entries(&query).unwrap_or_default(); settings_page() - .children(groups.into_iter().enumerate().map(|(i, group)| { - let current = group.version == installed_version; + .children(entries.into_iter().enumerate().map(|(i, entry)| { + let current = entry.version == installed_version; ReleaseCard { - version: group.version, + version: entry.version, current, - changes: group.changes, + body: entry.body, initially_open: i == 0, } .into_element() @@ -79,7 +79,7 @@ const CHEVRON_CLOSED_DEG: f32 = -90.; struct ReleaseCard { version: String, current: bool, - changes: Vec, + body: String, initially_open: bool, } @@ -110,8 +110,6 @@ impl Component for ReleaseCard { self.version.clone() }; - let changes = self.changes.clone(); - rect() .vertical() .width(Size::fill()) @@ -137,51 +135,37 @@ impl Component for ReleaseCard { .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), ) - .child(Icon::new(IconType::ChevronDown).size(18.).rotate(chevron_deg)), + .child( + Icon::new(IconType::ChevronDown) + .size(18.) + .rotate(chevron_deg), + ), ) .maybe_child(is_open.then(|| { - rect() - .vertical() - .width(Size::fill()) - .spacing(4.) - .padding(Gaps::new(0., 0., 0., 6.)) - .children(if changes.is_empty() { - vec![ - rect() - .child( - label() - .text("No changes recorded for this version.") - .font_size(12.) - .color(colors::fg_secondary()), - ) - .into_element(), - ] - } else { - changes - .into_iter() - .map(|change| { - rect() - .horizontal() - .width(Size::fill()) - .spacing(8.) - .child( - label() - .text("•") - .font_size(12.) - .color(colors::fg_secondary()), - ) - .child( - label() - .text(change) - .font_size(12.) - .width(Size::flex(1.0)) - .color(colors::fg_primary()), - ) - .into_element() - }) - .collect() - }) - .into_element() + if self.body.trim().is_empty() { + label() + .text("No changes recorded for this version.") + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + } else { + Markdown::new(self.body.clone()) + .width(Size::fill()) + .style(MarkdownStyle { + color: colors::fg_primary(), + color_link: colors::code_info(), + color_code: colors::fg_primary(), + background_code: colors::component_bg(), + background_blockquote: colors::component_bg(), + border_blockquote: colors::brand(), + background_divider: colors::component_border(), + headings: [18., 16., 14., 13., 12., 12.], + paragraph_size: 12., + code_font_size: 11., + ..MarkdownStyle::default() + }) + .into_element() + } })) .into_element() } diff --git a/packages/oneclient_app/src/view/app/settings/developer.rs b/packages/oneclient_app/src/view/app/settings/developer.rs index 3c28b5d1..cbbafaef 100644 --- a/packages/oneclient_app/src/view/app/settings/developer.rs +++ b/packages/oneclient_app/src/view/app/settings/developer.rs @@ -1,11 +1,14 @@ use freya::prelude::*; use freya::router::RouterContext; +use oneclient_core::settings::LauncherSettings; use super::settings_page; use crate::Route; use crate::components::{IconType, link_button, toggle}; -use crate::hooks::{use_browser_compat, use_dispatch, use_settings_snapshot}; -use crate::view::app::settings::{section_header, settings_row}; +use crate::hooks::{ + BROWSER_COMPAT_DEFAULT, use_browser_compat, use_dispatch, use_settings_snapshot, +}; +use crate::view::app::settings::{resettable, section_header, settings_row}; use crate::view::console::open_log_console; #[derive(PartialEq)] @@ -14,6 +17,7 @@ pub struct SettingsDeveloper; impl Component for SettingsDeveloper { fn render(&self) -> impl IntoElement { let settings = use_settings_snapshot().settings; + let defaults = LauncherSettings::default(); let dispatch = use_dispatch(); let log_debug = use_state({ @@ -28,9 +32,7 @@ impl Component for SettingsDeveloper { first.set(false); return; } - let mut next = settings.clone(); - next.log_debug = enabled; - dispatch.set_settings(next); + dispatch.edit_settings(|settings| settings.log_debug = enabled); }); let browser_compat = use_browser_compat(); @@ -41,14 +43,18 @@ impl Component for SettingsDeveloper { IconType::SearchMd, "Compatible content only", "Filter the content browser to the active cluster's version and loader.", - toggle(browser_compat), + resettable( + toggle(browser_compat), + browser_compat, + BROWSER_COMPAT_DEFAULT, + ), )) .child(section_header("DEV TOOLS")) .child(settings_row( IconType::Sliders04, "Log Debug Info", "WARNING! This requires a restart to apply. Logs out debug info.", - toggle(log_debug), + resettable(toggle(log_debug), log_debug, defaults.log_debug), )) .child(settings_row( IconType::Terminal, diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs index 6a229796..5ed23b43 100644 --- a/packages/oneclient_app/src/view/app/settings/java.rs +++ b/packages/oneclient_app/src/view/app/settings/java.rs @@ -23,20 +23,6 @@ impl Component for SettingsJava { let mut show_manager = use_state(|| false); let pending_remove = use_state(|| None::); - #[allow(dead_code, unused_variables)] - fn invalidate_runtimes(dispatch: Actions) { - spawn(async move { - invalidate_java_queries().await; - dispatch - .notify("Java runtimes refreshed") - .body("The installed runtime list is up to date") - .info() - .send(); - }); - } - - let _refresh_dispatch = dispatch.clone(); - let mut shell = settings_page() .child(section_header("ADD RUNTIME")) .child(AddRow { show_manager }.into_element()) @@ -57,7 +43,11 @@ impl Component for SettingsJava { let pending = pending_remove.read().clone(); if let Some(target) = pending { - shell = shell.child(confirm_remove_modal(removing_dispatch, pending_remove, target)) + shell = shell.child(confirm_remove_modal( + removing_dispatch, + pending_remove, + target, + )) } shell.into_element() @@ -74,8 +64,9 @@ impl Component for AddRow { let dispatch = use_dispatch(); let mut show_manager = self.show_manager; + let pick_dispatch = dispatch.clone(); let pick = move |_| { - let dispatch = dispatch.clone(); + let dispatch = pick_dispatch.clone(); spawn(async move { if let Some(handle) = rfd::AsyncFileDialog::new() .set_title("Select a Java installation folder") @@ -87,6 +78,20 @@ impl Component for AddRow { }); }; + let refresh_dispatch = dispatch; + let refresh = move |_| { + let dispatch = refresh_dispatch.clone(); + spawn(async move { + invalidate_java_queries().await; + dispatch + .notify("Java runtimes refreshed") + .body("The installed runtime list is up to date") + .info() + .toast_only() + .send(); + }); + }; + rect() .horizontal() .width(Size::fill()) @@ -105,6 +110,13 @@ impl Component for AddRow { .child(Icon::new(IconType::Folder).size(14.)) .text("Add from folder"), ) + .child( + Button::new() + .secondary() + .on_press(refresh) + .child(Icon::new(IconType::RefreshCcw02).size(14.)) + .text("Refresh"), + ) } } @@ -142,7 +154,7 @@ fn runtimes_table( RuntimeRow { runtime, last: idx + 1 == count, - pending_remove + pending_remove, } .into_element(), ); @@ -192,7 +204,7 @@ fn table_header() -> impl IntoElement { struct RuntimeRow { runtime: JavaRuntime, last: bool, - pending_remove: State> + pending_remove: State>, } impl Component for RuntimeRow { @@ -293,9 +305,9 @@ fn confirm_remove_modal( let remove_path = path.clone(); let consequence = if managed { - "This runtime is managed by OneClient. It's files WILL be deleted." + "This runtime is managed by OneClient. Its files WILL be deleted." } else { - "This runtime is not managed by OneClient. It's files will not be deleted." + "This runtime is not managed by OneClient. Its files will not be deleted." }; let confirm = if managed { @@ -304,7 +316,7 @@ fn confirm_remove_modal( Button::new().primary().text("Remove from list") }; - OverlayPopup::new() + OverlayPopup::new() .on_close(move |()| pending.set(None)) .child( rect() @@ -360,7 +372,7 @@ fn confirm_remove_modal( pending.set(None); })), ), - ) + ), ) .into_element() } diff --git a/packages/oneclient_app/src/view/app/settings/launcher.rs b/packages/oneclient_app/src/view/app/settings/launcher.rs index 55580965..d6bca2ba 100644 --- a/packages/oneclient_app/src/view/app/settings/launcher.rs +++ b/packages/oneclient_app/src/view/app/settings/launcher.rs @@ -3,12 +3,14 @@ use std::path::{Path, PathBuf}; use freya::prelude::*; use freya::router::RouterContext; use oneclient_core::relocate::RelocationPlan; -use oneclient_core::settings::data_dir; +use oneclient_core::settings::{LaunchBehaviour, LauncherSettings, data_dir}; use oneclient_core::storage::format_bytes; -use super::{section_header, settings_page, settings_row}; +use super::{resettable, section_header, settings_page, settings_row}; use crate::Route; -use crate::components::{Button, Icon, IconType, OverlayPopup, open_folder_button, toggle}; +use crate::components::{ + Button, Dropdown, Icon, IconType, OverlayPopup, open_folder_button, toggle, +}; use crate::hooks::{ Actions, DiscardLeftoversKeys, mutation_error, mutation_is_running, try_leftovers, use_discard_leftovers, use_dispatch, use_launcher, use_leftovers, use_settings_snapshot, @@ -22,6 +24,7 @@ pub struct SettingsLauncher; impl Component for SettingsLauncher { fn render(&self) -> impl IntoElement { let settings = use_settings_snapshot().settings; + let defaults = LauncherSettings::default(); let dispatch = use_dispatch(); let discord_rpc = use_state({ @@ -39,24 +42,42 @@ impl Component for SettingsLauncher { move || v }); + let run_in_background = use_state({ + let v = settings.run_in_background; + move || v + }); + + let show_tray_icon = use_state({ + let v = settings.show_tray_icon; + move || v + }); + + let launch_behaviour = use_state({ + let v = settings.launch_behaviour; + move || v + }); + let mut first = use_state(|| true); - { - let settings = settings.clone(); - use_side_effect(move || { - let discord = *discord_rpc.read(); - let crash = *crash_reporting.read(); - let maximized = *start_maximized.read(); - if *first.peek() { - first.set(false); - return; - } - let mut next = settings.clone(); + use_side_effect(move || { + let discord = *discord_rpc.read(); + let crash = *crash_reporting.read(); + let maximized = *start_maximized.read(); + let background = *run_in_background.read(); + let tray = *show_tray_icon.read(); + let behaviour = *launch_behaviour.read(); + if *first.peek() { + first.set(false); + return; + } + dispatch.edit_settings(|next| { next.discord_enabled = discord; next.crash_reporting = crash; next.start_maximized = maximized; - dispatch.set_settings(next); + next.run_in_background = background; + next.show_tray_icon = tray; + next.launch_behaviour = behaviour; }); - } + }); // The only way back for someone who declined during onboarding let consent_summary = if settings.declined_tos { @@ -78,19 +99,27 @@ impl Component for SettingsLauncher { IconType::Link03, "Discord RPC", "Enable Discord Rich Presence.", - toggle(discord_rpc), + resettable(toggle(discord_rpc), discord_rpc, defaults.discord_enabled), )) .child(settings_row( IconType::AlertTriangle, "Crash Reporting", "Send anonymous crash and error reports to help fix bugs. Applies on restart.", - toggle(crash_reporting), + resettable( + toggle(crash_reporting), + crash_reporting, + defaults.crash_reporting, + ), )) .child(settings_row( IconType::Maximize01, "Start Maximized", "Open the launcher window maximized. Applies on restart.", - toggle(start_maximized), + resettable( + toggle(start_maximized), + start_maximized, + defaults.start_maximized, + ), )) .child(settings_row( IconType::File02, @@ -98,12 +127,59 @@ impl Component for SettingsLauncher { consent_summary, review_terms, )) + .child(section_header("SYSTEM")) + .child(settings_row( + IconType::Eye, + "While Playing", + "What the launcher window does once a game is running.", + resettable( + launch_behaviour_field(launch_behaviour), + launch_behaviour, + defaults.launch_behaviour, + ), + )) + .child(settings_row( + IconType::Moon01, + "Run in Background", + "Closing the window keeps OneClient running. Open it again from the tray icon or by launching it.", + resettable( + toggle(run_in_background), + run_in_background, + defaults.run_in_background, + ), + )) + .child(settings_row( + IconType::LayoutTop, + "Show Tray Icon", + "Show OneClient in the system tray or menu bar. Applies on restart.", + resettable( + toggle(show_tray_icon), + show_tray_icon, + defaults.show_tray_icon, + ), + )) .child(section_header("FOLDERS AND FILES")) .child(DataFolder.into_element()) .into_element() } } +fn launch_behaviour_field(mut selected: State) -> impl IntoElement { + let options: Vec = LaunchBehaviour::ALL + .iter() + .map(|behaviour| behaviour.label().to_string()) + .collect(); + + Dropdown::new(selected.read().label(), options) + .width(Size::px(220.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| { + if let Some(behaviour) = LaunchBehaviour::ALL.get(idx).copied() { + selected.set(behaviour); + } + }) +} + #[derive(PartialEq)] struct DataFolder; @@ -125,7 +201,11 @@ impl Component for DataFolder { .small() .disabled(checking_now) .on_press(move |_| browse(pending, error, checking)) - .text(if checking_now { "Checking…" } else { "Change…" }); + .text(if checking_now { + "Checking…" + } else { + "Change…" + }); let mut buttons = Vec::new(); @@ -239,7 +319,7 @@ fn plan( checking.set(true); error.set(None); - match plan_move(picked.as_deref()).await { + match crate::launcher::off_ui(async move { plan_move(picked.as_deref()).await }).await { Ok(planned) => pending.set(Some(planned)), Err(message) => error.set(Some(message)), } diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs index a1f0e559..0466f203 100644 --- a/packages/oneclient_app/src/view/app/settings/minecraft.rs +++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs @@ -1,18 +1,16 @@ use freya::prelude::*; use oneclient_common::Patch; -use oneclient_core::settings::{PackageUpdateMode, ProfileUpdate, Resolution}; -#[cfg(any(target_os = "linux", windows))] -use oneclient_core::settings::SettingsOsExtra; +use oneclient_core::settings::{ + GameSettingsProfile, PackageUpdateMode, ProfileUpdate, Resolution, SettingsOsExtra, +}; use super::settings_page; use crate::components::{ Dropdown, Icon, IconType, TextInput, memory_field, toggle, validate_number, }; -#[cfg(any(target_os = "linux", windows))] -use crate::components::toggle_controlled; use crate::hooks::{use_dispatch, use_settings_snapshot}; use crate::theme::colors; -use crate::view::app::settings::{section_header, settings_row}; +use crate::view::app::settings::{resettable, resettable_all, section_header, settings_row}; #[derive(PartialEq)] pub struct SettingsMinecraft; @@ -21,6 +19,7 @@ impl Component for SettingsMinecraft { fn render(&self) -> impl IntoElement { let settings = use_settings_snapshot().settings; let profile = settings.global_game_settings.clone(); + let defaults = GameSettingsProfile::default_global_profile(); let dispatch = use_dispatch(); let fullscreen = use_state({ @@ -49,6 +48,10 @@ impl Component for SettingsMinecraft { let v = profile.launch_args.clone().unwrap_or_default(); move || v }); + let game_args = use_state({ + let v = profile.game_args.clone().unwrap_or_default(); + move || v + }); let pre_launch_command = use_state({ let v = profile.hook_pre.clone().unwrap_or_default(); move || v @@ -61,9 +64,18 @@ impl Component for SettingsMinecraft { let v = profile.hook_post.clone().unwrap_or_default(); move || v }); + let update_mode = use_state({ + let v = profile.browser_update_mode.unwrap_or_default(); + move || v + }); + + let gpu = use_state({ + let v = profile.gpu().map(str::to_owned); + move || v + }); + let gpus = use_state(oneclient_core::game::gpu::detect); let mut first = use_state(|| true); - let batched = dispatch.clone(); use_side_effect(move || { let update = build_update( *fullscreen.read(), @@ -71,15 +83,21 @@ impl Component for SettingsMinecraft { &height.read(), &memory.read(), &jvm_args.read(), + &game_args.read(), &pre_launch_command.read(), &wrapper_command.read(), &post_exit_command.read(), + *update_mode.read(), ); + let selected_gpu = gpu.read().clone(); if *first.peek() { first.set(false); return; } - batched.update_global_profile(update); + + let update = with_gpu(update, selected_gpu); + + dispatch.update_global_profile(update); }); let page = settings_page() @@ -88,36 +106,81 @@ impl Component for SettingsMinecraft { IconType::Maximize01, "Force Fullscreen", "Force Minecraft to start in fullscreen mode.", - toggle(fullscreen), + resettable( + toggle(fullscreen), + fullscreen, + defaults.force_fullscreen.unwrap_or(false), + ), )) .child(settings_row( IconType::LayoutTop, "Resolution", "The game window resolution in pixels.", - resolution_field(width, height), + resettable_all( + resolution_field(width, height), + vec![ + ( + width, + defaults + .resolution + .map(|r| r.width.to_string()) + .unwrap_or_default(), + ), + ( + height, + defaults + .resolution + .map(|r| r.height.to_string()) + .unwrap_or_default(), + ), + ], + ), )) .child(settings_row( IconType::Database01, "Memory", "The amount of memory in megabytes allocated for the game. Presets leave 2 GB for the system.", - memory_field(memory, "Default", oneclient_common::default_mem_max()), + resettable( + memory_field(memory, "Default", oneclient_common::default_mem_max()), + memory, + String::new(), + ), )) .child(settings_row( IconType::Terminal, "JVM Arguments", "Extra arguments passed to Java. Separate them with spaces; quote values containing spaces.", - TextInput::new(jvm_args) - .placeholder("-XX:+UseG1GC") - .width(Size::px(220.)), + resettable( + TextInput::new(jvm_args) + .placeholder("-XX:+UseG1GC") + .expandable(true) + .width(Size::px(220.)), + jvm_args, + defaults.launch_args.clone().unwrap_or_default(), + ), + )) + .child(settings_row( + IconType::Play, + "Game Arguments", + "Extra arguments passed to Minecraft. Separate them with spaces; quote values containing spaces.", + resettable( + TextInput::new(game_args) + .placeholder("--tracy --tracyNoImages") + .expandable(true) + .width(Size::px(220.)), + game_args, + defaults.game_args.clone().unwrap_or_default(), + ), )) .child(section_header("CONTENT")) .child(settings_row( IconType::RefreshCw01, "Browser Package Updates", "What to do when content you installed from the browser has a newer version. Packs from bundles are not affected.", - update_mode_field( - profile.browser_update_mode.unwrap_or_default(), - dispatch.clone(), + resettable( + update_mode_field(update_mode), + update_mode, + defaults.browser_update_mode.unwrap_or_default(), ), )) .child(section_header("PROCESS")) @@ -125,61 +188,90 @@ impl Component for SettingsMinecraft { IconType::FilePlus02, "Pre-Launch Command", "Command to run before launching the game.", - TextInput::new(pre_launch_command) - .placeholder("echo 'Game started'") - .width(Size::px(220.)), + resettable( + TextInput::new(pre_launch_command) + .placeholder("echo 'Game started'") + .expandable(true) + .width(Size::px(220.)), + pre_launch_command, + defaults.hook_pre.clone().unwrap_or_default(), + ), )) .child(settings_row( IconType::ParagraphWrap, "Wrapper Command", "Command to run when launching the game.", - TextInput::new(wrapper_command) - .placeholder("gamescope") - .width(Size::px(220.)), + resettable( + TextInput::new(wrapper_command) + .placeholder("gamescope") + .expandable(true) + .width(Size::px(220.)), + wrapper_command, + defaults.hook_wrapper.clone().unwrap_or_default(), + ), )) .child(settings_row( IconType::FileX02, "Post-Exit Command", "Command to run after exiting the game.", - TextInput::new(post_exit_command) - .placeholder("echo 'Game exited'") - .width(Size::px(220.)), + resettable( + TextInput::new(post_exit_command) + .placeholder("echo 'Game exited'") + .expandable(true) + .width(Size::px(220.)), + post_exit_command, + defaults.hook_post.clone().unwrap_or_default(), + ), )); - #[cfg(any(target_os = "linux", windows))] - let page = page - .child(section_header("GRAPHICS")) - .child(discrete_gpu_row(profile.os_extra.clone(), dispatch)); + let page = if gpus.read().is_empty() { + page + } else { + page.child(section_header("GRAPHICS")).child(settings_row( + IconType::Rocket02, + "GPU", + "The graphics card the game renders on. System default leaves the choice to your drivers.", + resettable(gpu_field(gpu, &gpus.read()), gpu, None), + )) + }; page.into_element() } } -#[cfg(any(target_os = "linux", windows))] -fn discrete_gpu_row( - os_extra: Option, - dispatch: crate::Actions, +fn with_gpu(mut update: ProfileUpdate, gpu: Option) -> ProfileUpdate { + let base = crate::launcher::state() + .ok() + .and_then(|state| state.settings.read().global_game_settings.os_extra.clone()) + .unwrap_or_default(); + + update.os_extra = Patch::Set(SettingsOsExtra { gpu, ..base }); + update +} + +fn gpu_field( + mut selected: State>, + gpus: &[oneclient_core::game::gpu::Gpu], ) -> impl IntoElement { - let current = os_extra.unwrap_or_default(); - let on = current.use_discrete_gpu.unwrap_or(false); + const SYSTEM_DEFAULT: &str = "System default"; - let on_toggle: EventHandler<()> = (move |()| { - dispatch.update_global_profile(ProfileUpdate { - os_extra: Patch::Set(SettingsOsExtra { - use_discrete_gpu: Some(!on), - ..current.clone() - }), - ..Default::default() - }); - }) - .into(); + let current = selected + .read() + .as_deref() + .and_then(|id| gpus.iter().find(|gpu| gpu.id == id)) + .map_or(SYSTEM_DEFAULT.to_string(), |gpu| gpu.name.clone()); - settings_row( - IconType::Rocket02, - "Use Discrete GPU", - "Render the game on the dedicated graphics card. Does nothing on a machine with only one GPU, and draws noticeably more power on a laptop.", - toggle_controlled(on, on_toggle), - ) + let options = std::iter::once(SYSTEM_DEFAULT.to_string()) + .chain(gpus.iter().map(|gpu| gpu.name.clone())) + .collect(); + let ids: Vec = gpus.iter().map(|gpu| gpu.id.clone()).collect(); + + Dropdown::new(current, options) + .width(Size::px(220.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| { + selected.set(idx.checked_sub(1).and_then(|i| ids.get(i).cloned())); + }) } #[allow(clippy::too_many_arguments)] @@ -189,9 +281,11 @@ fn build_update( height: &str, memory: &str, jvm_args: &str, + game_args: &str, pre: &str, wrapper: &str, post: &str, + update_mode: PackageUpdateMode, ) -> ProfileUpdate { let resolution = match (width.trim(), height.trim()) { ("", "") => Patch::Clear, @@ -211,32 +305,27 @@ fn build_update( resolution, mem_max, launch_args: command_patch(jvm_args), + game_args: command_patch(game_args), hook_pre: command_patch(pre), hook_wrapper: command_patch(wrapper), hook_post: command_patch(post), + browser_update_mode: Patch::Set(update_mode), ..Default::default() } } -/// Dispatched separately from [`build_update`] which debounces keystrokes a dropdown has no intermediate states -fn update_mode_field( - selected: PackageUpdateMode, - dispatch: crate::Actions, -) -> impl IntoElement { +fn update_mode_field(mut selected: State) -> impl IntoElement { let options: Vec = PackageUpdateMode::ALL .iter() .map(|mode| mode.label().to_string()) .collect(); - Dropdown::new(selected.label(), options) + Dropdown::new(selected.read().label(), options) .width(Size::px(220.)) .height(Size::px(34.)) .on_select(move |idx: usize| { if let Some(mode) = PackageUpdateMode::ALL.get(idx).copied() { - dispatch.update_global_profile(ProfileUpdate { - browser_update_mode: Patch::Set(mode), - ..Default::default() - }); + selected.set(mode); } }) } @@ -276,4 +365,3 @@ fn resolution_field(width: State, height: State) -> impl IntoEle ) .into_element() } - diff --git a/packages/oneclient_app/src/view/app/settings/mod.rs b/packages/oneclient_app/src/view/app/settings/mod.rs index 44173202..37172085 100644 --- a/packages/oneclient_app/src/view/app/settings/mod.rs +++ b/packages/oneclient_app/src/view/app/settings/mod.rs @@ -14,9 +14,9 @@ use freya::prelude::*; pub use accounts::SettingsAccounts; pub use apis::SettingsApis; +pub use appearance::SettingsAppearance; pub use changelog::SettingsChangelog; pub use developer::SettingsDeveloper; -pub use appearance::SettingsAppearance; pub use java::SettingsJava; pub use language::SettingsLanguage; pub use launcher::SettingsLauncher; @@ -24,7 +24,7 @@ pub use minecraft::SettingsMinecraft; pub use storage::SettingsStorage; use crate::{ - components::{Icon, IconType}, + components::{Button, Icon, IconType}, theme::colors, }; @@ -104,3 +104,61 @@ fn settings_row_inner( .child(trailing) .into_element() } + +#[derive(PartialEq)] +struct ResetButton { + values: Vec<(State, T)>, +} + +impl Component for ResetButton { + fn render(&self) -> impl IntoElement { + let at_default = self + .values + .iter() + .all(|(value, default)| *value.read() == *default); + + let values = self.values.clone(); + + Button::new() + .ghost() + .icon() + .disabled(at_default) + .alt(if at_default { + "Already at the default" + } else { + "Reset to default" + }) + .on_press(move |_| { + for (value, default) in &values { + let mut value = *value; + value.set(default.clone()); + } + }) + .child( + Icon::new(IconType::RefreshCcw02) + .size(14.) + .color(colors::fg_secondary()), + ) + } +} + +pub fn resettable( + control: impl IntoElement, + value: State, + default: T, +) -> impl IntoElement { + resettable_all(control, vec![(value, default)]) +} + +pub fn resettable_all( + control: impl IntoElement, + values: Vec<(State, T)>, +) -> impl IntoElement { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child(control) + .child(ResetButton { values }) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/settings/storage.rs b/packages/oneclient_app/src/view/app/settings/storage.rs index 28b9f99f..9c3d35e3 100644 --- a/packages/oneclient_app/src/view/app/settings/storage.rs +++ b/packages/oneclient_app/src/view/app/settings/storage.rs @@ -322,10 +322,7 @@ fn unused_cache_description(entry: &ReclaimableEntry) -> String { ) } -fn legacy_content_description( - content: &ReclaimableEntry, - natives: &ReclaimableEntry, -) -> String { +fn legacy_content_description(content: &ReclaimableEntry, natives: &ReclaimableEntry) -> String { if content.is_empty() && natives.is_empty() { return "Nothing here - no old cluster content and no unused natives.".to_string(); } diff --git a/packages/oneclient_app/src/view/app/stats.rs b/packages/oneclient_app/src/view/app/stats.rs index 744c0fd3..49340a53 100644 --- a/packages/oneclient_app/src/view/app/stats.rs +++ b/packages/oneclient_app/src/view/app/stats.rs @@ -3,7 +3,7 @@ use freya::prelude::*; use crate::components::ScrollArea; use crate::hooks::{try_global_analytics, use_global_analytics}; use crate::theme::colors; -use crate::ui::{centered_note}; +use crate::ui::centered_note; use crate::view::app::{analytics_body, analytics_placeholder}; #[derive(PartialEq)] diff --git a/packages/oneclient_app/src/view/console.rs b/packages/oneclient_app/src/view/console.rs index 64563ac5..eae4b184 100644 --- a/packages/oneclient_app/src/view/console.rs +++ b/packages/oneclient_app/src/view/console.rs @@ -85,7 +85,11 @@ fn raise_to_debug() { } fn restore_directives() { - let Some(previous) = PREVIOUS_DIRECTIVES.lock().expect("console directives").take() else { + let Some(previous) = PREVIOUS_DIRECTIVES + .lock() + .expect("console directives") + .take() + else { return; }; diff --git a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs index c6619d61..ef2e52a8 100644 --- a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs @@ -1,21 +1,24 @@ use std::collections::{HashMap, HashSet}; use freya::prelude::*; -use oneclient_core::clusters::Cluster; use oneclient_content::packages::ProviderId; -use oneclient_core::{BundleArchive, BundleFile, BundleFileKind}; +use oneclient_core::BundleArchive; +use oneclient_core::clusters::Cluster; use crate::components::ScrollArea; -use crate::hooks::{ClusterBundles, onboarding_bundles_items, package_meta_batch, query_error, query_is_loading, use_onboarding_bundles, use_onboarding_selection, use_package_meta_batch}; +use crate::hooks::{ + ClusterBundles, onboarding_bundles_items, package_meta_batch, query_error, query_is_loading, + use_onboarding_bundles, use_onboarding_selection, use_package_meta_batch, +}; type MetaMap = HashMap; use crate::routes::Route; use crate::theme::colors; -use crate::ui::border_all_color; +use crate::ui::{border_all_color, fixed_grid}; +use crate::utils::bundle_display_name; use crate::view::onboarding::{ - archive_selected, choice_row_sized, is_default_bundle, is_optional_file, onboarding_nav, - onboarding_slide, pkg_key, predownload_toggle_row, set_archive_selected, step_heading, - version_chip, + archive_selected, choice_row_sized, is_default_bundle, onboarding_nav, onboarding_slide, + pkg_key, set_archive_selected, step_heading, version_chip, }; mod row; @@ -64,22 +67,6 @@ fn fps_warning_banner() -> impl IntoElement { .child(text) } -fn file_provider(file: &BundleFile) -> ProviderId { - match &file.kind { - BundleFileKind::Managed { provider, .. } => *provider, - BundleFileKind::External(_) => ProviderId::Local, - } -} - -fn bundle_display_name(archive: &BundleArchive) -> String { - let category = archive.manifest.category.trim(); - if category.is_empty() { - archive.manifest.name.clone() - } else { - category.to_string() - } -} - #[derive(Clone)] struct OptInBundle { display_name: String, @@ -146,7 +133,9 @@ fn opt_in_bundles(clusters: &[ClusterBundles]) -> Vec { #[derive(Clone)] struct OptionalMod { package_id: String, + metadata_id: String, provider: ProviderId, + github_hosted: bool, fallback_name: String, size: u64, /// `(cluster_id, bundle_name)` pairs this mod is available from @@ -176,14 +165,16 @@ fn optional_mods(clusters: &[ClusterBundles]) -> Vec { .manifest .files .iter() - .filter(|f| is_optional_file(f)) + .filter(|f| f.is_optional_offer()) { let package_id = file.kind.package_id(); let entry = map.entry(package_id.clone()).or_insert_with(|| { order.push(package_id.clone()); OptionalMod { package_id: package_id.clone(), - provider: file_provider(file), + metadata_id: file.kind.metadata_id(), + provider: file.kind.metadata_provider(), + github_hosted: file.is_github_hosted(), fallback_name: file.display_name(), size: file.size, locations: Vec::new(), @@ -241,7 +232,6 @@ impl Component for OnboardingBundles { let selection = use_onboarding_selection(); let selected = selection.selected; let user_touched = selection.user_touched; - let predownload = selection.predownload; let clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default(); let bundles_loaded = onboarding_bundles_items(&bundles_query).is_some(); @@ -256,9 +246,10 @@ impl Component for OnboardingBundles { let extras = optional_mods(&clusters); // Hooks so they run every render regardless of whether there are extras yet - let (mr_ids, cf_ids) = collect_ids(&extras); + let (mr_ids, cf_ids, local_ids) = collect_ids(&extras); let mr_meta = package_meta_batch(&use_package_meta_batch(ProviderId::Modrinth, mr_ids)); let cf_meta = package_meta_batch(&use_package_meta_batch(ProviderId::CurseForge, cf_ids)); + let local_meta = package_meta_batch(&use_package_meta_batch(ProviderId::Local, local_ids)); let mut sections: Vec = Vec::new(); @@ -275,6 +266,7 @@ impl Component for OnboardingBundles { user_touched, &mr_meta, &cf_meta, + &local_meta, )); } @@ -315,8 +307,7 @@ impl Component for OnboardingBundles { .children(sections), ) .maybe_child(nothing_to_ask.then(|| empty_hint(&catalog_msg))), - ) - .child(predownload_toggle_row(predownload)), + ), )) .child(onboarding_nav( Some(Route::OnboardingAccount {}), @@ -341,55 +332,50 @@ fn opt_in_card( let yes_ids = all_ids.clone(); let no_bundle = bundle.clone(); - let mut card = rect() - .vertical() - .width(Size::fill()) - .spacing(10.) - .child( - label() - .text(format!("Do you want to install {name} mods?")) - .font_size(15.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ); + let mut card = rect().vertical().width(Size::fill()).spacing(10.).child( + label() + .text(format!("Do you want to install {name} mods?")) + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ); - card = card - .child( - rect() - .horizontal() - .width(Size::fill()) - .spacing(8.) - .content(Content::Flex) - .child( - rect() - .width(Size::flex(1.0)) - .height(Size::px(ANSWER_H)) - .child(choice_row_sized( - "Yes", - &format!("{} mods", yes_bundle.mod_count()), - wanted, - Size::fill(), - move |()| { - if wanted { - return; - } - set_bundle_versions(selected, user_touched, &yes_bundle, &yes_ids) - }, - )), - ) - .child( - rect() - .width(Size::flex(1.0)) - .height(Size::px(ANSWER_H)) - .child(choice_row_sized( - "No", - "", - !wanted, - Size::fill(), - move |()| set_bundle_versions(selected, user_touched, &no_bundle, &[]), - )), - ), - ); + card = card.child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(8.) + .content(Content::Flex) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(ANSWER_H)) + .child(choice_row_sized( + "Yes", + &format!("{} mods", yes_bundle.mod_count()), + wanted, + Size::fill(), + move |()| { + if wanted { + return; + } + set_bundle_versions(selected, user_touched, &yes_bundle, &yes_ids) + }, + )), + ) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(ANSWER_H)) + .child(choice_row_sized( + "No", + "", + !wanted, + Size::fill(), + move |()| set_bundle_versions(selected, user_touched, &no_bundle, &[]), + )), + ), + ); if wanted && name.eq_ignore_ascii_case(FPS_WARNING_BUNDLE) { card = card.child(fps_warning_banner()); @@ -443,14 +429,15 @@ fn extras_section( user_touched: State, mr_meta: &MetaMap, cf_meta: &MetaMap, + local_meta: &MetaMap, ) -> Element { let cards: Vec = extras .iter() .map(|entry| { let meta = match entry.provider { - ProviderId::Modrinth => mr_meta.get(&entry.package_id), - ProviderId::CurseForge => cf_meta.get(&entry.package_id), - ProviderId::Local => None, + ProviderId::Modrinth => mr_meta.get(&entry.metadata_id), + ProviderId::CurseForge => cf_meta.get(&entry.metadata_id), + ProviderId::Local => local_meta.get(&entry.metadata_id), }; let name = meta .map(|m| m.name.clone()) @@ -463,6 +450,7 @@ fn extras_section( OnboardingModCard { provider: entry.provider, + github_hosted: entry.github_hosted, name, author, description, @@ -476,29 +464,6 @@ fn extras_section( }) .collect(); - let mut rows: Vec = Vec::new(); - for (index, chunk) in cards.chunks(MOD_GRID_COLS).enumerate() { - let mut row = rect() - .key(index) - .horizontal() - .width(Size::fill()) - .height(Size::px(CARD_GRID_H)) - .spacing(GRID_GAP) - .content(Content::Flex); - for card in chunk { - row = row.child( - rect() - .width(Size::flex(1.0)) - .height(Size::fill()) - .child(card.clone()), - ); - } - for _ in chunk.len()..MOD_GRID_COLS { - row = row.child(rect().width(Size::flex(1.0)).height(Size::fill())); - } - rows.push(row.into_element()); - } - rect() .vertical() .width(Size::fill()) @@ -510,25 +475,20 @@ fn extras_section( .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), ) - .child( - rect() - .vertical() - .width(Size::fill()) - .spacing(GRID_GAP) - .children(rows), - ) + .child(fixed_grid(cards, MOD_GRID_COLS, CARD_GRID_H, GRID_GAP)) .into_element() } -fn collect_ids(extras: &[OptionalMod]) -> (Vec, Vec) { +fn collect_ids(extras: &[OptionalMod]) -> (Vec, Vec, Vec) { let mut mr = Vec::new(); let mut cf = Vec::new(); + let mut local = Vec::new(); for entry in extras { match entry.provider { - ProviderId::Modrinth => mr.push(entry.package_id.clone()), - ProviderId::CurseForge => cf.push(entry.package_id.clone()), - ProviderId::Local => {} + ProviderId::Modrinth => mr.push(entry.metadata_id.clone()), + ProviderId::CurseForge => cf.push(entry.metadata_id.clone()), + ProviderId::Local => local.push(entry.metadata_id.clone()), } } - (mr, cf) + (mr, cf, local) } diff --git a/packages/oneclient_app/src/view/onboarding/bundles/row.rs b/packages/oneclient_app/src/view/onboarding/bundles/row.rs index 705696ae..35d2d9fa 100644 --- a/packages/oneclient_app/src/view/onboarding/bundles/row.rs +++ b/packages/oneclient_app/src/view/onboarding/bundles/row.rs @@ -1,7 +1,7 @@ use freya::prelude::*; use oneclient_content::packages::ProviderId; -use crate::components::{Icon, provider_badge}; +use crate::components::{Icon, IconType, github_badge, provider_badge}; use crate::hooks::{loaded_image, use_cached_image}; use crate::theme::colors; use crate::ui::{ImageFallbackExt, border_all_color}; @@ -16,6 +16,7 @@ pub(super) const GRID_GAP: f32 = 10.; #[derive(PartialEq)] pub(super) struct OnboardingModCard { pub(super) provider: ProviderId, + pub(super) github_hosted: bool, pub(super) name: String, pub(super) author: String, pub(super) description: String, @@ -36,10 +37,10 @@ impl Component for OnboardingModCard { .height(Size::px(44.)) .aspect_ratio(AspectRatio::Min) .corner_radius(CornerRadius::new_all(8.)) - .fallback(icon_box(self.provider)) + .fallback(icon_box(self.placeholder_icon())) .into_element(), - None => icon_box(self.provider).into_element(), + None => icon_box(self.placeholder_icon()).into_element(), }; let (bg, border, alpha) = if self.enabled { @@ -82,7 +83,11 @@ impl Component for OnboardingModCard { .color(colors::fg_secondary().with_a(alpha)), ) }) - .child(provider_badge(self.provider)), + .child(if self.github_hosted { + github_badge() + } else { + provider_badge(self.provider) + }), ); let description = (!self.description.is_empty()).then(|| { @@ -132,14 +137,24 @@ impl Component for OnboardingModCard { } } -fn icon_box(provider: ProviderId) -> impl IntoElement { +impl OnboardingModCard { + fn placeholder_icon(&self) -> IconType { + if self.github_hosted { + IconType::Github + } else { + self.provider.into() + } + } +} + +fn icon_box(icon: IconType) -> impl IntoElement { rect() .center() .width(Size::px(44.)) .height(Size::px(44.)) .corner_radius(CornerRadius::new_all(8.)) .background(colors::component_bg()) - .child(Icon::new(provider).size(20.).color(colors::fg_secondary())) + .child(Icon::new(icon).size(20.).color(colors::fg_secondary())) .into_element() } diff --git a/packages/oneclient_app/src/view/onboarding/migration.rs b/packages/oneclient_app/src/view/onboarding/migration.rs index b09f74c0..ca682123 100644 --- a/packages/oneclient_app/src/view/onboarding/migration.rs +++ b/packages/oneclient_app/src/view/onboarding/migration.rs @@ -32,8 +32,9 @@ pub(crate) fn matching_new_cluster_id( new: &[ClusterBundles], ) -> Option { let want = instance.import_version(); + let loader = instance.import_loader(); new.iter() - .find(|cb| cb.cluster.mc_version == want && cb.cluster.mc_loader == instance.mc_loader) + .find(|cb| cb.cluster.mc_version == want && cb.cluster.mc_loader == loader) .map(|cb| cb.cluster.id) } diff --git a/packages/oneclient_app/src/view/onboarding/mod.rs b/packages/oneclient_app/src/view/onboarding/mod.rs index 8cb1ec22..460ed374 100644 --- a/packages/oneclient_app/src/view/onboarding/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/mod.rs @@ -1,29 +1,28 @@ mod account; mod bundles; -mod downloading; mod language; mod location; mod migration; mod preferences; mod selection; +mod summary; mod terms; #[cfg(test)] pub(crate) mod test_support; mod welcome; pub(crate) use selection::{ - archive_selected, default_selection, is_default_bundle, is_optional_file, pkg_key, - set_archive_selected, + archive_selected, default_selection, is_default_bundle, pkg_key, set_archive_selected, }; pub use account::OnboardingAccount; pub use bundles::OnboardingBundles; -pub use downloading::{LoadingBackdrop, OnboardingDownloading}; pub use language::OnboardingLanguage; pub use location::OnboardingLocation; pub use migration::OnboardingMigration; pub(crate) use migration::matching_new_cluster_id; pub use preferences::OnboardingPreferences; +pub use summary::OnboardingSummary; pub use terms::OnboardingTerms; pub use welcome::OnboardingWelcome; @@ -34,7 +33,7 @@ use freya::prelude::*; use freya::router::{RouterContext, use_route}; use crate::Route; -use crate::components::{Button, Icon, IconType, toggle}; +use crate::components::{Button, Icon, IconType}; use crate::hooks::{has_migration_data, use_migration, use_onboarding_selection}; use crate::theme::colors; use crate::ui::{border_all_color, entrance_motion_layer}; @@ -56,7 +55,7 @@ pub fn onboarding_step_index(route: &Route, has_migration: bool, picks_location: Route::OnboardingAccount {} => 3 + shift, Route::OnboardingBundles {} => 4 + shift, Route::OnboardingPreferences {} => 5 + shift, - Route::OnboardingDownloading {} => 6 + shift, + Route::OnboardingSummary {} => 6 + shift, _ => 0, } } @@ -207,44 +206,6 @@ pub(crate) fn onboarding_nav_action( .into_element() } -pub(crate) fn predownload_toggle_row(predownload: State) -> impl IntoElement { - rect() - .horizontal() - .width(Size::fill()) - .cross_align(Alignment::Center) - .spacing(16.) - .content(Content::Flex) - .padding(Gaps::new_symmetric(12., 16.)) - .corner_radius(CornerRadius::new_all(12.)) - .background(colors::page_elevated()) - .border(border_all_color(1., colors::component_border())) - .child( - rect() - .vertical() - .width(Size::flex(1.0)) - .spacing(3.) - .child( - label() - .text("Download mods now") - .font_size(14.) - .font_weight(FontWeight::MEDIUM) - .color(colors::fg_primary()), - ) - .child( - label() - .text( - "Downloads your chosen mods now. Minecraft itself is always \ - downloaded the first time you play a version. Turn this off to \ - leave the mods until then too.", - ) - .font_size(11.) - .color(colors::fg_secondary()), - ), - ) - .child(toggle(predownload)) - .into_element() -} - pub(crate) fn choice_row( title: &str, subtitle: &str, @@ -389,7 +350,7 @@ mod tests { Route::OnboardingAccount {}, Route::OnboardingBundles {}, Route::OnboardingPreferences {}, - Route::OnboardingDownloading {}, + Route::OnboardingSummary {}, ]); routes @@ -421,14 +382,4 @@ mod tests { } } } - - #[test] - fn a_run_without_the_folder_step_is_numbered_as_it_always_was() { - assert_eq!(onboarding_total(false, false), 7); - assert_eq!(onboarding_total(true, false), 8); - assert_eq!( - onboarding_step_index(&Route::OnboardingTerms {}, false, false), - 1 - ); - } } diff --git a/packages/oneclient_app/src/view/onboarding/preferences.rs b/packages/oneclient_app/src/view/onboarding/preferences.rs index e5c461cd..a7bdb911 100644 --- a/packages/oneclient_app/src/view/onboarding/preferences.rs +++ b/packages/oneclient_app/src/view/onboarding/preferences.rs @@ -60,7 +60,7 @@ impl Component for OnboardingPreferences { .child(settings_row( IconType::Play, "Animations", - "Disable all launcher animations and transitions.", + "Whether all launcher animations and transitions should be enabled.", toggle(animations_on), )) .child(settings_row( @@ -77,7 +77,7 @@ impl Component for OnboardingPreferences { content, onboarding_nav( Some(Route::OnboardingBundles {}), - Route::OnboardingDownloading {}, + Route::OnboardingSummary {}, true, ), ) diff --git a/packages/oneclient_app/src/view/onboarding/selection.rs b/packages/oneclient_app/src/view/onboarding/selection.rs index c2b38160..a3332ece 100644 --- a/packages/oneclient_app/src/view/onboarding/selection.rs +++ b/packages/oneclient_app/src/view/onboarding/selection.rs @@ -16,10 +16,6 @@ pub fn is_default_file(file: &BundleFile) -> bool { file.enabled && !file.hidden } -pub fn is_optional_file(file: &BundleFile) -> bool { - !file.enabled && !file.hidden -} - pub fn default_selection( items: &[ClusterBundles], opted_in_categories: Option<&[String]>, diff --git a/packages/oneclient_app/src/view/onboarding/summary/mod.rs b/packages/oneclient_app/src/view/onboarding/summary/mod.rs new file mode 100644 index 00000000..ea3f341d --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/summary/mod.rs @@ -0,0 +1,555 @@ +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::clusters::Cluster; +use oneclient_core::images::BACKGROUND_IMAGE_EDGE; +use oneclient_core::{BundleArchive, BundleFile, ImportTarget, MigrationSource, SentryExclusion}; +use oneclient_db::models::OverrideType; + +use crate::components::ART_PREVIEW_EDGE; +use crate::hooks::{ + Actions, ClusterBundles, cluster_art_url, invalidate_cluster_queries, migration_detection, + onboarding_bundles_items, try_default_account, use_current_account, use_dispatch, + use_migration, use_onboarding_bundles, use_onboarding_selection, use_settings_snapshot, +}; +use crate::routes::Route; +use crate::utils::{home_cluster, sort_clusters_for_home}; +use crate::view::onboarding::{matching_new_cluster_id, pkg_key}; + +mod view; +use view::{SummaryView, summary_view}; + +#[derive(Clone, PartialEq)] +struct ClusterPlan { + cluster_id: i64, + /// `(bundle_name, package_id, override)` for every file whose fate differs from the manifest default + overrides: Vec<(String, String, OverrideType)>, + choices: Vec<(String, bool)>, +} + +#[derive(PartialEq)] +pub struct OnboardingSummary; + +impl Component for OnboardingSummary { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let bundles_query = use_onboarding_bundles(); + let migration_query = use_migration(); + let selection = use_onboarding_selection(); + let settings = use_settings_snapshot().settings; + let account_query = use_current_account(); + + let selected_state = selection.selected; + let mut attempted = use_state(|| false); + let mut finishing = use_state(|| false); + let mut failure = use_state(|| None::); + + let loaded = onboarding_bundles_items(&bundles_query); + let ready = loaded.is_some(); + let items = loaded.unwrap_or_default(); + let selected = selected_state.read().clone(); + let language = selection.language.read().clone(); + let reduce_motion = *selection.reduce_motion.read(); + let account_name = try_default_account(&account_query) + .map(|account| account.username.clone()) + .unwrap_or_else(|| "Not signed in".to_string()); + + let import_dispatch = dispatch.clone(); + let import_folder = selection.import_folder; + let import_dedicated = selection.import_dedicated; + let import_detection = migration_detection(&migration_query); + + let migration_summary = import_detection.as_ref().map(|detection| { + let source_name = detection.source.display_name().to_string(); + let is_vanilla = detection.source == MigrationSource::Vanilla; + match import_folder.read().clone() { + Some(folder) => { + let dedicated = !is_vanilla + && *import_dedicated.read() + && detection + .instances + .iter() + .find(|c| c.folder_name == folder) + .and_then(|inst| matching_new_cluster_id(inst, &items)) + .is_some(); + let target = if dedicated { + "This version only" + } else { + "Shared game directory" + }; + let files = if is_vanilla { + "Your Minecraft folder".to_string() + } else { + folder + }; + (source_name, files, target.to_string()) + } + None => (source_name, "No files imported".to_string(), String::new()), + } + }); + + let finish_items = items.clone(); + let finish_versions = seen_versions(&items); + let finish_dispatch = dispatch.clone(); + let continue_versions = finish_versions.clone(); + let continue_dispatch = dispatch; + + summary_view( + SummaryView { + items: &items, + selected: &selected, + language: &language, + reduce_motion, + parallax: settings.dynamic_background_enabled, + account_name, + migration: migration_summary, + ready, + finishing: *finishing.read(), + failure: failure.read().clone(), + }, + move |_| { + if *finishing.peek() { + return; + } + + if !*attempted.peek() { + if let (Some(detection), Some(folder)) = + (import_detection.as_ref(), import_folder.peek().clone()) + { + let target = if detection.source != MigrationSource::Vanilla + && *import_dedicated.peek() + { + detection + .instances + .iter() + .find(|c| c.folder_name == folder) + .and_then(|inst| matching_new_cluster_id(inst, &finish_items)) + .map(|new_cluster_id| ImportTarget::Dedicated { new_cluster_id }) + .unwrap_or(ImportTarget::Shared) + } else { + ImportTarget::Shared + }; + import_dispatch.import_launcher(detection.source, folder, target); + } + attempted.set(true); + } + + failure.set(None); + finishing.set(true); + finish_setup( + build_plans(&finish_items, &selected_state.peek()), + finish_versions.clone(), + finish_dispatch.clone(), + finishing, + failure, + ); + }, + move |_| { + if *finishing.peek() { + return; + } + finishing.set(true); + // Leaves the manifest defaults in place the picks are already lost + finish_setup( + Vec::new(), + continue_versions.clone(), + continue_dispatch.clone(), + finishing, + failure, + ); + }, + ) + .into_element() + } +} + +fn build_plans( + items: &[ClusterBundles], + selected: &std::collections::HashSet, +) -> Vec { + items + .iter() + .map(|cb| { + let kept = kept_package_ids(cb.cluster.id, &cb.archives, selected); + let mut overrides = Vec::new(); + for archive in &cb.archives { + overrides.extend(archive_overrides(cb.cluster.id, archive, selected, &kept)); + } + let choices = cb + .archives + .iter() + .filter(|archive| !archive.manifest.enabled) + .map(|archive| { + let taken = bundle_taken(cb.cluster.id, archive, selected); + (archive.manifest.name.clone(), taken) + }) + .collect(); + ClusterPlan { + cluster_id: cb.cluster.id, + overrides, + choices, + } + }) + .collect() +} + +fn seen_versions(items: &[ClusterBundles]) -> Vec { + let mut versions: Vec = items + .iter() + .map(|cb| cb.cluster.mc_version.clone()) + .collect(); + + versions.sort(); + versions.dedup(); + versions +} + +/// True once anything visible in the bundle was taken including one opted-in extra that would otherwise lose its dependencies +fn bundle_taken( + cluster_id: i64, + archive: &BundleArchive, + selected: &std::collections::HashSet, +) -> bool { + archive.manifest.files.iter().any(|file| { + !file.hidden + && selected.contains(&pkg_key( + cluster_id, + &archive.manifest.name, + &file.kind.package_id(), + )) + }) +} + +/// Bundles overlap deciding each archive alone recorded a removal for a package an accepted bundle was about to install +fn kept_package_ids( + cluster_id: i64, + archives: &[BundleArchive], + selected: &std::collections::HashSet, +) -> std::collections::HashSet { + let mut kept = std::collections::HashSet::new(); + + for archive in archives { + let takes_hidden = bundle_taken(cluster_id, archive, selected); + for file in &archive.manifest.files { + let package_id = file.kind.package_id(); + let wanted = if file.hidden { + takes_hidden + } else { + selected.contains(&pkg_key(cluster_id, &archive.manifest.name, &package_id)) + }; + if wanted { + kept.insert(package_id); + } + } + } + + kept +} + +fn archive_overrides( + cluster_id: i64, + archive: &BundleArchive, + selected: &std::collections::HashSet, + kept_elsewhere: &std::collections::HashSet, +) -> Vec<(String, String, OverrideType)> { + let bundle_name = &archive.manifest.name; + let wants = |file: &BundleFile| { + selected.contains(&pkg_key(cluster_id, bundle_name, &file.kind.package_id())) + }; + + // Hidden files follow the bundle kept if anything from it was taken dropped if not + let takes_hidden = bundle_taken(cluster_id, archive, selected); + + let mut overrides = Vec::new(); + for file in &archive.manifest.files { + let wanted = if file.hidden { + takes_hidden + } else { + wants(file) + }; + + let override_type = match (wanted, file.enabled) { + // Matches the manifest default nothing to record + (true, true) | (false, false) => continue, + // Opting into a mod the bundle ships turned off + (true, false) => OverrideType::Enabled, + // Only really declined if no bundle the user took ships the same package + (false, true) => { + if kept_elsewhere.contains(&file.kind.package_id()) { + continue; + } + OverrideType::Removed + } + }; + + overrides.push((bundle_name.clone(), file.kind.package_id(), override_type)); + } + overrides +} + +/// The bundle picks are written nowhere else so a failure has to stop the run rather than drop them silently +fn finish_setup( + plans: Vec, + versions: Vec, + dispatch: Actions, + mut finishing: State, + mut failure: State>, +) { + spawn(async move { + for plan in &plans { + if let Err(err) = apply_overrides(plan).await { + // Expected failures (a busy or unwritable database) are not crashes warn! is a breadcrumb error! reports to Sentry + if err.is_sentry_excluded() { + tracing::warn!( + cluster_id = plan.cluster_id, + "saving package choices failed (expected): {err}" + ); + } else { + tracing::error!( + cluster_id = plan.cluster_id, + "saving package choices failed: {err}" + ); + } + failure.set(Some(err.to_string())); + finishing.set(false); + return; + } + } + + invalidate_cluster_queries().await; + + let _ = tokio::time::timeout(std::time::Duration::from_secs(30), warm_home_art()).await; + + for version in versions { + dispatch.record_seen_version(version); + } + + dispatch.mark_onboarding_seen(); + let _ = RouterContext::get().replace(Route::Home {}); + }); +} + +const HOME_ART_CLUSTERS: usize = 3; + +async fn warm_home_art() { + let Ok(state) = crate::launcher::state() else { + return; + }; + let clusters = match state.clusters.list().await { + Ok(clusters) => clusters, + Err(err) => { + tracing::debug!("could not list clusters to warm home art: {err}"); + return; + } + }; + + let meta_url_base = &state.services.requester.config().meta_url_base; + let (list, arts) = tokio::join!( + state.versions.metadata(meta_url_base), + state.versions.arts(meta_url_base), + ); + let art_url = |cluster: &Cluster| { + cluster + .cover_file() + .is_none() + .then(|| cluster_art_url(cluster, &list, &arts)) + .flatten() + }; + + let background = home_cluster(&clusters, None).and_then(art_url); + let mut warms: Vec<(String, &[u32])> = sort_clusters_for_home(clusters.clone()) + .iter() + .take(HOME_ART_CLUSTERS) + .filter_map(art_url) + .filter(|url| Some(url) != background.as_ref()) + .map(|url| (url, &[ART_PREVIEW_EDGE][..])) + .collect(); + warms.sort(); + warms.dedup(); + warms.extend(background.map(|url| (url, &[ART_PREVIEW_EDGE, BACKGROUND_IMAGE_EDGE][..]))); + + let net = &state.services.requester; + let results = futures_util::future::join_all( + warms + .iter() + .map(|(url, edges)| state.images.prefetch(net, url, edges)), + ) + .await; + for ((url, _), result) in warms.iter().zip(results) { + if let Err(err) = result { + tracing::debug!("home art warm failed for {url}: {err}"); + } + } +} + +async fn apply_overrides(plan: &ClusterPlan) -> oneclient_core::LauncherResult<()> { + let state = crate::launcher::state()?; + oneclient_core::set_bundle_package_overrides( + plan.cluster_id, + &plan.overrides, + &state.services.content(), + ) + .await?; + oneclient_content::bundles::set_bundle_choices( + plan.cluster_id, + &plan.choices, + &state.services.content(), + ) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::{archive, cluster, file}; + use std::collections::HashSet; + + fn skyblock() -> BundleArchive { + archive( + "SkyBlock", + false, + vec![ + file("skyblock-main", true, false), + file("skycubed", false, false), + file("sb-dep", true, true), + ], + ) + } + + fn items(archives: Vec) -> Vec { + vec![ClusterBundles { + cluster: cluster(1), + archives, + }] + } + + fn keys(archive: &BundleArchive, pids: &[&str]) -> HashSet { + pids.iter() + .map(|p| pkg_key(1, &archive.manifest.name, p)) + .collect() + } + + #[test] + fn declining_a_bundle_removes_its_visible_and_hidden_files() { + let sb = skyblock(); + let plans = build_plans(&items(vec![sb.clone()]), &HashSet::new()); + + let ov = &plans[0].overrides; + // The hidden dependency must be dropped too or it installs anyway + assert!(ov.contains(&( + sb.manifest.name.clone(), + "sb-dep".to_string(), + OverrideType::Removed + ))); + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skyblock-main".to_string(), + OverrideType::Removed + ))); + assert!(!ov.iter().any(|(_, pid, _)| pid == "skycubed")); + } + + #[test] + fn accepting_a_bundle_records_nothing_for_its_defaults() { + let sb = skyblock(); + let selected = keys(&sb, &["skyblock-main"]); + let plans = build_plans(&items(vec![sb.clone()]), &selected); + + assert!(plans[0].overrides.is_empty()); + } + + #[test] + fn opting_into_an_extra_writes_enabled() { + let sb = skyblock(); + let selected = keys(&sb, &["skyblock-main", "skycubed"]); + let plans = build_plans(&items(vec![sb.clone()]), &selected); + + assert_eq!( + plans[0].overrides, + vec![( + sb.manifest.name.clone(), + "skycubed".to_string(), + OverrideType::Enabled + )] + ); + } + + #[test] + fn an_extra_alone_still_keeps_hidden_dependencies() { + let sb = skyblock(); + let selected = keys(&sb, &["skycubed"]); + let plans = build_plans(&items(vec![sb.clone()]), &selected); + let ov = &plans[0].overrides; + + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skycubed".to_string(), + OverrideType::Enabled + ))); + assert!(!ov.iter().any(|(_, pid, _)| pid == "sb-dep")); + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skyblock-main".to_string(), + OverrideType::Removed + ))); + } + + #[test] + fn rejections_are_removed_never_disabled() { + let plans = build_plans(&items(vec![skyblock()]), &HashSet::new()); + + assert!( + !plans[0] + .overrides + .iter() + .any(|(_, _, ty)| *ty == OverrideType::Disabled) + ); + } + + #[test] + fn per_cluster_choices_are_independent() { + let sb = skyblock(); + let both = vec![ + ClusterBundles { + cluster: cluster(1), + archives: vec![sb.clone()], + }, + ClusterBundles { + cluster: cluster(2), + archives: vec![sb.clone()], + }, + ]; + let selected: HashSet = [pkg_key(1, &sb.manifest.name, "skyblock-main")].into(); + let plans = build_plans(&both, &selected); + + assert!(plans[0].overrides.is_empty()); + assert!( + plans[1] + .overrides + .iter() + .any(|(_, pid, ty)| pid == "skyblock-main" && *ty == OverrideType::Removed) + ); + } + + #[test] + fn every_version_is_recorded_once() { + let mut second = cluster(2); + second.mc_version = "1.20.1".to_string(); + let items = vec![ + ClusterBundles { + cluster: cluster(1), + archives: Vec::new(), + }, + ClusterBundles { + cluster: second, + archives: Vec::new(), + }, + ClusterBundles { + cluster: cluster(3), + archives: Vec::new(), + }, + ]; + + assert_eq!(seen_versions(&items), vec!["1.20.1", "1.21.11"]); + } +} diff --git a/packages/oneclient_app/src/view/onboarding/summary/view.rs b/packages/oneclient_app/src/view/onboarding/summary/view.rs new file mode 100644 index 00000000..f6381c7f --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/summary/view.rs @@ -0,0 +1,286 @@ +use freya::prelude::*; +use freya::router::RouterContext; + +use crate::components::{ART_PREVIEW_EDGE, Button, DynamicArt, Icon, IconType, ScrollArea}; +use crate::hooks::ClusterBundles; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::onboarding::{onboarding_illustration, onboarding_slide, step_heading}; + +pub(super) struct SummaryView<'a> { + pub items: &'a [ClusterBundles], + pub selected: &'a std::collections::HashSet, + pub language: &'a str, + pub reduce_motion: bool, + pub parallax: bool, + pub account_name: String, + pub migration: Option<(String, String, String)>, + pub ready: bool, + /// A run is in flight both buttons stay down until it settles + pub finishing: bool, + pub failure: Option, +} + +pub(super) fn summary_view( + view: SummaryView<'_>, + on_finish: impl FnMut(Event) + 'static, + on_continue: impl FnMut(Event) + 'static, +) -> impl IntoElement { + let SummaryView { + items, + selected, + language, + reduce_motion, + parallax, + account_name, + migration, + ready, + finishing, + failure, + } = view; + + let version_rows: Vec = items + .iter() + .map(|cb| { + let prefix = format!("{}|", cb.cluster.id); + let count = selected.iter().filter(|k| k.starts_with(&prefix)).count(); + summary_line( + &cb.cluster.mc_version, + &format!("{count} package{}", if count == 1 { "" } else { "s" }), + ) + }) + .collect(); + + let migration_section: Element = match migration { + Some((source, folder, target)) => { + let mut rows = vec![ + summary_line("From", &source), + summary_line("Files", &folder), + ]; + if !target.is_empty() { + rows.push(summary_line("Destination", &target)); + } + summary_section("Migration", None, rows).into_element() + } + None => rect().into_element(), + }; + + let failed = failure.is_some(); + + let content = rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .content(Content::Flex) + .spacing(20.) + .child(step_heading( + "You're all set", + "Review your setup below, then finish.", + )) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(20.) + .child(summary_section( + "General", + None, + vec![ + summary_line("Language", language), + summary_line("Account", &account_name), + ], + )) + .child(migration_section) + .child(summary_section( + "Accessibility", + None, + vec![ + summary_line("Animations", if reduce_motion { "Off" } else { "On" }), + summary_line("Parallax background", if parallax { "On" } else { "Off" }), + ], + )) + .child(summary_section( + "Packages per version", + Some("Content is downloaded the first time you launch each version."), + if version_rows.is_empty() { + vec![summary_line("No versions", "")] + } else { + version_rows + }, + )), + ) + .maybe_child(failure.map(|reason| failure_notice(&reason))); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child(onboarding_slide( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .center() + .padding(Gaps::new_all(48.)) + .child(onboarding_illustration(IconType::OnboardingComplete)), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new(48., 80., 24., 24.)) + .child(content), + ), + )) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new(0., 40., 32., 40.)) + .child(if failed { + Button::new() + .secondary() + .width(Size::px(180.)) + .enabled(ready && !finishing) + .on_press(on_continue) + .text("Continue anyway") + .into_element() + } else { + Button::new() + .secondary() + .width(Size::px(128.)) + .enabled(!finishing) + .on_press(move |_| { + let _ = RouterContext::get().replace(Route::OnboardingPreferences {}); + }) + .text("Back") + .into_element() + }) + .child( + Button::new() + .primary() + .width(Size::px(160.)) + .enabled(ready && !finishing) + .on_press(on_finish) + .text(if failed { "Try again" } else { "Finish" }) + .child(Icon::new(IconType::Check).size(16.)), + ), + ) + .child(prefetch_art(items)) +} + +fn failure_notice(reason: &str) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .padding(Gaps::new_all(14.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::danger().with_a(26)) + .border(border_all_color(1., colors::danger().with_a(128))) + .child( + label() + .text("Your package choices could not be saved.") + .font_size(14.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "{}. Continuing anyway keeps the bundle defaults for any version whose choices were not saved.", + reason.trim_end_matches('.') + )) + .font_size(11.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn prefetch_art(items: &[ClusterBundles]) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(1.)) + .overflow(Overflow::Clip) + .opacity(0.) + .interactive(false) + .children(items.iter().map(|cb| { + rect() + .width(Size::px(1.)) + .height(Size::px(1.)) + .child(DynamicArt::for_cluster(&cb.cluster).max_edge(ART_PREVIEW_EDGE)) + .into_element() + })) + .into_element() +} + +fn summary_section(title: &str, note: Option<&str>, rows: Vec) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + label() + .text(title.to_string()) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_secondary()), + ) + .maybe_child(note.map(|text| { + label() + .text(text.to_string()) + .font_size(11.) + .color(colors::fg_secondary()) + .into_element() + })) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(2.) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .padding(Gaps::new_symmetric(6., 4.)) + .children(rows), + ) + .into_element() +} + +fn summary_line(label_text: &str, value: &str) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(10., 12.)) + .child( + rect().width(Size::flex(1.0)).child( + label() + .text(label_text.to_string()) + .font_size(14.) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(value.to_string()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/test_support.rs b/packages/oneclient_app/src/view/onboarding/test_support.rs index 3ce0fc03..55dd6d63 100644 --- a/packages/oneclient_app/src/view/onboarding/test_support.rs +++ b/packages/oneclient_app/src/view/onboarding/test_support.rs @@ -1,7 +1,9 @@ -use oneclient_core::clusters::{Cluster, ClusterStage}; use oneclient_common::domain::{ContentType, GameLoader}; use oneclient_content::packages::types::ExternalFile; -use oneclient_core::{Bundle, BundleArchive, BundleFile, BundleFileKind, BundleManifest}; +use oneclient_core::clusters::{Cluster, ClusterStage}; +use oneclient_core::{ + Bundle, BundleArchive, BundleFile, BundleFileKind, BundleFileType, BundleManifest, +}; use std::path::PathBuf; pub fn file(package_id: &str, enabled: bool, hidden: bool) -> BundleFile { @@ -10,13 +12,18 @@ pub fn file(package_id: &str, enabled: bool, hidden: bool) -> BundleFile { hidden, path: format!("mods/{package_id}.jar"), size: 1, - kind: BundleFileKind::External(ExternalFile { - name: format!("{package_id}.jar"), - url: format!("https://example.invalid/{package_id}.jar"), - sha1: package_id.to_string(), - size: 1, - content_type: ContentType::Mod, - }), + file_type: BundleFileType::Normal, + kind: BundleFileKind::External { + file: ExternalFile { + name: format!("{package_id}.jar"), + url: format!("https://example.invalid/{package_id}.jar"), + sha1: package_id.to_string(), + size: 1, + content_type: ContentType::Mod, + }, + id: None, + meta: None, + }, } } @@ -43,6 +50,7 @@ pub fn archive(category: &str, enabled: bool, files: Vec) -> BundleA loader: GameLoader::Fabric, loader_version: "0.16.0".to_string(), enabled, + java_version_override: None, files, }, } @@ -62,5 +70,10 @@ pub fn cluster(id: i64) -> Cluster { last_played: None, overall_played: std::time::Duration::ZERO, linked_modpack_hash: None, + kind: oneclient_db::models::ClusterKind::OneClient, + user_created: false, + description: None, + tags: Vec::new(), + cover_path: None, } } diff --git a/packages/oneclient_app/src/view/relocating.rs b/packages/oneclient_app/src/view/relocating.rs index fbd35566..04538a9a 100644 --- a/packages/oneclient_app/src/view/relocating.rs +++ b/packages/oneclient_app/src/view/relocating.rs @@ -46,7 +46,11 @@ fn copying(plan: &RelocationPlan, copied: u64, total: u64) -> Element { let total = if total > 0 { total } else { plan.bytes }.max(1); screen() - .child(heading(IconType::FolderDownload, "Moving your data folder", colors::fg_primary())) + .child(heading( + IconType::FolderDownload, + "Moving your data folder", + colors::fg_primary(), + )) .child(path_block("From", &plan.from)) .child(path_block("To", &plan.to)) .child(progress_track( @@ -90,7 +94,11 @@ fn moved(outcome: &RelocationOutcome, actions: Actions) -> Element { } screen() - .child(heading(IconType::FolderCheck, "Game data moved", colors::brand())) + .child(heading( + IconType::FolderCheck, + "Game data moved", + colors::brand(), + )) .child(note(body, colors::brand())) .child( label() @@ -103,26 +111,23 @@ fn moved(outcome: &RelocationOutcome, actions: Actions) -> Element { .color(colors::fg_secondary()), ) .child( - buttons() - .child(back_button(actions)) - .child( - Button::new() - .primary() - .on_press(|_| { - let platform = Platform::get(); - Platform::get().with_window(Platform::window_id(), move |window| { - platform.close_window(window.id()); - }); - }) - .text("Quit OneClient"), - ), + buttons().child(back_button(actions)).child( + Button::new() + .primary() + .on_press(|_| crate::platform::quit()) + .text("Quit OneClient"), + ), ) .into_element() } fn failed(message: String, actions: Actions) -> Element { screen() - .child(heading(IconType::AlertTriangle, "The move didn't finish", colors::danger())) + .child(heading( + IconType::AlertTriangle, + "The move didn't finish", + colors::danger(), + )) .child(note(message, colors::danger())) .child( label() diff --git a/packages/oneclient_app/src/view/startup.rs b/packages/oneclient_app/src/view/startup.rs index 79077dbd..79d6648b 100644 --- a/packages/oneclient_app/src/view/startup.rs +++ b/packages/oneclient_app/src/view/startup.rs @@ -3,12 +3,12 @@ use freya::{prelude::*, router::*}; use crate::AppAssets; use crate::Route; -use crate::hooks::{ - terms_document, terms_is_loading, use_launcher, use_notifications_snapshot, use_settings_snapshot, - use_splash, use_terms, -}; -use crate::hooks::use_dispatch; use crate::components::{Button, progress_track}; +use crate::hooks::use_dispatch; +use crate::hooks::{ + terms_document, terms_is_loading, use_launcher, use_notifications_snapshot, + use_settings_snapshot, use_splash, use_terms, +}; use crate::theme::colors; /// Startup is a sequence of boolean gates, not measurable work, so the bar steps @@ -227,7 +227,9 @@ impl Component for Startup { if is_error { content = content .child(rect().height(Size::px(6.))) - .child(RecoveryActions { snapshots: launcher.snapshots }) + .child(RecoveryActions { + snapshots: launcher.snapshots, + }) .child( label() .text(format!( diff --git a/packages/oneclient_auth/src/data.rs b/packages/oneclient_auth/src/data.rs index 63beb128..4b37d66e 100644 --- a/packages/oneclient_auth/src/data.rs +++ b/packages/oneclient_auth/src/data.rs @@ -18,6 +18,10 @@ pub struct MinecraftAccount { pub expires: DateTime, #[serde(default = "default_account_kind")] pub kind: AccountKind, + /// Microsoft rejected the refresh token the entry stays listed but cannot + /// launch until the user signs in to it again + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub signed_out: bool, } fn default_account_kind() -> AccountKind { @@ -33,6 +37,10 @@ impl MinecraftAccount { self.kind == AccountKind::Offline } + pub fn needs_sign_in(&self) -> bool { + self.is_microsoft() && self.signed_out + } + pub fn is_expired(&self) -> bool { self.expires <= Utc::now() + chrono::TimeDelta::seconds(60) } diff --git a/packages/oneclient_auth/src/diagnostics.rs b/packages/oneclient_auth/src/diagnostics.rs index af0a1d91..f3605a59 100644 --- a/packages/oneclient_auth/src/diagnostics.rs +++ b/packages/oneclient_auth/src/diagnostics.rs @@ -4,7 +4,7 @@ use oneclient_net::{NetworkFailure, classify_network_failure}; use reqwest::StatusCode; -use super::error::{friendly_xbox_error, MinecraftAuthError, MinecraftAuthStep}; +use super::error::{MinecraftAuthError, MinecraftAuthStep, friendly_xbox_error}; #[derive(Debug, Clone, serde::Serialize)] pub struct AuthErrorGuidance { @@ -298,7 +298,9 @@ fn xerr(code: u64) -> MinecraftAuthError { MinecraftAuthError::XboxError { step: MinecraftAuthStep::XstsAuthorize, error_code: code, - message: friendly_xbox_error(code).unwrap_or("Xbox rejected sign-in.").to_string(), + message: friendly_xbox_error(code) + .unwrap_or("Xbox rejected sign-in.") + .to_string(), redirect: None, } } diff --git a/packages/oneclient_auth/src/error.rs b/packages/oneclient_auth/src/error.rs index 2a7242c6..1ebf4d76 100644 --- a/packages/oneclient_auth/src/error.rs +++ b/packages/oneclient_auth/src/error.rs @@ -35,7 +35,10 @@ pub enum MinecraftAuthError { }, // Rendered via `error_chain` reqwest's `Display` names only the URL and the // cause we need (certificate handshake DNS refused) is under it - #[error("failed to request using HTTP during MSA step {step:?}: {}", oneclient_net::error_chain(source))] + #[error( + "failed to request using HTTP during MSA step {step:?}: {}", + oneclient_net::error_chain(source) + )] RequestError { step: MinecraftAuthStep, #[source] @@ -66,6 +69,9 @@ pub enum MinecraftAuthError { message: String, redirect: Option, }, + /// Retrying cannot fix this only a fresh interactive sign-in can + #[error("Microsoft rejected the saved sign-in ({error})")] + RefreshTokenRevoked { error: String }, #[error( "The sign-in service returned an error (HTTP {status_code}) during step {step:?}. Please wait a moment and try again." )] @@ -79,12 +85,24 @@ pub enum MinecraftAuthError { pub fn friendly_xbox_error(code: u64) -> Option<&'static str> { Some(match code { 2_148_916_227 => "This account has been banned or suspended from Xbox.", - 2_148_916_229 => "This account is a child account that must be added to a Family group by an adult before signing in.", - 2_148_916_233 => "This Microsoft account does not have an Xbox profile yet. Create one at xbox.com, then try again.", - 2_148_916_234 => "This account has not accepted the Xbox Terms of Service. Sign in at xbox.com to accept them first.", - 2_148_916_235 => "Xbox Live is not available in your country or region, so sign-in is blocked.", - 2_148_916_236 | 2_148_916_237 => "This account requires adult verification (South Korea) before it can sign in.", - 2_148_916_238 => "This is a child account. An adult must add it to a Microsoft Family group before it can sign in.", + 2_148_916_229 => { + "This account is a child account that must be added to a Family group by an adult before signing in." + } + 2_148_916_233 => { + "This Microsoft account does not have an Xbox profile yet. Create one at xbox.com, then try again." + } + 2_148_916_234 => { + "This account has not accepted the Xbox Terms of Service. Sign in at xbox.com to accept them first." + } + 2_148_916_235 => { + "Xbox Live is not available in your country or region, so sign-in is blocked." + } + 2_148_916_236 | 2_148_916_237 => { + "This account requires adult verification (South Korea) before it can sign in." + } + 2_148_916_238 => { + "This is a child account. An adult must add it to a Microsoft Family group before it can sign in." + } _ => return None, }) } @@ -116,6 +134,11 @@ pub enum AuthError { #[error("invalid offline username: {reason}")] InvalidOfflineUsername { reason: String }, + #[error( + "Your Microsoft sign-in for {username} has expired. Sign in to it again in Settings > Accounts." + )] + SessionExpired { username: String }, + #[error("account {0} is not registered")] AccountNotFound(uuid::Uuid), diff --git a/packages/oneclient_auth/src/lib.rs b/packages/oneclient_auth/src/lib.rs index ce401a6b..920dc164 100644 --- a/packages/oneclient_auth/src/lib.rs +++ b/packages/oneclient_auth/src/lib.rs @@ -11,7 +11,7 @@ mod service; mod store; pub use data::{ - AccountKind, BrowserLogin, DeviceCodeLogin, MicrosoftLoginSession, MinecraftAccount, + AccountKind, BrowserLogin, DeviceCodeLogin, MicrosoftLoginSession, MinecraftAccount, }; pub use diagnostics::{AuthErrorGuidance, AuthErrorSample, diagnose_auth_error, preview_samples}; pub use error::{AuthError, AuthResult, MinecraftAuthError, MinecraftAuthStep}; diff --git a/packages/oneclient_auth/src/msa.rs b/packages/oneclient_auth/src/msa.rs index b485f71b..5c72b793 100644 --- a/packages/oneclient_auth/src/msa.rs +++ b/packages/oneclient_auth/src/msa.rs @@ -1,13 +1,12 @@ - use std::future::Future; use std::time::Duration; -use base64::prelude::BASE64_URL_SAFE_NO_PAD; use base64::Engine; +use base64::prelude::BASE64_URL_SAFE_NO_PAD; use chrono::{DateTime, Utc}; use reqwest::Client; -use serde::de::Error as DeError; use serde::Deserialize; +use serde::de::Error as DeError; use serde_json::json; use sha2::{Digest, Sha256}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -16,20 +15,17 @@ use url::Url; use uuid::Uuid; use super::data::{AccountKind, BrowserLogin, DeviceCodeLogin, MinecraftAccount}; -use super::error::{friendly_xbox_error, MinecraftAuthError, MinecraftAuthStep}; +use super::error::{MinecraftAuthError, MinecraftAuthStep, friendly_xbox_error}; use oneclient_common::constants::{MICROSOFT_CLIENT_ID, MINECRAFT_SCOPES}; -const DEVICE_CODE_URL: &str = - "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; +const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; -const AUTHORIZE_URL: &str = - "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"; +const AUTHORIZE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"; const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; const AUTH_STEPS: u64 = 5; - #[tracing::instrument(level = "debug", skip_all)] pub async fn begin_device_login(client: &Client) -> Result { request_device_code(client).await @@ -446,9 +442,7 @@ async fn poll_device_token( "expired_token" => return Err(MinecraftAuthError::DeviceAuthorizationExpired), other => { return Err(MinecraftAuthError::DeviceAuthorizationFailed { - error: err - .error_description - .unwrap_or_else(|| other.to_string()), + error: err.error_description.unwrap_or_else(|| other.to_string()), }); } } @@ -488,8 +482,30 @@ async fn refresh_msa_token( source, })?; - parse_json_response(res, MinecraftAuthStep::RefreshToken).await - .map(MsaToken::from_response) + let status = res.status(); + let text = res + .text() + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::RefreshToken, + source, + })?; + + if status.is_client_error() + && let Ok(err) = serde_json::from_str::(&text) + && is_revoked_refresh_error(&err.error) + { + tracing::warn!(error = %err.error, description = ?err.error_description, "Microsoft rejected the refresh token"); + return Err(MinecraftAuthError::RefreshTokenRevoked { error: err.error }); + } + + parse_json_text(status, text, MinecraftAuthStep::RefreshToken).map(MsaToken::from_response) +} + +/// `invalid_grant` covers expired revoked and password-changed tokens +/// `interaction_required` is MFA or consent neither can be answered silently +fn is_revoked_refresh_error(error: &str) -> bool { + matches!(error, "invalid_grant" | "interaction_required") } struct MsaToken { @@ -546,6 +562,7 @@ async fn account_from_msa_token( #[allow(clippy::cast_possible_wrap)] expires: msa.obtained_at + chrono::TimeDelta::seconds(msa.expires_in as i64), kind: AccountKind::Microsoft, + signed_out: false, }) } @@ -784,13 +801,24 @@ async fn parse_json_response Deserialize<'de>>( .await .map_err(|source| MinecraftAuthError::RequestError { step, source })?; + parse_json_text(status, text, step) +} + +fn parse_json_text Deserialize<'de>>( + status: reqwest::StatusCode, + text: String, + step: MinecraftAuthStep, +) -> Result { if !status.is_success() { tracing::error!(sentry = false, ?step, %status, body = %text, "MSA endpoint returned a non-success status"); } serde_json::from_str(&text).map_err(|source| { if !status.is_success() { - MinecraftAuthError::ServiceError { step, status_code: status } + MinecraftAuthError::ServiceError { + step, + status_code: status, + } } else { MinecraftAuthError::DeserializeError { step, diff --git a/packages/oneclient_auth/src/offline.rs b/packages/oneclient_auth/src/offline.rs index d5d0e962..331b57db 100644 --- a/packages/oneclient_auth/src/offline.rs +++ b/packages/oneclient_auth/src/offline.rs @@ -2,8 +2,8 @@ use chrono::{Duration, Utc}; use md5::{Digest, Md5}; use uuid::Uuid; -use super::error::AuthError; use super::data::{AccountKind, MinecraftAccount}; +use super::error::AuthError; use crate::error::AuthResult; pub fn offline_uuid(username: &str) -> Uuid { @@ -23,8 +23,7 @@ pub fn validate_offline_username(username: &str) -> AuthResult<()> { if !(3..=16).contains(&len) { return Err(AuthError::InvalidOfflineUsername { reason: "username must be 3-16 characters".into(), - } - ); + }); } if !username @@ -33,8 +32,7 @@ pub fn validate_offline_username(username: &str) -> AuthResult<()> { { return Err(AuthError::InvalidOfflineUsername { reason: "username may only contain letters, digits, and underscores".into(), - } - ); + }); } Ok(()) @@ -50,5 +48,6 @@ pub fn offline_account(username: String) -> MinecraftAccount { refresh_token: String::new(), expires: Utc::now() + Duration::days(3650), kind: AccountKind::Offline, + signed_out: false, } } diff --git a/packages/oneclient_auth/src/service.rs b/packages/oneclient_auth/src/service.rs index 4d16c166..a8e3b61a 100644 --- a/packages/oneclient_auth/src/service.rs +++ b/packages/oneclient_auth/src/service.rs @@ -15,405 +15,554 @@ use crate::store::{self, CredentialsStore}; /// Stable id for the Microsoft login's progress so a front-end can recognise /// it among everything else on the bus pub const MICROSOFT_LOGIN_PROGRESS: Uuid = - Uuid::from_u128(0x4D53_4141_5554_4800_0000_0000_0000_0001); + Uuid::from_u128(0x4D53_4141_5554_4800_0000_0000_0000_0001); /// `finish_microsoft_login` takes the listener and runs with it while the /// token stays here so a cancel from the UI has something to flip struct PendingLogin { - /// `None` once a flow owns the listener or once a cancel freed the port - /// before any flow got that far - browser: Option, - cancel: CancellationToken, + /// `None` once a flow owns the listener or once a cancel freed the port + /// before any flow got that far + browser: Option, + cancel: CancellationToken, } pub struct AuthService { - store: Mutex, - /// Keyed by CSRF state token - pending_logins: Mutex>, - /// Serialises token renewal per account Microsoft rotates the refresh token - /// on every use so concurrent renewals would sign the account out - refresh_guards: StdMutex>>>, - net: RequestClient, - events: EventBus, + store: Mutex, + /// Keyed by CSRF state token + pending_logins: Mutex>, + /// Serialises token renewal per account Microsoft rotates the refresh token + /// on every use so concurrent renewals would sign the account out + refresh_guards: StdMutex>>>, + net: RequestClient, + events: EventBus, } impl AuthService { - /// A missing or unreadable `auth.json` yields an empty store rather than - /// failing so a corrupt file means "sign in again" not "will not start" - pub async fn load(net: RequestClient, events: EventBus) -> AuthResult { - Ok(Self::with_store(CredentialsStore::load().await?, net, events)) - } + /// A missing or unreadable `auth.json` yields an empty store rather than + /// failing so a corrupt file means "sign in again" not "will not start" + pub async fn load(net: RequestClient, events: EventBus) -> AuthResult { + Ok(Self::with_store( + CredentialsStore::load().await?, + net, + events, + )) + } - #[must_use] - pub fn with_store(store: CredentialsStore, net: RequestClient, events: EventBus) -> Self { - Self { - store: Mutex::new(store), - pending_logins: Mutex::new(HashMap::new()), - refresh_guards: StdMutex::new(HashMap::new()), - net, - events, - } - } + #[must_use] + pub fn with_store(store: CredentialsStore, net: RequestClient, events: EventBus) -> Self { + Self { + store: Mutex::new(store), + pending_logins: Mutex::new(HashMap::new()), + refresh_guards: StdMutex::new(HashMap::new()), + net, + events, + } + } - #[tracing::instrument(skip_all)] - pub async fn begin_microsoft_login(&self) -> AuthResult { - tracing::info!("beginning Microsoft login"); - let client = self.net.http(); + #[tracing::instrument(skip_all)] + pub async fn begin_microsoft_login(&self) -> AuthResult { + tracing::info!("beginning Microsoft login"); + let client = self.net.http(); - let (browser, pending) = msa::begin_browser_login().await?; - let device = msa::begin_device_login(client).await?; + let (browser, pending) = msa::begin_browser_login().await?; + let device = msa::begin_device_login(client).await?; - let mut logins = self.pending_logins.lock().await; - // A login cancelled before its flow ever started has nobody left to remove - // it so the next login sweeps it up - logins.retain(|_, login| !login.cancel.is_cancelled()); - logins.insert( - browser.state.clone(), - PendingLogin { - browser: Some(pending), - cancel: CancellationToken::new(), - }, - ); + let mut logins = self.pending_logins.lock().await; + // A login cancelled before its flow ever started has nobody left to remove + // it so the next login sweeps it up + logins.retain(|_, login| !login.cancel.is_cancelled()); + logins.insert( + browser.state.clone(), + PendingLogin { + browser: Some(pending), + cancel: CancellationToken::new(), + }, + ); - Ok(MicrosoftLoginSession { browser, device }) - } + Ok(MicrosoftLoginSession { browser, device }) + } - /// Only one caller can ever claim a login - async fn claim_pending_login( - &self, - state_token: &str, - ) -> AuthResult<(PendingBrowserLogin, CancellationToken)> { - let mut logins = self.pending_logins.lock().await; + /// Only one caller can ever claim a login + async fn claim_pending_login( + &self, + state_token: &str, + ) -> AuthResult<(PendingBrowserLogin, CancellationToken)> { + let mut logins = self.pending_logins.lock().await; - // Cancelled between `begin` and here the listener is already gone and - // there is nothing to run - if logins - .get(state_token) - .is_some_and(|login| login.cancel.is_cancelled()) - { - logins.remove(state_token); - return Err(AuthError::LoginCancelled); - } + // Cancelled between `begin` and here the listener is already gone and + // there is nothing to run + if logins + .get(state_token) + .is_some_and(|login| login.cancel.is_cancelled()) + { + logins.remove(state_token); + return Err(AuthError::LoginCancelled); + } - let login = logins.get_mut(state_token).ok_or(AuthError::Minecraft( - MinecraftAuthError::BrowserLoginNotFound, - ))?; - let cancel = login.cancel.clone(); - let browser = login.browser.take().ok_or(AuthError::Minecraft( - MinecraftAuthError::BrowserLoginNotFound, - ))?; + let login = logins.get_mut(state_token).ok_or(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound, + ))?; + let cancel = login.cancel.clone(); + let browser = login.browser.take().ok_or(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound, + ))?; - Ok((browser, cancel)) - } + Ok((browser, cancel)) + } - #[tracing::instrument(skip_all)] - pub async fn finish_microsoft_login( - &self, - session: MicrosoftLoginSession, - ) -> AuthResult { - tracing::info!("finishing Microsoft login"); + #[tracing::instrument(skip_all)] + pub async fn finish_microsoft_login( + &self, + session: MicrosoftLoginSession, + ) -> AuthResult { + tracing::info!("finishing Microsoft login"); - let (pending, cancel) = self.claim_pending_login(&session.browser.state).await?; + let (pending, cancel) = self.claim_pending_login(&session.browser.state).await?; - let events = self.events.clone(); - let flow = msa::finish_dual_login( - self.net.http(), - pending, - &session.device, - |label, current, total| { - events.progress(MICROSOFT_LOGIN_PROGRESS, label, current, total); - }, - ); + let events = self.events.clone(); + let flow = msa::finish_dual_login( + self.net.http(), + pending, + &session.device, + |label, current, total| { + events.progress(MICROSOFT_LOGIN_PROGRESS, label, current, total); + }, + ); - // Losing the race drops `flow` and with it the device-code poll the - // loopback listener and whichever request was mid-flight - let result = tokio::select! { - biased; - () = cancel.cancelled() => Err(AuthError::LoginCancelled), - res = flow => res.map_err(AuthError::from), - }; + // Losing the race drops `flow` and with it the device-code poll the + // loopback listener and whichever request was mid-flight + let result = tokio::select! { + biased; + () = cancel.cancelled() => Err(AuthError::LoginCancelled), + res = flow => res.map_err(AuthError::from), + }; - // Whoever runs the flow owns the cleanup a cancel only flips the token - self.pending_logins.lock().await.remove(&session.browser.state); + // Whoever runs the flow owns the cleanup a cancel only flips the token + self.pending_logins + .lock() + .await + .remove(&session.browser.state); - // Drives the card to 100% so the front-end can clear it a cancelled flow - // needs this as much as a successful one - self.events - .progress(MICROSOFT_LOGIN_PROGRESS, "Signed in", 1, 1); + // Drives the card to 100% so the front-end can clear it a cancelled flow + // needs this as much as a successful one + self.events + .progress(MICROSOFT_LOGIN_PROGRESS, "Signed in", 1, 1); - // Re-read rather than trusting `result` the last handshake step can - // complete in the same breath as a cancel - if cancel.is_cancelled() { - tracing::info!("Microsoft login cancelled by the user"); - return Err(AuthError::LoginCancelled); - } + // Re-read rather than trusting `result` the last handshake step can + // complete in the same breath as a cancel + if cancel.is_cancelled() { + tracing::info!("Microsoft login cancelled by the user"); + return Err(AuthError::LoginCancelled); + } - if let Err(err) = &result { - let mut chain = String::new(); - let mut source = std::error::Error::source(err); - while let Some(cause) = source { - chain.push_str(&format!("\n caused by: {cause}")); - source = cause.source(); - } - tracing::warn!("Microsoft login failed: {err}{chain}"); - } + if let Err(err) = &result { + let mut chain = String::new(); + let mut source = std::error::Error::source(err); + while let Some(cause) = source { + chain.push_str(&format!("\n caused by: {cause}")); + source = cause.source(); + } + tracing::warn!("Microsoft login failed: {err}{chain}"); + } - let account = result?; - tracing::info!(username = %account.username, "Microsoft login succeeded"); - self.store - .lock() - .await - .commit_account(account, &self.events) - .await - } + let account = result?; + tracing::info!(username = %account.username, "Microsoft login succeeded"); + self.store + .lock() + .await + .commit_account(account, &self.events) + .await + } - /// The entry is deliberately left in the map `finish_microsoft_login` may - /// not have claimed it yet and must find the cancelled token not a hole - #[tracing::instrument(level = "debug", skip_all)] - pub async fn cancel_microsoft_login(&self, state_token: &str) { - let mut logins = self.pending_logins.lock().await; - let Some(login) = logins.get_mut(state_token) else { - return; - }; + /// The entry is deliberately left in the map `finish_microsoft_login` may + /// not have claimed it yet and must find the cancelled token not a hole + #[tracing::instrument(level = "debug", skip_all)] + pub async fn cancel_microsoft_login(&self, state_token: &str) { + let mut logins = self.pending_logins.lock().await; + let Some(login) = logins.get_mut(state_token) else { + return; + }; - login.cancel.cancel(); - // Frees the port now if no flow ever took the listener once one has the - // flow drops it as it unwinds - login.browser = None; - } + login.cancel.cancel(); + // Frees the port now if no flow ever took the listener once one has the + // flow drops it as it unwinds + login.browser = None; + } - #[tracing::instrument(skip(self), fields(username = %username))] - pub async fn add_offline_account(&self, username: String) -> AuthResult { - self.store - .lock() - .await - .add_offline_account_and_save(username) - .await - } + #[tracing::instrument(skip(self), fields(username = %username))] + pub async fn add_offline_account(&self, username: String) -> AuthResult { + self.store + .lock() + .await + .add_offline_account_and_save(username) + .await + } - pub async fn list_accounts(&self) -> Vec { - self.store.lock().await.list_accounts() - } + pub async fn list_accounts(&self) -> Vec { + self.store.lock().await.list_accounts() + } - pub async fn get_account(&self, id: Uuid) -> Option { - self.store.lock().await.get_account(id).cloned() - } + pub async fn get_account(&self, id: Uuid) -> Option { + self.store.lock().await.get_account(id).cloned() + } - #[tracing::instrument(level = "debug", skip_all)] - pub async fn default_account(&self) -> AuthResult> { - self.store.lock().await.default_account().await - } + #[tracing::instrument(level = "debug", skip_all)] + pub async fn default_account(&self) -> AuthResult> { + self.store.lock().await.default_account().await + } - #[tracing::instrument(level = "debug", skip(self), fields(?id))] - pub async fn set_default_account(&self, id: Option) -> AuthResult<()> { - self.store.lock().await.set_default_user(id).await - } + #[tracing::instrument(level = "debug", skip(self), fields(?id))] + pub async fn set_default_account(&self, id: Option) -> AuthResult<()> { + self.store.lock().await.set_default_user(id).await + } - #[tracing::instrument(skip(self), fields(%id))] - pub async fn remove_account(&self, id: Uuid) -> AuthResult<()> { - self.store.lock().await.remove_account(id).await?; - Ok(()) - } + #[tracing::instrument(skip(self), fields(%id))] + pub async fn remove_account(&self, id: Uuid) -> AuthResult<()> { + self.store.lock().await.remove_account(id).await?; + Ok(()) + } - pub async fn has_microsoft_account(&self) -> bool { - self.store.lock().await.has_microsoft_account() - } + /// Debug aid puts the account straight into the state a rejected refresh + /// token leaves behind + #[tracing::instrument(skip(self), fields(%id))] + pub async fn mark_signed_out(&self, id: Uuid) -> AuthResult<()> { + let guard = self.refresh_guard(id); + let _serialised = guard.lock().await; + self.store.lock().await.mark_signed_out(id).await + } - fn refresh_guard(&self, id: Uuid) -> Arc> { - Arc::clone( - self.refresh_guards - .lock() - .expect("refresh guard registry poisoned") - .entry(id) - .or_default(), - ) - } + /// Debug aid see [`CredentialsStore::corrupt_refresh_token`] + #[tracing::instrument(skip(self), fields(%id))] + pub async fn corrupt_refresh_token(&self, id: Uuid) -> AuthResult<()> { + let guard = self.refresh_guard(id); + let _serialised = guard.lock().await; + self.store.lock().await.corrupt_refresh_token(id).await + } - async fn account_snapshot(&self, id: Uuid) -> AuthResult { - self.store - .lock() - .await - .get_account(id) - .cloned() - .ok_or(AuthError::AccountNotFound(id)) - } + pub async fn has_microsoft_account(&self) -> bool { + self.store.lock().await.has_microsoft_account() + } - /// The store lock is never held across the Microsoft handshake only around - /// the reads and the final write - #[tracing::instrument(level = "debug", skip(self), fields(%id))] - async fn renew_token(&self, id: Uuid, force: bool) -> AuthResult { - let existing = self.account_snapshot(id).await?; - if !existing.is_microsoft() || (!force && !existing.is_expired()) { - return Ok(existing); - } + fn refresh_guard(&self, id: Uuid) -> Arc> { + Arc::clone( + self.refresh_guards + .lock() + .expect("refresh guard registry poisoned") + .entry(id) + .or_default(), + ) + } - let guard = self.refresh_guard(id); - let _serialised = guard.lock().await; + async fn account_snapshot(&self, id: Uuid) -> AuthResult { + self.store + .lock() + .await + .get_account(id) + .cloned() + .ok_or(AuthError::AccountNotFound(id)) + } - // Re-read under the guard whoever held it may have just refreshed - let existing = self.account_snapshot(id).await?; - if !existing.is_microsoft() || (!force && !existing.is_expired()) { - return Ok(existing); - } + /// The store lock is never held across the Microsoft handshake only around + /// the reads and the final write + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + async fn renew_token(&self, id: Uuid, force: bool) -> AuthResult { + let existing = self.account_snapshot(id).await?; + if existing.needs_sign_in() { + return Err(AuthError::SessionExpired { + username: existing.username, + }); + } + if !existing.is_microsoft() || (!force && !existing.is_expired()) { + return Ok(existing); + } - tracing::info!(username = %existing.username, "renewing Microsoft access token"); - match msa::refresh_microsoft_account(self.net.http(), &existing).await { - Ok(refreshed) => { - self.store - .lock() - .await - .commit_refreshed_account(refreshed.clone()) - .await?; - Ok(refreshed) - } - Err(err) => { - let err = AuthError::from(err); - if store::is_transient_auth_error(&err) { - tracing::warn!("keeping existing token after transient renewal failure: {err}"); - Ok(existing) - } else { - Err(err) - } - } - } - } + let guard = self.refresh_guard(id); + let _serialised = guard.lock().await; - #[tracing::instrument(level = "debug", skip(self), fields(%id))] - pub async fn refresh_account(&self, id: Uuid) -> AuthResult { - self.renew_token(id, true).await - } + // Re-read under the guard whoever held it may have just refreshed + let existing = self.account_snapshot(id).await?; + if existing.needs_sign_in() { + return Err(AuthError::SessionExpired { + username: existing.username, + }); + } + if !existing.is_microsoft() || (!force && !existing.is_expired()) { + return Ok(existing); + } - #[tracing::instrument(level = "debug", skip_all)] - pub async fn refresh_all_accounts(&self) -> AuthResult> { - let ids: Vec = self.store.lock().await.users.keys().copied().collect(); - let mut refreshed = Vec::with_capacity(ids.len()); + tracing::info!(username = %existing.username, "renewing Microsoft access token"); + match msa::refresh_microsoft_account(self.net.http(), &existing).await { + Ok(refreshed) => { + self.store + .lock() + .await + .commit_refreshed_account(refreshed.clone()) + .await?; + Ok(refreshed) + } + Err(MinecraftAuthError::RefreshTokenRevoked { .. }) => { + tracing::warn!(username = %existing.username, "refresh token rejected, marking account signed out"); + self.store.lock().await.mark_signed_out(id).await?; + Err(AuthError::SessionExpired { + username: existing.username, + }) + } + Err(err) => { + let err = AuthError::from(err); + if store::is_transient_auth_error(&err) { + tracing::warn!("keeping existing token after transient renewal failure: {err}"); + Ok(existing) + } else { + Err(err) + } + } + } + } - for id in ids { - refreshed.push(self.renew_token(id, true).await?); - } + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn refresh_account(&self, id: Uuid) -> AuthResult { + self.renew_token(id, true).await + } - Ok(refreshed) - } + /// A signed-out account is skipped rather than failing the whole batch it + /// already shows as needing a sign-in + #[tracing::instrument(level = "debug", skip_all)] + pub async fn refresh_all_accounts(&self) -> AuthResult> { + let ids: Vec = self.store.lock().await.users.keys().copied().collect(); + let mut refreshed = Vec::with_capacity(ids.len()); - #[tracing::instrument(level = "debug", skip(self), fields(%id))] - pub async fn account_for_launch(&self, id: Uuid) -> AuthResult { - self.renew_token(id, false).await - } + for id in ids { + match self.renew_token(id, true).await { + Ok(account) => refreshed.push(account), + Err(AuthError::SessionExpired { .. }) => {} + Err(err) => return Err(err), + } + } - #[tracing::instrument(level = "debug", skip_all)] - pub async fn default_account_for_launch(&self) -> AuthResult> { - let Some(id) = self.store.lock().await.resolve_default_id().await? else { - return Ok(None); - }; - Ok(Some(self.account_for_launch(id).await?)) - } + Ok(refreshed) + } + + /// A revoked refresh token is only discovered by trying it doing so at + /// startup surfaces the sign-out before the user presses play + /// Only lapsed tokens are tried a live one needs no renewal yet + /// Returns whether any account was newly marked signed out + #[tracing::instrument(level = "debug", skip_all)] + pub async fn check_expired_sessions(&self) -> bool { + let ids: Vec = self + .store + .lock() + .await + .users + .values() + .filter(|a| a.is_microsoft() && !a.needs_sign_in() && a.is_expired()) + .map(|a| a.id) + .collect(); + + let mut signed_out = false; + for id in ids { + match self.renew_token(id, false).await { + Ok(_) => {} + Err(AuthError::SessionExpired { .. }) => signed_out = true, + Err(err) => tracing::warn!(%id, "startup session check failed: {err}"), + } + } + signed_out + } + + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn account_for_launch(&self, id: Uuid) -> AuthResult { + let account = match self.renew_token(id, false).await { + Ok(account) => account, + // A signed-out account still plays under its saved name and uuid + // with an empty token like an offline account singleplayer works + // and the UI keeps asking for a sign-in + Err(AuthError::SessionExpired { username }) => { + tracing::info!(%username, "launching signed-out account without a token"); + self.account_snapshot(id).await? + } + Err(err) => return Err(err), + }; + + Ok(account) + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn default_account_for_launch(&self) -> AuthResult> { + let Some(id) = self.store.lock().await.resolve_default_id().await? else { + return Ok(None); + }; + Ok(Some(self.account_for_launch(id).await?)) + } } #[cfg(test)] mod tests { - use oneclient_net::NetConfig; + use oneclient_net::NetConfig; - use super::*; - use crate::data::DeviceCodeLogin; + use super::*; + use crate::data::DeviceCodeLogin; - fn service(events: EventBus) -> AuthService { - let net = RequestClient::new(NetConfig::default()).expect("net client"); - AuthService::with_store(CredentialsStore::default(), net, events) - } + fn service(events: EventBus) -> AuthService { + let net = RequestClient::new(NetConfig::default()).expect("net client"); + AuthService::with_store(CredentialsStore::default(), net, events) + } - /// Registers a login the way `begin_microsoft_login` does minus the device - /// code request which is the only half that needs the network - async fn register_login(service: &AuthService) -> MicrosoftLoginSession { - let (browser, pending) = msa::begin_browser_login().await.expect("loopback bind"); + /// Registers a login the way `begin_microsoft_login` does minus the device + /// code request which is the only half that needs the network + async fn register_login(service: &AuthService) -> MicrosoftLoginSession { + let (browser, pending) = msa::begin_browser_login().await.expect("loopback bind"); - service.pending_logins.lock().await.insert( - browser.state.clone(), - PendingLogin { - browser: Some(pending), - cancel: CancellationToken::new(), - }, - ); + service.pending_logins.lock().await.insert( + browser.state.clone(), + PendingLogin { + browser: Some(pending), + cancel: CancellationToken::new(), + }, + ); - MicrosoftLoginSession { - browser, - device: DeviceCodeLogin { - user_code: "ABCD-EFGH".to_string(), - device_code: "device-code".to_string(), - verification_uri: "https://microsoft.com/link".to_string(), - expires_in: 900, - interval: 5, - message: String::new(), - }, - } - } + MicrosoftLoginSession { + browser, + device: DeviceCodeLogin { + user_code: "ABCD-EFGH".to_string(), + device_code: "device-code".to_string(), + verification_uri: "https://microsoft.com/link".to_string(), + expires_in: 900, + interval: 5, + message: String::new(), + }, + } + } - #[tokio::test] - async fn a_cancel_that_beats_the_flow_is_still_a_cancel() { - // A quick cancel can land before the flow claims the login - let (events, _rx) = EventBus::channel(); - let service = service(events); - let session = register_login(&service).await; + #[tokio::test] + async fn a_cancel_that_beats_the_flow_is_still_a_cancel() { + // A quick cancel can land before the flow claims the login + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; - service.cancel_microsoft_login(session.dedupe_key()).await; - let err = service.finish_microsoft_login(session).await.unwrap_err(); + service.cancel_microsoft_login(session.dedupe_key()).await; + let err = service.finish_microsoft_login(session).await.unwrap_err(); - assert!(matches!(err, AuthError::LoginCancelled), "{err}"); - assert!(service.pending_logins.lock().await.is_empty()); - assert!(service.store.lock().await.list_accounts().is_empty()); - } + assert!(matches!(err, AuthError::LoginCancelled), "{err}"); + assert!(service.pending_logins.lock().await.is_empty()); + assert!(service.store.lock().await.list_accounts().is_empty()); + } - #[tokio::test] - async fn a_cancel_frees_the_loopback_port_it_was_holding() { - let (events, _rx) = EventBus::channel(); - let service = service(events); - let session = register_login(&service).await; + #[tokio::test] + async fn a_cancel_frees_the_loopback_port_it_was_holding() { + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; - service.cancel_microsoft_login(session.dedupe_key()).await; + service.cancel_microsoft_login(session.dedupe_key()).await; - let logins = service.pending_logins.lock().await; - let login = logins - .get(session.dedupe_key()) - .expect("the entry is what a flow starting late reads the cancel from"); - assert!(login.cancel.is_cancelled()); - assert!( - login.browser.is_none(), - "the listener should be dropped rather than left bound" - ); - } + let logins = service.pending_logins.lock().await; + let login = logins + .get(session.dedupe_key()) + .expect("the entry is what a flow starting late reads the cancel from"); + assert!(login.cancel.is_cancelled()); + assert!( + login.browser.is_none(), + "the listener should be dropped rather than left bound" + ); + } - #[tokio::test] - async fn a_cancel_reaches_a_flow_that_has_already_claimed_the_login() { - // Once claimed the entry is the only channel a cancel has to the flow - let (events, _rx) = EventBus::channel(); - let service = service(events); - let session = register_login(&service).await; + #[tokio::test] + async fn a_cancel_reaches_a_flow_that_has_already_claimed_the_login() { + // Once claimed the entry is the only channel a cancel has to the flow + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; - let (_listener, cancel) = service - .claim_pending_login(session.dedupe_key()) - .await - .expect("claim"); - service.cancel_microsoft_login(session.dedupe_key()).await; + let (_listener, cancel) = service + .claim_pending_login(session.dedupe_key()) + .await + .expect("claim"); + service.cancel_microsoft_login(session.dedupe_key()).await; - assert!(cancel.is_cancelled()); - } + assert!(cancel.is_cancelled()); + } - #[tokio::test] - async fn a_login_can_only_be_claimed_once() { - let (events, _rx) = EventBus::channel(); - let service = service(events); - let session = register_login(&service).await; + #[tokio::test] + async fn a_login_can_only_be_claimed_once() { + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; - service - .claim_pending_login(session.dedupe_key()) - .await - .expect("claim"); - let claimed_again = service.claim_pending_login(session.dedupe_key()).await; + service + .claim_pending_login(session.dedupe_key()) + .await + .expect("claim"); + let claimed_again = service.claim_pending_login(session.dedupe_key()).await; - assert!(matches!( - claimed_again, - Err(AuthError::Minecraft( - MinecraftAuthError::BrowserLoginNotFound - )) - )); - } + assert!(matches!( + claimed_again, + Err(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound + )) + )); + } + + fn signed_out_service() -> (AuthService, Uuid) { + let mut account = crate::offline::offline_account("MsaUser".to_string()); + account.kind = crate::data::AccountKind::Microsoft; + account.signed_out = true; + let id = account.id; + + let mut store = CredentialsStore::default(); + store.users.insert(id, account); + store.default_user = Some(id); + + let (events, _rx) = EventBus::channel(); + let net = RequestClient::new(NetConfig::default()).expect("net client"); + (AuthService::with_store(store, net, events), id) + } + + #[tokio::test] + async fn a_signed_out_account_still_launches_without_touching_the_network() { + let (service, id) = signed_out_service(); + + let account = service + .default_account_for_launch() + .await + .expect("a signed-out account must still launch") + .expect("the only account is the default"); + assert_eq!(account.id, id); + assert!(account.needs_sign_in()); + assert!(account.access_token.is_empty()); + } + + #[tokio::test] + async fn refresh_all_skips_signed_out_accounts() { + let (service, id) = signed_out_service(); + + let refreshed = service.refresh_all_accounts().await.unwrap(); + assert!(refreshed.is_empty()); + assert!( + service.get_account(id).await.is_some(), + "the entry must stay listed" + ); + } + + #[tokio::test] + async fn the_startup_check_leaves_live_and_signed_out_accounts_alone() { + let (service, signed_out_id) = signed_out_service(); + + let mut live = crate::offline::offline_account("LiveUser".to_string()); + live.kind = crate::data::AccountKind::Microsoft; + live.expires = chrono::Utc::now() + chrono::TimeDelta::hours(12); + let live_id = live.id; + service.store.lock().await.users.insert(live_id, live); + + // Neither account qualifies so this must return without a request + assert!(!service.check_expired_sessions().await); + assert!(!service.get_account(live_id).await.unwrap().needs_sign_in()); + assert!( + service + .get_account(signed_out_id) + .await + .unwrap() + .needs_sign_in() + ); + } } diff --git a/packages/oneclient_auth/src/store.rs b/packages/oneclient_auth/src/store.rs index 9ce24c6a..8b159da5 100644 --- a/packages/oneclient_auth/src/store.rs +++ b/packages/oneclient_auth/src/store.rs @@ -123,6 +123,35 @@ impl CredentialsStore { Ok(()) } + /// Drops the dead tokens but keeps the entry and its default slot so the UI + /// can offer a sign-in for this exact account + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn mark_signed_out(&mut self, id: Uuid) -> AuthResult<()> { + let Some(account) = self.users.get_mut(&id) else { + return Err(AuthError::AccountNotFound(id)); + }; + + account.signed_out = true; + account.access_token.clear(); + account.refresh_token.clear(); + self.save().await?; + Ok(()) + } + + /// Debug aid swaps in a token Microsoft will reject and backdates expiry so + /// the next launch walks the real `invalid_grant` path + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn corrupt_refresh_token(&mut self, id: Uuid) -> AuthResult<()> { + let Some(account) = self.users.get_mut(&id) else { + return Err(AuthError::AccountNotFound(id)); + }; + + account.refresh_token = "oneclient-debug-invalid-refresh-token".to_string(); + account.expires = chrono::DateTime::UNIX_EPOCH; + self.save().await?; + Ok(()) + } + #[tracing::instrument(level = "debug", skip(self), fields(%id))] pub async fn remove_account(&mut self, id: Uuid) -> AuthResult> { let removed = self.users.remove(&id); diff --git a/packages/oneclient_auth/tests/offline.rs b/packages/oneclient_auth/tests/offline.rs index d3deff43..be36ed76 100644 --- a/packages/oneclient_auth/tests/offline.rs +++ b/packages/oneclient_auth/tests/offline.rs @@ -109,6 +109,48 @@ async fn default_account_returns_expired_token_without_refreshing() { assert!(account.is_expired()); } +#[tokio::test] +async fn signed_out_account_stays_listed_and_default() { + isolate_launcher_dir(); + + let mut store = CredentialsStore::default(); + let msa = fake_microsoft_account("MsaUser"); + let msa_id = msa.id; + store.users.insert(msa_id, msa); + store.default_user = Some(msa_id); + + store.mark_signed_out(msa_id).await.unwrap(); + + let reloaded = CredentialsStore::load().await.unwrap(); + let account = reloaded.get_account(msa_id).expect("entry must survive"); + assert!(account.needs_sign_in()); + assert!( + account.refresh_token.is_empty(), + "the dead token must not be kept" + ); + assert!(account.access_token.is_empty()); + assert_eq!(reloaded.default_user, Some(msa_id)); +} + +#[test] +fn accounts_saved_before_the_flag_existed_load_as_signed_in() { + let account: oneclient_auth::MinecraftAccount = serde_json::from_str(&format!( + r#"{{"id":"{}","username":"MsaUser","access_token":"a","refresh_token":"r","expires":"2026-01-01T00:00:00Z","kind":"microsoft"}}"#, + Uuid::new_v4() + )) + .unwrap(); + + assert!(!account.needs_sign_in()); +} + +#[test] +fn offline_accounts_never_need_sign_in() { + let mut account = offline_account("Steve".into()); + account.signed_out = true; + + assert!(!account.needs_sign_in()); +} + #[tokio::test] async fn offline_account_for_launch_succeeds_without_microsoft() { isolate_launcher_dir(); diff --git a/packages/oneclient_cluster/Cargo.toml b/packages/oneclient_cluster/Cargo.toml index 9643c8f3..0b918b3c 100644 --- a/packages/oneclient_cluster/Cargo.toml +++ b/packages/oneclient_cluster/Cargo.toml @@ -25,6 +25,7 @@ bytes.workspace = true chrono.workspace = true image.workspace = true reqwest.workspace = true +regex.workspace = true serde.workspace = true serde_repr.workspace = true strum.workspace = true diff --git a/packages/oneclient_cluster/src/cluster.rs b/packages/oneclient_cluster/src/cluster.rs index 60a89fa7..184378e0 100644 --- a/packages/oneclient_cluster/src/cluster.rs +++ b/packages/oneclient_cluster/src/cluster.rs @@ -3,12 +3,13 @@ use std::time::Duration; use chrono::{DateTime, Utc}; use oneclient_db::models::ClusterId; +use oneclient_db::models::ClusterKind; use oneclient_db::models::ClusterRow; use serde::{Deserialize, Serialize}; +use crate::error::ClusterResult; use oneclient_common::domain::GameLoader; use oneclient_common::paths; -use crate::error::ClusterResult; use crate::error::ClusterError; use crate::stage::ClusterStage; @@ -17,36 +18,47 @@ pub use oneclient_common::paths::DEDICATED_MARKER; // takes a cluster out of the shared `mods` folder pub async fn remove_mods_link(folder_name: &str) { - let Ok(link) = paths::shared_mods_link(folder_name) else { - return; - }; + let Ok(link) = paths::shared_mods_link(folder_name) else { + return; + }; - match polyio::symlink_metadata(&link).await { - Ok(meta) if meta.file_type().is_symlink() => { - if let Err(err) = polyio::remove_symlink_dir(&link).await { - tracing::warn!(folder = folder_name, error = %err, "failed to clear cluster mods link"); - } - } + match polyio::symlink_metadata(&link).await { + Ok(meta) if meta.file_type().is_symlink() => { + if let Err(err) = polyio::remove_symlink_dir(&link).await { + tracing::warn!(folder = folder_name, error = %err, "failed to clear cluster mods link"); + } + } - _ => {} - } + _ => {} + } +} + +pub const MODPACK_ICON_FILE: &str = ".oneclient/modpack-icon.png"; + +pub fn encode_tags(tags: &[String]) -> String { + serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string()) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Cluster { - pub id: ClusterId, - pub name: String, - pub folder_name: String, - pub setting_profile_name: Option, - pub mc_version: String, - pub mc_loader: GameLoader, - pub mc_loader_version: Option, - pub stage: ClusterStage, - pub created_at: Option>, - pub last_played: Option>, - #[serde(with = "serde_duration_secs")] - pub overall_played: Duration, - pub linked_modpack_hash: Option, + pub id: ClusterId, + pub name: String, + pub folder_name: String, + pub setting_profile_name: Option, + pub mc_version: String, + pub mc_loader: GameLoader, + pub mc_loader_version: Option, + pub stage: ClusterStage, + pub created_at: Option>, + pub last_played: Option>, + #[serde(with = "serde_duration_secs")] + pub overall_played: Duration, + pub linked_modpack_hash: Option, + pub kind: ClusterKind, + pub user_created: bool, + pub description: Option, + pub tags: Vec, + pub cover_path: Option, } impl PartialEq for Cluster { @@ -56,75 +68,121 @@ impl PartialEq for Cluster { } mod serde_duration_secs { - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - use std::time::Duration; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::time::Duration; - pub fn serialize(duration: &Duration, serializer: S) -> Result { - duration.as_secs().serialize(serializer) - } + pub fn serialize(duration: &Duration, serializer: S) -> Result { + duration.as_secs().serialize(serializer) + } - pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { - Ok(Duration::from_secs(u64::deserialize(deserializer)?)) - } + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + Ok(Duration::from_secs(u64::deserialize(deserializer)?)) + } } impl Cluster { - pub fn try_from_row(row: ClusterRow) -> Result { - let mc_loader = GameLoader::from_repr(row.mc_loader as u8) - .ok_or(ClusterError::InvalidLoader(row.mc_loader))?; - let stage = ClusterStage::from_repr(row.stage) - .ok_or(ClusterError::InvalidStage(row.stage))?; + pub fn try_from_row(row: ClusterRow) -> Result { + let mc_loader = GameLoader::from_repr(row.mc_loader as u8) + .ok_or(ClusterError::InvalidLoader(row.mc_loader))?; + let stage = + ClusterStage::from_repr(row.stage).ok_or(ClusterError::InvalidStage(row.stage))?; + let kind = ClusterKind::from_repr(row.kind).ok_or(ClusterError::InvalidKind(row.kind))?; - Ok(Self { - id: row.id, - name: row.name, - folder_name: row.folder_name, - setting_profile_name: row.setting_profile_name, - mc_version: row.mc_version, - mc_loader, - mc_loader_version: row.mc_loader_version, - stage, - created_at: parse_timestamp(row.created_at), - last_played: parse_timestamp(row.last_played), - overall_played: Duration::from_secs(row.overall_played.unwrap_or(0).max(0) as u64), - linked_modpack_hash: row.linked_modpack_hash, - }) - } + Ok(Self { + id: row.id, + name: row.name, + folder_name: row.folder_name, + setting_profile_name: row.setting_profile_name, + mc_version: row.mc_version, + mc_loader, + mc_loader_version: row.mc_loader_version, + stage, + created_at: parse_timestamp(row.created_at), + last_played: parse_timestamp(row.last_played), + overall_played: Duration::from_secs(row.overall_played.unwrap_or(0).max(0) as u64), + linked_modpack_hash: row.linked_modpack_hash, + kind, + user_created: row.user_created != 0, + description: row.description, + tags: serde_json::from_str(&row.tags).unwrap_or_default(), + cover_path: row.cover_path, + }) + } - pub fn dir(&self) -> ClusterResult { - Ok(paths::cluster_dir(&self.folder_name)?) - } + pub fn dir(&self) -> ClusterResult { + Ok(paths::cluster_dir(&self.folder_name)?) + } - pub fn dedicated_marker(&self) -> ClusterResult { - Ok(self.dir()?.join(DEDICATED_MARKER)) - } + pub fn dedicated_marker(&self) -> ClusterResult { + Ok(self.dir()?.join(DEDICATED_MARKER)) + } - pub fn uses_dedicated_dir(&self) -> bool { - paths::cluster_uses_dedicated_dir(&self.folder_name) - } + pub fn uses_dedicated_dir(&self) -> bool { + self.is_isolated() || paths::cluster_uses_dedicated_dir(&self.folder_name) + } - pub fn game_dir(&self) -> ClusterResult { - Ok(paths::cluster_game_dir(&self.folder_name)?) - } + pub fn is_isolated(&self) -> bool { + self.kind.is_isolated() + } - pub fn as_link_target(&self) -> ClusterLinkTarget<'_> { - ClusterLinkTarget { - id: self.id, - folder_name: &self.folder_name, - mc_version: &self.mc_version, - mc_loader: self.mc_loader, - } - } + pub fn uses_bundles(&self) -> bool { + self.kind.uses_bundles() + } + + pub fn lacks_mod_loader(&self) -> bool { + self.is_isolated() && self.mc_loader == oneclient_common::domain::GameLoader::Vanilla + } + + pub fn shares_content(&self, content_type: oneclient_common::domain::ContentType) -> bool { + content_type.is_global() && !self.is_isolated() + } + + pub fn modpack_icon_file(&self) -> Option { + self.linked_modpack_hash.as_ref()?; + Some(self.dir().ok()?.join(MODPACK_ICON_FILE)) + } + + pub fn cover_file(&self) -> Option { + let cover = self.cover_path.as_deref()?; + let mut parts = std::path::Path::new(cover).components(); + let Some(std::path::Component::Normal(name)) = parts.next() else { + return None; + }; + if parts.next().is_some() { + return None; + } + Some(self.dir().ok()?.join(name)) + } + + pub fn game_dir(&self) -> ClusterResult { + Ok(paths::cluster_game_dir( + &self.folder_name, + self.is_isolated(), + )?) + } + + pub fn as_link_target(&self) -> ClusterLinkTarget<'_> { + ClusterLinkTarget { + id: self.id, + folder_name: &self.folder_name, + mc_version: &self.mc_version, + mc_loader: self.mc_loader, + } + } } #[derive(Debug, Clone)] pub struct ClusterLinkTarget<'a> { - pub id: ClusterId, - pub folder_name: &'a str, - pub mc_version: &'a str, - pub mc_loader: GameLoader, + pub id: ClusterId, + pub folder_name: &'a str, + pub mc_version: &'a str, + pub mc_loader: GameLoader, } fn parse_timestamp(value: Option) -> Option> { - value.and_then(|text| DateTime::parse_from_rfc3339(&text).ok().map(|dt| dt.with_timezone(&Utc))) + value.and_then(|text| { + DateTime::parse_from_rfc3339(&text) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + }) } diff --git a/packages/oneclient_cluster/src/error.rs b/packages/oneclient_cluster/src/error.rs index 1f9cf0f1..89f68879 100644 --- a/packages/oneclient_cluster/src/error.rs +++ b/packages/oneclient_cluster/src/error.rs @@ -7,69 +7,81 @@ pub type ClusterResult = Result; #[derive(Debug, Error)] pub enum ClusterError { - #[error(transparent)] - Io(#[from] polyio::IOError), + #[error(transparent)] + Io(#[from] polyio::IOError), - #[error(transparent)] - StdIo(#[from] std::io::Error), + #[error(transparent)] + StdIo(#[from] std::io::Error), - #[error(transparent)] - Db(#[from] oneclient_db::DbError), + #[error(transparent)] + Db(#[from] oneclient_db::DbError), - #[error("Database execution failed: {0}")] - Sql(#[from] sqlx::Error), + #[error("Database execution failed: {0}")] + Sql(#[from] sqlx::Error), - #[error(transparent)] - Paths(#[from] oneclient_common::PathsError), + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), - #[error("Unable to parse JSON: {0}")] - Json(#[from] serde_json::Error), + #[error("Unable to parse JSON: {0}")] + Json(#[from] serde_json::Error), - #[error("invalid settings profile: {reason}")] - InvalidProfile { reason: String }, + #[error("invalid settings profile: {reason}")] + InvalidProfile { reason: String }, - #[error("invalid minecraft version: {0}")] - InvalidVersion(String), + #[error("invalid minecraft version: {0}")] + InvalidVersion(String), - #[error(transparent)] - Logs(#[from] crate::logs::LogsError), + #[error(transparent)] + Logs(#[from] crate::logs::LogsError), - #[error(transparent)] - Screenshots(#[from] crate::screenshots::ScreenshotsError), + #[error(transparent)] + Screenshots(#[from] crate::screenshots::ScreenshotsError), - #[error(transparent)] - Request(#[from] oneclient_net::RequestError), + #[error(transparent)] + Worlds(#[from] crate::worlds::WorldsError), - #[error("cluster {0} not found")] - NotFound(i64), + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), - #[error("cluster folder '{0}' not found on disk")] - FolderNotFound(String), + #[error("cluster {0} not found")] + NotFound(i64), - #[error("cluster is in {0} stage")] - Busy(ClusterStage), + #[error("cluster folder '{0}' not found on disk")] + FolderNotFound(String), + + #[error("cluster is in {0} stage")] + Busy(ClusterStage), #[error("cluster is missing a required Java version")] MissingJavaVersion, - #[error("loader {0} is not compatible with {1}")] - MismatchedLoader(GameLoader, GameLoader), + #[error("loader {0} is not compatible with {1}")] + MismatchedLoader(GameLoader, GameLoader), - #[error("setting profile '{0}' not found")] - ProfileNotFound(String), + #[error("setting profile '{0}' not found")] + ProfileNotFound(String), - #[error("cluster has no settings profile assigned")] - NoProfile, + #[error("cluster has no settings profile assigned")] + NoProfile, - #[error("cluster {0} is already running")] - AlreadyRunning(i64), + #[error("cluster {0} is already running")] + AlreadyRunning(i64), - #[error("cluster name is empty after sanitization")] - EmptyName, + #[error("cluster name is empty after sanitization")] + EmptyName, - #[error("unknown loader id {0} in database")] - InvalidLoader(i64), + #[error("invalid name: {0}")] + InvalidName(crate::naming::NameProblem), - #[error("unknown stage id {0} in database")] - InvalidStage(i64), + #[error("unknown loader id {0} in database")] + InvalidLoader(i64), + + #[error("unknown stage id {0} in database")] + InvalidStage(i64), + + #[error("unknown cluster kind id {0} in database")] + InvalidKind(i64), + + #[error("cluster {0} always uses its own game directory")] + DedicatedRequired(i64), } diff --git a/packages/oneclient_cluster/src/identity.rs b/packages/oneclient_cluster/src/identity.rs new file mode 100644 index 00000000..2a5262a0 --- /dev/null +++ b/packages/oneclient_cluster/src/identity.rs @@ -0,0 +1,54 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; +use oneclient_db::models::ClusterKind; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstanceIdentity { + pub name: String, + pub mc_version: String, + pub mc_loader: GameLoader, + #[serde(default)] + pub mc_loader_version: Option, + #[serde(default)] + pub kind: ClusterKind, + #[serde(default)] + pub user_created: bool, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub cover_path: Option, +} + +pub async fn read(cluster_dir: &Path) -> Option { + let bytes = polyio::read(cluster_dir.join(paths::INSTANCE_FILE)) + .await + .ok()?; + match serde_json::from_slice(&bytes) { + Ok(identity) => Some(identity), + Err(err) => { + tracing::warn!(dir = %cluster_dir.display(), error = %err, "instance file is unreadable"); + None + } + } +} + +pub async fn write(cluster_dir: &Path, identity: &InstanceIdentity) { + let Ok(bytes) = serde_json::to_vec_pretty(identity) else { + return; + }; + + if !polyio::try_exists(cluster_dir).await.unwrap_or(false) { + tracing::warn!(dir = %cluster_dir.display(), "cluster folder is missing; not writing the instance file"); + return; + } + + if let Err(err) = polyio::write_atomic(cluster_dir.join(paths::INSTANCE_FILE), &bytes).await { + tracing::warn!(dir = %cluster_dir.display(), error = %err, "failed to write the instance file"); + } +} diff --git a/packages/oneclient_cluster/src/lib.rs b/packages/oneclient_cluster/src/lib.rs index 904113bd..e9de17b5 100644 --- a/packages/oneclient_cluster/src/lib.rs +++ b/packages/oneclient_cluster/src/lib.rs @@ -9,13 +9,18 @@ mod options; mod profile; mod stage; +pub mod identity; pub mod logs; +pub mod naming; pub mod profiles; pub mod screenshots; +pub mod worlds; -pub use cluster::{Cluster, ClusterLinkTarget, remove_mods_link}; +pub use cluster::{Cluster, ClusterLinkTarget, encode_tags, remove_mods_link}; pub use error::{ClusterError, ClusterResult}; -pub use manager::ClusterManager; +pub use identity::InstanceIdentity; +pub use manager::{ClusterManager, unlinked_mods_repair_id}; +pub use oneclient_db::models::ClusterKind; pub use options::{ClusterUpdate, CreateClusterOptions}; pub use profile::{GameSettingsProfile, PackageUpdateMode, SettingsOsExtra}; pub use profiles::ProfileUpdate; diff --git a/packages/oneclient_cluster/src/logs/manage.rs b/packages/oneclient_cluster/src/logs/manage.rs index 3f15250b..7245b691 100644 --- a/packages/oneclient_cluster/src/logs/manage.rs +++ b/packages/oneclient_cluster/src/logs/manage.rs @@ -2,8 +2,8 @@ use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; -use crate::error::ClusterResult; use crate::cluster::Cluster; +use crate::error::ClusterResult; use oneclient_common::paths; use super::parse::parse_level; @@ -109,7 +109,7 @@ pub(super) fn lines_from(content: &str, opts: &ReadOptions) -> Vec { lines.push(LogLine { number: idx + 1, level, - text: raw.to_string(), + text: super::censor(raw).into_owned(), }); } diff --git a/packages/oneclient_cluster/src/logs/mclogs.rs b/packages/oneclient_cluster/src/logs/mclogs.rs index baa51ca1..692b6d9d 100644 --- a/packages/oneclient_cluster/src/logs/mclogs.rs +++ b/packages/oneclient_cluster/src/logs/mclogs.rs @@ -2,8 +2,8 @@ use std::path::Path; use serde::Deserialize; -use oneclient_net::RequestError; use crate::error::ClusterResult; +use oneclient_net::RequestError; use super::manage::{ensure_allowed, read_file_string}; use super::{LogsError, MclogsUploadResponse}; @@ -32,18 +32,27 @@ pub async fn upload_log_at( path: &Path, ) -> ClusterResult { let path = ensure_allowed(path)?; - let mut content = read_file_string(&path).await?; + let mut content = super::censor(&read_file_string(&path).await?).into_owned(); + let mut truncated = false; let line_count = content.lines().count(); if line_count > MAX_LINES { + truncated = true; content = content .lines() .skip(line_count - MAX_LINES) .collect::>() .join("\n"); } - if content.len() > MAX_BYTES { - let mut cut = content.len() - MAX_BYTES; + let mut encoded = form_encoded_len(content.as_bytes()); + if encoded > MAX_BYTES { + truncated = true; + let bytes = content.as_bytes(); + let mut cut = 0; + while encoded > MAX_BYTES && cut < bytes.len() { + encoded -= form_encoded_byte_len(bytes[cut]); + cut += 1; + } while cut < content.len() && !content.is_char_boundary(cut) { cut += 1; } @@ -58,7 +67,22 @@ pub async fn upload_log_at( .await .map_err(RequestError::ReqwestError)?; + let status = response.status(); let bytes = response.bytes().await.map_err(RequestError::ReqwestError)?; + + if !status.is_success() { + let reason = serde_json::from_slice::(&bytes) + .ok() + .and_then(|r| r.error) + .unwrap_or_else(|| status.to_string()); + tracing::warn!( + %status, + body = %String::from_utf8_lossy(&bytes[..bytes.len().min(512)]), + "mclogs upload rejected" + ); + return Err(LogsError::Upload(reason).into()); + } + let parsed: MclogsResponse = serde_json::from_slice(&bytes)?; if !parsed.success { @@ -67,11 +91,26 @@ pub async fn upload_log_at( return Err(LogsError::Upload(reason).into()); } - tracing::info!(url = parsed.url.as_deref().unwrap_or(""), "uploaded log to mclo.gs"); + tracing::info!( + url = parsed.url.as_deref().unwrap_or(""), + "uploaded log to mclo.gs" + ); Ok(MclogsUploadResponse { id: parsed.id.unwrap_or_default(), url: parsed.url.unwrap_or_default(), raw: parsed.raw.unwrap_or_default(), + truncated, }) } + +fn form_encoded_byte_len(byte: u8) -> usize { + match byte { + b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'*' | b'-' | b'.' | b'_' | b' ' => 1, + _ => 3, + } +} + +fn form_encoded_len(bytes: &[u8]) -> usize { + bytes.iter().map(|&b| form_encoded_byte_len(b)).sum() +} diff --git a/packages/oneclient_cluster/src/logs/mod.rs b/packages/oneclient_cluster/src/logs/mod.rs index 2d4789f0..87f0e113 100644 --- a/packages/oneclient_cluster/src/logs/mod.rs +++ b/packages/oneclient_cluster/src/logs/mod.rs @@ -10,7 +10,7 @@ use chrono::{DateTime, Utc}; pub use error::LogsError; pub use manage::{cluster_output_log, delete_log_at, list_cluster_logs, read_log_at}; pub use mclogs::upload_log_at; -pub use parse::parse_level; +pub use parse::{censor, parse_level}; /// No launcher variant on purpose the launcher's own logs live in the log console #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -92,6 +92,7 @@ pub struct MclogsUploadResponse { pub id: String, pub url: String, pub raw: String, + pub truncated: bool, } #[cfg(test)] @@ -110,7 +111,7 @@ mod tests { ); assert_eq!(parse_level("[ERROR] boom"), Some(LogLevel::Error)); assert_eq!(parse_level("[main/FATAL]"), Some(LogLevel::Fatal)); - + assert_eq!(parse_level("\tat java.base/java.lang.Thread.run"), None); assert_eq!(parse_level("[12:34:56] just a timestamp"), None); } diff --git a/packages/oneclient_cluster/src/logs/parse.rs b/packages/oneclient_cluster/src/logs/parse.rs index 089afb32..1c8320b2 100644 --- a/packages/oneclient_cluster/src/logs/parse.rs +++ b/packages/oneclient_cluster/src/logs/parse.rs @@ -1,5 +1,21 @@ +use std::borrow::Cow; +use std::sync::LazyLock; + +use regex::Regex; + use super::LogLevel; +static SECRET: LazyLock = LazyLock::new(|| { + Regex::new( + r#"(--accessToken|--session(?:Id)?)([\s,=]+)[^\s,\[\]'"]+|(token:)[^:\s]+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*"#, + ) + .expect("secret pattern") +}); + +pub fn censor(line: &str) -> Cow<'_, str> { + SECRET.replace_all(line, "${1}${2}${3}[censored]") +} + pub fn parse_level(line: &str) -> Option { let bytes = line.as_bytes(); let mut i = 0; diff --git a/packages/oneclient_cluster/src/manager.rs b/packages/oneclient_cluster/src/manager.rs index bc3b719a..e1560d01 100644 --- a/packages/oneclient_cluster/src/manager.rs +++ b/packages/oneclient_cluster/src/manager.rs @@ -1,369 +1,806 @@ -use oneclient_db::dao::{cluster as cluster_dao, setting_profile as profile_dao}; -use oneclient_db::models::{ClusterId, ClusterPatch, NewCluster}; - -use oneclient_common::domain::ContentType; -use crate::profiles::{ - create_profile_from_global, resolve_cluster_profile, update_named_profile, +use oneclient_db::dao::{ + applied_migration as migration_dao, cluster as cluster_dao, setting_profile as profile_dao, }; -use oneclient_common::patch::Patch; +use oneclient_db::models::{ClusterId, ClusterKind, ClusterPatch, NewCluster}; + +use crate::error::ClusterResult; use crate::profile::GameSettingsProfile; use crate::profiles::ProfileUpdate; -use crate::error::ClusterResult; +use crate::profiles::{create_profile_from_global, resolve_cluster_profile, update_named_profile}; +use oneclient_common::domain::ContentType; +use oneclient_common::patch::Patch; use oneclient_db::DbPool; use tokio::sync::Mutex; -use crate::cluster::{Cluster, remove_mods_link}; +use crate::cluster::{Cluster, encode_tags, remove_mods_link}; use crate::error::ClusterError; use crate::options::{ClusterUpdate, CreateClusterOptions}; +use crate::screenshots::thumbnail; use crate::stage::ClusterStage; +const COVER_STEM: &str = "cover"; +const COVER_MAX_EDGE: u32 = 1200; + +fn shrink_cover(raw: Vec) -> (Vec, bool) { + match thumbnail(&raw, COVER_MAX_EDGE) { + Some(out) => (Vec::from(out), true), + None => (raw, false), + } +} + pub struct ClusterManager { - db: DbPool, - /// Serialises creation so two concurrent creates cannot resolve to the same - /// folder name and race each other onto disk - provisioning: Mutex<()>, + db: DbPool, + /// Serialises creation so two concurrent creates cannot resolve to the same + /// folder name and race each other onto disk + provisioning: Mutex<()>, } impl ClusterManager { - #[must_use] - pub fn new(db: DbPool) -> Self { - Self { - db, - provisioning: Mutex::new(()), - } - } + #[must_use] + pub fn new(db: DbPool) -> Self { + Self { + db, + provisioning: Mutex::new(()), + } + } - /// Orphan-folder recovery must hold this across its whole scan or a - /// concurrent create's folder is adopted into a duplicate row - pub async fn provisioning_guard(&self) -> tokio::sync::MutexGuard<'_, ()> { - self.provisioning.lock().await - } + /// Orphan-folder recovery must hold this across its whole scan or a + /// concurrent create's folder is adopted into a duplicate row + pub async fn provisioning_guard(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.provisioning.lock().await + } - pub fn sanitize_name(name: &str) -> String { - let mut name = name.to_string(); - name.retain(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ' ' | '.' | '(' | ')')); - name.trim().to_string() - } + pub fn sanitize_name(name: &str) -> String { + let mut name = name.to_string(); + name.retain(crate::naming::is_allowed_name_char); + let name = name.trim().trim_end_matches(['.', ' ']); - #[tracing::instrument(level = "debug", skip(self))] - pub async fn get(&self, cluster_id: ClusterId) -> ClusterResult { - let row = cluster_dao::get_by_id(&self.db, cluster_id) - .await? - .ok_or(ClusterError::NotFound(cluster_id))?; - Cluster::try_from_row(row) - } + let stem_len = name.find('.').unwrap_or(name.len()); + let stem = name[..stem_len].trim_end(); + if is_reserved_device_name(stem) { + return format!("{stem}_{}", &name[stem.len()..]); + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn find_by_folder_name(&self, folder_name: &str) -> ClusterResult> { - cluster_dao::get_by_folder_name(&self.db, folder_name) - .await? - .map(Cluster::try_from_row) - .transpose() - } + name.to_string() + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn list(&self) -> ClusterResult> { - let rows = cluster_dao::list_all(&self.db).await?; - rows.into_iter() - .map(Cluster::try_from_row) - .collect::, _>>()} + #[tracing::instrument(level = "debug", skip(self))] + pub async fn get(&self, cluster_id: ClusterId) -> ClusterResult { + let row = cluster_dao::get_by_id(&self.db, cluster_id) + .await? + .ok_or(ClusterError::NotFound(cluster_id))?; + Cluster::try_from_row(row) + } - #[tracing::instrument(skip(self))] - pub async fn create( - &self, - global: &GameSettingsProfile, - options: CreateClusterOptions, - ) -> ClusterResult { - let _guard = self.provisioning.lock().await; - self.create_core(global, options).await - } + #[tracing::instrument(level = "debug", skip(self))] + pub async fn find_by_folder_name(&self, folder_name: &str) -> ClusterResult> { + cluster_dao::get_by_folder_name(&self.db, folder_name) + .await? + .map(Cluster::try_from_row) + .transpose() + } - /// Returns `None` if a cluster for this version/loader already exists - #[tracing::instrument(level = "debug", skip(self))] - pub async fn create_provisioned( - &self, - global: &GameSettingsProfile, - options: CreateClusterOptions, - ) -> ClusterResult> { - let _guard = self.provisioning.lock().await; - if cluster_dao::find_by_version_loader( - &self.db, - &options.mc_version, - options.mc_loader as i64, - ) - .await? - .is_some() - { - return Ok(None); - } - self.create_core(global, options).await.map(Some) - } + #[tracing::instrument(level = "debug", skip(self))] + pub async fn list(&self) -> ClusterResult> { + let rows = cluster_dao::list_all(&self.db).await?; + rows.into_iter() + .map(Cluster::try_from_row) + .collect::, _>>() + } - /// Callers MUST hold `self.provisioning` this does not lock - #[tracing::instrument(level = "debug", skip(self, global))] - async fn create_core( - &self, - global: &GameSettingsProfile, - options: CreateClusterOptions, - ) -> ClusterResult { - let name = Self::sanitize_name(&options.name); - if name.is_empty() { - return Err(ClusterError::EmptyName); - } + #[tracing::instrument(skip(self))] + pub async fn create( + &self, + global: &GameSettingsProfile, + options: CreateClusterOptions, + ) -> ClusterResult { + let _guard = self.provisioning.lock().await; + self.create_core(global, options).await + } - let folder_name = resolve_unique_folder_name(&name).await?; - let cluster_path = oneclient_common::paths::clusters_dir()?.join(&folder_name); + /// Returns `None` if a cluster for this version/loader already exists + #[tracing::instrument(level = "debug", skip(self))] + pub async fn create_provisioned( + &self, + global: &GameSettingsProfile, + mut options: CreateClusterOptions, + ) -> ClusterResult> { + options.kind = ClusterKind::OneClient; + options.user_created = false; - match create_inner(&self.db, global, &options, &name, &folder_name, &cluster_path).await { - Ok(cluster) => { - tracing::info!(cluster_id = cluster.id, name = %cluster.name, "created cluster"); - Ok(cluster) - } - Err(err) => { - tracing::warn!(name = %name, error = %err, "cluster creation failed, cleaning up directory"); - let _ = polyio::remove_dir_all(&cluster_path).await; - Err(err) - } - } - } + let _guard = self.provisioning.lock().await; + if cluster_dao::find_by_version_loader( + &self.db, + &options.mc_version, + options.mc_loader as i64, + ) + .await? + .is_some() + || cluster_dao::is_provision_dismissed( + &self.db, + &options.mc_version, + options.mc_loader as i64, + ) + .await? + { + return Ok(None); + } + self.create_core(global, options).await.map(Some) + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn update( - &self, - cluster_id: ClusterId, - update: ClusterUpdate, - ) -> ClusterResult { - let _ = self.get(cluster_id).await?; + /// Callers MUST hold `self.provisioning` this does not lock + #[tracing::instrument(level = "debug", skip(self, global))] + async fn create_core( + &self, + global: &GameSettingsProfile, + options: CreateClusterOptions, + ) -> ClusterResult { + if options.user_created { + let limit = (!options.modpack).then_some(crate::naming::MAX_NAME_CHARS); + crate::naming::validate_name(&options.name, limit) + .map_err(ClusterError::InvalidName)?; + for tag in &options.tags { + crate::naming::validate_tag(tag).map_err(ClusterError::InvalidName)?; + } + } - if let Patch::Set(ref profile_name) = update.setting_profile_name { - ensure_profile_exists(&self.db, profile_name).await?; - } + let folder_stem = Self::sanitize_name(&cap_folder_stem(&options.name)); + if folder_stem.is_empty() { + return Err(ClusterError::EmptyName); + } - let name = update.name.as_deref().map(Self::sanitize_name); - if name.as_deref().is_some_and(str::is_empty) { - return Err(ClusterError::EmptyName); - } + let name = if options.user_created { + options.name.trim().to_string() + } else { + folder_stem.clone() + }; - let patch = ClusterPatch { - name, - setting_profile_name: update.setting_profile_name.into_db_patch(), - mc_loader_version: update.mc_loader_version.into_db_patch(), - linked_modpack_hash: update.linked_modpack_hash.into_db_patch(), - }; + let folder_name = resolve_unique_folder_name(&folder_stem).await?; + let cluster_path = oneclient_common::paths::clusters_dir()?.join(&folder_name); - let row = cluster_dao::update(&self.db, cluster_id, &patch).await?; - Cluster::try_from_row(row) - } + match create_inner( + &self.db, + global, + &options, + &name, + &folder_name, + &cluster_path, + ) + .await + { + Ok(cluster) => { + tracing::info!(cluster_id = cluster.id, name = %cluster.name, "created cluster"); + if let Err(err) = + migration_dao::mark_applied(&self.db, &unlinked_mods_repair_id(cluster.id)) + .await + { + tracing::warn!(cluster_id = cluster.id, error = %err, "failed to record a one-time repair"); + } + Ok(cluster) + } + Err(err) => { + tracing::warn!(name = %name, error = %err, "cluster creation failed, cleaning up directory"); + let _ = polyio::remove_dir_all(&cluster_path).await; + Err(err) + } + } + } - #[tracing::instrument(skip(self))] - pub async fn delete( - &self, - cluster_id: ClusterId, - remove_files: bool, - ) -> ClusterResult<()> { - let cluster = self.get(cluster_id).await?; + #[tracing::instrument(level = "debug", skip(self))] + pub async fn update( + &self, + cluster_id: ClusterId, + update: ClusterUpdate, + ) -> ClusterResult { + let existing = self.get(cluster_id).await?; - if !cluster_dao::delete_by_id(&self.db, cluster_id).await? { - return Err(ClusterError::NotFound(cluster_id)); - } + if let Patch::Set(ref profile_name) = update.setting_profile_name { + ensure_profile_exists(&self.db, profile_name).await?; + } - remove_mods_link(&cluster.folder_name).await; + if existing.user_created { + if let Some(raw) = update.name.as_deref() + && raw.trim() != existing.name + { + let limit = existing + .linked_modpack_hash + .is_none() + .then_some(crate::naming::MAX_NAME_CHARS); + crate::naming::validate_name(raw, limit).map_err(ClusterError::InvalidName)?; + } + for tag in update.tags.iter().flatten() { + if !existing.tags.contains(tag) { + crate::naming::validate_tag(tag).map_err(ClusterError::InvalidName)?; + } + } + } - if remove_files { - let path = cluster.dir()?; - if path.exists() { - polyio::remove_dir_all(&path).await?; - } - } + let name = match update.name.as_deref() { + Some(raw) if existing.user_created => Some(raw.trim().to_string()), + Some(raw) => Some(Self::sanitize_name(raw)), + None => None, + }; + if name.as_deref().is_some_and(str::is_empty) { + return Err(ClusterError::EmptyName); + } - tracing::info!(cluster_id, remove_files, "deleted cluster"); - Ok(()) - } + let patch = ClusterPatch { + name, + setting_profile_name: update.setting_profile_name.into_db_patch(), + mc_loader_version: update.mc_loader_version.into_db_patch(), + linked_modpack_hash: update.linked_modpack_hash.into_db_patch(), + description: update.description.into_db_patch(), + tags: update.tags.as_deref().map(encode_tags), + cover_path: update.cover_path.into_db_patch(), + }; - #[tracing::instrument(level = "debug", skip(self))] - pub async fn set_stage( - &self, - cluster_id: ClusterId, - stage: ClusterStage, - ) -> ClusterResult { - let row = cluster_dao::set_stage(&self.db, cluster_id, stage as i64).await?; - Cluster::try_from_row(row) - } + let row = cluster_dao::update(&self.db, cluster_id, &patch).await?; + let cluster = Cluster::try_from_row(row)?; - #[tracing::instrument(level = "debug", skip(self))] - pub async fn uses_dedicated_dir(&self, cluster_id: ClusterId) -> ClusterResult { - Ok(self.get(cluster_id).await?.uses_dedicated_dir()) - } + if let Ok(dir) = cluster.dir() { + crate::identity::write(&dir, &identity_of(&cluster)).await; + } - #[tracing::instrument(level = "debug", skip(self))] - /// `is_running` is passed in because process liveness belongs to the game - /// lifecycle not to cluster records - pub async fn set_dedicated_dir( - &self, - cluster_id: ClusterId, - dedicated: bool, - is_running: bool, - ) -> ClusterResult<()> { - if is_running { - return Err(ClusterError::AlreadyRunning(cluster_id)); - } + Ok(cluster) + } - let cluster = self.get(cluster_id).await?; - let marker = cluster.dedicated_marker()?; - if dedicated { - polyio::create_dir_all(cluster.dir()?).await?; - polyio::write(&marker, b"").await.ok(); - } else if marker.exists() { - polyio::remove_file(&marker).await.ok(); - } - Ok(()) - } + pub async fn ensure_dedicated_marker(&self, cluster: &Cluster) -> ClusterResult<()> { + if !cluster.is_isolated() { + return Ok(()); + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn add_playtime( - &self, - cluster_id: ClusterId, - duration: std::time::Duration, - ) -> ClusterResult { - let seconds = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX); - let row = cluster_dao::add_playtime(&self.db, cluster_id, seconds).await?; + let dir = cluster.dir()?; + if !oneclient_common::paths::cluster_uses_dedicated_dir(&cluster.folder_name) { + polyio::create_dir_all(&dir).await?; + polyio::write(dir.join(oneclient_common::paths::DEDICATED_MARKER), b"") + .await + .ok(); + tracing::info!( + cluster_id = cluster.id, + "restored the dedicated directory marker" + ); + } - Cluster::try_from_row(row) - } + if crate::identity::read(&dir).await.is_none() { + crate::identity::write(&dir, &identity_of(cluster)).await; + tracing::info!(cluster_id = cluster.id, "restored the instance file"); + } + Ok(()) + } - #[tracing::instrument(level = "debug", skip(self, global, cluster), fields(cluster_id = cluster.id))] - pub async fn resolve_settings( - &self, - global: &GameSettingsProfile, - cluster: &Cluster, - ) -> ClusterResult { - resolve_cluster_profile( - &self.db, - global, - cluster.setting_profile_name.as_deref(), - ) - .await - } + pub async fn refresh_identity(&self, cluster_id: ClusterId) -> ClusterResult<()> { + let cluster = self.get(cluster_id).await?; + let dir = cluster.dir()?; + let recorded = polyio::try_exists(dir.join(oneclient_common::paths::INSTANCE_FILE)) + .await + .unwrap_or(false); - #[tracing::instrument(level = "debug", skip(self, update))] - pub async fn update_profile( - &self, - cluster_id: ClusterId, - update: ProfileUpdate, - ) -> ClusterResult { - let cluster = self.get(cluster_id).await?; - let profile_name = cluster - .setting_profile_name - .ok_or(ClusterError::NoProfile)?; + if recorded || cluster.is_isolated() { + crate::identity::write(&dir, &identity_of(&cluster)).await; + } + Ok(()) + } - update_named_profile(&self.db, &profile_name, update).await - } + pub async fn sweep_trash(&self) { + let Ok(trash) = oneclient_common::paths::cluster_trash_dir() else { + return; + }; + let Ok(mut entries) = polyio::read_dir(&trash).await else { + return; + }; - #[tracing::instrument(level = "debug", skip(self))] - pub async fn create_and_assign_profile( - &self, - global: &GameSettingsProfile, - cluster_id: ClusterId, - profile_name: &str, - ) -> ClusterResult { - let profile = create_profile_from_global( - &self.db, - global, - profile_name, - None, - None, - ) - .await?; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let name = entry.file_name().to_string_lossy().into_owned(); + let parsed = name + .split_once('-') + .and_then(|(id, folder)| Some((id.parse::().ok()?, folder))); - self.update( - cluster_id, - ClusterUpdate::default().setting_profile(&profile.name), - ) - .await?; + if let Some((id, folder)) = parsed + && let Ok(Some(row)) = cluster_dao::get_by_id(&self.db, id).await + && row.folder_name == folder + { + let Ok(home) = oneclient_common::paths::cluster_dir(folder) else { + continue; + }; + if polyio::symlink_metadata(&home).await.is_err() + && let Err(err) = polyio::rename(&path, &home).await + { + tracing::warn!(cluster_id = id, error = %err, "failed to return a trashed cluster folder whose cluster still exists"); + } + continue; + } - Ok(profile) - } + let removed = match entry.file_type().await { + Ok(kind) if kind.is_dir() => polyio::remove_dir_all(&path).await, + _ => polyio::remove_file(&path).await, + }; + match removed { + Ok(()) => tracing::info!(entry = %name, "cleared a leftover deleted cluster folder"), + Err(err) => { + tracing::warn!(entry = %name, error = %err, "failed to clear a leftover deleted cluster folder") + } + } + } + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn set_cover_from_file( + &self, + cluster_id: ClusterId, + source: &std::path::Path, + ) -> ClusterResult { + let cluster = self.get(cluster_id).await?; + let dir = cluster.dir()?; + polyio::create_dir_all(&dir).await?; + + let original_extension = source + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .filter(|ext| matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "webp" | "gif")) + .unwrap_or_else(|| "png".to_string()); + + let raw = polyio::read(source).await?; + let (bytes, shrunk) = tokio::task::spawn_blocking(move || shrink_cover(raw)) + .await + .map_err(|err| ClusterError::StdIo(std::io::Error::other(err)))?; + + let extension = if shrunk { + "png".to_string() + } else { + original_extension + }; + let file_name = format!( + "{COVER_STEM}-{}.{extension}", + chrono::Utc::now().timestamp_millis() + ); + let path = dir.join(&file_name); + polyio::write(&path, &bytes).await?; + + let patch = ClusterPatch { + cover_path: Some(Some(file_name.clone())), + ..Default::default() + }; + let row = match cluster_dao::update(&self.db, cluster_id, &patch).await { + Ok(row) => row, + Err(err) => { + polyio::remove_file(&path).await.ok(); + return Err(err.into()); + } + }; + let cluster = Cluster::try_from_row(row)?; + crate::identity::write(&dir, &identity_of(&cluster)).await; + + self.clear_cover_files(&dir, Some(&file_name)).await; + Ok(file_name) + } + + pub async fn clear_cover(&self, cluster_id: ClusterId) -> ClusterResult<()> { + let cluster = self.get(cluster_id).await?; + if let Ok(dir) = cluster.dir() { + self.clear_cover_files(&dir, None).await; + } + Ok(()) + } + + async fn clear_cover_files(&self, dir: &std::path::Path, keep: Option<&str>) { + let Ok(mut entries) = polyio::read_dir(dir).await else { + return; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if keep != Some(name.as_ref()) && is_cover_file(&name) { + polyio::remove_file(entry.path()).await.ok(); + } + } + } + + #[tracing::instrument(skip(self))] + pub async fn delete(&self, cluster_id: ClusterId, remove_files: bool) -> ClusterResult<()> { + let cluster = self.get(cluster_id).await?; + + if !cluster.user_created { + cluster_dao::dismiss_provision( + &self.db, + &cluster.mc_version, + cluster.mc_loader as i64, + ) + .await?; + } + + let trashed = if remove_files && cluster.is_isolated() { + move_to_trash(&cluster).await + } else { + None + }; + + let deleted = cluster_dao::delete_by_id(&self.db, cluster_id).await; + if !matches!(deleted, Ok(true)) + && let Some(trashed) = &trashed + && let Ok(home) = cluster.dir() + && let Err(err) = polyio::rename(trashed, &home).await + { + tracing::warn!(cluster_id, error = %err, "failed to put the cluster folder back after its row could not be deleted"); + } + if !deleted? { + return Err(ClusterError::NotFound(cluster_id)); + } + + remove_mods_link(&cluster.folder_name).await; + + if let Some(trashed) = trashed { + if let Err(err) = polyio::remove_dir_all(&trashed).await { + tracing::warn!( + cluster_id, + dir = %trashed.display(), + error = %err, + "failed to clear the deleted cluster's folder; it will be retried on the next start" + ); + } + } else if remove_files { + let path = cluster.dir()?; + if path.exists() { + polyio::remove_dir_all(&path).await?; + } + } + + tracing::info!(cluster_id, remove_files, "deleted cluster"); + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn set_stage( + &self, + cluster_id: ClusterId, + stage: ClusterStage, + ) -> ClusterResult { + let row = cluster_dao::set_stage(&self.db, cluster_id, stage as i64).await?; + Cluster::try_from_row(row) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn uses_dedicated_dir(&self, cluster_id: ClusterId) -> ClusterResult { + Ok(self.get(cluster_id).await?.uses_dedicated_dir()) + } + + #[tracing::instrument(level = "debug", skip(self))] + /// `is_running` is passed in because process liveness belongs to the game + /// lifecycle not to cluster records + pub async fn set_dedicated_dir( + &self, + cluster_id: ClusterId, + dedicated: bool, + is_running: bool, + ) -> ClusterResult<()> { + if is_running { + return Err(ClusterError::AlreadyRunning(cluster_id)); + } + + let cluster = self.get(cluster_id).await?; + if !dedicated && cluster.is_isolated() { + return Err(ClusterError::DedicatedRequired(cluster_id)); + } + + let marker = cluster.dedicated_marker()?; + if dedicated { + polyio::create_dir_all(cluster.dir()?).await?; + polyio::write(&marker, b"").await.ok(); + } else if marker.exists() { + polyio::remove_file(&marker).await.ok(); + } + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn mark_played(&self, cluster_id: ClusterId) -> ClusterResult<()> { + cluster_dao::touch_last_played(&self.db, cluster_id).await?; + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn add_playtime( + &self, + cluster_id: ClusterId, + duration: std::time::Duration, + ) -> ClusterResult { + let seconds = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX); + let row = cluster_dao::add_playtime(&self.db, cluster_id, seconds).await?; + + Cluster::try_from_row(row) + } + + #[tracing::instrument(level = "debug", skip(self, global, cluster), fields(cluster_id = cluster.id))] + pub async fn resolve_settings( + &self, + global: &GameSettingsProfile, + cluster: &Cluster, + ) -> ClusterResult { + resolve_cluster_profile(&self.db, global, cluster.setting_profile_name.as_deref()).await + } + + #[tracing::instrument(level = "debug", skip(self, update))] + pub async fn update_profile( + &self, + cluster_id: ClusterId, + update: ProfileUpdate, + ) -> ClusterResult { + let cluster = self.get(cluster_id).await?; + let profile_name = cluster + .setting_profile_name + .ok_or(ClusterError::NoProfile)?; + + update_named_profile(&self.db, &profile_name, update).await + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn create_and_assign_profile( + &self, + global: &GameSettingsProfile, + cluster_id: ClusterId, + profile_name: &str, + ) -> ClusterResult { + let profile = + create_profile_from_global(&self.db, global, profile_name, None, None).await?; + + self.update( + cluster_id, + ClusterUpdate::default().setting_profile(&profile.name), + ) + .await?; + + Ok(profile) + } } #[tracing::instrument(level = "debug", skip(db, global, options))] async fn create_inner( - db: &DbPool, - global: &GameSettingsProfile, - options: &CreateClusterOptions, - name: &str, - folder_name: &str, - cluster_path: &std::path::Path, + db: &DbPool, + global: &GameSettingsProfile, + options: &CreateClusterOptions, + name: &str, + folder_name: &str, + cluster_path: &std::path::Path, ) -> ClusterResult { - polyio::create_dir_all(cluster_path).await?; - ensure_content_dirs(cluster_path).await?; + polyio::create_dir_all(cluster_path).await?; + ensure_content_dirs(cluster_path).await?; - let profile = create_profile_from_global( - db, - global, - name, - options.mem_max, - None, - ) - .await?; + if options.kind.is_isolated() { + polyio::write( + cluster_path.join(oneclient_common::paths::DEDICATED_MARKER), + b"", + ) + .await + .ok(); - let row = cluster_dao::insert( - db, - &NewCluster { - name, - folder_name, - mc_version: &options.mc_version, - mc_loader: options.mc_loader as i64, - mc_loader_version: options.mc_loader_version.as_deref(), - setting_profile_name: Some(&profile.name), - stage: ClusterStage::NotReady as i64, - }, - ) - .await?; + crate::identity::write( + cluster_path, + &crate::identity::InstanceIdentity { + name: name.to_string(), + mc_version: options.mc_version.clone(), + mc_loader: options.mc_loader, + mc_loader_version: options.mc_loader_version.clone(), + kind: options.kind, + user_created: options.user_created, + description: options.description.clone(), + tags: options.tags.clone(), + cover_path: None, + }, + ) + .await; + } - Cluster::try_from_row(row) + let profile = + create_profile_from_global(db, global, folder_name, options.mem_max, None).await?; + + let tags = encode_tags(&options.tags); + + let row = cluster_dao::insert( + db, + &NewCluster { + name, + folder_name, + mc_version: &options.mc_version, + mc_loader: options.mc_loader as i64, + mc_loader_version: options.mc_loader_version.as_deref(), + setting_profile_name: Some(&profile.name), + stage: ClusterStage::NotReady as i64, + kind: options.kind.as_i64(), + user_created: i64::from(options.user_created), + description: options.description.as_deref(), + tags: &tags, + cover_path: None, + }, + ) + .await?; + + let cluster = Cluster::try_from_row(row)?; + crate::identity::write(cluster_path, &identity_of(&cluster)).await; + Ok(cluster) +} + +fn is_reserved_device_name(stem: &str) -> bool { + let upper = stem.to_ascii_uppercase(); + match upper.as_str() { + "CON" | "PRN" | "AUX" | "NUL" => true, + _ => { + upper.len() == 4 + && (upper.starts_with("COM") || upper.starts_with("LPT")) + && matches!(upper.as_bytes()[3], b'1'..=b'9') + } + } +} + +async fn move_to_trash(cluster: &Cluster) -> Option { + let home = cluster.dir().ok()?; + if polyio::symlink_metadata(&home).await.is_err() { + return None; + } + + let trash = oneclient_common::paths::cluster_trash_dir().ok()?; + polyio::create_dir_all(&trash).await.ok()?; + + let dest = trash.join(format!("{}-{}", cluster.id, cluster.folder_name)); + if polyio::symlink_metadata(&dest).await.is_ok() { + polyio::remove_dir_all(&dest).await.ok(); + } + + match polyio::rename(&home, &dest).await { + Ok(()) => Some(dest), + Err(err) => { + tracing::warn!( + cluster_id = cluster.id, + error = %err, + "could not move the cluster folder aside; deleting it in place" + ); + None + } + } +} + +fn is_cover_file(name: &str) -> bool { + name.rsplit_once('.').is_some_and(|(stem, _)| { + stem == COVER_STEM + || stem + .strip_prefix(COVER_STEM) + .is_some_and(|rest| rest.starts_with('-')) + }) +} + +fn identity_of(cluster: &Cluster) -> crate::identity::InstanceIdentity { + crate::identity::InstanceIdentity { + name: cluster.name.clone(), + mc_version: cluster.mc_version.clone(), + mc_loader: cluster.mc_loader, + mc_loader_version: cluster.mc_loader_version.clone(), + kind: cluster.kind, + user_created: cluster.user_created, + description: cluster.description.clone(), + tags: cluster.tags.clone(), + cover_path: cluster.cover_path.clone(), + } +} + +pub fn unlinked_mods_repair_id(cluster_id: ClusterId) -> String { + format!("clear-unlinked-mods:{cluster_id}") } #[tracing::instrument(level = "debug", skip(pool))] async fn ensure_profile_exists(pool: &oneclient_db::DbPool, name: &str) -> ClusterResult<()> { - if profile_dao::get_by_name(pool, name).await?.is_none() { - return Err(ClusterError::ProfileNotFound(name.to_string())); - } - Ok(()) + if profile_dao::get_by_name(pool, name).await?.is_none() { + return Err(ClusterError::ProfileNotFound(name.to_string())); + } + Ok(()) } #[tracing::instrument(level = "debug")] +fn cap_folder_stem(name: &str) -> String { + name.trim() + .chars() + .take(crate::naming::MAX_FOLDER_CHARS) + .collect() +} + async fn resolve_unique_folder_name(name: &str) -> ClusterResult { - let cluster_dir = oneclient_common::paths::clusters_dir()?; - let mut folder_name = name.to_string(); - let mut path = cluster_dir.join(&folder_name); + let cluster_dir = oneclient_common::paths::clusters_dir()?; + let mut folder_name = name.to_string(); + let mut path = cluster_dir.join(&folder_name); - if path.exists() { - let mut which = 1; - loop { - let candidate = format!("{folder_name} ({which})"); - path = cluster_dir.join(&candidate); - if !path.exists() { - folder_name = candidate; - break; - } - which += 1; - } - } + if path.exists() { + let mut which = 1; + loop { + let candidate = format!("{folder_name} ({which})"); + path = cluster_dir.join(&candidate); + if !path.exists() { + folder_name = candidate; + break; + } + which += 1; + } + } - Ok(folder_name) + Ok(folder_name) } #[tracing::instrument(level = "debug")] async fn ensure_content_dirs(cluster_path: &std::path::Path) -> ClusterResult<()> { - for content_type in [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, - ContentType::DataPack, - ContentType::World, - ] { - polyio::create_dir_all(cluster_path.join(content_type.folder_name())).await?; - } - Ok(()) + for content_type in [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, + ContentType::World, + ] { + polyio::create_dir_all(cluster_path.join(content_type.folder_name())).await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{COVER_MAX_EDGE, ClusterManager, cap_folder_stem, is_cover_file, shrink_cover}; + + #[test] + fn a_long_modpack_name_gets_a_short_folder() { + let name = "All the Mods 10 - To the Sky Community Edition Extended"; + let folder = ClusterManager::sanitize_name(&cap_folder_stem(name)); + + assert_eq!(folder, "All the Mods 10 - To the Sky Community E"); + assert_eq!( + ClusterManager::sanitize_name(&cap_folder_stem("Short Pack")), + "Short Pack" + ); + } + + fn png(width: u32, height: u32) -> Vec { + let mut out = Vec::new(); + image::DynamicImage::ImageRgb8(image::RgbImage::new(width, height)) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .expect("encode fixture"); + out + } + + #[test] + fn an_oversized_cover_is_shrunk_once_on_assignment() { + let raw = png(COVER_MAX_EDGE * 2, COVER_MAX_EDGE); + let original_len = raw.len(); + + let (bytes, shrunk) = shrink_cover(raw); + + assert!(shrunk); + assert!(bytes.len() < original_len); + let decoded = image::load_from_memory(&bytes).expect("decode result"); + assert_eq!(decoded.width(), COVER_MAX_EDGE); + assert_eq!(decoded.height(), COVER_MAX_EDGE / 2); + } + + #[test] + fn a_cover_that_already_fits_is_stored_untouched() { + let raw = png(64, 64); + let original = raw.clone(); + + let (bytes, shrunk) = shrink_cover(raw); + + assert!(!shrunk); + assert_eq!(bytes, original); + } + + #[test] + fn an_undecodable_cover_falls_back_to_the_original_bytes() { + let (bytes, shrunk) = shrink_cover(b"not an image".to_vec()); + + assert!(!shrunk); + assert_eq!(bytes, b"not an image"); + } + + #[test] + fn legacy_and_unique_cover_names_are_both_cleared() { + assert!(is_cover_file("cover.png")); + assert!(is_cover_file("cover-1759140000000.webp")); + assert!(!is_cover_file("covers.png")); + assert!(!is_cover_file("cover")); + assert!(!is_cover_file("instance.json")); + } } diff --git a/packages/oneclient_cluster/src/naming.rs b/packages/oneclient_cluster/src/naming.rs new file mode 100644 index 00000000..017ef277 --- /dev/null +++ b/packages/oneclient_cluster/src/naming.rs @@ -0,0 +1,127 @@ +use crate::manager::ClusterManager; + +pub const MAX_NAME_CHARS: usize = 20; +pub const MAX_TAG_CHARS: usize = 20; +pub const MAX_FOLDER_CHARS: usize = 40; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NameProblem { + Empty, + TooLong, + ForbiddenCharacters, + NoUsableCharacters, +} + +impl NameProblem { + #[must_use] + pub fn message(self) -> &'static str { + match self { + Self::Empty => "Enter a name.", + Self::TooLong => "Use 20 characters or fewer.", + Self::ForbiddenCharacters => "Use only letters A-Z, digits, spaces and _ - . ( )", + Self::NoUsableCharacters => "Include at least one letter (A-Z) or digit.", + } + } +} + +impl std::fmt::Display for NameProblem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.message()) + } +} + +#[must_use] +pub fn is_allowed_name_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ' ' | '.' | '(' | ')') +} + +pub fn validate_instance_name(name: &str) -> Result<(), NameProblem> { + validate_name(name, Some(MAX_NAME_CHARS)) +} + +pub fn validate_modpack_instance_name(name: &str) -> Result<(), NameProblem> { + validate_name(name, None) +} + +pub fn validate_name(name: &str, max_chars: Option) -> Result<(), NameProblem> { + let name = name.trim(); + if name.is_empty() { + return Err(NameProblem::Empty); + } + if max_chars.is_some_and(|max| name.chars().count() > max) { + return Err(NameProblem::TooLong); + } + if !name.chars().all(is_allowed_name_char) { + return Err(NameProblem::ForbiddenCharacters); + } + if ClusterManager::sanitize_name(name).is_empty() { + return Err(NameProblem::NoUsableCharacters); + } + Ok(()) +} + +pub fn validate_tag(tag: &str) -> Result<(), NameProblem> { + let tag = tag.trim(); + if tag.is_empty() { + return Err(NameProblem::Empty); + } + if tag.chars().count() > MAX_TAG_CHARS { + return Err(NameProblem::TooLong); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn instance_names() { + assert_eq!(validate_instance_name("My Survival"), Ok(())); + assert_eq!(validate_instance_name("Modpack (v2.1)_x-y"), Ok(())); + assert_eq!(validate_instance_name(" "), Err(NameProblem::Empty)); + assert_eq!( + validate_instance_name("Åšwiat"), + Err(NameProblem::ForbiddenCharacters) + ); + assert_eq!( + validate_instance_name("世界"), + Err(NameProblem::ForbiddenCharacters) + ); + assert_eq!( + validate_instance_name("a/b"), + Err(NameProblem::ForbiddenCharacters) + ); + assert_eq!( + validate_instance_name("my:world"), + Err(NameProblem::ForbiddenCharacters) + ); + assert_eq!( + validate_instance_name("..."), + Err(NameProblem::NoUsableCharacters) + ); + assert_eq!( + validate_instance_name("abcdefghijklmnopqrstu"), + Err(NameProblem::TooLong) + ); + assert_eq!(validate_instance_name("abcdefghijklmnopqrst"), Ok(())); + assert_eq!( + validate_modpack_instance_name("All the Mods 10 - To the Sky"), + Ok(()) + ); + assert_eq!( + validate_modpack_instance_name("my:world"), + Err(NameProblem::ForbiddenCharacters) + ); + } + + #[test] + fn tags() { + assert_eq!(validate_tag("pvp"), Ok(())); + assert_eq!(validate_tag(""), Err(NameProblem::Empty)); + assert_eq!( + validate_tag("abcdefghijklmnopqrstu"), + Err(NameProblem::TooLong) + ); + } +} diff --git a/packages/oneclient_cluster/src/options.rs b/packages/oneclient_cluster/src/options.rs index f7bfd697..2aacd763 100644 --- a/packages/oneclient_cluster/src/options.rs +++ b/packages/oneclient_cluster/src/options.rs @@ -2,54 +2,97 @@ use serde::{Deserialize, Serialize}; use oneclient_common::domain::GameLoader; use oneclient_common::patch::Patch; +use oneclient_db::models::ClusterKind; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateClusterOptions { - pub name: String, - pub mc_version: String, - pub mc_loader: GameLoader, - pub mc_loader_version: Option, - pub mem_max: Option, + pub name: String, + pub mc_version: String, + pub mc_loader: GameLoader, + pub mc_loader_version: Option, + pub mem_max: Option, + pub kind: ClusterKind, + pub user_created: bool, + pub modpack: bool, + pub description: Option, + pub tags: Vec, } impl CreateClusterOptions { - pub fn new(name: impl Into, mc_version: impl Into, mc_loader: GameLoader) -> Self { - Self { - name: name.into(), - mc_version: mc_version.into(), - mc_loader, - mc_loader_version: None, - mem_max: None, - } - } + pub fn new( + name: impl Into, + mc_version: impl Into, + mc_loader: GameLoader, + ) -> Self { + Self { + name: name.into(), + mc_version: mc_version.into(), + mc_loader, + mc_loader_version: None, + mem_max: None, + kind: ClusterKind::OneClient, + user_created: false, + modpack: false, + description: None, + tags: Vec::new(), + } + } - pub fn mem_max(mut self, megabytes: u32) -> Self { - self.mem_max = Some(megabytes); - self - } + pub fn mem_max(mut self, megabytes: u32) -> Self { + self.mem_max = Some(megabytes); + self + } - pub fn loader_version(mut self, version: impl Into) -> Self { - self.mc_loader_version = Some(version.into()); - self - } + pub fn loader_version(mut self, version: impl Into) -> Self { + self.mc_loader_version = Some(version.into()); + self + } + + pub fn kind(mut self, kind: ClusterKind) -> Self { + self.kind = kind; + self + } + + pub fn user_created(mut self, user_created: bool) -> Self { + self.user_created = user_created; + self + } + + pub fn modpack(mut self, modpack: bool) -> Self { + self.modpack = modpack; + self + } + + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn tags(mut self, tags: Vec) -> Self { + self.tags = tags; + self + } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ClusterUpdate { - pub name: Option, - pub setting_profile_name: Patch, - pub mc_loader_version: Patch, - pub linked_modpack_hash: Patch, + pub name: Option, + pub setting_profile_name: Patch, + pub mc_loader_version: Patch, + pub linked_modpack_hash: Patch, + pub description: Patch, + pub tags: Option>, + pub cover_path: Patch, } impl ClusterUpdate { - pub fn setting_profile(mut self, name: impl Into) -> Self { - self.setting_profile_name = Patch::Set(name.into()); - self - } + pub fn setting_profile(mut self, name: impl Into) -> Self { + self.setting_profile_name = Patch::Set(name.into()); + self + } - pub fn loader_version(mut self, version: impl Into) -> Self { - self.mc_loader_version = Patch::Set(version.into()); - self - } + pub fn loader_version(mut self, version: impl Into) -> Self { + self.mc_loader_version = Patch::Set(version.into()); + self + } } diff --git a/packages/oneclient_cluster/src/profile.rs b/packages/oneclient_cluster/src/profile.rs index 14c3b8c6..a855bc05 100644 --- a/packages/oneclient_cluster/src/profile.rs +++ b/packages/oneclient_cluster/src/profile.rs @@ -6,19 +6,20 @@ pub const GLOBAL_PROFILE_NAME: &str = "Global"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct GameSettingsProfile { - pub name: String, - pub java_path: Option, - pub resolution: Option, - pub force_fullscreen: Option, - pub mem_max: Option, - pub launch_args: Option, - pub launch_env: Option, - pub hook_pre: Option, - pub hook_wrapper: Option, - pub hook_post: Option, - pub os_extra: Option, - /// Never applies to bundle content - pub browser_update_mode: Option, + pub name: String, + pub java_path: Option, + pub resolution: Option, + pub force_fullscreen: Option, + pub mem_max: Option, + pub launch_args: Option, + pub game_args: Option, + pub launch_env: Option, + pub hook_pre: Option, + pub hook_wrapper: Option, + pub hook_post: Option, + pub os_extra: Option, + /// Never applies to bundle content + pub browser_update_mode: Option, } // `Resolution` lives in oneclient_common the launch-argument builder needs it @@ -29,162 +30,160 @@ pub use oneclient_common::domain::PackageUpdateMode; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct SettingsOsExtra { - #[cfg(target_os = "linux")] - pub enable_gamemode: Option, - #[cfg(any(target_os = "linux", windows))] - pub use_discrete_gpu: Option, - #[serde(flatten)] - pub unknown: serde_json::Map, + #[cfg(target_os = "linux")] + pub enable_gamemode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gpu: Option, + #[serde(flatten)] + pub unknown: serde_json::Map, } impl Default for SettingsOsExtra { - fn default() -> Self { - Self { - #[cfg(target_os = "linux")] - enable_gamemode: Some(true), - #[cfg(any(target_os = "linux", windows))] - use_discrete_gpu: Some(true), - unknown: serde_json::Map::new(), - } - } + fn default() -> Self { + Self { + #[cfg(target_os = "linux")] + enable_gamemode: Some(true), + gpu: None, + unknown: serde_json::Map::new(), + } + } } impl GameSettingsProfile { - pub fn default_global_profile() -> Self { - Self { - name: GLOBAL_PROFILE_NAME.into(), - java_path: None, - resolution: None, - force_fullscreen: Some(false), - mem_max: Some(oneclient_common::default_mem_max()), - launch_args: None, - launch_env: None, - hook_pre: None, - hook_wrapper: None, - hook_post: None, - os_extra: Some(SettingsOsExtra::default()), - browser_update_mode: Some(PackageUpdateMode::default()), - } - } + pub fn default_global_profile() -> Self { + Self { + name: GLOBAL_PROFILE_NAME.into(), + java_path: None, + resolution: None, + force_fullscreen: Some(false), + mem_max: Some(oneclient_common::default_mem_max()), + launch_args: None, + game_args: None, + launch_env: None, + hook_pre: None, + hook_wrapper: None, + hook_post: None, + os_extra: Some(SettingsOsExtra::default()), + browser_update_mode: Some(PackageUpdateMode::default()), + } + } - pub fn use_discrete_gpu(&self) -> bool { - cfg_select! { - any(target_os = "linux", target_os = "windows") => self - .os_extra - .as_ref() - .and_then(|extra| extra.use_discrete_gpu) - .unwrap_or(false), - _ => false - } - } + pub fn gpu(&self) -> Option<&str> { + self.os_extra.as_ref()?.gpu.as_deref() + } - pub fn is_global(&self) -> bool { - self.name == GLOBAL_PROFILE_NAME - } + pub fn is_global(&self) -> bool { + self.name == GLOBAL_PROFILE_NAME + } - pub fn merge_global(&mut self, global: &Self) { - if self.java_path.is_none() { - self.java_path = global.java_path.clone(); - } - if self.resolution.is_none() { - self.resolution = global.resolution; - } - if self.force_fullscreen.is_none() { - self.force_fullscreen = global.force_fullscreen; - } - if self.mem_max.is_none() { - self.mem_max = global.mem_max; - } - if self.launch_args.is_none() { - self.launch_args = global.launch_args.clone(); - } - if self.launch_env.is_none() { - self.launch_env = global.launch_env.clone(); - } - if self.hook_pre.is_none() { - self.hook_pre = global.hook_pre.clone(); - } - if self.hook_wrapper.is_none() { - self.hook_wrapper = global.hook_wrapper.clone(); - } - if self.hook_post.is_none() { - self.hook_post = global.hook_post.clone(); - } - if self.os_extra.is_none() { - self.os_extra = global.os_extra.clone(); - } - if self.browser_update_mode.is_none() { - self.browser_update_mode = global.browser_update_mode; - } - } + pub fn merge_global(&mut self, global: &Self) { + if self.java_path.is_none() { + self.java_path = global.java_path.clone(); + } + if self.resolution.is_none() { + self.resolution = global.resolution; + } + if self.force_fullscreen.is_none() { + self.force_fullscreen = global.force_fullscreen; + } + if self.mem_max.is_none() { + self.mem_max = global.mem_max; + } + if self.launch_args.is_none() { + self.launch_args = global.launch_args.clone(); + } + if self.game_args.is_none() { + self.game_args = global.game_args.clone(); + } + if self.launch_env.is_none() { + self.launch_env = global.launch_env.clone(); + } + if self.hook_pre.is_none() { + self.hook_pre = global.hook_pre.clone(); + } + if self.hook_wrapper.is_none() { + self.hook_wrapper = global.hook_wrapper.clone(); + } + if self.hook_post.is_none() { + self.hook_post = global.hook_post.clone(); + } + if self.os_extra.is_none() { + self.os_extra = global.os_extra.clone(); + } + if self.browser_update_mode.is_none() { + self.browser_update_mode = global.browser_update_mode; + } + } - pub fn from_row(row: SettingProfileRow) -> crate::ClusterResult { - Ok(Self { - name: row.name, - java_path: row.java_path, - resolution: row - .resolution - .map(|json| serde_json::from_str(&json)) - .transpose()?, - force_fullscreen: row.force_fullscreen.map(|v| v != 0), - mem_max: row.mem_max.map(|v| v as u32), - launch_args: row.launch_args, - launch_env: row.launch_env, - hook_pre: row.hook_pre, - hook_wrapper: row.hook_wrapper, - hook_post: row.hook_post, - os_extra: row - .os_extra - .map(|json| serde_json::from_str(&json)) - .transpose()?, - // Unrecognised values inherit rather than error so a profile from a - // newer build cannot make the cluster unloadable - browser_update_mode: row - .browser_update_mode - .as_deref() - .and_then(PackageUpdateMode::parse), - }) - } + pub fn from_row(row: SettingProfileRow) -> crate::ClusterResult { + Ok(Self { + name: row.name, + java_path: row.java_path, + resolution: row + .resolution + .map(|json| serde_json::from_str(&json)) + .transpose()?, + force_fullscreen: row.force_fullscreen.map(|v| v != 0), + mem_max: row.mem_max.map(|v| v as u32), + launch_args: row.launch_args, + game_args: row.game_args, + launch_env: row.launch_env, + hook_pre: row.hook_pre, + hook_wrapper: row.hook_wrapper, + hook_post: row.hook_post, + os_extra: row + .os_extra + .map(|json| serde_json::from_str(&json)) + .transpose()?, + // Unrecognised values inherit rather than error so a profile from a + // newer build cannot make the cluster unloadable + browser_update_mode: row + .browser_update_mode + .as_deref() + .and_then(PackageUpdateMode::parse), + }) + } - pub fn into_row(&self) -> crate::ClusterResult { - Ok(SettingProfileRow { - name: self.name.clone(), - java_path: self.java_path.clone(), - resolution: self - .resolution - .map(|res| serde_json::to_string(&res)) - .transpose()?, - force_fullscreen: self.force_fullscreen.map(i64::from), - mem_max: self.mem_max.map(i64::from), - launch_args: self.launch_args.clone(), - launch_env: self.launch_env.clone(), - hook_pre: self.hook_pre.clone(), - hook_wrapper: self.hook_wrapper.clone(), - hook_post: self.hook_post.clone(), - os_extra: self - .os_extra - .as_ref() - .map(serde_json::to_string) - .transpose()?, - browser_update_mode: self - .browser_update_mode - .map(|mode| mode.as_str().to_string()), - }) - } + pub fn into_row(&self) -> crate::ClusterResult { + Ok(SettingProfileRow { + name: self.name.clone(), + java_path: self.java_path.clone(), + resolution: self + .resolution + .map(|res| serde_json::to_string(&res)) + .transpose()?, + force_fullscreen: self.force_fullscreen.map(i64::from), + mem_max: self.mem_max.map(i64::from), + launch_args: self.launch_args.clone(), + game_args: self.game_args.clone(), + launch_env: self.launch_env.clone(), + hook_pre: self.hook_pre.clone(), + hook_wrapper: self.hook_wrapper.clone(), + hook_post: self.hook_post.clone(), + os_extra: self + .os_extra + .as_ref() + .map(serde_json::to_string) + .transpose()?, + browser_update_mode: self + .browser_update_mode + .map(|mode| mode.as_str().to_string()), + }) + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn os_extra_keeps_the_keys_this_build_has_no_field_for() { - let stored = r#"{"enable_gamemode":true,"use_discrete_gpu":true,"some_other_os_flag":7}"#; + #[test] + fn os_extra_keeps_the_keys_this_build_has_no_field_for() { + let stored = r#"{"enable_gamemode":true,"use_discrete_gpu":true,"some_other_os_flag":7}"#; - let extra: SettingsOsExtra = serde_json::from_str(stored).unwrap(); - let written = serde_json::to_value(&extra).unwrap(); - let original: serde_json::Value = serde_json::from_str(stored).unwrap(); + let extra: SettingsOsExtra = serde_json::from_str(stored).unwrap(); + let written = serde_json::to_value(&extra).unwrap(); + let original: serde_json::Value = serde_json::from_str(stored).unwrap(); - assert_eq!(written, original); - } + assert_eq!(written, original); + } } diff --git a/packages/oneclient_cluster/src/profiles.rs b/packages/oneclient_cluster/src/profiles.rs index ce442b66..6bbff31f 100644 --- a/packages/oneclient_cluster/src/profiles.rs +++ b/packages/oneclient_cluster/src/profiles.rs @@ -115,6 +115,7 @@ pub struct ProfileUpdate { pub force_fullscreen: Patch, pub mem_max: Patch, pub launch_args: Patch, + pub game_args: Patch, pub launch_env: Patch, pub hook_pre: Patch, pub hook_wrapper: Patch, @@ -132,6 +133,8 @@ impl ProfileUpdate { self.mem_max.apply_to_option(&mut profile.mem_max); self.launch_args .apply_to_command_option(&mut profile.launch_args); + self.game_args + .apply_to_command_option(&mut profile.game_args); self.launch_env .apply_to_command_option(&mut profile.launch_env); self.hook_pre.apply_to_command_option(&mut profile.hook_pre); @@ -162,4 +165,3 @@ pub async fn update_named_profile( upsert_named_profile(pool, &profile).await } - diff --git a/packages/oneclient_cluster/src/screenshots/mod.rs b/packages/oneclient_cluster/src/screenshots/mod.rs index 027184ae..c3f3f19e 100644 --- a/packages/oneclient_cluster/src/screenshots/mod.rs +++ b/packages/oneclient_cluster/src/screenshots/mod.rs @@ -5,8 +5,8 @@ use chrono::{DateTime, Utc}; use thiserror::Error; use crate::cluster::Cluster; -use oneclient_common::paths; use crate::error::ClusterResult; +use oneclient_common::paths; #[derive(Debug, Error)] pub enum ScreenshotsError { @@ -33,8 +33,11 @@ fn is_image(name: &str) -> bool { } fn ensure_in_clusters(path: &Path) -> ClusterResult { - polyio::ensure_under(path, [paths::clusters_dir()?, paths::shared_minecraft_dir()?])? - .ok_or_else(|| ScreenshotsError::InvalidPath(path.display().to_string()).into()) + polyio::ensure_under( + path, + [paths::clusters_dir()?, paths::shared_minecraft_dir()?], + )? + .ok_or_else(|| ScreenshotsError::InvalidPath(path.display().to_string()).into()) } #[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] @@ -87,16 +90,18 @@ pub fn list_cluster_screenshots(cluster: &Cluster) -> ClusterResult) -> ClusterResult { - let path = ensure_in_clusters(path)?; - let raw = std::fs::read(&path).map_err(ScreenshotsError::Io)?; - let bytes = match max_edge { - Some(edge) => thumbnail(&raw, edge).unwrap_or_else(|| Bytes::from(raw)), - None => Bytes::from(raw), - }; - Ok(bytes) + load_picked_image(&ensure_in_clusters(path)?, max_edge) } -fn thumbnail(raw: &[u8], max_edge: u32) -> Option { +pub fn load_picked_image(path: &Path, max_edge: Option) -> ClusterResult { + let raw = std::fs::read(path).map_err(ScreenshotsError::Io)?; + Ok(match max_edge { + Some(edge) => thumbnail(&raw, edge).unwrap_or_else(|| Bytes::from(raw)), + None => Bytes::from(raw), + }) +} + +pub(crate) fn thumbnail(raw: &[u8], max_edge: u32) -> Option { let img = image::load_from_memory(raw).ok()?; if img.width().max(img.height()) <= max_edge { return None; @@ -105,7 +110,9 @@ fn thumbnail(raw: &[u8], max_edge: u32) -> Option { let mut out = Vec::new(); let mut cursor = std::io::Cursor::new(&mut out); - resized.write_to(&mut cursor, image::ImageFormat::Png).ok()?; + resized + .write_to(&mut cursor, image::ImageFormat::Png) + .ok()?; Some(Bytes::from(out)) } @@ -119,3 +126,46 @@ pub fn delete_screenshot(path: &Path) -> ClusterResult<()> { Err(err) => Err(ScreenshotsError::Trash(err.to_string()).into()), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch_png(tag: &str) -> PathBuf { + use std::sync::atomic::{AtomicU32, Ordering}; + static NEXT: AtomicU32 = AtomicU32::new(0); + + let unique = NEXT.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "oneclient-picked-{tag}-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cover.png"); + + let image = image::RgbImage::new(4, 3); + image.save(&path).unwrap(); + + path + } + + #[test] + fn a_file_the_user_picked_outside_the_launcher_folders_still_loads() { + let path = scratch_png("outside"); + + let bytes = load_picked_image(&path, None).expect("a picked file reads"); + assert!(!bytes.is_empty()); + + assert!( + load_screenshot(&path, None).is_err(), + "the screenshot loader must keep refusing paths outside the launcher folders" + ); + } + + #[test] + fn a_missing_picked_file_is_an_error_not_an_empty_image() { + let missing = std::env::temp_dir().join("oneclient-picked-missing-does-not-exist.png"); + + assert!(load_picked_image(&missing, None).is_err()); + } +} diff --git a/packages/oneclient_cluster/src/worlds/mod.rs b/packages/oneclient_cluster/src/worlds/mod.rs new file mode 100644 index 00000000..b0bcbd80 --- /dev/null +++ b/packages/oneclient_cluster/src/worlds/mod.rs @@ -0,0 +1,326 @@ +use std::path::{Component, Path, PathBuf}; + +use chrono::{DateTime, Utc}; +use serde_json::Value; +use thiserror::Error; + +use crate::cluster::Cluster; +use crate::error::ClusterResult; + +const SAVES_DIR: &str = "saves"; +const DATAPACKS_DIR: &str = "datapacks"; +pub const LEVEL_DAT: &str = "level.dat"; +pub const WORLD_ICON: &str = "icon.png"; +const PACK_META: &str = "pack.mcmeta"; +const PACK_ICON: &str = "pack.png"; + +#[derive(Debug, Error)] +pub enum WorldsError { + #[error("not a plain file or folder name: {0}")] + InvalidName(String), + #[error("world '{0}' not found")] + NotFound(String), + #[error("failed to move to trash: {0}")] + Trash(String), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct WorldInfo { + pub folder_name: String, + pub path: PathBuf, + pub icon: Option, + pub last_played: DateTime, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PackIcon { + File(PathBuf), + Cached(String), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DataPackInfo { + pub file_name: String, + pub is_dir: bool, + pub size_bytes: u64, + pub modified: DateTime, + pub description: Option, + pub icon: Option, +} + +fn plain_name(name: &str) -> ClusterResult<&str> { + let mut components = Path::new(name).components(); + match (components.next(), components.next()) { + (Some(Component::Normal(_)), None) => Ok(name), + _ => Err(WorldsError::InvalidName(name.to_string()).into()), + } +} + +fn world_dir(cluster: &Cluster, world: &str) -> ClusterResult { + let dir = cluster.game_dir()?.join(SAVES_DIR).join(plain_name(world)?); + if dir.is_dir() { + Ok(dir) + } else { + Err(WorldsError::NotFound(world.to_string()).into()) + } +} + +fn modified_or_now(meta: &std::fs::Metadata) -> DateTime { + meta.modified() + .map(DateTime::::from) + .unwrap_or_else(|_| Utc::now()) +} + +fn dir_size(dir: &Path) -> u64 { + let Ok(entries) = std::fs::read_dir(dir) else { + return 0; + }; + + entries + .flatten() + .map(|entry| match entry.file_type() { + Ok(kind) if kind.is_dir() => dir_size(&entry.path()), + Ok(kind) if kind.is_file() => entry.metadata().map(|m| m.len()).unwrap_or(0), + _ => 0, + }) + .sum() +} + +fn move_to_trash(path: &Path) -> ClusterResult<()> { + trash::delete(path).map_err(|err| WorldsError::Trash(err.to_string()).into()) +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub fn list_cluster_worlds(cluster: &Cluster) -> ClusterResult> { + let dir = cluster.game_dir()?.join(SAVES_DIR); + let mut out = Vec::new(); + + let Ok(entries) = std::fs::read_dir(&dir) else { + return Ok(out); + }; + + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + + if !path.is_dir() { + continue; + } + + let Ok(level_meta) = std::fs::metadata(path.join(LEVEL_DAT)) else { + continue; + }; + + let icon = path.join(WORLD_ICON); + + out.push(WorldInfo { + folder_name: name.to_string(), + icon: icon.is_file().then_some(icon), + last_played: modified_or_now(&level_meta), + path, + }); + } + + out.sort_by_key(|w| std::cmp::Reverse(w.last_played)); + Ok(out) +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub fn world_size(cluster: &Cluster, world: &str) -> ClusterResult { + Ok(dir_size(&world_dir(cluster, world)?)) +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub fn delete_world(cluster: &Cluster, world: &str) -> ClusterResult<()> { + move_to_trash(&world_dir(cluster, world)?) +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub async fn list_world_datapacks( + cluster: &Cluster, + world: &str, +) -> ClusterResult> { + let dir = world_dir(cluster, world)?.join(DATAPACKS_DIR); + let mut found = tokio::task::spawn_blocking(move || scan_datapacks(&dir)) + .await + .map_err(std::io::Error::other)?; + + for (path, info) in &mut found { + if !info.is_dir { + (info.description, info.icon) = zip_meta(path).await; + } + } + + let mut out: Vec = found.into_iter().map(|(_, info)| info).collect(); + out.sort_by_key(|p| p.file_name.to_lowercase()); + Ok(out) +} + +fn scan_datapacks(dir: &Path) -> Vec<(PathBuf, DataPackInfo)> { + let mut out = Vec::new(); + + let Ok(entries) = std::fs::read_dir(dir) else { + return out; + }; + + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + let Ok(meta) = std::fs::metadata(&path) else { + continue; + }; + + let is_dir = meta.is_dir(); + let is_pack = if is_dir { + path.join(PACK_META).is_file() + } else { + name.to_ascii_lowercase().ends_with(".zip") + }; + if !is_pack { + continue; + } + + let info = DataPackInfo { + file_name: name.to_string(), + is_dir, + size_bytes: if is_dir { dir_size(&path) } else { meta.len() }, + modified: modified_or_now(&meta), + description: if is_dir { + std::fs::read(path.join(PACK_META)) + .ok() + .and_then(|raw| parse_description(&raw)) + } else { + None + }, + icon: is_dir + .then(|| path.join(PACK_ICON)) + .filter(|icon| icon.is_file()) + .map(PackIcon::File), + }; + out.push((path, info)); + } + + out +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub async fn add_world_datapacks( + cluster: &Cluster, + world: &str, + files: &[PathBuf], +) -> ClusterResult<()> { + let dir = world_dir(cluster, world)?.join(DATAPACKS_DIR); + polyio::create_dir_all(&dir).await?; + + for file in files { + let Some(name) = file.file_name() else { + continue; + }; + let target = dir.join(name); + if is_same_file(file, &target) { + continue; + } + if file.is_dir() { + polyio::copy_dir(file, &target, &[]).await?; + } else { + polyio::copy(file, &target).await?; + } + } + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub fn delete_world_datapack(cluster: &Cluster, world: &str, file_name: &str) -> ClusterResult<()> { + let path = world_dir(cluster, world)? + .join(DATAPACKS_DIR) + .join(plain_name(file_name)?); + move_to_trash(&path) +} + +fn is_same_file(a: &Path, b: &Path) -> bool { + match (polyio::canonicalize(a), polyio::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +async fn zip_meta(path: &Path) -> (Option, Option) { + let Ok(entries) = + polyio::read_zip_file_entries(path, |name| name == PACK_META || name == PACK_ICON).await + else { + return (None, None); + }; + + let mut description = None; + let mut icon = None; + for (name, raw) in entries { + if name == PACK_META { + description = parse_description(&raw); + } else { + icon = store_icon(&raw).await; + } + } + (description, icon) +} + +async fn store_icon(bytes: &[u8]) -> Option { + if bytes.is_empty() { + return None; + } + + let mut hash = polyio::Sha1Stream::new(); + hash.update(bytes); + let dir = oneclient_common::paths::local_icons_dir().ok()?; + let name = format!("{}.png", hash.finish()); + let target = dir.join(&name); + if !target.is_file() { + polyio::create_dir_all(&dir).await.ok()?; + polyio::write(&target, bytes).await.ok()?; + } + Some(PackIcon::Cached(format!( + "{}{name}", + oneclient_common::paths::LOCAL_IMAGE_SCHEME + ))) +} + +fn parse_description(raw: &[u8]) -> Option { + let raw = raw.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(raw); + let meta: Value = serde_json::from_slice(raw).ok()?; + let text = strip_formatting(flatten_text(meta.get("pack")?.get("description")?).trim()); + (!text.is_empty()).then_some(text) +} + +fn flatten_text(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Array(items) => items.iter().map(flatten_text).collect(), + Value::Object(map) => { + let mut out = map.get("text").map(flatten_text).unwrap_or_default(); + if let Some(extra) = map.get("extra") { + out.push_str(&flatten_text(extra)); + } + out + } + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + } +} + +fn strip_formatting(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(c) = chars.next() { + if c == '§' { + chars.next(); + } else { + out.push(c); + } + } + out +} diff --git a/packages/oneclient_common/src/consent.rs b/packages/oneclient_common/src/consent.rs index 4e3ac14e..68699bb1 100644 --- a/packages/oneclient_common/src/consent.rs +++ b/packages/oneclient_common/src/consent.rs @@ -5,123 +5,123 @@ const GATED_DOMAINS: &[&str] = &["polyfrost.org", "sentry.io"]; /// Hosts that are allowed even if someone didn't accept TOS const ALLOWED_HOSTS: &[&str] = &[ - "data-v2.polyfrost.org", - "meta.polyfrost.org", - "status.polyfrost.org", + "data-v2.polyfrost.org", + "meta.polyfrost.org", + "status.polyfrost.org", ]; static DECLINED: AtomicBool = AtomicBool::new(false); pub fn init(declined: bool) { - DECLINED.store(declined, Ordering::SeqCst); + DECLINED.store(declined, Ordering::SeqCst); } pub fn decline() { - DECLINED.store(true, Ordering::SeqCst); + DECLINED.store(true, Ordering::SeqCst); } #[must_use] pub fn declined() -> bool { - DECLINED.load(Ordering::Relaxed) + DECLINED.load(Ordering::Relaxed) } #[must_use] pub fn blocks_url(url: &str) -> bool { - declined() && is_gated_url(url) + declined() && is_gated_url(url) } #[must_use] pub fn is_gated_url(url: &str) -> bool { - url::Url::parse(url) - .ok() - .and_then(|parsed| parsed.host_str().map(is_gated_host)) - .unwrap_or(true) + url::Url::parse(url) + .ok() + .and_then(|parsed| parsed.host_str().map(is_gated_host)) + .unwrap_or(true) } #[must_use] pub fn is_gated_host(host: &str) -> bool { - let host = host.trim_end_matches('.').to_ascii_lowercase(); + let host = host.trim_end_matches('.').to_ascii_lowercase(); - if ALLOWED_HOSTS.contains(&host.as_str()) { - return false; - } + if ALLOWED_HOSTS.contains(&host.as_str()) { + return false; + } - GATED_DOMAINS - .iter() - .any(|domain| host == *domain || host.ends_with(&format!(".{domain}"))) + GATED_DOMAINS + .iter() + .any(|domain| host == *domain || host.ends_with(&format!(".{domain}"))) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn gates_polyfrost_services_and_the_crash_reporter() { - for host in [ - "polyfrost.org", - "plus.polyfrost.org", - "PLUS.POLYFROST.ORG", - "plus.polyfrost.org.", - "o4511714343124992.ingest.us.sentry.io", - ] { - assert!(is_gated_host(host), "expected {host} to be gated"); - } - } + #[test] + fn gates_polyfrost_services_and_the_crash_reporter() { + for host in [ + "polyfrost.org", + "plus.polyfrost.org", + "PLUS.POLYFROST.ORG", + "plus.polyfrost.org.", + "o4511714343124992.ingest.us.sentry.io", + ] { + assert!(is_gated_host(host), "expected {host} to be gated"); + } + } - #[test] - fn leaves_everything_else_alone() { - for host in [ - "api.modrinth.com", - "api.curseforge.com", - "sessionserver.mojang.com", - "api.minecraftservices.com", - "www.gstatic.com", - "github.com", - "notpolyfrost.org", - "polyfrost.org.attacker.com", - "evil-sentry.io.attacker.com", - ] { - assert!(!is_gated_host(host), "unexpected gate on {host}"); - } - } + #[test] + fn leaves_everything_else_alone() { + for host in [ + "api.modrinth.com", + "api.curseforge.com", + "sessionserver.mojang.com", + "api.minecraftservices.com", + "www.gstatic.com", + "github.com", + "notpolyfrost.org", + "polyfrost.org.attacker.com", + "evil-sentry.io.attacker.com", + ] { + assert!(!is_gated_host(host), "unexpected gate on {host}"); + } + } - #[test] - fn lets_the_exempt_hosts_through() { - for url in [ - "https://data-v2.polyfrost.org/oneclient/bundles/metadata.json", - "https://data-v2.polyfrost.org/oneclient/versions/art/Tricky_Trials.jpg", - "https://data-v2.polyfrost.org/oneclient/tos.json", - "https://meta.polyfrost.org/fabric/v2/manifest.json", - "https://status.polyfrost.org/index.json", - "https://META.POLYFROST.ORG./minecraft/v1/manifest.json", - ] { - assert!(!is_gated_url(url), "unexpected gate on {url}"); - } - } + #[test] + fn lets_the_exempt_hosts_through() { + for url in [ + "https://data-v2.polyfrost.org/oneclient/bundles/metadata.json", + "https://data-v2.polyfrost.org/oneclient/versions/art/Tricky_Trials.jpg", + "https://data-v2.polyfrost.org/oneclient/tos.json", + "https://meta.polyfrost.org/fabric/v2/manifest.json", + "https://status.polyfrost.org/index.json", + "https://META.POLYFROST.ORG./minecraft/v1/manifest.json", + ] { + assert!(!is_gated_url(url), "unexpected gate on {url}"); + } + } - #[test] - fn the_exemption_is_the_exact_host_and_nothing_near_it() { - for host in [ - "plus.polyfrost.org", - "evil.data-v2.polyfrost.org", - "meta.polyfrost.org.evil.polyfrost.org", - "status.polyfrost.org.evil.polyfrost.org", - ] { - assert!(is_gated_host(host), "expected {host} to stay gated"); - } - } + #[test] + fn the_exemption_is_the_exact_host_and_nothing_near_it() { + for host in [ + "plus.polyfrost.org", + "evil.data-v2.polyfrost.org", + "meta.polyfrost.org.evil.polyfrost.org", + "status.polyfrost.org.evil.polyfrost.org", + ] { + assert!(is_gated_host(host), "expected {host} to stay gated"); + } + } - #[test] - fn gates_urls_by_their_host() { - assert!(is_gated_url("https://plus.polyfrost.org/account/login")); - assert!(is_gated_url("wss://plus.polyfrost.org/websocket")); - assert!(!is_gated_url("https://api.modrinth.com/v2/search")); - } + #[test] + fn gates_urls_by_their_host() { + assert!(is_gated_url("https://plus.polyfrost.org/account/login")); + assert!(is_gated_url("wss://plus.polyfrost.org/websocket")); + assert!(!is_gated_url("https://api.modrinth.com/v2/search")); + } - #[test] - fn unparseable_urls_fail_closed() { - assert!(is_gated_url("not a url")); - assert!(is_gated_url("")); - assert!(is_gated_url("file:///etc/hosts")); - } + #[test] + fn unparseable_urls_fail_closed() { + assert!(is_gated_url("not a url")); + assert!(is_gated_url("")); + assert!(is_gated_url("file:///etc/hosts")); + } } diff --git a/packages/oneclient_common/src/constants.rs b/packages/oneclient_common/src/constants.rs index ab28ed17..ed35f8c8 100644 --- a/packages/oneclient_common/src/constants.rs +++ b/packages/oneclient_common/src/constants.rs @@ -1,4 +1,3 @@ - pub const DUMMY_REPLACE_NEWLINE: &str = "\n"; pub const MICROSOFT_CLIENT_ID: &str = "9419b7ee-1448-4d1b-b52a-550d8f36ab56"; @@ -13,15 +12,17 @@ pub const CURSEFORGE_GAME_ID: u32 = 432; pub const METADATA_API_URL: &str = "https://meta.polyfrost.org"; pub const MCLOGS_API_URL: &str = "https://api.mclo.gs/1"; pub const SKYCLIENT_BASE_URL: &str = - "https://raw.githubusercontent.com/SkyblockClient/SkyblockClient-REPO/refs/heads/main/v1"; + "https://raw.githubusercontent.com/SkyblockClient/SkyblockClient-REPO/refs/heads/main/v1"; pub const META_URL_BASE: &str = "https://data-v2.polyfrost.org"; pub const TOS_URL: &str = "https://polyfrost.org/legal/terms"; pub const PRIVACY_URL: &str = "https://polyfrost.org/legal/privacy"; pub const PLUS_BACKEND_URL: &str = "https://plus.polyfrost.org"; pub const SENTRY_DSN: &str = match option_env!("ONECLIENT_SENTRY_DSN") { - Some(dsn) => dsn, - None => "https://e7dff7e07427e1a28b9212cfcc8ddc1e@o4511714343124992.ingest.us.sentry.io/4511714354135040", + Some(dsn) => dsn, + None => { + "https://e7dff7e07427e1a28b9212cfcc8ddc1e@o4511714343124992.ingest.us.sentry.io/4511714354135040" + } }; pub const TARGET_OS: &str = cfg_select! { @@ -39,9 +40,9 @@ pub const NATIVE_ARCH: &str = cfg_select! { pub const JAVA_BIN: &str = if cfg!(windows) { "javaw.exe" } else { "java" }; pub const ARCH_WIDTH: &str = if cfg!(target_pointer_width = "64") { - "64" + "64" } else { - "32" + "32" }; pub const LINE_ENDING: &str = if cfg!(windows) { "\r\n" } else { "\n" }; diff --git a/packages/oneclient_common/src/domain.rs b/packages/oneclient_common/src/domain.rs index 652e906f..7ab8c1bf 100644 --- a/packages/oneclient_common/src/domain.rs +++ b/packages/oneclient_common/src/domain.rs @@ -62,6 +62,11 @@ impl ContentType { matches!(self, Self::ResourcePack | Self::Shader) } + #[must_use] + pub const fn needs_mod_loader(self) -> bool { + matches!(self, Self::Mod | Self::Shader) + } + #[must_use] pub const fn reloads_in_game(self) -> bool { matches!(self, Self::ResourcePack | Self::Shader) @@ -139,17 +144,7 @@ pub enum HashAlgorithm { /// Applies only to browser-installed packages bundles have their own update flow /// The check always runs regardless of variant so "out of date" markers stay populated #[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - Hash, - Serialize, - Deserialize, - Default, - EnumIter, - StrumDisplay, + Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, EnumIter, StrumDisplay, )] #[serde(rename_all = "snake_case")] pub enum PackageUpdateMode { @@ -290,7 +285,7 @@ impl Display for GameLoader { Self::NeoForge => "NeoForge", Self::Quilt => "Quilt", Self::Fabric => "Fabric", - Self::Ornithe => "Fabric (Ornithe)", + Self::Ornithe => "Fabric", }) } } @@ -322,7 +317,7 @@ mod tests { #[test] fn a_display_name_is_not_a_metadata_path() { - assert_eq!(GameLoader::Ornithe.to_string(), "Fabric (Ornithe)"); + assert_eq!(GameLoader::Ornithe.to_string(), "Fabric"); assert_eq!(GameLoader::Ornithe.get_format_name(), "ornithe"); assert_eq!(GameLoader::Vanilla.get_format_name(), "minecraft"); assert_eq!(GameLoader::NeoForge.get_format_name(), "neo"); diff --git a/packages/oneclient_common/src/error.rs b/packages/oneclient_common/src/error.rs index 86b3b938..ef1fbcc8 100644 --- a/packages/oneclient_common/src/error.rs +++ b/packages/oneclient_common/src/error.rs @@ -3,6 +3,6 @@ pub type PathsResult = Result; /// Kept separate from the launcher-wide error so this crate stays a leaf #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] pub enum PathsError { - #[error("Could not resolve launcher data directory")] - DataDirUnavailable, + #[error("Could not resolve launcher data directory")] + DataDirUnavailable, } diff --git a/packages/oneclient_common/src/lib.rs b/packages/oneclient_common/src/lib.rs index 27376f67..3e251f95 100644 --- a/packages/oneclient_common/src/lib.rs +++ b/packages/oneclient_common/src/lib.rs @@ -6,8 +6,8 @@ pub mod constants; pub mod domain; pub mod memory; pub mod os_ext; -pub mod paths; pub mod patch; +pub mod paths; pub mod process; pub mod search; pub mod version; diff --git a/packages/oneclient_common/src/patch.rs b/packages/oneclient_common/src/patch.rs index 38058801..828b8a10 100644 --- a/packages/oneclient_common/src/patch.rs +++ b/packages/oneclient_common/src/patch.rs @@ -1,57 +1,55 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum Patch { - #[default] - Unchanged, - Clear, - Set(T), + #[default] + Unchanged, + Clear, + Set(T), } impl Patch { - pub fn is_unchanged(&self) -> bool { - matches!(self, Self::Unchanged) - } + pub fn is_unchanged(&self) -> bool { + matches!(self, Self::Unchanged) + } - pub fn apply_to_option(&self, slot: &mut Option) - where - T: Clone, - { - match self { - Self::Unchanged => {} - Self::Clear => *slot = None, - Self::Set(value) => *slot = Some(value.clone()), - } - } + pub fn apply_to_option(&self, slot: &mut Option) + where + T: Clone, + { + match self { + Self::Unchanged => {} + Self::Clear => *slot = None, + Self::Set(value) => *slot = Some(value.clone()), + } + } - pub fn into_db_patch(self) -> Option> { - match self { - Self::Unchanged => None, - Self::Clear => Some(None), - Self::Set(value) => Some(Some(value)), - } - } + pub fn into_db_patch(self) -> Option> { + match self { + Self::Unchanged => None, + Self::Clear => Some(None), + Self::Set(value) => Some(Some(value)), + } + } } impl Patch { - pub fn apply_to_command_option(&self, slot: &mut Option) - { - match self { - Self::Unchanged => {} - Self::Clear => *slot = None, - Self::Set(value) => *slot = Some(value.trim().to_owned()), - } - } + pub fn apply_to_command_option(&self, slot: &mut Option) { + match self { + Self::Unchanged => {} + Self::Clear => *slot = None, + Self::Set(value) => *slot = Some(value.trim().to_owned()), + } + } } impl Patch<&str> { - pub fn apply_to_command_option(&self, slot: &mut Option) - { - match self { - Self::Unchanged => {} - Self::Clear => *slot = None, - Self::Set(value) => *slot = Some(value.trim().to_owned()), - } - } + pub fn apply_to_command_option(&self, slot: &mut Option) { + match self { + Self::Unchanged => {} + Self::Clear => *slot = None, + Self::Set(value) => *slot = Some(value.trim().to_owned()), + } + } } diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs index 2da602e7..99331506 100644 --- a/packages/oneclient_common/src/paths.rs +++ b/packages/oneclient_common/src/paths.rs @@ -14,277 +14,298 @@ const APPLICATION: &str = "OneClient"; #[cfg(debug_assertions)] const APPLICATION: &str = "OneClient-dev"; -const SETTINGS_FILE: &str = "settings.json"; +pub const SETTINGS_FILE: &str = "settings.json"; +pub const DATABASE_FILE: &str = "user_data.db"; static DEFAULT_DIR: OnceLock = OnceLock::new(); static CONFIG_DIR_OVERRIDE: OnceLock = OnceLock::new(); static DATA_DIR_OVERRIDE: OnceLock = OnceLock::new(); pub fn set_launcher_dir(dir: PathBuf) { - let _ = CONFIG_DIR_OVERRIDE.set(dir.clone()); - let _ = DATA_DIR_OVERRIDE.set(dir); + let _ = CONFIG_DIR_OVERRIDE.set(dir.clone()); + let _ = DATA_DIR_OVERRIDE.set(dir); } pub fn set_data_dir(dir: PathBuf) { - let _ = DATA_DIR_OVERRIDE.set(dir); + let _ = DATA_DIR_OVERRIDE.set(dir); } fn organization_dir() -> PathsResult { - BaseDirs::new() - .map(|dirs| dirs.data_dir().join(ORGANIZATION)) - .ok_or(PathsError::DataDirUnavailable) + BaseDirs::new() + .map(|dirs| dirs.data_dir().join(ORGANIZATION)) + .ok_or(PathsError::DataDirUnavailable) } -fn legacy_dir() -> Option { - ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) - .map(|dirs| dirs.data_local_dir().to_path_buf()) +#[must_use] +pub fn legacy_dir() -> Option { + ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) + .map(|dirs| dirs.data_local_dir().to_path_buf()) } fn resolve_default_dir() -> PathsResult { - if let Some(legacy) = legacy_dir() - && legacy.join(SETTINGS_FILE).is_file() - { - return Ok(legacy); - } + if let Some(legacy) = legacy_dir() + && legacy.join(SETTINGS_FILE).is_file() + { + return Ok(legacy); + } - Ok(organization_dir()?.join(APPLICATION)) + standard_dir() +} + +pub fn standard_dir() -> PathsResult { + Ok(organization_dir()?.join(APPLICATION)) } fn default_dir() -> PathsResult<&'static Path> { - if let Some(dir) = DEFAULT_DIR.get() { - return Ok(dir); - } + if let Some(dir) = DEFAULT_DIR.get() { + return Ok(dir); + } - let _ = DEFAULT_DIR.set(resolve_default_dir()?); - DEFAULT_DIR - .get() - .map(PathBuf::as_path) - .ok_or(PathsError::DataDirUnavailable) + let _ = DEFAULT_DIR.set(resolve_default_dir()?); + DEFAULT_DIR + .get() + .map(PathBuf::as_path) + .ok_or(PathsError::DataDirUnavailable) } pub fn config_dir() -> PathsResult<&'static Path> { - if let Some(dir) = CONFIG_DIR_OVERRIDE.get() { - return Ok(dir); - } + if let Some(dir) = CONFIG_DIR_OVERRIDE.get() { + return Ok(dir); + } - default_dir() + default_dir() } pub fn data_dir() -> PathsResult<&'static Path> { - if let Some(dir) = DATA_DIR_OVERRIDE.get() { - return Ok(dir); - } + if let Some(dir) = DATA_DIR_OVERRIDE.get() { + return Ok(dir); + } - config_dir() + config_dir() } #[must_use] pub fn picker_start_dir() -> Option { - organization_dir() - .ok() - .filter(|dir| dir.is_dir()) - .or_else(|| data_dir().ok().map(Path::to_path_buf)) + organization_dir() + .ok() + .filter(|dir| dir.is_dir()) + .or_else(|| data_dir().ok().map(Path::to_path_buf)) } pub fn database_file() -> PathsResult { - Ok(data_dir()?.join("user_data.db")) + Ok(data_dir()?.join(DATABASE_FILE)) } pub fn settings_file() -> PathsResult { - Ok(config_dir()?.join(SETTINGS_FILE)) + Ok(config_dir()?.join(SETTINGS_FILE)) } pub fn damaged_settings_file() -> PathsResult { - Ok(config_dir()?.join("settings.json.corrupt")) + Ok(config_dir()?.join("settings.json.corrupt")) } pub fn auth_file() -> PathsResult { - Ok(config_dir()?.join("auth.json")) + Ok(config_dir()?.join("auth.json")) } pub fn logs_dir() -> PathsResult { - Ok(config_dir()?.join("logs")) + Ok(config_dir()?.join("logs")) } pub fn skins_dir() -> PathsResult { - Ok(data_dir()?.join("skins")) + Ok(data_dir()?.join("skins")) } pub fn skin_file_path(uuid: &str) -> PathsResult { - Ok(skins_dir()?.join(format!("{uuid}.png"))) + Ok(skins_dir()?.join(format!("{uuid}.png"))) } pub fn skin_meta_path(uuid: &str) -> PathsResult { - Ok(skins_dir()?.join(format!("{uuid}.json"))) + Ok(skins_dir()?.join(format!("{uuid}.json"))) } pub fn java_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("java")) + Ok(data_dir()?.join("metadata").join("java")) } pub fn authlib_injector_path() -> PathsResult { - Ok(data_dir()?.join("metadata").join("authlib-injector.jar")) + Ok(data_dir()?.join("metadata").join("authlib-injector.jar")) } pub fn clusters_dir() -> PathsResult { - Ok(data_dir()?.join("clusters")) + Ok(data_dir()?.join("clusters")) +} + +pub fn cluster_trash_dir() -> PathsResult { + Ok(data_dir()?.join(".trash")) } pub fn shared_minecraft_dir() -> PathsResult { - Ok(data_dir()?.join(".minecraft")) + Ok(data_dir()?.join(".minecraft")) } /// Presence marks a cluster folder as its own game dir instead of the shared `.minecraft` pub const DEDICATED_MARKER: &str = ".dedicated_directory"; +pub const INSTANCE_FILE: &str = ".instance.json"; + pub fn cluster_dir(folder_name: &str) -> PathsResult { - Ok(clusters_dir()?.join(folder_name)) + Ok(clusters_dir()?.join(folder_name)) } pub fn cluster_uses_dedicated_dir(folder_name: &str) -> bool { - cluster_dir(folder_name).is_ok_and(|dir| dir.join(DEDICATED_MARKER).exists()) + cluster_dir(folder_name).is_ok_and(|dir| dir.join(DEDICATED_MARKER).exists()) } /// Lives here not on `Cluster` so the content layer can resolve it from a bare /// `ClusterRow` without duplicating the marker-file rule -pub fn cluster_game_dir(folder_name: &str) -> PathsResult { - if cluster_uses_dedicated_dir(folder_name) { - cluster_dir(folder_name) - } else { - shared_minecraft_dir() - } +pub fn cluster_game_dir(folder_name: &str, isolated: bool) -> PathsResult { + if isolated || cluster_uses_dedicated_dir(folder_name) { + cluster_dir(folder_name) + } else { + shared_minecraft_dir() + } } pub fn cluster_mods_dir(folder_name: &str) -> PathsResult { - Ok(cluster_dir(folder_name)?.join(ContentType::Mod.folder_name())) + Ok(cluster_dir(folder_name)?.join(ContentType::Mod.folder_name())) } pub fn shared_mods_dir() -> PathsResult { - Ok(shared_minecraft_dir()?.join(ContentType::Mod.folder_name())) + Ok(shared_minecraft_dir()?.join(ContentType::Mod.folder_name())) } pub fn global_content_dir(content_type: ContentType) -> PathsResult { - Ok(shared_minecraft_dir()?.join(content_type.folder_name())) + Ok(shared_minecraft_dir()?.join(content_type.folder_name())) } pub fn shared_mods_link(folder_name: &str) -> PathsResult { - Ok(shared_mods_dir()?.join(folder_name)) + Ok(shared_mods_dir()?.join(folder_name)) } pub fn packages_cache_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("packages")) + Ok(data_dir()?.join("metadata").join("packages")) } pub fn caches_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("caches")) + Ok(data_dir()?.join("metadata").join("caches")) } pub fn bundles_dir() -> PathsResult { - Ok(caches_dir()?.join("bundles")) + Ok(caches_dir()?.join("bundles")) } pub fn images_cache_dir() -> PathsResult { - Ok(caches_dir()?.join("images")) + Ok(caches_dir()?.join("images")) } pub const LOCAL_IMAGE_SCHEME: &str = "local:"; pub fn local_icons_dir() -> PathsResult { - Ok(images_cache_dir()?.join("local")) + Ok(images_cache_dir()?.join("local")) } pub fn local_image_path(reference: &str) -> Option { - let name = reference.strip_prefix(LOCAL_IMAGE_SCHEME)?; + let name = reference.strip_prefix(LOCAL_IMAGE_SCHEME)?; - if name.is_empty() || name.contains(['/', '\\']) || name.contains("..") { - return None; - } + if name.is_empty() || name.contains(['/', '\\']) || name.contains("..") { + return None; + } - Some(local_icons_dir().ok()?.join(name)) + Some(local_icons_dir().ok()?.join(name)) } pub fn profiles_cache_dir() -> PathsResult { - Ok(caches_dir()?.join("profiles")) + Ok(caches_dir()?.join("profiles")) } pub fn versions_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("versions")) + Ok(data_dir()?.join("metadata").join("versions")) +} + +pub fn metadata_dir() -> PathsResult { + Ok(data_dir()?.join("metadata")) } pub fn libraries_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("libraries")) + Ok(metadata_dir()?.join("libraries")) } pub fn natives_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("natives")) + Ok(data_dir()?.join("metadata").join("natives")) } pub fn assets_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("assets")) + Ok(data_dir()?.join("metadata").join("assets")) } pub fn assets_index_dir() -> PathsResult { - Ok(assets_dir()?.join("indexes")) + Ok(assets_dir()?.join("indexes")) } pub fn assets_object_dir() -> PathsResult { - Ok(assets_dir()?.join("objects")) + Ok(assets_dir()?.join("objects")) } pub fn legacy_assets_dir() -> PathsResult { - Ok(data_dir()?.join("metadata").join("resources")) + Ok(data_dir()?.join("metadata").join("resources")) } pub fn package_version_dir( - content_type: ContentType, - provider: ProviderId, - project_id: &str, - version_id: &str, + content_type: ContentType, + provider: ProviderId, + project_id: &str, + version_id: &str, ) -> PathsResult { - Ok(packages_cache_dir()? - .join(content_type.folder_name()) - .join(provider.dir_name()) - .join(project_id) - .join(version_id)) + Ok(packages_cache_dir()? + .join(content_type.folder_name()) + .join(provider.dir_name()) + .join(project_id) + .join(version_id)) } #[cfg(test)] mod tests { - use super::*; - use std::ffi::OsStr; + use super::*; + use std::ffi::OsStr; - #[test] - fn the_launcher_sits_one_level_under_the_organization_folder() { - let organization = organization_dir().expect("a home directory"); - assert_eq!(organization.file_name(), Some(OsStr::new(ORGANIZATION))); + #[test] + fn the_launcher_sits_one_level_under_the_organization_folder() { + let organization = organization_dir().expect("a home directory"); + assert_eq!(organization.file_name(), Some(OsStr::new(ORGANIZATION))); - let app = organization.join(APPLICATION); - let tail: Vec<&OsStr> = app.iter().rev().take(2).collect(); + let app = organization.join(APPLICATION); + let tail: Vec<&OsStr> = app.iter().rev().take(2).collect(); - assert_eq!( - tail, - vec![OsStr::new(APPLICATION), OsStr::new(ORGANIZATION)], - "every Polyfrost product shares one folder and takes a single name inside it" - ); - } + assert_eq!( + tail, + vec![OsStr::new(APPLICATION), OsStr::new(ORGANIZATION)], + "every Polyfrost product shares one folder and takes a single name inside it" + ); + } - #[cfg(windows)] - #[test] - fn a_windows_install_roams_with_the_user() { - let organization = organization_dir().expect("a home directory"); + #[cfg(windows)] + #[test] + fn a_windows_install_roams_with_the_user() { + let organization = organization_dir().expect("a home directory"); - assert!( - organization.iter().any(|part| part == OsStr::new("Roaming")), - "settings and saves follow the user between machines; Local would strand them" - ); - } + assert!( + organization + .iter() + .any(|part| part == OsStr::new("Roaming")), + "settings and saves follow the user between machines; Local would strand them" + ); + } - #[test] - fn a_debug_build_never_shares_a_folder_with_a_release_one() { - assert_eq!(APPLICATION, if cfg!(debug_assertions) { - "OneClient-dev" - } else { - "OneClient" - }); - } + #[test] + fn a_debug_build_never_shares_a_folder_with_a_release_one() { + assert_eq!( + APPLICATION, + if cfg!(debug_assertions) { + "OneClient-dev" + } else { + "OneClient" + } + ); + } } diff --git a/packages/oneclient_common/src/process.rs b/packages/oneclient_common/src/process.rs index 6ccc5661..c5aa4d06 100644 --- a/packages/oneclient_common/src/process.rs +++ b/packages/oneclient_common/src/process.rs @@ -1,12 +1,12 @@ pub fn no_window(command: &mut std::process::Command) { - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - command.creation_flags(CREATE_NO_WINDOW); - } + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } - #[cfg(not(windows))] - let _ = command; + #[cfg(not(windows))] + let _ = command; } diff --git a/packages/oneclient_common/src/version.rs b/packages/oneclient_common/src/version.rs index 00928961..291f70b6 100644 --- a/packages/oneclient_common/src/version.rs +++ b/packages/oneclient_common/src/version.rs @@ -1,4 +1,3 @@ - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ParsedMcVersion { pub major: u32, diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs index 496fe527..0fe181eb 100644 --- a/packages/oneclient_content/src/bundles/install.rs +++ b/packages/oneclient_content/src/bundles/install.rs @@ -6,22 +6,174 @@ use oneclient_db::dao::cluster_bundle as bundle_dao; use oneclient_db::models::ClusterRow; use oneclient_db::models::OverrideType; -use crate::error::ContentError; -use crate::error::ContentResult; use crate::bundles::error::BundleError; use crate::bundles::manager::BundlesManager; use crate::bundles::overrides; use crate::bundles::types::{BundleArchive, BundleFile, BundleFileKind}; -use oneclient_events::{GroupedProgressChild, GroupedProgressSession, TaskCategory, TaskPhase}; -use oneclient_common::domain::{ContentType, GameLoader}; -use crate::packages::store::{LiveSync, PackageStore, evict_if_unused, try_unlink_materialized}; -use crate::packages::types::ExternalFile; use crate::ctx::ContentCtx; +use crate::error::ContentError; +use crate::error::ContentResult; +use crate::packages::store::{ + LiveSync, PackageStore, drop_unmanaged_mod, evict_if_unused, try_unlink_materialized, +}; +use crate::packages::types::ExternalFile; +use oneclient_common::domain::{ContentType, GameLoader}; +use oneclient_events::{GroupedProgressChild, GroupedProgressSession, TaskCategory, TaskPhase}; fn is_base62(s: &str) -> bool { !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()) } +pub(crate) fn takes_bundle( + archive: &BundleArchive, + accepted: &std::collections::HashSet, +) -> bool { + archive.manifest.enabled || accepted.contains(&archive.manifest.name) +} + +fn wants_file(file: &BundleFile, user_override: Option, taken: bool) -> bool { + if taken { + effective_enabled(file, user_override) + } else { + user_override == Some(OverrideType::Enabled) + } +} + +pub(crate) async fn accepted_bundles( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + Ok(bundle_dao::list_bundle_choices(&ctx.db, cluster_id) + .await? + .into_iter() + .filter_map(|(name, accepted)| accepted.then_some(name)) + .collect()) +} + +pub async fn taken_bundle_names( + cluster_id: i64, + archives: &[BundleArchive], + ctx: &ContentCtx, +) -> ContentResult> { + let accepted = accepted_bundles(cluster_id, ctx).await?; + Ok(archives + .iter() + .filter(|archive| takes_bundle(archive, &accepted)) + .map(|archive| archive.manifest.name.clone()) + .collect()) +} + +pub(crate) async fn overrides_outside_declined( + cluster_id: i64, + archives: &[BundleArchive], + mut overrides: Vec, + ctx: &ContentCtx, +) -> ContentResult> { + let taken = taken_bundle_names(cluster_id, archives, ctx).await?; + let declined: std::collections::HashSet<&str> = archives + .iter() + .map(|archive| archive.manifest.name.as_str()) + .filter(|name| !taken.contains(*name)) + .collect(); + + overrides.retain(|o| !declined.contains(o.bundle_name.as_str())); + Ok(overrides) +} + +#[tracing::instrument(level = "debug", skip(bundles, ctx))] +pub async fn pending_bundle_choices( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult> { + let Some(cluster) = bundle_cluster(cluster_id, ctx).await? else { + return Ok(Vec::new()); + }; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); + let decided: std::collections::HashSet = + bundle_dao::list_bundle_choices(&ctx.db, cluster_id) + .await? + .into_iter() + .map(|(name, _)| name) + .collect(); + Ok(bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await? + .into_iter() + .filter(|archive| { + !archive.manifest.enabled + && !archive.bundle.hidden + && !decided.contains(&archive.manifest.name) + }) + .collect()) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn set_bundle_choices( + cluster_id: i64, + choices: &[(String, bool)], + ctx: &ContentCtx, +) -> ContentResult<()> { + bundle_dao::save_bundle_choices(&ctx.db, cluster_id, choices).await?; + + for row in bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await? { + let declined = row.bundle_name.as_ref().is_some_and(|name| { + choices + .iter() + .any(|(choice, accepted)| choice == name && !accepted) + }); + if declined { + detach_artifact(cluster_id, &row.hash, ctx).await?; + } + } + Ok(()) +} + +const UNASKED_SKYBLOCK: &str = "decline_unasked_skyblock"; + +#[tracing::instrument(level = "debug", skip_all, fields(cluster_id = cluster.id))] +pub(crate) async fn decline_unasked_skyblock( + cluster: &ClusterRow, + archives: &[BundleArchive], + ctx: &ContentCtx, +) -> ContentResult<()> { + let marker = format!("{UNASKED_SKYBLOCK}:{}", cluster.id); + if migration_dao::is_applied(&ctx.db, &marker).await? { + return Ok(()); + } + + let unasked = cluster.mc_version == "26.3" + && bundle_dao::predates_bundle_choices(&ctx.db, cluster.id).await?; + if unasked { + let declined: Vec<(String, bool)> = archives + .iter() + .filter(|archive| { + !archive.manifest.enabled + && archive + .manifest + .category + .trim() + .eq_ignore_ascii_case("SkyBlock") + }) + .map(|archive| (archive.manifest.name.clone(), false)) + .collect(); + set_bundle_choices(cluster.id, &declined, ctx).await?; + } + + migration_dao::mark_applied(&ctx.db, &marker).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn inherit_bundle_choices( + source_cluster_id: i64, + target_cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult<()> { + bundle_dao::copy_bundle_choices(&ctx.db, source_cluster_id, target_cluster_id).await?; + Ok(()) +} + pub fn effective_enabled(file: &BundleFile, user_override: Option) -> bool { match user_override { Some(OverrideType::Removed | OverrideType::Disabled) => false, @@ -30,7 +182,7 @@ pub fn effective_enabled(file: &BundleFile, user_override: Option) } } -fn find_override( +pub(crate) fn find_override( overrides: &[oneclient_db::models::ClusterBundleOverrideRow], bundle_name: &str, package_id: &str, @@ -100,15 +252,11 @@ pub async fn install_package_from_bundle( .await; let version = if is_base62(version_id) { - crate::packages::get_version_cached(ctx, *provider, project_id, version_id) - .await? - } else if let Ok(Some((_, version))) = - ctx.providers.lookup_version(sha1, ctx).await - { + crate::packages::get_version_cached(ctx, *provider, project_id, version_id).await? + } else if let Ok(Some((_, version))) = ctx.providers.lookup_version(sha1, ctx).await { version } else { - crate::packages::get_version_cached(ctx, *provider, project_id, version_id) - .await? + crate::packages::get_version_cached(ctx, *provider, project_id, version_id).await? }; let (artifact, _) = PackageStore::install_to_cluster( @@ -124,7 +272,7 @@ pub async fn install_package_from_bundle( .await?; artifact.hash } - BundleFileKind::External(ext) => { + BundleFileKind::External { file: ext, .. } => { install_external(ext, &cluster, skip_compatibility, child, ctx).await? } }; @@ -143,7 +291,7 @@ pub async fn install_package_from_bundle( } #[tracing::instrument(level = "debug", skip(ext, cluster, child, ctx), fields(file = %ext.name))] -async fn install_external( +pub(crate) async fn install_external( ext: &ExternalFile, cluster: &ClusterRow, skip_compatibility: bool, @@ -178,6 +326,9 @@ pub async fn install_bundle( ctx: &ContentCtx, ) -> ContentResult> { let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + if !cluster.uses_bundles() { + return Ok(Vec::new()); + } let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { ContentError::InvalidData { reason: format!("unknown loader {}", cluster.mc_loader), @@ -191,7 +342,7 @@ pub async fn install_bundle( .find(|a| a.manifest.name == bundle_name) .ok_or(BundleError::NotFound(bundle_name.to_string()))?; - install_enabled_bundle_files(&archive, cluster_id, skip_compatibility, None, ctx).await + install_enabled_bundle_files(&archive, cluster_id, true, skip_compatibility, None, ctx).await } /// `suppression` comes from [`find_user_suppression`] so a choice filed under a @@ -243,6 +394,74 @@ async fn clear_reconciler_disables( Ok(()) } +pub(crate) fn external_ids_by_sha1( + archives: &[BundleArchive], +) -> std::collections::HashMap { + archives + .iter() + .flat_map(|archive| &archive.manifest.files) + .filter_map(|file| match &file.kind { + BundleFileKind::External { + file: ext, + id: Some(id), + .. + } => Some((ext.sha1.clone(), id.clone())), + _ => None, + }) + .collect() +} + +async fn adopt_external_ids( + cluster_id: i64, + archives: &[BundleArchive], + ctx: &ContentCtx, +) -> ContentResult<()> { + let ids = external_ids_by_sha1(archives); + if ids.is_empty() { + return Ok(()); + } + + for row in bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await? { + let (Some(bundle_name), Some(version_id), Some(package_id)) = + (&row.bundle_name, &row.bundle_version_id, &row.package_id) + else { + continue; + }; + if *package_id != row.hash { + continue; + } + let Some(id) = ids.get(package_id) else { + continue; + }; + bundle_dao::track_bundle_artifact( + &ctx.db, + cluster_id, + &row.hash, + bundle_name, + version_id, + id, + ) + .await?; + } + + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + for row in &overrides { + let Some(id) = ids.get(&row.package_id) else { + continue; + }; + let superseded = overrides + .iter() + .any(|other| other.bundle_name == row.bundle_name && other.package_id == *id); + if !superseded && let Some(override_type) = OverrideType::parse(&row.override_type) { + bundle_dao::save_override(&ctx.db, cluster_id, &row.bundle_name, id, override_type) + .await?; + } + bundle_dao::remove_override(&ctx.db, cluster_id, &row.bundle_name, &row.package_id).await?; + } + + Ok(()) +} + #[tracing::instrument(level = "debug", skip(archives, ctx))] pub async fn heal_bundle_activity( cluster_id: i64, @@ -250,6 +469,7 @@ pub async fn heal_bundle_activity( ctx: &ContentCtx, ) -> ContentResult<()> { clear_reconciler_disables(cluster_id, archives, ctx).await?; + adopt_external_ids(cluster_id, archives, ctx).await?; let tracked = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; if tracked.iter().all(|row| row.enabled != 0) { @@ -267,7 +487,13 @@ pub async fn heal_bundle_activity( } } - let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let overrides = overrides_outside_declined( + cluster_id, + archives, + bundle_dao::list_overrides(&ctx.db, cluster_id).await?, + ctx, + ) + .await?; let mut live_packages: std::collections::HashSet = tracked .iter() @@ -325,29 +551,24 @@ pub async fn heal_bundle_activity( pub async fn install_enabled_bundle_files( archive: &BundleArchive, cluster_id: i64, + taken: bool, skip_compatibility: bool, progress: Option<&GroupedProgressSession>, ctx: &ContentCtx, ) -> ContentResult> { - extract_bundle_overrides_for_cluster(archive, cluster_id, ctx).await?; + if taken { + extract_bundle_overrides_for_cluster(archive, cluster_id, ctx).await?; + } heal_bundle_activity(cluster_id, std::slice::from_ref(archive), ctx).await?; let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; let bundle_name = archive.manifest.name.clone(); let mut installed = Vec::new(); - let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; - let mut linked_projects: std::collections::HashSet<&str> = std::collections::HashSet::new(); - let mut linked_hashes: std::collections::HashSet<&str> = std::collections::HashSet::new(); // "Already installed" means the database not disk // content lives in the cache between sessions so probing the folder would // reinstall everything - for info in &linked { - if let Some(pid) = &info.project_id { - linked_projects.insert(pid.as_str()); - } - linked_hashes.insert(info.hash.as_str()); - } + let present = PresentContent::load(cluster_id, ctx).await?; let to_install: Vec = archive .manifest @@ -355,16 +576,11 @@ pub async fn install_enabled_bundle_files( .iter() .filter(|file| { let package_id = file.kind.package_id(); - if !effective_enabled(file, find_override(&overrides, &bundle_name, &package_id)) { - return false; - } - let already_installed = match &file.kind { - BundleFileKind::Managed { project_id, .. } => { - linked_projects.contains(project_id.as_str()) - } - BundleFileKind::External(ext) => linked_hashes.contains(ext.sha1.as_str()), - }; - !already_installed + wants_file( + file, + find_override(&overrides, &bundle_name, &package_id), + taken, + ) && !present.contains(file) }) .cloned() .collect(); @@ -376,13 +592,46 @@ pub async fn install_enabled_bundle_files( "installing enabled bundle files" ); - if let Some(p) = progress { - let reserved_bytes: u64 = to_install.iter().map(|f| f.size.max(1)).sum(); - p.expect(TaskCategory::Packages, to_install.len() as u64, reserved_bytes); + let results = install_bundle_files( + to_install, + cluster_id, + &bundle_name, + skip_compatibility, + progress, + ctx, + ) + .await; + + for (file, result) in results { + match result { + Ok(hash) => installed.push(hash), + Err(err) => { + tracing::warn!(file = %file.display_name(), error = %err, "failed to install bundle file"); + } + } } - let bundle_name = &bundle_name; - let results = futures_util::stream::iter(to_install.into_iter().map(|file| async move { + Ok(installed) +} + +pub(crate) async fn install_bundle_files( + to_install: Vec, + cluster_id: i64, + bundle_name: &str, + skip_compatibility: bool, + progress: Option<&GroupedProgressSession>, + ctx: &ContentCtx, +) -> Vec<(BundleFile, ContentResult)> { + if let Some(p) = progress { + let reserved_bytes: u64 = to_install.iter().map(|f| f.size.max(1)).sum(); + p.expect( + TaskCategory::Packages, + to_install.len() as u64, + reserved_bytes, + ); + } + + futures_util::stream::iter(to_install.into_iter().map(|file| async move { let child = progress.map(|p| { let c = p.child( format!("Mod {}", file.display_name()), @@ -407,22 +656,51 @@ pub async fn install_enabled_bundle_files( child.set_phase(TaskPhase::Installing); child.finish(); } - (file.display_name(), result) + (file, result) })) .buffer_unordered(BUNDLE_INSTALL_CONCURRENCY) .collect::>() - .await; + .await +} - for (name, result) in results { - match result { - Ok(hash) => installed.push(hash), - Err(err) => { - tracing::warn!(file = %name, error = %err, "failed to install bundle file"); +pub(crate) struct PresentContent { + projects: std::collections::HashSet, + hashes: std::collections::HashSet, + tracked_ids: std::collections::HashSet, +} + +impl PresentContent { + pub(crate) async fn load(cluster_id: i64, ctx: &ContentCtx) -> ContentResult { + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let tracked_ids = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id) + .await? + .into_iter() + .filter_map(|row| row.package_id) + .collect(); + + Ok(Self { + projects: linked + .iter() + .filter_map(|info| info.project_id.clone()) + .collect(), + hashes: linked.into_iter().map(|info| info.hash).collect(), + tracked_ids, + }) + } + + pub(crate) fn has_hash(&self, hash: &str) -> bool { + self.hashes.contains(hash) + } + + pub(crate) fn contains(&self, file: &BundleFile) -> bool { + match &file.kind { + BundleFileKind::Managed { project_id, .. } => self.projects.contains(project_id), + BundleFileKind::External { file: ext, id, .. } => { + self.hashes.contains(&ext.sha1) + || id.as_ref().is_some_and(|id| self.tracked_ids.contains(id)) } } } - - Ok(installed) } /// Kept modest each fetch also costs a provider API call rate limited per-minute @@ -434,7 +712,10 @@ pub async fn enabled_bundle_bytes( bundles: &BundlesManager, ctx: &ContentCtx, ) -> ContentResult { - let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let Some(cluster) = bundle_cluster(cluster_id, ctx).await? else { + return Ok(0); + }; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { ContentError::InvalidData { reason: format!("unknown loader {}", cluster.mc_loader), @@ -445,32 +726,21 @@ pub async fn enabled_bundle_bytes( .archives_for(ctx, &cluster.mc_version, loader) .await?; let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; - let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; - - let mut linked_projects: std::collections::HashSet<&str> = std::collections::HashSet::new(); - let mut linked_hashes: std::collections::HashSet<&str> = std::collections::HashSet::new(); - for info in &linked { - if let Some(pid) = &info.project_id { - linked_projects.insert(pid.as_str()); - } - linked_hashes.insert(info.hash.as_str()); - } + let accepted = accepted_bundles(cluster_id, ctx).await?; + let present = PresentContent::load(cluster_id, ctx).await?; let mut total = 0u64; for archive in &archives { let bundle_name = &archive.manifest.name; + let taken = takes_bundle(archive, &accepted); for file in &archive.manifest.files { let package_id = file.kind.package_id(); - if !effective_enabled(file, find_override(&overrides, bundle_name, &package_id)) { - continue; - } - let already_installed = match &file.kind { - BundleFileKind::Managed { project_id, .. } => { - linked_projects.contains(project_id.as_str()) - } - BundleFileKind::External(ext) => linked_hashes.contains(ext.sha1.as_str()), - }; - if already_installed { + if !wants_file( + file, + find_override(&overrides, bundle_name, &package_id), + taken, + ) || present.contains(file) + { continue; } total += file.size; @@ -488,6 +758,10 @@ pub async fn set_bundle_package_override( override_type: Option, ctx: &ContentCtx, ) -> ContentResult<()> { + if bundle_cluster(cluster_id, ctx).await?.is_none() { + return Ok(()); + } + match override_type { // The UI shows one row per package across bundles so an objection left // under another bundle would let the next pass undo this switch @@ -508,8 +782,6 @@ pub async fn set_bundle_package_override( /// For bundle files the cluster has not installed /// installed ones go through [`set_artifact_enabled_to`] -/// Matching the manifest default clears the override -/// switching *on* also drops objections filed under other bundles #[tracing::instrument(level = "debug", skip(ctx))] pub async fn set_bundle_package_enabled( cluster_id: i64, @@ -520,18 +792,12 @@ pub async fn set_bundle_package_enabled( ctx: &ContentCtx, ) -> ContentResult<()> { let override_type = match (enabled, manifest_default) { - (true, true) | (false, false) => None, - (true, false) => Some(OverrideType::Enabled), + (true, _) => Some(OverrideType::Enabled), + (false, false) => None, (false, true) => Some(OverrideType::Disabled), }; - set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, ctx).await?; - - if enabled { - clear_suppressing_overrides(cluster_id, package_id, ctx).await?; - } - - Ok(()) + set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, ctx).await } #[tracing::instrument(level = "debug", skip(overrides, ctx), fields(count = overrides.len()))] @@ -540,6 +806,10 @@ pub async fn set_bundle_package_overrides( overrides: &[(String, String, OverrideType)], ctx: &ContentCtx, ) -> ContentResult<()> { + if bundle_cluster(cluster_id, ctx).await?.is_none() { + return Ok(()); + } + bundle_dao::save_overrides(&ctx.db, cluster_id, overrides).await?; Ok(()) } @@ -572,6 +842,57 @@ pub async fn list_cluster_bundle_overrides( .collect()) } +#[tracing::instrument(level = "debug", skip(bundles, ctx))] +pub async fn enabled_bundle_projects( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + if !cluster.uses_bundles() { + return Ok(std::collections::HashSet::new()); + } + let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { + ContentError::InvalidData { + reason: format!("unknown loader {}", cluster.mc_loader), + } + })?; + + let archives = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await?; + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let accepted = accepted_bundles(cluster_id, ctx).await?; + + let mut projects = std::collections::HashSet::new(); + for archive in &archives { + let bundle_name = &archive.manifest.name; + let taken = takes_bundle(archive, &accepted); + for file in &archive.manifest.files { + let BundleFileKind::Managed { project_id, .. } = &file.kind else { + continue; + }; + if wants_file( + file, + find_override(&overrides, bundle_name, project_id), + taken, + ) { + projects.insert(project_id.clone()); + } + } + } + + Ok(projects) +} + +pub(crate) async fn bundle_cluster( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + Ok(cluster.uses_bundles().then_some(cluster)) +} + #[tracing::instrument(skip(bundles, progress, ctx))] pub async fn install_cluster_bundles( cluster_id: i64, @@ -580,6 +901,11 @@ pub async fn install_cluster_bundles( ctx: &ContentCtx, ) -> ContentResult<()> { let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + if !cluster.uses_bundles() { + tracing::debug!(cluster_id, "instance does not take bundle content"); + return Ok(()); + } + let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { ContentError::InvalidData { reason: format!("unknown loader {}", cluster.mc_loader), @@ -595,9 +921,11 @@ pub async fn install_cluster_bundles( bundles = archives.len(), "installing enabled bundle content" ); + let accepted = accepted_bundles(cluster_id, ctx).await?; for archive in &archives { + let taken = takes_bundle(archive, &accepted); let installed = - install_enabled_bundle_files(archive, cluster_id, true, progress, ctx).await?; + install_enabled_bundle_files(archive, cluster_id, taken, true, progress, ctx).await?; tracing::info!( cluster_id, bundle = %archive.manifest.name, @@ -640,31 +968,10 @@ pub async fn set_artifact_enabled_to( } // which clusters have to record what the user just did -async fn override_scope( - cluster_id: i64, - hash: &str, - ctx: &ContentCtx, -) -> ContentResult> { - let global = artifact_dao::get_artifact_by_hash(&ctx.db, hash) +async fn override_scope(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult> { + Ok(sharing_scope(cluster_id, hash, ctx) .await? - .and_then(|artifact| ContentType::from_repr(artifact.content_type as u8)) - .is_some_and(ContentType::is_global); - - if !global { - return Ok(vec![cluster_id]); - } - - let mut ids: Vec = cluster_dao::list_all(&ctx.db) - .await? - .into_iter() - .map(|row| row.id) - .collect(); - - if !ids.contains(&cluster_id) { - ids.push(cluster_id); - } - - Ok(ids) + .unwrap_or_else(|| vec![cluster_id])) } #[tracing::instrument(level = "debug", skip(ctx))] @@ -688,9 +995,10 @@ pub async fn on_user_enable_artifact( ) -> ContentResult<()> { for id in override_scope(cluster_id, hash, ctx).await? { if let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, id, hash).await? - && let Some(package_id) = tracked.package_id { - clear_suppressing_overrides(id, &package_id, ctx).await?; - } + && let Some(package_id) = tracked.package_id + { + clear_suppressing_overrides(id, &package_id, ctx).await?; + } } Ok(()) @@ -703,8 +1011,7 @@ async fn handle_user_artifact_action( ctx: &ContentCtx, override_type: OverrideType, ) -> ContentResult<()> { - let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await? - else { + let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await? else { return Ok(()); }; @@ -733,6 +1040,35 @@ pub async fn remove_artifact_from_cluster( ) -> ContentResult<()> { let bundle_data = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await?; + detach_artifact(cluster_id, hash, ctx).await?; + + if let Some(tracked) = bundle_data + && let (Some(bundle_name), Some(package_id)) = + (tracked.bundle_name.clone(), tracked.package_id.clone()) + { + if record_override { + bundle_dao::save_override( + &ctx.db, + cluster_id, + &bundle_name, + &package_id, + OverrideType::Removed, + ) + .await?; + } else { + let replacement_exists = + bundle_dao::has_bundle_mapping_for_package(&ctx.db, cluster_id, &package_id) + .await?; + if !replacement_exists { + clear_suppressing_overrides(cluster_id, &package_id, ctx).await?; + } + } + } + + Ok(()) +} + +async fn detach_artifact(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult<()> { // Looked up first but never allowed to block removal // a package whose artifact row has gone missing must still be removable let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; @@ -748,10 +1084,15 @@ pub async fn remove_artifact_from_cluster( // The folder is rebuilt from the database at the next launch artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; + if let (Some(content_type), Some(link)) = (target, &link) { + let name = &link.cluster_file_name; + drop_unmanaged_mod(&cluster, content_type, name, hash, &ctx.db).await; + } + // Best-effort folder cleanup failure here is not an error let deferred = match (target, link) { (Some(content_type), Some(link)) => { - try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await + try_unlink_materialized(&cluster, content_type, &link.cluster_file_name, &ctx.db).await == LiveSync::Deferred } _ => false, @@ -759,44 +1100,81 @@ pub async fn remove_artifact_from_cluster( // The package actually lives in the cache // `evict_if_unused` drops it only once no other cluster still needs it - if !deferred - && let Err(err) = evict_if_unused(hash, ctx).await - { + if !deferred && let Err(err) = evict_if_unused(hash, ctx).await { tracing::warn!(hash, error = %err, "failed to evict unused artifact from the cache"); } - if let Some(tracked) = bundle_data - && let (Some(bundle_name), Some(package_id)) = - (tracked.bundle_name.clone(), tracked.package_id.clone()) - { - if record_override { - bundle_dao::save_override( - &ctx.db, - cluster_id, - &bundle_name, - &package_id, - OverrideType::Removed, - ) - .await?; - } else { - let replacement_exists = bundle_dao::has_bundle_mapping_for_package( - &ctx.db, - cluster_id, - &package_id, - ) - .await?; - if !replacement_exists { - clear_suppressing_overrides(cluster_id, &package_id, ctx).await?; - } - } - } - Ok(()) } +async fn sharing_scope( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult>> { + let global = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .and_then(|artifact| ContentType::from_repr(artifact.content_type as u8)) + .is_some_and(|content_type| content_type.is_global()); + if !global { + return Ok(None); + } + + let sharing = cluster_dao::list_oneclient_ids(&ctx.db).await?; + + Ok(sharing.contains(&cluster_id).then_some(sharing)) +} + +async fn sharing_clusters( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult>> { + let Some(sharing) = sharing_scope(cluster_id, hash, ctx).await? else { + return Ok(None); + }; + + Ok(Some( + artifact_dao::list_clusters_linking(&ctx.db, hash) + .await? + .into_iter() + .filter(|id| sharing.contains(id)) + .collect(), + )) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn clusters_sharing_artifact( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult> { + Ok(sharing_clusters(cluster_id, hash, ctx) + .await? + .map(|clusters| clusters.len())) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn delete_artifact(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult<()> { + let Some(linked_clusters) = sharing_clusters(cluster_id, hash, ctx).await? else { + return remove_artifact_from_cluster(cluster_id, hash, true, ctx).await; + }; + + let mut first_error = None; + for linked_cluster in linked_clusters { + if let Err(err) = remove_artifact_from_cluster(linked_cluster, hash, true, ctx).await { + tracing::warn!(cluster_id = linked_cluster, hash, error = %err, "failed to delete shared package from a cluster"); + first_error.get_or_insert(err); + } + } + + first_error.map_or(Ok(()), Err) +} + #[cfg(test)] mod tests { use super::*; + use crate::bundles::types::BundleFileType; use oneclient_common::domain::ContentType; use oneclient_db::models::ClusterBundleOverrideRow; @@ -806,13 +1184,18 @@ mod tests { hidden: false, path: "mods/example.jar".to_string(), size: 1, - kind: BundleFileKind::External(ExternalFile { - name: "example.jar".to_string(), - url: "https://example.invalid/example.jar".to_string(), - sha1: "abc123".to_string(), - size: 1, - content_type: ContentType::Mod, - }), + file_type: BundleFileType::Normal, + kind: BundleFileKind::External { + file: ExternalFile { + name: "example.jar".to_string(), + url: "https://example.invalid/example.jar".to_string(), + sha1: "abc123".to_string(), + size: 1, + content_type: ContentType::Mod, + }, + id: None, + meta: None, + }, } } @@ -824,7 +1207,10 @@ mod tests { #[test] fn suppressing_overrides_win_over_enabled_manifest() { - assert!(!effective_enabled(&file(true), Some(OverrideType::Disabled))); + assert!(!effective_enabled( + &file(true), + Some(OverrideType::Disabled) + )); assert!(!effective_enabled(&file(true), Some(OverrideType::Removed))); } @@ -842,7 +1228,6 @@ mod tests { #[test] fn unknown_override_string_is_ignored() { assert_eq!(OverrideType::parse("something-new"), None); - assert!(!effective_enabled(&file(false), None)); } fn row(bundle: &str, pid: &str, ty: OverrideType) -> ClusterBundleOverrideRow { diff --git a/packages/oneclient_content/src/bundles/manager.rs b/packages/oneclient_content/src/bundles/manager.rs index 2159057a..24da00c9 100644 --- a/packages/oneclient_content/src/bundles/manager.rs +++ b/packages/oneclient_content/src/bundles/manager.rs @@ -4,19 +4,20 @@ use std::str::FromStr; use chrono::Utc; use oneclient_db::dao::bundle as bundle_dao; +use oneclient_db::dao::package_metadata as meta_dao; use oneclient_db::models::{BundleRow, NewBundle}; use tokio::sync::RwLock; use crate::bundles::error::BundleError; use crate::bundles::manifest::{BundleManifest as RemoteBundleManifest, RemoteBundleRef}; -use crate::bundles::types::BundleArchive; use crate::bundles::polymrpack; -use oneclient_net::RequestError; -use oneclient_common::domain::GameLoader; -use oneclient_common::paths; -use oneclient_net::{EtagPolicy, fetch_cached}; +use crate::bundles::types::{BundleArchive, BundleFileKind}; use crate::ctx::ContentCtx; use crate::error::{ContentError, ContentResult}; +use oneclient_common::domain::{GameLoader, ProviderId}; +use oneclient_common::paths; +use oneclient_net::RequestError; +use oneclient_net::{EtagPolicy, fetch_cached}; #[derive(Debug, Clone)] pub struct Bundle { @@ -149,21 +150,23 @@ impl BundlesManager { ) -> ContentResult> { let mut archives = Vec::new(); for bundle in self.list_for(ctx, mc_version, loader).await? { - let manifest = self.manifest_for_archive(&bundle.path).await?; + let manifest = self.manifest_for_archive(ctx, &bundle.path).await?; archives.push(BundleArchive { bundle, manifest }); } Ok(archives) } - #[tracing::instrument(level = "debug", skip(self))] + #[tracing::instrument(level = "debug", skip(self, ctx))] async fn manifest_for_archive( &self, + ctx: &ContentCtx, path: &Path, ) -> ContentResult { if let Some(manifest) = self.archive_cache.read().await.get(path) { return Ok(manifest.clone()); } let manifest = polymrpack::read_manifest_from_archive(path).await?; + record_external_metadata(&manifest, ctx).await; self.archive_cache .write() .await @@ -179,8 +182,7 @@ impl BundlesManager { loader: GameLoader, ) -> ContentResult> { let rows = - bundle_dao::list_visible_for_version_loader(&ctx.db, mc_version, loader as i64) - .await?; + bundle_dao::list_visible_for_version_loader(&ctx.db, mc_version, loader as i64).await?; rows.into_iter() .map(Bundle::try_from_row) @@ -245,17 +247,14 @@ impl BundlesManager { /// bundles that never arrived /// `sync` commits it once all land #[tracing::instrument(level = "debug", skip(ctx))] - async fn fetch_manifest( - ctx: &ContentCtx, - ) -> ContentResult> { + async fn fetch_manifest(ctx: &ContentCtx) -> ContentResult> { let manifest_path = paths::bundles_dir()?.join("metadata.json"); let url = format!( "{}/oneclient/bundles/metadata.json", ctx.net.config().meta_url_base ); - let Some(fetched) = - fetch_cached(&ctx.net, &url, &manifest_path, EtagPolicy::Defer).await? + let Some(fetched) = fetch_cached(&ctx.net, &url, &manifest_path, EtagPolicy::Defer).await? else { return Ok(None); }; @@ -274,13 +273,43 @@ impl Default for BundlesManager { } } +#[tracing::instrument(level = "debug", skip_all, fields(bundle = %manifest.name))] +async fn record_external_metadata( + manifest: &crate::bundles::types::BundleManifest, + ctx: &ContentCtx, +) { + for bundle_file in &manifest.files { + let BundleFileKind::External { + file, + meta: Some(meta), + .. + } = &bundle_file.kind + else { + continue; + }; + + if let Err(err) = meta_dao::upsert_package_metadata( + &ctx.db, + ProviderId::Local as i64, + &file.sha1, + meta.name.as_deref().unwrap_or(&file.name), + meta.description.as_deref().unwrap_or_default(), + &meta.authors.join(", "), + meta.icon_url.as_deref(), + ) + .await + { + tracing::warn!(file = %file.name, error = %err, "failed to record bundle file metadata"); + } + } +} + struct FetchedManifest { manifest: RemoteBundleManifest, changed: bool, etag: Option, } - #[tracing::instrument(level = "debug", skip(ctx))] async fn download_bundle_if_needed( ctx: &ContentCtx, @@ -308,7 +337,6 @@ async fn download_bundle_if_needed( .map_err(map_request_error) } - fn map_request_error(err: RequestError) -> ContentError { match err { RequestError::DeserializeError { diff --git a/packages/oneclient_content/src/bundles/mod.rs b/packages/oneclient_content/src/bundles/mod.rs index 9fdfc4de..f4f88a0c 100644 --- a/packages/oneclient_content/src/bundles/mod.rs +++ b/packages/oneclient_content/src/bundles/mod.rs @@ -1,36 +1,36 @@ mod error; -mod install; +pub(crate) mod install; mod manager; mod manifest; mod optional; -mod overrides; -mod polymrpack; +pub(crate) mod overrides; +pub(crate) mod polymrpack; mod types; mod updates; pub use error::BundleError; pub use install::{ - effective_enabled, enabled_bundle_bytes, extract_bundle_overrides_for_cluster, - heal_bundle_activity, install_bundle, - install_cluster_bundles, install_enabled_bundle_files, install_package_from_bundle, - list_cluster_bundle_overrides, - on_user_disable_artifact, on_user_enable_artifact, on_user_remove_artifact, - remove_artifact_from_cluster, set_artifact_enabled_to, set_bundle_package_enabled, - set_bundle_package_opt_in, - set_bundle_package_override, set_bundle_package_overrides, + clusters_sharing_artifact, delete_artifact, effective_enabled, enabled_bundle_bytes, + enabled_bundle_projects, extract_bundle_overrides_for_cluster, heal_bundle_activity, + inherit_bundle_choices, install_bundle, install_cluster_bundles, install_enabled_bundle_files, + install_package_from_bundle, list_cluster_bundle_overrides, on_user_disable_artifact, + on_user_enable_artifact, on_user_remove_artifact, pending_bundle_choices, + remove_artifact_from_cluster, set_artifact_enabled_to, set_bundle_choices, + set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override, + set_bundle_package_overrides, taken_bundle_names, }; pub use manager::{Bundle, BundlesManager}; +pub use manifest::BundleManifest as RemoteBundleManifest; pub use optional::{ PendingOptionalMod, pending_optional_mods, resolve_optional_mods, skip_optional_mods, }; -pub use manifest::BundleManifest as RemoteBundleManifest; +pub use overrides::bundle_override_paths; pub use types::{ - ApplyBundleUpdatesResult, BundleArchive, BundleFile, BundleFileKind, BundleOptionalPackage, - BundleManifest, BundlePackageAddition, BundlePackageRemoval, BundlePackageUpdate, - BundleUpdateCheckResult, BundleWithUpdateStatus, FileUpdateStatus, + ApplyBundleUpdatesResult, BundleArchive, BundleFile, BundleFileKind, BundleFileType, + BundleManifest, BundleOptionalPackage, BundlePackageAddition, BundlePackageRemoval, + BundlePackageUpdate, BundleUpdateCheckResult, BundleWithUpdateStatus, FileUpdateStatus, }; pub use updates::{ apply_bundle_updates, apply_bundle_updates_with, check_bundle_updates, - cluster_has_bundle_content, - get_bundles_with_update_status, + cluster_has_bundle_content, get_bundles_with_update_status, }; diff --git a/packages/oneclient_content/src/bundles/optional.rs b/packages/oneclient_content/src/bundles/optional.rs index 79d3e6f8..5e2b8e34 100644 --- a/packages/oneclient_content/src/bundles/optional.rs +++ b/packages/oneclient_content/src/bundles/optional.rs @@ -1,12 +1,12 @@ -use oneclient_db::dao::cluster_bundle as bundle_dao; -use oneclient_db::dao::cluster_optional_mod as optional_dao; -use oneclient_db::models::{OptionalModStatus, OverrideType}; use crate::bundles::manager::BundlesManager; use crate::bundles::types::{BundleArchive, BundleFile}; use crate::ctx::ContentCtx; use crate::error::ContentResult; use crate::packages::store::PackageStore; use oneclient_common::domain::GameLoader; +use oneclient_db::dao::cluster_bundle as bundle_dao; +use oneclient_db::dao::cluster_optional_mod as optional_dao; +use oneclient_db::models::{OptionalModStatus, OverrideType}; #[derive(Debug, Clone)] pub struct PendingOptionalMod { @@ -51,6 +51,10 @@ pub async fn pending_optional_mods( continue; }; + if !file.is_optional_offer() { + continue; + } + // The queue row can outlive the offer the user may have opted in from // the package manager since it was written let user_override = overrides diff --git a/packages/oneclient_content/src/bundles/overrides.rs b/packages/oneclient_content/src/bundles/overrides.rs index 9afd2980..afd1ad04 100644 --- a/packages/oneclient_content/src/bundles/overrides.rs +++ b/packages/oneclient_content/src/bundles/overrides.rs @@ -1,12 +1,15 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use oneclient_common::domain::GameLoader; use oneclient_db::models::ClusterRow; use serde::{Deserialize, Serialize}; +use crate::bundles::BundlesManager; +use crate::ctx::ContentCtx; use crate::error::ContentResult; -use polyio::{sha1_bytes, sha1_file}; use oneclient_events::EventBus; +use polyio::{ZipEntryCursor, sha1_bytes, sha1_file}; const ALWAYS_UPDATE_GLOBS: &[&str] = &["config/fabric_loader_dependencies.json"]; @@ -71,20 +74,112 @@ async fn sync_bundle_overrides_at( root: &Path, events: Option<&EventBus>, ) -> ContentResult { - let entries = - polyio::read_zip_file_entries(archive_path, |name| name.starts_with(OVERRIDES_PREFIX)) - .await?; + sync_layered_overrides( + archive_path, + bundle_name, + root, + &[OVERRIDES_PREFIX], + &|_| true, + events, + ) + .await +} + +pub async fn bundle_override_paths( + bundles: &BundlesManager, + ctx: &ContentCtx, + mc_version: &str, + loader: GameLoader, +) -> ContentResult> { + let mut paths = HashSet::new(); + for archive in bundles.archives_for(ctx, mc_version, loader).await? { + let layers = OverrideLayers::open(&archive.bundle.path, &[OVERRIDES_PREFIX]).await?; + paths.extend(layers.entries().iter().map(|(rel, _)| rel.clone())); + } + Ok(paths) +} + +pub(crate) struct OverrideLayers { + cursor: ZipEntryCursor, + entries: Vec<(String, String)>, +} + +impl OverrideLayers { + pub(crate) async fn open(archive_path: &Path, prefixes: &[&str]) -> ContentResult { + let layer_of = |name: &str| prefixes.iter().position(|prefix| name.starts_with(prefix)); + let cursor = ZipEntryCursor::open(archive_path, |name| layer_of(name).is_some()).await?; + + let mut winners: HashMap = HashMap::new(); + for name in cursor.names() { + let Some(layer) = layer_of(name) else { + continue; + }; + let rel = &name[prefixes[layer].len()..]; + if rel.is_empty() { + continue; + } + let replaces = winners + .get(rel) + .is_none_or(|(current, _)| layer >= *current); + if replaces { + winners.insert(rel.to_string(), (layer, name.clone())); + } + } + + let mut entries: Vec<(String, String)> = winners + .into_iter() + .map(|(rel, (_, name))| (rel, name)) + .collect(); + entries.sort(); + + Ok(Self { cursor, entries }) + } + + pub(crate) fn entries(&self) -> &[(String, String)] { + &self.entries + } + + pub(crate) async fn read(&mut self, entry: &str) -> Option> { + match self.cursor.read(entry).await { + Ok(bytes) => Some(bytes), + Err(err) => { + tracing::warn!(entry, error = %err, "skipping an override that could not be read"); + None + } + } + } +} + +#[tracing::instrument(level = "debug", skip(keep, events))] +pub(crate) async fn sync_layered_overrides( + archive_path: &Path, + bundle_name: &str, + root: &Path, + prefixes: &[&str], + keep: &(dyn Fn(&str) -> bool + Sync), + events: Option<&EventBus>, +) -> ContentResult { + let mut layers = OverrideLayers::open(archive_path, prefixes).await?; + let entries: Vec<(String, String)> = layers + .entries() + .iter() + .filter(|(rel, _)| keep(rel)) + .cloned() + .collect(); let mut lock = OverrideLock::load(root).await; let previous = lock.bundles.remove(bundle_name).unwrap_or_default(); let mut next: HashMap = HashMap::new(); let mut report = OverrideSyncReport::default(); - for (name, bytes) in entries { - let rel = name.trim_start_matches(OVERRIDES_PREFIX); - if rel.is_empty() { + for (rel, entry) in entries { + let rel = rel.as_str(); + let Some(bytes) = layers.read(&entry).await else { + if let Some(base) = previous.get(rel) { + next.insert(rel.to_string(), base.clone()); + } continue; - } + }; let new_sha1 = sha1_bytes(&bytes); let dest = root.join(polyio::sanitize_path(rel)); @@ -166,6 +261,42 @@ async fn sync_bundle_overrides_at( Ok(report) } +pub(crate) async fn sync_file_lock( + root: &Path, + key: &str, + written: HashMap, + listed: &std::collections::HashSet, +) -> Vec { + let mut lock = OverrideLock::load(root).await; + let previous = lock.bundles.remove(key).unwrap_or_default(); + let mut next = written; + let mut deleted = Vec::new(); + + for (rel, base_sha1) in previous { + if next.contains_key(&rel) { + continue; + } + if listed.contains(&rel) { + next.insert(rel, base_sha1); + continue; + } + + let dest = root.join(polyio::sanitize_path(&rel)); + if current_sha1(&dest).await.as_deref() == Some(base_sha1.as_str()) + && polyio::remove_file(&dest).await.is_ok() + { + deleted.push(rel); + } + } + + lock.bundles.insert(key.to_string(), next); + if let Err(err) = lock.save(root).await { + tracing::warn!(error = %err, "failed to persist the file lock"); + } + + deleted +} + async fn write_override(dest: &Path, bytes: &[u8], rel: &str) -> bool { if let Some(parent) = dest.parent() { polyio::create_dir_all(parent).await.ok(); @@ -187,11 +318,7 @@ async fn current_sha1(path: &Path) -> Option { } } -fn notify_conflicts( - events: Option<&EventBus>, - bundle_name: &str, - conflicts: &[String], -) { +fn notify_conflicts(events: Option<&EventBus>, bundle_name: &str, conflicts: &[String]) { let Some(events) = events else { return; }; @@ -320,6 +447,116 @@ mod tests { .unwrap() } + #[tokio::test] + async fn a_later_layer_wins_over_an_earlier_one() { + let root = polyio::testing::ScratchDir::new("layers"); + let zip = root.join("pack.mrpack"); + write_bundle( + &zip, + &[ + ("client-overrides/options.txt", b"client"), + ("overrides/options.txt", b"common"), + ("overrides/config/a.toml", b"alpha"), + ("server-overrides/server.properties", b"server"), + ], + ) + .await; + + let report = sync_layered_overrides( + &zip, + "test-pack", + root.path(), + &["overrides/", "client-overrides/"], + &|_| true, + None, + ) + .await + .unwrap(); + + assert_eq!(report.written.len(), 2); + assert_eq!(read(&root.join("options.txt")).await.unwrap(), b"client"); + assert_eq!(read(&root.join("config/a.toml")).await.unwrap(), b"alpha"); + assert!(read(&root.join("server.properties")).await.is_none()); + } + + #[tokio::test] + async fn entries_the_filter_rejects_are_left_alone() { + let root = polyio::testing::ScratchDir::new("keep-filter"); + let zip = root.join("pack.mrpack"); + write_bundle( + &zip, + &[ + ("overrides/mods/bundled.jar", b"jar"), + ("overrides/config/a.toml", b"alpha"), + ], + ) + .await; + + let report = sync_layered_overrides( + &zip, + "test-pack", + root.path(), + &["overrides/"], + &|rel| !rel.starts_with("mods/"), + None, + ) + .await + .unwrap(); + + assert_eq!(report.written, vec!["config/a.toml".to_string()]); + assert!(read(&root.join("mods/bundled.jar")).await.is_none()); + } + + #[tokio::test] + async fn a_file_dropped_from_the_lock_is_deleted_only_when_untouched() { + let root = polyio::testing::ScratchDir::new("file-lock"); + polyio::create_dir_all(root.join("config")).await.unwrap(); + polyio::write(root.join("config/kept.json"), b"mine") + .await + .unwrap(); + polyio::write(root.join("config/gone.json"), b"pack") + .await + .unwrap(); + polyio::write(root.join("config/edited.json"), b"edited") + .await + .unwrap(); + + let first: HashMap = [ + ("config/kept.json", b"mine".as_slice()), + ("config/gone.json", b"pack".as_slice()), + ("config/edited.json", b"pack".as_slice()), + ] + .into_iter() + .map(|(rel, bytes)| (rel.to_string(), sha1_bytes(bytes))) + .collect(); + sync_file_lock(root.path(), "files", first, &Default::default()).await; + + let listed = ["config/kept.json".to_string()].into_iter().collect(); + let deleted = sync_file_lock(root.path(), "files", HashMap::new(), &listed).await; + + assert_eq!(deleted, vec!["config/gone.json".to_string()]); + assert!(read(&root.join("config/kept.json")).await.is_some()); + assert!(read(&root.join("config/edited.json")).await.is_some()); + } + + #[tokio::test] + async fn a_large_override_is_not_skipped() { + let root = polyio::testing::ScratchDir::new("large-override"); + let zip = root.join("pack.mrpack"); + let big = vec![7u8; 17 * 1024 * 1024]; + write_bundle(&zip, &[("overrides/resourcepacks/big.zip", big.as_slice())]).await; + + let report = sync(&zip, root.path()).await; + + assert_eq!(report.written, vec!["resourcepacks/big.zip".to_string()]); + assert_eq!( + read(&root.join("resourcepacks/big.zip")) + .await + .map(|bytes| bytes.len()), + Some(big.len()) + ); + } + #[tokio::test] async fn fresh_install_writes_all() { let root = polyio::testing::ScratchDir::new("fresh"); @@ -482,9 +719,7 @@ mod tests { async fn bootstrap_pre_existing_edit_is_never_overwritten() { let root = polyio::testing::ScratchDir::new("bootstrap"); - polyio::create_dir_all(root.join("config")) - .await - .unwrap(); + polyio::create_dir_all(root.join("config")).await.unwrap(); polyio::write(root.join("config/a.toml"), b"pre-existing") .await .unwrap(); diff --git a/packages/oneclient_content/src/bundles/polymrpack.rs b/packages/oneclient_content/src/bundles/polymrpack.rs index fe3d460e..c51b2361 100644 --- a/packages/oneclient_content/src/bundles/polymrpack.rs +++ b/packages/oneclient_content/src/bundles/polymrpack.rs @@ -4,14 +4,15 @@ use std::str::FromStr; use serde::Deserialize; -use crate::error::ContentResult; use crate::bundles::error::BundleError; use crate::bundles::types::{ - BundleFile, BundleFileKind, BundleManifest, content_type_from_bundle_path, + BundleFile, BundleFileKind, BundleFileType, BundleManifest, ExternalFileMeta, + content_type_from_bundle_path, }; +use crate::error::ContentResult; +use crate::packages::types::ExternalFile; use oneclient_common::constants::MODRINTH_CDN_PREFIX; use oneclient_common::domain::{GameLoader, ProviderId}; -use crate::packages::types::ExternalFile; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PolyMrpackMeta { @@ -32,6 +33,8 @@ struct PolyMrpackManifest { pub version_id: String, pub name: String, #[serde(default)] + pub java_version_override: Option, + #[serde(default)] pub dependencies: HashMap, #[serde(default)] pub files: Vec, @@ -49,6 +52,24 @@ struct PolyMrpackFile { pub enabled: bool, #[serde(default)] pub hidden: bool, + #[serde(rename = "type", default)] + pub file_type: Option, + #[serde(default)] + pub id: Option, + #[serde(default)] + pub overrides: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct PolyMrpackFileOverrides { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub authors: Vec, + #[serde(default)] + pub icon: Option, } #[derive(Debug, Deserialize)] @@ -97,37 +118,90 @@ fn parse_manifest_bytes(bytes: &[u8]) -> ContentResult { loader, loader_version, enabled: manifest.enabled, + java_version_override: manifest.java_version_override, files, }) } +pub(crate) struct PackDependencies { + pub(crate) mc_version: Option, + pub(crate) loader: Option<(GameLoader, String)>, +} + +pub(crate) fn scan_dependencies( + deps: &HashMap, + accept: impl Fn(GameLoader) -> bool, +) -> PackDependencies { + let mut scanned = PackDependencies { + mc_version: None, + loader: None, + }; + + for (key, value) in deps { + let normalized = key.to_lowercase().replace(['_', '.', ' ', '-'], ""); + if normalized == "minecraft" { + scanned.mc_version = Some(value.clone()); + } else if let Ok(parsed) = GameLoader::from_str(&normalized) + && accept(parsed) + { + scanned.loader = Some((parsed, value.clone())); + } + } + + scanned +} + fn parse_dependencies( deps: &HashMap, ) -> ContentResult<(String, GameLoader, String)> { - let mut mc_version = None; - let mut loader = None; - let mut loader_version = None; - - for (key, value) in deps { - let normalized = key.to_lowercase().replace(['_', '.', ' ', '-'], ""); - if normalized == "minecraft" { - mc_version = Some(value.clone()); - } else if let Ok(parsed) = GameLoader::from_str(&normalized) { - loader = Some(parsed); - loader_version = Some(value.clone()); - } - } + let scanned = scan_dependencies(deps, |_| true); + let (loader, loader_version) = scanned.loader.ok_or(BundleError::InvalidManifest)?; Ok(( - mc_version.ok_or(BundleError::InvalidManifest)?, - loader.ok_or(BundleError::InvalidManifest)?, - loader_version.ok_or(BundleError::InvalidManifest)?, + scanned.mc_version.ok_or(BundleError::InvalidManifest)?, + loader, + loader_version, )) } fn parse_bundle_file(file: &PolyMrpackFile) -> Option { - if let Some(url) = file - .downloads + let mut kind = mrpack_file_kind( + &file.path, + &file.downloads, + &file.hashes.sha1, + file.file_size, + )?; + + if let BundleFileKind::External { id, meta, .. } = &mut kind { + *id = non_blank(file.id.as_deref()).map(|id| { + if id.starts_with(EXTERNAL_ID_PREFIX) { + id + } else { + format!("{EXTERNAL_ID_PREFIX}{id}") + } + }); + *meta = file.overrides.as_ref().and_then(external_meta); + } + + Some(BundleFile { + enabled: file.enabled, + hidden: file.hidden, + path: file.path.clone(), + size: file.file_size, + file_type: file.file_type.unwrap_or_default(), + kind, + }) +} + +pub(crate) fn mrpack_file_kind( + path: &str, + downloads: &[String], + sha1: &str, + size: u64, +) -> Option { + let sha1 = sha1.to_ascii_lowercase(); + + if let Some(url) = downloads .iter() .find(|url| url.starts_with(MODRINTH_CDN_PREFIX)) { @@ -135,42 +209,52 @@ fn parse_bundle_file(file: &PolyMrpackFile) -> Option { .split('/') .collect::>(); if paths.len() >= 4 { - return Some(BundleFile { - enabled: file.enabled, - hidden: file.hidden, - path: file.path.clone(), - size: file.file_size, - kind: BundleFileKind::Managed { - provider: ProviderId::Modrinth, - project_id: paths[0].to_string(), - version_id: paths[2].to_string(), - sha1: file.hashes.sha1.to_ascii_lowercase(), - }, + return Some(BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: paths[0].to_string(), + version_id: paths[2].to_string(), + sha1, }); } tracing::error!("invalid modrinth file URL in bundle: '{url}'"); return None; } - let download_url = file.downloads.first().cloned()?; - let file_name = file - .path - .split('/') - .next_back() - .unwrap_or(&file.path) - .to_string(); + let download_url = downloads.first().cloned()?; + let file_name = path.split('/').next_back().unwrap_or(path).to_string(); - Some(BundleFile { - enabled: file.enabled, - hidden: file.hidden, - path: file.path.clone(), - size: file.file_size, - kind: BundleFileKind::External(ExternalFile { + Some(BundleFileKind::External { + file: ExternalFile { name: file_name, url: download_url, - sha1: file.hashes.sha1.to_ascii_lowercase(), - size: file.file_size, - content_type: content_type_from_bundle_path(&file.path), - }), + sha1, + size, + content_type: content_type_from_bundle_path(path), + }, + id: None, + meta: None, }) } + +const EXTERNAL_ID_PREFIX: &str = "ext:"; + +fn non_blank(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn external_meta(overrides: &PolyMrpackFileOverrides) -> Option { + let meta = ExternalFileMeta { + name: non_blank(overrides.name.as_deref()), + description: non_blank(overrides.description.as_deref()), + authors: overrides + .authors + .iter() + .filter_map(|author| non_blank(Some(author))) + .collect(), + icon_url: non_blank(overrides.icon.as_deref()), + }; + (meta != ExternalFileMeta::default()).then_some(meta) +} diff --git a/packages/oneclient_content/src/bundles/types.rs b/packages/oneclient_content/src/bundles/types.rs index 41c207e2..f099bbdf 100644 --- a/packages/oneclient_content/src/bundles/types.rs +++ b/packages/oneclient_content/src/bundles/types.rs @@ -1,8 +1,10 @@ +use std::collections::HashSet; + use serde::{Deserialize, Serialize}; use crate::bundles::Bundle; -use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use crate::packages::types::ExternalFile; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; #[derive(Debug, Clone)] pub struct BundleArchive { @@ -19,6 +21,8 @@ pub struct BundleManifest { pub loader: GameLoader, pub loader_version: String, pub enabled: bool, + #[serde(default)] + pub java_version_override: Option, pub files: Vec, } @@ -30,24 +34,73 @@ pub struct BundleFile { pub path: String, #[serde(default)] pub size: u64, + #[serde(default)] + pub file_type: BundleFileType, pub kind: BundleFileKind, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BundleFileType { + Advanced, + #[default] + #[serde(other)] + Normal, +} + impl BundleFile { + pub fn is_optional_offer(&self) -> bool { + !self.enabled && !self.hidden && self.file_type == BundleFileType::Normal + } + pub fn content_type(&self) -> ContentType { - if let BundleFileKind::External(ext) = &self.kind { - return ext.content_type; + if let BundleFileKind::External { file, .. } = &self.kind { + return file.content_type; } content_type_from_bundle_path(&self.path) } + pub fn is_github_hosted(&self) -> bool { + let BundleFileKind::External { file, .. } = &self.kind else { + return false; + }; + url::Url::parse(&file.url).is_ok_and(|url| { + url.host_str().is_some_and(|host| { + host == "github.com" + || host.ends_with(".github.com") + || host.ends_with(".githubusercontent.com") + }) + }) + } + + pub fn github_repo_url(&self) -> Option { + let BundleFileKind::External { file, .. } = &self.kind else { + return None; + }; + let url = url::Url::parse(&file.url).ok()?; + if !matches!(url.host_str()?, "github.com" | "raw.githubusercontent.com") { + return None; + } + let mut segments = url.path_segments()?.filter(|s| !s.is_empty()); + let owner = segments.next()?; + let repo = segments.next()?; + Some(format!("https://github.com/{owner}/{repo}")) + } + pub fn display_name(&self) -> String { + if let BundleFileKind::External { + meta: Some(meta), .. + } = &self.kind + && let Some(name) = &meta.name + { + return name.clone(); + } let from_path = self.path.rsplit('/').next().filter(|s| !s.is_empty()); if let Some(name) = from_path { return name.to_string(); } match &self.kind { - BundleFileKind::External(ext) => ext.name.clone(), + BundleFileKind::External { file, .. } => file.name.clone(), BundleFileKind::Managed { project_id, .. } => project_id.clone(), } } @@ -61,21 +114,58 @@ pub enum BundleFileKind { version_id: String, sha1: String, }, - External(ExternalFile), + External { + file: ExternalFile, + id: Option, + meta: Option, + }, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExternalFileMeta { + pub name: Option, + pub description: Option, + pub authors: Vec, + pub icon_url: Option, } impl BundleFileKind { pub fn package_id(&self) -> String { match self { Self::Managed { project_id, .. } => project_id.clone(), - Self::External(ext) => ext.sha1.clone(), + Self::External { file, id, .. } => id.clone().unwrap_or_else(|| file.sha1.clone()), } } pub fn bundle_version_id(&self) -> String { match self { Self::Managed { version_id, .. } => version_id.clone(), - Self::External(ext) => ext.sha1.clone(), + Self::External { file, .. } => file.sha1.clone(), + } + } + + pub fn metadata_id(&self) -> String { + match self { + Self::Managed { project_id, .. } => project_id.clone(), + Self::External { file, .. } => file.sha1.clone(), + } + } + + pub fn metadata_provider(&self) -> ProviderId { + match self { + Self::Managed { provider, .. } => *provider, + Self::External { .. } => ProviderId::Local, + } + } + + pub fn bundle_key(&self) -> String { + match self { + Self::Managed { + provider, + project_id, + .. + } => managed_bundle_key(*provider, project_id), + Self::External { .. } => external_bundle_key(&self.package_id()), } } } @@ -84,8 +174,8 @@ pub fn managed_bundle_key(provider: ProviderId, package_id: &str) -> String { format!("m:{}:{package_id}", provider.dir_name()) } -pub fn external_bundle_key(sha1: &str) -> String { - format!("e:{sha1}") +pub fn external_bundle_key(package_id: &str) -> String { + format!("e:{package_id}") } pub fn content_type_from_bundle_path(path: &str) -> ContentType { @@ -179,4 +269,5 @@ pub struct BundleWithUpdateStatus { pub archive: BundleArchive, pub files: Vec<(BundleFile, FileUpdateStatus)>, pub has_updates: bool, + pub opted_in_types: HashSet, } diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs index 9144b5d7..7e8f79c8 100644 --- a/packages/oneclient_content/src/bundles/updates.rs +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -4,23 +4,23 @@ use std::sync::{Arc, Mutex, OnceLock}; use std::time::Instant; use oneclient_db::dao::artifact as artifact_dao; -use oneclient_db::dao::cluster as cluster_dao; use oneclient_db::dao::bundle as bundle_catalog_dao; +use oneclient_db::dao::cluster as cluster_dao; use oneclient_db::dao::cluster_bundle as bundle_dao; use oneclient_db::dao::cluster_optional_mod as optional_dao; use oneclient_db::models::ClusterPatch; use oneclient_db::models::{ - BundleTrackedArtifactRow, ClusterBundleOverrideRow, OptionalModStatus, OverrideType, - SeenStatus, + BundleTrackedArtifactRow, ClusterBundleOverrideRow, OptionalModStatus, OverrideType, SeenStatus, }; use tokio::sync::Mutex as AsyncMutex; use futures_util::StreamExt; use crate::bundles::install::{ - BUNDLE_INSTALL_CONCURRENCY, disable_was_deliberate, find_user_suppression, - heal_bundle_activity, install_package_from_bundle, remove_artifact_from_cluster, - set_artifact_enabled_to, + BUNDLE_INSTALL_CONCURRENCY, accepted_bundles, bundle_cluster, decline_unasked_skyblock, + disable_was_deliberate, external_ids_by_sha1, find_override, find_user_suppression, + heal_bundle_activity, install_package_from_bundle, overrides_outside_declined, + remove_artifact_from_cluster, set_artifact_enabled_to, takes_bundle, }; use crate::bundles::manager::BundlesManager; use crate::bundles::overrides; @@ -29,11 +29,11 @@ use crate::bundles::types::{ BundlePackageAddition, BundlePackageRemoval, BundlePackageUpdate, BundleUpdateCheckResult, BundleWithUpdateStatus, FileUpdateStatus, external_bundle_key, managed_bundle_key, }; -use oneclient_common::domain::{GameLoader, ProviderId}; -use crate::packages::store::PackageStore; -use crate::packages::types::LinkedArtifactInfo; use crate::ctx::ContentCtx; use crate::error::{ContentError, ContentResult}; +use crate::packages::store::PackageStore; +use crate::packages::types::LinkedArtifactInfo; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; static CLUSTER_UPDATE_LOCKS: OnceLock>>>> = OnceLock::new(); @@ -59,6 +59,10 @@ pub async fn check_bundle_updates( /// update so the catalog never has to be fetched for it #[tracing::instrument(level = "debug", skip(ctx))] pub async fn cluster_has_bundle_content(cluster_id: i64, ctx: &ContentCtx) -> ContentResult { + if bundle_cluster(cluster_id, ctx).await?.is_none() { + return Ok(false); + } + if !bundle_dao::list_bundle_tracked(&ctx.db, cluster_id) .await? .is_empty() @@ -79,6 +83,12 @@ async fn check_bundle_updates_inner( overrides: &[ClusterBundleOverrideRow], ) -> ContentResult { let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + if !cluster.uses_bundles() { + return Ok(BundleUpdateCheckResult { + cluster_id, + ..Default::default() + }); + } let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { ContentError::InvalidData { reason: format!("unknown loader {}", cluster.mc_loader), @@ -137,6 +147,10 @@ async fn check_bundle_updates_inner( let mut hidden_dependency_keys_by_bundle: HashMap> = HashMap::new(); let mut hidden_explicit_keys_by_bundle: HashMap> = HashMap::new(); let mut shipped_keys_by_bundle: HashMap> = HashMap::new(); + let mut user_disabled_versions: HashMap< + (String, String), + (String, crate::bundles::types::BundleFile), + > = HashMap::new(); for archive in &archives { let mut files_map = HashMap::new(); @@ -145,41 +159,24 @@ async fn check_bundle_updates_inner( let mut shipped_keys = HashSet::new(); for file in &archive.manifest.files { - shipped_keys.insert(match &file.kind { - BundleFileKind::Managed { - provider, - project_id, - .. - } => managed_bundle_key(*provider, project_id), - BundleFileKind::External(ext) => external_bundle_key(&ext.sha1), - }); + shipped_keys.insert(file.kind.bundle_key()); let user_override = overrides_map .get(&(archive.manifest.name.clone(), file.kind.package_id())) .copied(); if !crate::bundles::effective_enabled(file, user_override) { + if user_override == Some(OverrideType::Disabled) { + user_disabled_versions.insert( + (archive.manifest.name.clone(), file.kind.bundle_key()), + (file.kind.bundle_version_id(), file.clone()), + ); + } continue; } - match &file.kind { - BundleFileKind::Managed { - provider, - project_id, - version_id, - .. - } => { - let key = managed_bundle_key(*provider, project_id); - files_map.insert(key.clone(), (version_id.clone(), file.clone())); - if file.hidden { - hidden_explicit_keys.insert(key); - } - } - BundleFileKind::External(ext) => { - let key = external_bundle_key(&ext.sha1); - files_map.insert(key.clone(), (ext.sha1.clone(), file.clone())); - if file.hidden { - hidden_explicit_keys.insert(key); - } - } + let key = file.kind.bundle_key(); + files_map.insert(key.clone(), (file.kind.bundle_version_id(), file.clone())); + if file.hidden { + hidden_explicit_keys.insert(key); } } @@ -231,6 +228,7 @@ async fn check_bundle_updates_inner( .map(|a| a.manifest.name.as_str()) .collect(); + let external_ids = external_ids_by_sha1(&archives); let mut updates_available = Vec::new(); let mut removals_available = Vec::new(); @@ -242,7 +240,8 @@ async fn check_bundle_updates_inner( continue; }; - let installed_key = bundle_package_key(bundle_pkg, &all_linked_by_hash, pkg_id); + let installed_key = + bundle_package_key(bundle_pkg, &all_linked_by_hash, &external_ids, pkg_id); let Some(bundle_name) = &bundle_pkg.bundle_name else { continue; @@ -282,6 +281,18 @@ async fn check_bundle_updates_inner( } } + if matched_target.is_none() { + matched_target = user_disabled_versions + .get(&(bundle_name.clone(), installed_key.clone())) + .map(|(new_version_id, new_file)| { + ( + bundle_name.clone(), + new_version_id.clone(), + new_file.clone(), + ) + }); + } + if let Some((resolved_bundle_name, new_version_id, new_file)) = matched_target { if installed_version_id != &new_version_id { updates_available.push(BundlePackageUpdate { @@ -334,46 +345,63 @@ async fn check_bundle_updates_inner( let addition_eligible_bundles = addition_eligible_bundles( ctx, + cluster_id, + &archives, &bundle_packages, &all_linked, - &candidate_keys_by_bundle, overrides, + &overrides_map, ) .await?; let mut additions_available = Vec::new(); let mut optional_available: Vec<(String, BundleOptionalPackage)> = Vec::new(); let mut planned_addition_keys = all_installed_managed_keys.clone(); - for hash in all_installed_external_hashes { - planned_addition_keys.insert(external_bundle_key(&hash)); - } + planned_addition_keys.extend(installed_external_keys( + &all_installed_external_hashes, + &bundle_packages, + )); + let installed_keys = planned_addition_keys.clone(); + let opt_outs = TypeOptOuts::load( + cluster_id, + &archives, + &addition_eligible_bundles, + overrides, + &installed_keys, + ctx, + ) + .await?; for archive in &archives { if !addition_eligible_bundles.contains(&archive.manifest.name) { continue; } + let suppressed_types = opt_outs.remember(archive, ctx).await?; for file in &archive.manifest.files { + let suppressed = suppressed_types.contains(&file.content_type()); let file_id = file.kind.package_id(); let user_override = overrides_map .get(&(archive.manifest.name.clone(), file_id.clone())) .copied(); - let file_key = match &file.kind { - BundleFileKind::Managed { - provider, - project_id, - .. - } => managed_bundle_key(*provider, project_id), - BundleFileKind::External(ext) => external_bundle_key(&ext.sha1), - }; - if planned_addition_keys.contains(&file_key) { + let file_key = file.kind.bundle_key(); + let exact_file_installed = matches!( + &file.kind, + BundleFileKind::External { file: ext, .. } + if planned_addition_keys.contains(&external_bundle_key(&ext.sha1)) + ); + if planned_addition_keys.contains(&file_key) || exact_file_installed { + continue; + } + + if suppressed && user_override.is_none() { continue; } if !crate::bundles::effective_enabled(file, user_override) { if user_override.is_none() - && !file.hidden + && file.is_optional_offer() && !optional_available.iter().any(|(key, _)| *key == file_key) { optional_available.push(( @@ -421,11 +449,10 @@ pub async fn apply_bundle_updates( ctx: &ContentCtx, deadline: Option, ) -> ContentResult { - let session = oneclient_events::GroupedProgressSession::start( - &ctx.events, - "Updating bundle content", - ); - let result = apply_bundle_updates_with(cluster_id, bundles, ctx, Some(&session), deadline).await; + let session = + oneclient_events::GroupedProgressSession::start(&ctx.events, "Updating bundle content"); + let result = + apply_bundle_updates_with(cluster_id, bundles, ctx, Some(&session), deadline).await; session.finish(); result } @@ -444,22 +471,33 @@ pub async fn apply_bundle_updates_with( session: Option<&oneclient_events::GroupedProgressSession>, deadline: Option, ) -> ContentResult { + if bundle_cluster(cluster_id, ctx).await?.is_none() { + return Ok(ApplyBundleUpdatesResult::default()); + } + let lock = cluster_lock(cluster_id); let _guard = lock.lock().await; // Must run first // the check below never consults `enabled` so unrecorded disables would // read as healthy and be left untouched - { + let archives = { let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); - if let Ok(archives) = bundles.archives_for(ctx, &cluster.mc_version, loader).await { - heal_bundle_activity(cluster_id, &archives, ctx).await?; + let archives = bundles.archives_for(ctx, &cluster.mc_version, loader).await; + if let Ok(archives) = &archives { + heal_bundle_activity(cluster_id, archives, ctx).await?; + decline_unasked_skyblock(&cluster, archives, ctx).await?; } - } + archives + }; let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; let check = check_bundle_updates_inner(cluster_id, bundles, ctx, &overrides).await?; + let suppressing = match &archives { + Ok(archives) => overrides_outside_declined(cluster_id, archives, overrides, ctx).await?, + Err(_) => overrides, + }; tracing::info!( cluster_id, @@ -493,12 +531,17 @@ pub async fn apply_bundle_updates_with( .updates_available .iter() .map(|u| u.new_file.size.max(1)) - .chain(check.additions_available.iter().map(|a| a.new_file.size.max(1))) + .chain( + check + .additions_available + .iter() + .map(|a| a.new_file.size.max(1)), + ) .sum(); s.expect(oneclient_events::TaskCategory::Packages, count, bytes); } - let overrides_ref = &overrides; + let overrides_ref = &suppressing; let fetched_updates = futures_util::stream::iter(check.updates_available.into_iter().map( |update| async move { if past(deadline) { @@ -608,11 +651,9 @@ pub async fn apply_bundle_updates_with( { let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); - if let Ok(archives) = bundles - .archives_for(ctx, &cluster.mc_version, loader) - .await - { - for archive in archives { + if let Ok(archives) = bundles.archives_for(ctx, &cluster.mc_version, loader).await { + let accepted = accepted_bundles(cluster_id, ctx).await?; + for archive in archives.iter().filter(|a| takes_bundle(a, &accepted)) { if let Err(err) = overrides::sync_bundle_overrides( &archive.bundle.path, &archive.manifest.name, @@ -732,7 +773,8 @@ async fn reconcile_update( set_artifact_enabled_to(update.cluster_id, hash, enabled, ctx).await?; if hash != update.installed_hash { - artifact_dao::set_seen_status(&ctx.db, update.cluster_id, hash, SeenStatus::Updated).await?; + artifact_dao::set_seen_status(&ctx.db, update.cluster_id, hash, SeenStatus::Updated) + .await?; remove_artifact_from_cluster(update.cluster_id, &update.installed_hash, false, ctx).await?; } @@ -774,7 +816,11 @@ async fn sync_cluster_loader_version_from_bundles( let subscribed: HashSet = bundle_packages .iter() .filter_map(|bp| bp.bundle_name.clone()) - .chain(infer_subscribed_from_archives(&archives, &managed_keys)) + .chain(infer_subscribed_from_archives( + &archives, + &HashMap::new(), + &managed_keys, + )) .collect(); let target = select_highest_loader_version( @@ -812,18 +858,26 @@ pub async fn get_bundles_with_update_status( ctx: &ContentCtx, ) -> ContentResult> { let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + if !cluster.uses_bundles() { + return Ok(Vec::new()); + } let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); let bundle_packages = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; let all_linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; let all_linked_by_hash: HashMap = all_linked.iter().map(|a| (a.hash.clone(), a)).collect(); + let archives = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await?; + let external_ids = external_ids_by_sha1(&archives); + let mut installed_map: HashMap = HashMap::new(); for bundle_pkg in &bundle_packages { let Some(pkg_id) = &bundle_pkg.package_id else { continue; }; - let key = bundle_package_key(bundle_pkg, &all_linked_by_hash, pkg_id); + let key = bundle_package_key(bundle_pkg, &all_linked_by_hash, &external_ids, pkg_id); installed_map.insert(key, bundle_pkg); } @@ -836,9 +890,34 @@ pub async fn get_bundles_with_update_status( }) .collect(); - let archives = bundles - .archives_for(ctx, &cluster.mc_version, loader) - .await?; + // Same liveness the updater uses so an untracked older install is not hidden from the list while it still takes on new files + let live_bundles = addition_eligible_bundles( + ctx, + cluster_id, + &archives, + &bundle_packages, + &all_linked, + &overrides, + &overrides_map, + ) + .await?; + + let (installed_managed_keys, installed_external_hashes) = + installed_bundle_keys(ctx, &all_linked).await?; + let mut installed_keys = installed_managed_keys; + installed_keys.extend(installed_external_keys( + &installed_external_hashes, + &bundle_packages, + )); + let opt_outs = TypeOptOuts::load( + cluster_id, + &archives, + &live_bundles, + &overrides, + &installed_keys, + ctx, + ) + .await?; let mut results = Vec::new(); for archive in archives { @@ -846,53 +925,44 @@ pub async fn get_bundles_with_update_status( let mut has_updates = false; for file in &archive.manifest.files { - let status = match &file.kind { - BundleFileKind::Managed { - provider, - project_id, - version_id, - .. - } => { - let key = managed_bundle_key(*provider, project_id); - if let Some(installed) = installed_map.get(&key) { - let installed_version = - installed.bundle_version_id.as_deref().unwrap_or(""); - if installed_version == version_id.as_str() { - FileUpdateStatus::UpToDate - } else { - has_updates = true; - FileUpdateStatus::UpdateAvailable { - installed_version_id: installed_version.to_string(), - new_version_id: version_id.clone(), - } - } - } else if matches!( - overrides_map.get(&(archive.manifest.name.clone(), project_id.clone())), - Some(OverrideType::Removed) - ) { - FileUpdateStatus::RemovedByUser - } else { - FileUpdateStatus::NotInstalled - } - } - BundleFileKind::External(ext) => { - let key = external_bundle_key(&ext.sha1); - if installed_map.contains_key(&key) { - FileUpdateStatus::UpToDate - } else if matches!( - overrides_map.get(&(archive.manifest.name.clone(), ext.sha1.clone())), - Some(OverrideType::Removed) - ) { - FileUpdateStatus::RemovedByUser - } else { - FileUpdateStatus::NotInstalled + let new_version_id = file.kind.bundle_version_id(); + let status = if let Some(installed) = installed_map.get(&file.kind.bundle_key()) { + let installed_version = installed.bundle_version_id.as_deref().unwrap_or(""); + if installed_version == new_version_id { + FileUpdateStatus::UpToDate + } else { + has_updates = true; + FileUpdateStatus::UpdateAvailable { + installed_version_id: installed_version.to_string(), + new_version_id, } } + } else if matches!( + overrides_map.get(&(archive.manifest.name.clone(), file.kind.package_id())), + Some(OverrideType::Removed) + ) { + FileUpdateStatus::RemovedByUser + } else { + FileUpdateStatus::NotInstalled }; files.push((file.clone(), status)); } + let opted_in_types = if live_bundles.contains(&archive.manifest.name) { + let suppressed = opt_outs.suppressed(&archive); + archive + .manifest + .files + .iter() + .map(|f| f.content_type()) + .filter(|ct| !suppressed.contains(ct)) + .collect() + } else { + HashSet::new() + }; + results.push(BundleWithUpdateStatus { + opted_in_types, archive, files, has_updates, @@ -905,14 +975,189 @@ pub async fn get_bundles_with_update_status( fn bundle_package_key( bundle_pkg: &BundleTrackedArtifactRow, linked: &HashMap, + external_ids: &HashMap, package_id: &str, ) -> String { if package_id == bundle_pkg.hash { - external_bundle_key(&bundle_pkg.hash) - } else if let Some(info) = linked.get(&bundle_pkg.hash) { - managed_bundle_key(info.provider.unwrap_or(ProviderId::Modrinth), package_id) + return external_bundle_key( + external_ids + .get(package_id) + .map_or(package_id, String::as_str), + ); + } + let provider = linked.get(&bundle_pkg.hash).and_then(|info| info.provider); + if provider.is_none() && external_ids.values().any(|id| id == package_id) { + external_bundle_key(package_id) } else { - managed_bundle_key(ProviderId::Modrinth, package_id) + managed_bundle_key(provider.unwrap_or(ProviderId::Modrinth), package_id) + } +} + +/// Keys both the exact file and the id it was tracked under so a GitHub-hosted file still counts as installed once the bundle moves to a newer release +fn installed_external_keys( + hashes: &HashSet, + bundle_packages: &[BundleTrackedArtifactRow], +) -> HashSet { + let tracked_package_by_hash: HashMap<&str, &str> = bundle_packages + .iter() + .filter_map(|bp| Some((bp.hash.as_str(), bp.package_id.as_deref()?))) + .collect(); + hashes + .iter() + .flat_map(|hash| { + std::iter::once(hash.as_str()) + .chain(tracked_package_by_hash.get(hash.as_str()).copied()) + }) + .map(external_bundle_key) + .collect() +} + +fn keys_shipped_by_several_bundles<'a>( + archives: impl IntoIterator, +) -> HashSet { + let mut counts: HashMap = HashMap::new(); + + for archive in archives { + let mut seen: HashSet = HashSet::new(); + for file in &archive.manifest.files { + let key = file.kind.bundle_key(); + if seen.insert(key.clone()) { + *counts.entry(key).or_default() += 1; + } + } + } + + counts + .into_iter() + .filter_map(|(key, count)| (count > 1).then_some(key)) + .collect() +} + +struct TypeOptOuts<'a> { + cluster_id: i64, + overrides: &'a [ClusterBundleOverrideRow], + stray_overrides: Vec, + installed_keys: &'a HashSet, + shared_keys: HashSet, + remembered: HashMap>, +} + +impl<'a> TypeOptOuts<'a> { + async fn load( + cluster_id: i64, + archives: &[BundleArchive], + live: &HashSet, + overrides: &'a [ClusterBundleOverrideRow], + installed_keys: &'a HashSet, + ctx: &ContentCtx, + ) -> ContentResult { + let shipped: HashSet<(&str, String)> = archives + .iter() + .flat_map(|a| { + a.manifest + .files + .iter() + .map(|f| (a.manifest.name.as_str(), f.kind.package_id())) + }) + .collect(); + let stray_overrides = overrides + .iter() + .filter(|o| !shipped.contains(&(o.bundle_name.as_str(), o.package_id.clone()))) + .cloned() + .collect(); + + let mut remembered: HashMap> = HashMap::new(); + for (bundle_name, content_type) in + bundle_dao::list_type_opt_outs(&ctx.db, cluster_id).await? + { + if let Some(ct) = ContentType::from_repr(content_type as u8) { + remembered.entry(bundle_name).or_default().insert(ct); + } + } + + Ok(Self { + cluster_id, + overrides, + stray_overrides, + installed_keys, + shared_keys: keys_shipped_by_several_bundles( + archives.iter().filter(|a| live.contains(&a.manifest.name)), + ), + remembered, + }) + } + + fn signals(&self, archive: &BundleArchive) -> (HashSet, HashSet) { + let bundle_name = &archive.manifest.name; + let mut all_suppressed: HashMap = HashMap::new(); + + for file in archive.manifest.files.iter().filter(|f| !f.hidden) { + let package_id = file.kind.package_id(); + let override_type = find_override(self.overrides, bundle_name, &package_id) + .or_else(|| find_user_suppression(&self.stray_overrides, &package_id)); + let key = file.kind.bundle_key(); + let installed = self.installed_keys.contains(&key) + || matches!(&file.kind, BundleFileKind::External { file: ext, .. } + if self.installed_keys.contains(&external_bundle_key(&ext.sha1))); + if override_type.is_none() && !installed { + continue; + } + if self.shared_keys.contains(&key) { + continue; + } + + let suppressed = matches!( + override_type, + Some(OverrideType::Disabled | OverrideType::Removed) + ); + let entry = all_suppressed.entry(file.content_type()).or_insert(true); + *entry = *entry && suppressed; + } + + let seen = all_suppressed.keys().copied().collect(); + let suppressed = all_suppressed + .into_iter() + .filter_map(|(ct, all)| all.then_some(ct)) + .collect(); + (seen, suppressed) + } + + fn suppressed(&self, archive: &BundleArchive) -> HashSet { + let (seen, mut suppressed) = self.signals(archive); + suppressed.extend( + self.remembered + .get(&archive.manifest.name) + .into_iter() + .flatten() + .filter(|ct| !seen.contains(ct)), + ); + suppressed + } + + async fn remember( + &self, + archive: &BundleArchive, + ctx: &ContentCtx, + ) -> ContentResult> { + let bundle_name = &archive.manifest.name; + let (seen, suppressed) = self.signals(archive); + let remembered = self.remembered.get(bundle_name); + + for ct in &seen { + let opted_out = suppressed.contains(ct); + if opted_out != remembered.is_some_and(|r| r.contains(ct)) { + bundle_dao::set_type_opt_out( + &ctx.db, + self.cluster_id, + bundle_name, + *ct as i64, + opted_out, + ) + .await?; + } + } + + Ok(self.suppressed(archive)) } } @@ -922,33 +1167,47 @@ fn bundle_package_key( #[tracing::instrument(level = "debug", skip_all)] async fn addition_eligible_bundles( ctx: &ContentCtx, + cluster_id: i64, + archives: &[BundleArchive], bundle_packages: &[BundleTrackedArtifactRow], all_linked: &[LinkedArtifactInfo], - candidate_keys_by_bundle: &HashMap>, overrides: &[ClusterBundleOverrideRow], + overrides_map: &HashMap<(String, String), OverrideType>, ) -> ContentResult> { - let (live_managed_keys, _) = - installed_bundle_keys(ctx, all_linked.iter().filter(|item| item.enabled)).await?; + let live: Vec<_> = all_linked.iter().filter(|item| item.enabled).collect(); + let (live_managed_keys, _) = installed_bundle_keys(ctx, live).await?; - let mut eligible: HashSet = bundle_packages + let mut eligible = live_bundle_names(bundle_packages, overrides); + eligible.extend(infer_subscribed_from_archives( + archives, + overrides_map, + &live_managed_keys, + )); + let accepted = accepted_bundles(cluster_id, ctx).await?; + eligible.retain(|name| { + !archives + .iter() + .any(|archive| archive.manifest.name == *name && !takes_bundle(archive, &accepted)) + }); + + Ok(eligible) +} + +fn live_bundle_names( + bundle_packages: &[BundleTrackedArtifactRow], + overrides: &[ClusterBundleOverrideRow], +) -> HashSet { + bundle_packages .iter() .filter(|bp| bp.enabled != 0) .filter_map(|bp| bp.bundle_name.clone()) - .collect(); - - eligible.extend(infer_bundle_names_from_unique_installed_keys( - candidate_keys_by_bundle, - &live_managed_keys, - )); - - eligible.extend( - overrides - .iter() - .filter(|o| OverrideType::parse(&o.override_type) == Some(OverrideType::Enabled)) - .map(|o| o.bundle_name.clone()), - ); - - Ok(eligible) + .chain( + overrides + .iter() + .filter(|o| OverrideType::parse(&o.override_type) == Some(OverrideType::Enabled)) + .map(|o| o.bundle_name.clone()), + ) + .collect() } #[tracing::instrument(level = "debug", skip_all)] @@ -1017,13 +1276,17 @@ fn infer_bundle_names_from_unique_installed_keys( fn infer_subscribed_from_archives( archives: &[BundleArchive], + overrides_map: &HashMap<(String, String), OverrideType>, managed_keys: &HashSet, ) -> HashSet { let mut candidate_keys_by_bundle = HashMap::new(); for archive in archives { let mut keys = HashSet::new(); for file in &archive.manifest.files { - if !file.enabled { + let user_override = overrides_map + .get(&(archive.manifest.name.clone(), file.kind.package_id())) + .copied(); + if !crate::bundles::effective_enabled(file, user_override) { continue; } if let BundleFileKind::Managed { diff --git a/packages/oneclient_content/src/ctx.rs b/packages/oneclient_content/src/ctx.rs index f3bf88d4..1045ec72 100644 --- a/packages/oneclient_content/src/ctx.rs +++ b/packages/oneclient_content/src/ctx.rs @@ -9,25 +9,25 @@ use crate::packages::provider::PackageProviderRegistry; /// four subsystems #[derive(Clone)] pub struct ContentCtx { - pub db: DbPool, - pub net: RequestClient, - pub events: EventBus, - pub providers: PackageProviderRegistry, + pub db: DbPool, + pub net: RequestClient, + pub events: EventBus, + pub providers: PackageProviderRegistry, } impl ContentCtx { - #[must_use] - pub fn new( - db: DbPool, - net: RequestClient, - events: EventBus, - providers: PackageProviderRegistry, - ) -> Self { - Self { - db, - net, - events, - providers, - } - } + #[must_use] + pub fn new( + db: DbPool, + net: RequestClient, + events: EventBus, + providers: PackageProviderRegistry, + ) -> Self { + Self { + db, + net, + events, + providers, + } + } } diff --git a/packages/oneclient_content/src/error.rs b/packages/oneclient_content/src/error.rs index a696c2a7..481a41e1 100644 --- a/packages/oneclient_content/src/error.rs +++ b/packages/oneclient_content/src/error.rs @@ -5,62 +5,62 @@ pub type ContentResult = Result; #[derive(Debug, thiserror::Error)] pub enum ContentError { - #[error(transparent)] - Package(#[from] PackageError), + #[error(transparent)] + Package(#[from] PackageError), - #[error(transparent)] - Bundle(#[from] BundleError), + #[error(transparent)] + Bundle(#[from] BundleError), - #[error(transparent)] - Request(#[from] oneclient_net::RequestError), + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), - #[error(transparent)] - Db(#[from] oneclient_db::DbError), + #[error(transparent)] + Db(#[from] oneclient_db::DbError), - #[error("Database execution failed: {0}")] - Sql(#[from] sqlx::Error), + #[error("Database execution failed: {0}")] + Sql(#[from] sqlx::Error), - #[error(transparent)] - Io(#[from] polyio::IOError), + #[error(transparent)] + Io(#[from] polyio::IOError), - #[error(transparent)] - StdIo(#[from] std::io::Error), + #[error(transparent)] + StdIo(#[from] std::io::Error), - #[error(transparent)] - Paths(#[from] oneclient_common::PathsError), + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), - #[error(transparent)] - Events(#[from] oneclient_events::EventError), + #[error(transparent)] + Events(#[from] oneclient_events::EventError), - #[error("Unable to parse JSON: {0}")] - Json(#[from] serde_json::Error), + #[error("Unable to parse JSON: {0}")] + Json(#[from] serde_json::Error), - #[error("Invalid URL: {0}")] - Url(#[from] url::ParseError), + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), - #[error("invalid content data: {reason}")] - InvalidData { reason: String }, + #[error("invalid content data: {reason}")] + InvalidData { reason: String }, } impl ContentError { - #[must_use] - pub fn is_transient(&self) -> bool { - match self { - Self::Request(source) => source.is_transient(), - Self::Io(polyio::IOError::IOError(source)) - | Self::Io(polyio::IOError::PathIOError { source, .. }) - | Self::StdIo(source) => { - use std::io::ErrorKind; - matches!( - source.kind(), - ErrorKind::ConnectionRefused - | ErrorKind::ConnectionReset - | ErrorKind::ConnectionAborted - | ErrorKind::NotConnected - | ErrorKind::TimedOut - ) - } - _ => false, - } - } + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + Self::Request(source) => source.is_transient(), + Self::Io(polyio::IOError::IOError(source)) + | Self::Io(polyio::IOError::PathIOError { source, .. }) + | Self::StdIo(source) => { + use std::io::ErrorKind; + matches!( + source.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) + } + _ => false, + } + } } diff --git a/packages/oneclient_content/src/lib.rs b/packages/oneclient_content/src/lib.rs index 962c04c0..1df49b0d 100644 --- a/packages/oneclient_content/src/lib.rs +++ b/packages/oneclient_content/src/lib.rs @@ -1,4 +1,6 @@ +#![recursion_limit = "256"] pub mod bundles; +pub mod modpacks; pub mod packages; mod ctx; diff --git a/packages/oneclient_content/src/modpacks/checker.rs b/packages/oneclient_content/src/modpacks/checker.rs new file mode 100644 index 00000000..fbfd42b5 --- /dev/null +++ b/packages/oneclient_content/src/modpacks/checker.rs @@ -0,0 +1,307 @@ +use std::collections::HashSet; +use std::path::Path; + +use serde::Deserialize; + +use super::curseforge::{FileRequest, resolve_requests}; +use super::{ + ModpackContents, ModpackManifest, file_name_of, file_sha1, tracked_content_type, tracked_folder, +}; +use crate::bundles::overrides::OverrideLayers; +use crate::ctx::ContentCtx; +use oneclient_common::domain::ContentType; + +const CHECKER_CONFIG: &str = "config/missing_mods_checker.json"; +const CURSEFORGE_HOST: &str = "curseforge.com"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CheckerEntry { + #[serde(default)] + display_name: String, + #[serde(default)] + url: String, + #[serde(default)] + destination: String, +} + +pub(super) async fn add_checker_mods( + archive_path: &Path, + manifest: &mut ModpackManifest, + ctx: &ContentCtx, +) { + let Some(bytes) = checker_config(archive_path, &manifest.override_prefixes).await else { + return; + }; + + let requests = checker_requests(&bytes); + if requests.is_empty() { + return; + } + + match resolve_requests(&requests, ctx).await { + Ok(resolved) => { + tracing::info!( + listed = requests.len(), + "adding the curseforge mods the pack's missing mods checker asks for" + ); + let bundled = bundled_file_names(archive_path, &manifest.override_prefixes).await; + merge(manifest, resolved, &bundled); + } + Err(err) => { + tracing::warn!( + error = %err, + "could not resolve the missing mods checker list; the game will ask for them instead" + ); + } + } +} + +async fn checker_config(archive_path: &Path, prefixes: &[String]) -> Option> { + for prefix in prefixes.iter().rev() { + match super::zip_entry(archive_path, &format!("{prefix}{CHECKER_CONFIG}")).await { + Ok(Some(bytes)) => return Some(bytes), + Ok(None) => {} + Err(err) => { + tracing::debug!(error = %err, "could not read the missing mods checker config"); + return None; + } + } + } + None +} + +fn checker_requests(bytes: &[u8]) -> Vec { + let entries: Vec = match serde_json::from_slice(bytes) { + Ok(entries) => entries, + Err(err) => { + tracing::warn!(error = %err, "the missing mods checker config is not a list of mods"); + return Vec::new(); + } + }; + + let mut seen = HashSet::new(); + entries + .into_iter() + .filter_map(|entry| { + let file_id = curseforge_file_id(&entry.url)?; + seen.insert(file_id).then(|| FileRequest { + file_id, + project_id: None, + required: true, + label: Some(entry.display_name).filter(|name| !name.trim().is_empty()), + destination: destination(&entry.destination), + }) + }) + .collect() +} + +fn destination(folder: &str) -> Option { + tracked_folder(folder.trim_matches('/')) +} + +fn curseforge_file_id(url: &str) -> Option { + let parsed = url::Url::parse(url).ok()?; + let host = parsed.host_str()?; + if host != CURSEFORGE_HOST && !host.ends_with(&format!(".{CURSEFORGE_HOST}")) { + return None; + } + + let segments: Vec<&str> = parsed.path_segments()?.collect(); + segments + .windows(2) + .find(|pair| matches!(pair[0], "download" | "files")) + .and_then(|pair| pair[1].parse().ok()) +} + +fn merge( + manifest: &mut ModpackManifest, + resolved: ModpackContents, + bundled_names: &HashSet, +) { + let contents = &mut manifest.contents; + let mut ids = HashSet::new(); + let mut hashes = HashSet::new(); + let mut names: HashSet = bundled_names.clone(); + + let mut remember = |id: String, sha1: &str, path: &str| { + let name = file_name_of(path); + let known = ids.contains(&id) || hashes.contains(sha1) || names.contains(&name); + ids.insert(id); + hashes.insert(sha1.to_string()); + names.insert(name); + !known + }; + + for file in &contents.files { + remember(file.kind.package_id(), file_sha1(file), &file.path); + } + for file in &contents.blocked { + remember(file.project_id.clone(), &file.sha1, &file.file_name); + } + + for file in resolved.files { + if remember(file.kind.package_id(), file_sha1(&file), &file.path) { + contents.files.push(file); + } else { + tracing::debug!(file = %file.path, "the pack already has this checker mod"); + } + } + for file in resolved.blocked { + if remember(file.project_id.clone(), &file.sha1, &file.path) { + contents.blocked.push(file); + } else { + tracing::debug!(file = %file.path, "the pack already has this checker mod"); + } + } + for (sha1, file) in resolved.direct { + contents.direct.entry(sha1).or_insert(file); + } + contents.optional.extend(resolved.optional); + contents.loose.extend(resolved.loose); + contents.unresolved.extend(resolved.unresolved); +} + +async fn bundled_file_names(archive_path: &Path, prefixes: &[String]) -> HashSet { + let prefixes: Vec<&str> = prefixes.iter().map(String::as_str).collect(); + match OverrideLayers::open(archive_path, &prefixes).await { + Ok(layers) => layers + .entries() + .iter() + .filter(|(rel, _)| tracked_content_type(rel).is_some()) + .map(|(rel, _)| file_name_of(rel)) + .collect(), + Err(err) => { + tracing::debug!(error = %err, "could not list the pack's bundled files"); + HashSet::new() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_ids_come_from_curseforge_download_links() { + assert_eq!( + curseforge_file_id( + "https://www.curseforge.com/minecraft/mc-mods/balm-fabric/download/7420614" + ), + Some(7_420_614) + ); + assert_eq!( + curseforge_file_id("https://www.curseforge.com/minecraft/mc-mods/thing/files/5010620"), + Some(5_010_620) + ); + assert_eq!( + curseforge_file_id("https://example.com/minecraft/mc-mods/thing/download/5010620"), + None + ); + assert_eq!( + curseforge_file_id("https://www.curseforge.com/minecraft/mc-mods/thing"), + None + ); + } + + #[test] + fn the_checker_list_becomes_required_file_requests() { + let config = serde_json::json!([ + { + "displayName": "Balm (Fabric Edition)", + "pattern": "balm-fabric-1.20.1-7.3.38.jar", + "url": "https://www.curseforge.com/minecraft/mc-mods/balm-fabric/download/7420614", + "destination": "mods" + }, + { + "displayName": "Stay Clear", + "url": "https://www.curseforge.com/minecraft/texture-packs/stay-clear/download/6000001", + "destination": "resourcepacks" + }, + { + "displayName": "Duplicate", + "url": "https://www.curseforge.com/minecraft/mc-mods/balm-fabric/download/7420614" + }, + { + "displayName": "Elsewhere", + "url": "https://github.com/someone/thing/releases/download/1/thing.jar" + } + ]); + + let requests = checker_requests(&serde_json::to_vec(&config).unwrap()); + + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].file_id, 7_420_614); + assert!(requests[0].required); + assert_eq!(requests[0].label.as_deref(), Some("Balm (Fabric Edition)")); + assert_eq!(requests[0].destination, Some(ContentType::Mod)); + assert_eq!(requests[1].destination, Some(ContentType::ResourcePack)); + } + + #[test] + fn a_checker_mod_the_pack_already_has_is_not_added_twice() { + use crate::bundles::{BundleFile, BundleFileKind, BundleFileType}; + use oneclient_common::domain::{GameLoader, ProviderId}; + + let managed = |project: &str, sha1: &str, path: &str| BundleFile { + enabled: true, + hidden: false, + path: path.into(), + size: 1, + file_type: BundleFileType::Normal, + kind: BundleFileKind::Managed { + provider: ProviderId::CurseForge, + project_id: project.into(), + version_id: "1".into(), + sha1: sha1.into(), + }, + }; + + let mut manifest = ModpackManifest { + name: "Pack".into(), + version: "1".into(), + summary: None, + mc_version: "1.20.1".into(), + loader: GameLoader::Fabric, + loader_version: None, + contents: ModpackContents { + files: vec![managed( + "modrinth-balm", + "aaaa", + "mods/balm-fabric-1.20.1-7.3.38.jar", + )], + ..Default::default() + }, + override_prefixes: vec!["overrides/".into()], + }; + + let resolved = ModpackContents { + files: vec![ + managed("111", "bbbb", "mods/balm-fabric-1.20.1-7.3.38.jar"), + managed("222", "aaaa", "mods/renamed.jar"), + managed("333", "cccc", "mods/bundled.jar"), + managed("444", "dddd", "mods/new.jar"), + ], + ..Default::default() + }; + let bundled: HashSet = ["bundled.jar".to_string()].into_iter().collect(); + + merge(&mut manifest, resolved, &bundled); + + let paths: Vec<&str> = manifest + .contents + .files + .iter() + .map(|file| file.path.as_str()) + .collect(); + assert_eq!( + paths, + vec!["mods/balm-fabric-1.20.1-7.3.38.jar", "mods/new.jar"] + ); + } + + #[test] + fn a_config_that_is_not_a_list_is_ignored() { + assert!(checker_requests(br#"{"mods": []}"#).is_empty()); + } +} diff --git a/packages/oneclient_content/src/modpacks/curseforge.rs b/packages/oneclient_content/src/modpacks/curseforge.rs new file mode 100644 index 00000000..22ccb965 --- /dev/null +++ b/packages/oneclient_content/src/modpacks/curseforge.rs @@ -0,0 +1,461 @@ +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +use serde::Deserialize; + +use super::{BlockedFile, LooseFile, ModpackContents, ModpackManifest, is_safe_relative_path}; +use crate::bundles::{BundleFile, BundleFileKind, BundleFileType}; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::PackageError; +use crate::packages::types::{ExternalFile, ProjectDetail, VersionDetail}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; + +pub(super) const MANIFEST_ENTRY: &str = "manifest.json"; + +const MODPACK_MANIFEST_TYPE: &str = "minecraftModpack"; +const LOOKUP_CHUNK: usize = 500; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfManifest { + minecraft: CfMinecraft, + #[serde(default)] + manifest_type: String, + #[serde(default)] + name: String, + #[serde(default)] + version: String, + #[serde(default)] + files: Vec, + #[serde(default)] + overrides: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfMinecraft { + version: String, + #[serde(default)] + mod_loaders: Vec, +} + +#[derive(Deserialize)] +struct CfModLoader { + id: String, + #[serde(default)] + primary: bool, +} + +#[derive(Deserialize)] +struct CfManifestFile { + #[serde(rename = "projectID")] + project_id: u32, + #[serde(rename = "fileID")] + file_id: u32, + #[serde(default = "required_by_default")] + required: bool, +} + +fn required_by_default() -> bool { + true +} + +pub(super) fn is_modpack_manifest(bytes: &[u8]) -> bool { + serde_json::from_slice::(bytes) + .is_ok_and(|manifest| manifest.manifest_type == MODPACK_MANIFEST_TYPE) +} + +pub(super) async fn resolve(bytes: &[u8], ctx: &ContentCtx) -> ContentResult { + let manifest: CfManifest = + serde_json::from_slice(bytes).map_err(|_| PackageError::UnsupportedModpackFormat)?; + if manifest.manifest_type != MODPACK_MANIFEST_TYPE { + return Err(PackageError::UnsupportedModpackFormat.into()); + } + + let (loader, loader_version) = parse_loader(&manifest.minecraft.mod_loaders)?; + + let requests: Vec = manifest + .files + .iter() + .map(|file| FileRequest { + file_id: file.file_id, + project_id: Some(file.project_id), + required: file.required, + label: None, + destination: None, + }) + .collect(); + let contents = resolve_requests(&requests, ctx).await?; + + let overrides = manifest + .overrides + .map(|folder| folder.trim_matches('/').to_string()) + .filter(|folder| !folder.is_empty() && is_safe_relative_path(folder)) + .unwrap_or_else(|| "overrides".to_string()); + + Ok(ModpackManifest { + name: manifest.name, + version: manifest.version, + summary: None, + mc_version: manifest.minecraft.version, + loader, + loader_version, + contents, + override_prefixes: vec![format!("{overrides}/")], + }) +} + +pub(super) struct FileRequest { + pub(super) file_id: u32, + pub(super) project_id: Option, + pub(super) required: bool, + pub(super) label: Option, + pub(super) destination: Option, +} + +pub(super) async fn resolve_requests( + requests: &[FileRequest], + ctx: &ContentCtx, +) -> ContentResult { + let provider = ctx.providers.get(ProviderId::CurseForge)?; + + let file_ids: Vec = requests + .iter() + .map(|request| request.file_id.to_string()) + .collect(); + let mut versions: HashMap = HashMap::new(); + for chunk in file_ids.chunks(LOOKUP_CHUNK) { + for version in provider.get_versions(chunk, ctx).await? { + versions.insert(version.version_id.clone(), version); + } + } + + let project_of = |request: &FileRequest| { + request.project_id.map(|id| id.to_string()).or_else(|| { + versions + .get(&request.file_id.to_string()) + .map(|version| version.project_id.clone()) + }) + }; + let project_ids: Vec = requests + .iter() + .filter_map(project_of) + .collect::>() + .into_iter() + .collect(); + let mut projects: HashMap = HashMap::new(); + for chunk in project_ids.chunks(LOOKUP_CHUNK) { + for project in provider.get_projects(chunk, ctx).await? { + projects.insert(project.id.clone(), project); + } + } + + let mut contents = ModpackContents::default(); + for request in requests { + contents.add( + request, + versions.get(&request.file_id.to_string()), + project_of(request).and_then(|id| projects.get(&id)), + ); + } + Ok(contents) +} + +impl ModpackContents { + fn add( + &mut self, + entry: &FileRequest, + version: Option<&VersionDetail>, + project: Option<&ProjectDetail>, + ) { + let label = || { + project + .map(|project| project.name.clone()) + .or_else(|| entry.label.clone()) + .unwrap_or_else(|| format!("CurseForge file {}", entry.file_id)) + }; + + let (Some(version), Some(file)) = (version, version.and_then(VersionDetail::primary_file)) + else { + tracing::warn!(file_id = entry.file_id, "curseforge file not found"); + self.unresolved.push(label()); + return; + }; + + let content_type = entry + .destination + .or_else(|| project.map(|project| project.content_type)) + .unwrap_or(ContentType::Mod); + let path = format!("{}/{}", content_type.folder_name(), file.file_name); + if !is_safe_relative_path(&path) || file.sha1.is_empty() { + tracing::warn!(path = %path, "curseforge file has an unusable name or no sha1"); + self.unresolved.push(label()); + return; + } + + let sha1 = file.sha1.to_ascii_lowercase(); + let project_id = version.project_id.clone(); + let version_id = entry.file_id.to_string(); + + if file.url.is_empty() { + self.blocked.push(BlockedFile { + project_id, + version_id: version_id.clone(), + project_name: label(), + file_name: file.file_name.clone(), + path, + sha1, + size: file.size, + content_type, + page_url: project.and_then(|project| download_page(project, &version_id)), + }); + return; + } + + match content_type { + ContentType::Mod | ContentType::ResourcePack | ContentType::Shader => { + self.direct.insert( + sha1.clone(), + ExternalFile { + name: file.file_name.clone(), + url: file.url.clone(), + sha1: sha1.clone(), + size: file.size, + content_type, + }, + ); + if !entry.required { + self.optional.insert(project_id.clone()); + } + self.files.push(BundleFile { + enabled: true, + hidden: false, + path, + size: file.size, + file_type: BundleFileType::Normal, + kind: BundleFileKind::Managed { + provider: ProviderId::CurseForge, + project_id, + version_id, + sha1, + }, + }); + } + _ => self.loose.push(LooseFile { + path, + url: file.url.clone(), + sha1, + size: file.size, + }), + } + } +} + +fn download_page(project: &ProjectDetail, file_id: &str) -> Option { + let website = project + .links + .iter() + .find(|(label, _)| label == "Website") + .map(|(_, url)| url.trim_end_matches('/').to_string())?; + Some(format!("{website}/download/{file_id}")) +} + +fn parse_loader(loaders: &[CfModLoader]) -> ContentResult<(GameLoader, Option)> { + let Some(chosen) = loaders + .iter() + .find(|loader| loader.primary) + .or_else(|| loaders.first()) + else { + return Ok((GameLoader::Vanilla, None)); + }; + + let (name, version) = chosen + .id + .split_once('-') + .ok_or(PackageError::UnsupportedModpackFormat)?; + let loader = GameLoader::from_str(name).map_err(|_| PackageError::UnsupportedModpackFormat)?; + Ok((loader, Some(version.to_string()))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::packages::types::{PackageBody, VersionFile}; + use chrono::Utc; + + fn loader(id: &str, primary: bool) -> CfModLoader { + CfModLoader { + id: id.to_string(), + primary, + } + } + + #[test] + fn the_primary_loader_is_split_into_name_and_version() { + assert_eq!( + parse_loader(&[loader("fabric-0.15.3", false), loader("forge-47.2.0", true)]).unwrap(), + (GameLoader::Forge, Some("47.2.0".to_string())) + ); + assert_eq!( + parse_loader(&[loader("neoforge-21.1.72", true)]).unwrap(), + (GameLoader::NeoForge, Some("21.1.72".to_string())) + ); + assert_eq!(parse_loader(&[]).unwrap(), (GameLoader::Vanilla, None)); + assert!(parse_loader(&[loader("rift", true)]).is_err()); + } + + #[test] + fn only_minecraft_modpack_manifests_are_recognised() { + let pack = serde_json::json!({ + "minecraft": {"version": "1.20.1", "modLoaders": []}, + "manifestType": "minecraftModpack", + "files": [] + }); + let other = serde_json::json!({"name": "not a pack"}); + + assert!(is_modpack_manifest(&serde_json::to_vec(&pack).unwrap())); + assert!(!is_modpack_manifest(&serde_json::to_vec(&other).unwrap())); + } + + fn project(content_type: ContentType) -> ProjectDetail { + ProjectDetail { + id: "10".into(), + slug: "thing".into(), + provider: ProviderId::CurseForge, + content_type, + name: "Thing".into(), + summary: String::new(), + author: String::new(), + members: Vec::new(), + gallery: Vec::new(), + license: None, + links: vec![( + "Website".into(), + "https://www.curseforge.com/minecraft/mc-mods/thing/".into(), + )], + body: PackageBody::Raw(String::new()), + version_ids: Vec::new(), + game_versions: Vec::new(), + loaders: Vec::new(), + icon_url: None, + created: Utc::now(), + updated: Utc::now(), + downloads: 0, + } + } + + fn version(url: &str) -> VersionDetail { + VersionDetail { + version_id: "20".into(), + project_id: "10".into(), + name: "Thing 1.0".into(), + version_number: "thing-1.0.jar".into(), + changelog: None, + game_versions: Vec::new(), + loaders: Vec::new(), + published: Utc::now(), + downloads: 0, + files: vec![VersionFile { + sha1: "ABCD".into(), + url: url.into(), + file_name: "thing-1.0.jar".into(), + primary: true, + size: 5, + fingerprint: None, + }], + dependencies: Vec::new(), + } + } + + fn entry(required: bool) -> FileRequest { + FileRequest { + file_id: 20, + project_id: Some(10), + required, + label: None, + destination: None, + } + } + + #[test] + fn a_downloadable_mod_becomes_a_managed_file() { + let mut contents = ModpackContents::default(); + contents.add( + &entry(false), + Some(&version("https://edge.forgecdn.net/thing-1.0.jar")), + Some(&project(ContentType::Mod)), + ); + + assert_eq!(contents.files.len(), 1); + assert_eq!(contents.files[0].path, "mods/thing-1.0.jar"); + assert!(contents.files[0].enabled); + assert!(contents.optional.contains("10")); + assert_eq!( + contents.files[0].kind, + BundleFileKind::Managed { + provider: ProviderId::CurseForge, + project_id: "10".into(), + version_id: "20".into(), + sha1: "abcd".into(), + } + ); + } + + #[test] + fn a_file_without_a_download_url_is_blocked() { + let mut contents = ModpackContents::default(); + contents.add( + &entry(true), + Some(&version("")), + Some(&project(ContentType::ResourcePack)), + ); + + assert!(contents.files.is_empty()); + assert_eq!(contents.blocked.len(), 1); + assert_eq!(contents.blocked[0].path, "resourcepacks/thing-1.0.jar"); + assert_eq!( + contents.blocked[0].page_url.as_deref(), + Some("https://www.curseforge.com/minecraft/mc-mods/thing/download/20") + ); + } + + #[test] + fn a_requested_destination_beats_the_project_category() { + let mut contents = ModpackContents::default(); + let request = FileRequest { + destination: Some(ContentType::ResourcePack), + ..entry(true) + }; + contents.add( + &request, + Some(&version("https://edge.forgecdn.net/thing-1.0.jar")), + Some(&project(ContentType::Mod)), + ); + + assert_eq!(contents.files[0].path, "resourcepacks/thing-1.0.jar"); + } + + #[test] + fn a_missing_file_without_a_project_uses_its_label() { + let mut contents = ModpackContents::default(); + let request = FileRequest { + file_id: 20, + project_id: None, + required: true, + label: Some("Stay Clear".into()), + destination: None, + }; + contents.add(&request, None, None); + + assert_eq!(contents.unresolved, vec!["Stay Clear".to_string()]); + } + + #[test] + fn a_missing_file_is_reported_by_project_name() { + let mut contents = ModpackContents::default(); + contents.add(&entry(true), None, Some(&project(ContentType::Mod))); + + assert_eq!(contents.unresolved, vec!["Thing".to_string()]); + } +} diff --git a/packages/oneclient_content/src/modpacks/install.rs b/packages/oneclient_content/src/modpacks/install.rs new file mode 100644 index 00000000..c742b558 --- /dev/null +++ b/packages/oneclient_content/src/modpacks/install.rs @@ -0,0 +1,637 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::cluster_bundle as bundle_dao; +use oneclient_db::models::{ArtifactRow, ClusterBundleOverrideRow, ClusterRow, OverrideType}; +use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase}; + +use futures_util::StreamExt; + +use super::{ + BlockedFile, LooseFile, MODPACK_BUNDLE_NAME, ModpackContents, ModpackManifest, + tracked_content_type, +}; +use crate::bundles::install::{ + PresentContent, effective_enabled, find_override, install_bundle_files, install_external, + remove_artifact_from_cluster, +}; +use crate::bundles::overrides::{OverrideLayers, sync_file_lock, sync_layered_overrides}; +use crate::bundles::{BundleFile, BundleFileKind}; +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; +use crate::packages::PackageError; +use crate::packages::store::{self, PackageStore}; +use crate::packages::types::ExternalFile; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_common::paths; + +const LOOSE_LOCK_KEY: &str = "modpack:files"; +const LOOSE_CONCURRENCY: usize = 6; + +#[derive(Debug, Clone, Default)] +pub struct ModpackInstallReport { + pub installed: usize, + pub failed: Vec, + pub blocked: Vec, +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn store_modpack_archive(path: &Path, ctx: &ContentCtx) -> ContentResult { + store::cache_local_file(path, ContentType::Modpack, ctx).await +} + +struct InstallContext<'a> { + manifest: &'a ModpackManifest, + cluster: &'a ClusterRow, + present: &'a PresentContent, + ctx: &'a ContentCtx, +} + +#[tracing::instrument(skip(manifest, progress, ctx), fields(pack = %manifest.name))] +pub async fn install_modpack( + archive_path: &Path, + manifest: &ModpackManifest, + cluster_id: i64, + progress: Option<&GroupedProgressSession>, + ctx: &ContentCtx, +) -> ContentResult { + let contents = &manifest.contents; + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let root = paths::cluster_game_dir(&cluster.folder_name, cluster.is_isolated())?; + + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let present = PresentContent::load(cluster_id, ctx).await?; + let tracked: HashMap = + bundle_dao::list_bundle_tracked(&ctx.db, cluster_id) + .await? + .into_iter() + .filter(|row| row.bundle_name.as_deref() == Some(MODPACK_BUNDLE_NAME)) + .filter_map(|row| Some((row.package_id?, (row.hash, row.bundle_version_id?)))) + .collect(); + + let mut replaced: HashMap = HashMap::new(); + let to_install = contents + .files + .iter() + .filter(|file| { + let package_id = file.kind.package_id(); + let user_choice = find_override(&overrides, MODPACK_BUNDLE_NAME, &package_id); + if user_choice == Some(OverrideType::Removed) { + return false; + } + match tracked.get(&package_id) { + Some((_, version)) if *version == file.kind.bundle_version_id() => false, + Some((hash, _)) => { + replaced.insert(package_id, hash.clone()); + true + } + None => effective_enabled(file, user_choice) && !present.contains(file), + } + }) + .cloned() + .collect(); + + let mut report = ModpackInstallReport::default(); + let install = InstallContext { + manifest, + cluster: &cluster, + present: &present, + ctx, + }; + + let results = install_bundle_files( + to_install, + cluster_id, + MODPACK_BUNDLE_NAME, + true, + progress, + ctx, + ) + .await; + for (file, installed) in results { + let replaced = replaced.remove(&file.kind.package_id()); + match settle_file(&install, &file, installed, replaced).await { + Ok(()) => report.installed += 1, + Err(err) => { + let name = file.display_name(); + tracing::warn!(file = %name, error = %err, "failed to install modpack file"); + report.failed.push(name); + } + } + } + + let game_dir = root.as_path(); + let loose = futures_util::stream::iter(contents.loose.iter().cloned().map(|file| async move { + let result = install_loose_file(&file, game_dir, progress, ctx).await; + (file, result) + })) + .buffer_unordered(LOOSE_CONCURRENCY) + .collect::>() + .await; + let mut written = HashMap::new(); + for (file, result) in loose { + match result { + Ok(()) => { + written.insert(file.path.clone(), file.sha1.clone()); + } + Err(err) => { + tracing::warn!(path = %file.path, error = %err, "failed to install modpack file"); + report.failed.push(file.path.clone()); + } + } + } + let listed: HashSet = contents + .loose + .iter() + .map(|file| file.path.clone()) + .collect(); + sync_file_lock(&root, LOOSE_LOCK_KEY, written, &listed).await; + + let prefixes: Vec<&str> = manifest + .override_prefixes + .iter() + .map(String::as_str) + .collect(); + sync_layered_overrides( + archive_path, + MODPACK_BUNDLE_NAME, + &root, + &prefixes, + &|rel| tracked_content_type(rel).is_none(), + Some(&ctx.events), + ) + .await?; + + let bundled = + import_override_content(archive_path, &prefixes, &install, &overrides, &mut report).await?; + + remove_dropped_files(contents, &tracked, bundled, cluster_id, ctx).await; + + for file in &contents.blocked { + let suppressed = matches!( + find_override(&overrides, MODPACK_BUNDLE_NAME, &file.project_id), + Some(OverrideType::Removed | OverrideType::Disabled) + ); + if present.has_hash(&file.sha1) || suppressed { + continue; + } + + match cached_blocked_file(file, ctx).await { + Some(row) => match link_blocked_file(&cluster, file, &row, ctx).await { + Ok(()) => report.installed += 1, + Err(err) => { + tracing::warn!(file = %file.file_name, error = %err, "could not reuse a cached manual download"); + report.blocked.push(file.clone()); + } + }, + None => report.blocked.push(file.clone()), + } + } + + report.failed.extend(contents.unresolved.iter().cloned()); + + tracing::info!( + cluster_id, + installed = report.installed, + failed = report.failed.len(), + blocked = report.blocked.len(), + "modpack install finished" + ); + + Ok(report) +} + +async fn settle_file( + install: &InstallContext<'_>, + file: &BundleFile, + installed: ContentResult, + replaced: Option, +) -> ContentResult<()> { + let hash = match installed.and_then(|hash| verify_installed(file, hash)) { + Ok(hash) => hash, + Err(err) => { + unlink_mismatch(install, &err).await; + let Some(direct) = direct_download(file, install.manifest) else { + return Err(err); + }; + tracing::info!( + file = %file.display_name(), + error = %err, + "provider lookup failed, downloading the modpack's own link instead" + ); + install_direct(install, file, direct).await? + } + }; + + let cluster_id = install.cluster.id; + let ctx = install.ctx; + match replaced { + Some(old) if old != hash => { + let was_enabled = artifact_dao::get_cluster_artifact(&ctx.db, cluster_id, &old) + .await? + .is_none_or(|link| link.enabled != 0); + remove_artifact_from_cluster(cluster_id, &old, false, ctx).await?; + if !was_enabled { + PackageStore::set_artifact_enabled_to(cluster_id, &hash, false, ctx).await?; + } + } + Some(_) => {} + None if install + .manifest + .contents + .optional + .contains(&file.kind.package_id()) => + { + PackageStore::set_artifact_enabled_to(cluster_id, &hash, false, ctx).await?; + } + None => {} + } + Ok(()) +} + +fn verify_installed(file: &BundleFile, hash: String) -> ContentResult { + let expected = super::file_sha1(file); + if expected.is_empty() || expected == hash { + return Ok(hash); + } + + tracing::warn!( + file = %file.display_name(), + expected, + actual = %hash, + "the provider served a different file than the modpack lists" + ); + Err(PackageError::HashMismatch { + expected: expected.to_string(), + actual: hash, + } + .into()) +} + +async fn unlink_mismatch(install: &InstallContext<'_>, err: &ContentError) { + let ContentError::Package(PackageError::HashMismatch { actual, .. }) = err else { + return; + }; + if install.present.has_hash(actual) { + return; + } + if let Err(err) = + remove_artifact_from_cluster(install.cluster.id, actual, false, install.ctx).await + { + tracing::warn!(error = %err, "could not remove the mismatched file"); + } +} + +async fn remove_dropped_files( + contents: &ModpackContents, + tracked: &HashMap, + bundled: Vec, + cluster_id: i64, + ctx: &ContentCtx, +) { + if !contents.unresolved.is_empty() { + tracing::warn!( + cluster_id, + unresolved = contents.unresolved.len(), + "not removing dropped files because part of the modpack could not be resolved" + ); + return; + } + + let mut listed: HashSet = contents + .files + .iter() + .map(|file| file.kind.package_id()) + .collect(); + listed.extend(contents.blocked.iter().map(|file| file.project_id.clone())); + listed.extend(bundled); + + for (package_id, (hash, _)) in tracked { + if listed.contains(package_id) { + continue; + } + if let Err(err) = remove_artifact_from_cluster(cluster_id, hash, false, ctx).await { + tracing::warn!(package_id, error = %err, "could not remove a file the modpack dropped"); + } + } +} + +fn direct_download<'a>( + file: &BundleFile, + manifest: &'a ModpackManifest, +) -> Option<&'a ExternalFile> { + match &file.kind { + BundleFileKind::Managed { sha1, .. } => manifest.contents.direct.get(sha1), + BundleFileKind::External { .. } => None, + } +} + +async fn install_direct( + install: &InstallContext<'_>, + file: &BundleFile, + direct: &ExternalFile, +) -> ContentResult { + let ctx = install.ctx; + let hash = install_external(direct, install.cluster, true, None, ctx).await?; + bundle_dao::track_bundle_artifact( + &ctx.db, + install.cluster.id, + &hash, + MODPACK_BUNDLE_NAME, + &file.kind.bundle_version_id(), + &file.kind.package_id(), + ) + .await?; + Ok(hash) +} + +async fn import_override_content( + archive_path: &Path, + prefixes: &[&str], + install: &InstallContext<'_>, + overrides: &[ClusterBundleOverrideRow], + report: &mut ModpackInstallReport, +) -> ContentResult> { + let mut layers = OverrideLayers::open(archive_path, prefixes).await?; + let bundled: Vec<(String, String, ContentType)> = layers + .entries() + .iter() + .filter_map(|(rel, entry)| { + let content_type = tracked_content_type(rel)?; + Some((rel.clone(), entry.clone(), content_type)) + }) + .collect(); + let mut hashes = Vec::new(); + if bundled.is_empty() { + return Ok(hashes); + } + + let staging = polyio::tempdir().await?; + let mut files = Vec::new(); + + for (rel, entry, content_type) in bundled { + let Some(bytes) = layers.read(&entry).await else { + report.failed.push(rel); + continue; + }; + let hash = polyio::normalize_hash(&polyio::sha1_bytes(&bytes)); + hashes.push(hash.clone()); + let removed = matches!( + find_override(overrides, MODPACK_BUNDLE_NAME, &hash), + Some(OverrideType::Removed | OverrideType::Disabled) + ); + if removed || install.present.has_hash(&hash) { + continue; + } + + let Some(file_name) = rel.rsplit('/').next() else { + continue; + }; + let path = staging + .dir_path() + .join(content_type.folder_name()) + .join(file_name); + if let Some(parent) = path.parent() { + polyio::create_dir_all(parent).await?; + } + polyio::write(&path, bytes).await?; + files.push((path, content_type)); + } + + let ctx = install.ctx; + let imported = PackageStore::import_local_files(&files, install.cluster.id, ctx).await?; + + for row in &imported.imported { + bundle_dao::track_bundle_artifact( + &ctx.db, + install.cluster.id, + &row.hash, + MODPACK_BUNDLE_NAME, + &row.hash, + &row.hash, + ) + .await?; + report.installed += 1; + } + + for (path, err) in imported.failed { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + tracing::warn!(file = %name, error = %err, "failed to import a file bundled in the modpack"); + report.failed.push(name); + } + + Ok(hashes) +} + +async fn install_loose_file( + file: &LooseFile, + root: &Path, + progress: Option<&GroupedProgressSession>, + ctx: &ContentCtx, +) -> ContentResult<()> { + let dest = root.join(polyio::sanitize_path(&file.path)); + let child = progress.map(|p| { + let child = p.child(file.path.clone(), file.size.max(1), TaskCategory::Packages); + child.set_phase(TaskPhase::Downloading); + child + }); + + let result = store::ensure_artifact_file(&file.sha1, &file.url, &dest, child.as_ref(), ctx) + .await + .map(|_| ()); + + if let Some(child) = child { + child.finish(); + } + result +} + +#[tracing::instrument(level = "debug", skip(blocked))] +pub async fn find_blocked_downloads( + locations: &[PathBuf], + blocked: &[BlockedFile], +) -> Vec<(PathBuf, BlockedFile)> { + let mut by_size: HashMap> = HashMap::new(); + for file in blocked { + by_size.entry(file.size).or_default().push(file); + } + + let mut found: Vec<(PathBuf, BlockedFile)> = Vec::new(); + for path in candidate_files(locations).await { + let Ok(meta) = polyio::stat(&path).await else { + continue; + }; + let Some(candidates) = by_size.get(&meta.len()) else { + continue; + }; + let Ok(sha1) = polyio::sha1_file(&path).await else { + continue; + }; + let sha1 = polyio::normalize_hash(&sha1); + + if let Some(file) = candidates.iter().find(|file| file.sha1 == sha1) + && !found.iter().any(|(_, known)| known.sha1 == sha1) + { + found.push((path, (*file).clone())); + } + } + + found +} + +async fn candidate_files(locations: &[PathBuf]) -> Vec { + let mut files = Vec::new(); + for location in locations { + let Ok(meta) = polyio::stat(location).await else { + continue; + }; + if meta.is_file() { + files.push(location.clone()); + continue; + } + + let Ok(mut entries) = polyio::read_dir(location).await else { + continue; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + if entry.file_type().await.is_ok_and(|kind| kind.is_file()) { + files.push(entry.path()); + } + } + } + files +} + +#[tracing::instrument(level = "debug", skip(found, ctx), fields(files = found.len()))] +pub async fn import_blocked_files( + cluster_id: i64, + found: &[(PathBuf, BlockedFile)], + ctx: &ContentCtx, +) -> ContentResult> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let mut imported = Vec::new(); + + for (path, file) in found { + let row = store::cache_local_file(path, file.content_type, ctx).await?; + if row.hash != file.sha1 { + return Err(PackageError::HashMismatch { + expected: file.sha1.clone(), + actual: row.hash, + } + .into()); + } + + link_blocked_file(&cluster, file, &row, ctx).await?; + imported.push(row.hash); + } + + Ok(imported) +} + +async fn link_blocked_file( + cluster: &ClusterRow, + file: &BlockedFile, + row: &ArtifactRow, + ctx: &ContentCtx, +) -> ContentResult<()> { + PackageStore::link_artifact(row, cluster, Some(&file.file_name), ctx).await?; + bundle_dao::track_bundle_artifact( + &ctx.db, + cluster.id, + &row.hash, + MODPACK_BUNDLE_NAME, + &file.version_id, + &file.project_id, + ) + .await?; + + if artifact_dao::get_release_by_hash(&ctx.db, &row.hash) + .await? + .is_some() + { + return Ok(()); + } + + match crate::packages::get_version_cached( + ctx, + ProviderId::CurseForge, + &file.project_id, + &file.version_id, + ) + .await + { + Ok(version) => { + if let Err(err) = + store::record_release(ProviderId::CurseForge, &version, &row.hash, ctx).await + { + tracing::debug!(file = %file.file_name, error = %err, "could not record the release"); + } + } + Err(err) => { + tracing::debug!(file = %file.file_name, error = %err, "could not describe the manual download"); + } + } + + Ok(()) +} + +async fn cached_blocked_file(file: &BlockedFile, ctx: &ContentCtx) -> Option { + let row = artifact_dao::get_artifact_by_hash(&ctx.db, &file.sha1) + .await + .ok() + .flatten()?; + let path = store::artifact_absolute_path(&row.path).ok()?; + polyio::try_exists(&path) + .await + .unwrap_or(false) + .then_some(row) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn blocked(sha1: &str, size: u64) -> BlockedFile { + BlockedFile { + project_id: "1".into(), + version_id: "2".into(), + project_name: "Thing".into(), + file_name: "thing.jar".into(), + path: "mods/thing.jar".into(), + sha1: sha1.into(), + size, + content_type: ContentType::Mod, + page_url: None, + } + } + + #[tokio::test] + async fn downloads_are_matched_by_hash_not_by_name() { + let dir = polyio::testing::ScratchDir::new("blocked-downloads"); + let wanted = b"the real jar"; + let renamed = dir.path().join("thing (1).jar"); + tokio::fs::write(&renamed, wanted).await.unwrap(); + tokio::fs::write(dir.path().join("thing.jar"), b"another file!") + .await + .unwrap(); + + let sha1 = polyio::sha1_bytes(wanted); + let found = find_blocked_downloads( + &[dir.path().to_path_buf()], + &[blocked(&sha1, wanted.len() as u64)], + ) + .await; + + assert_eq!(found.len(), 1); + assert_eq!(found[0].0, renamed); + + let picked = + find_blocked_downloads(&[renamed.clone()], &[blocked(&sha1, wanted.len() as u64)]) + .await; + assert_eq!(picked.len(), 1); + } +} diff --git a/packages/oneclient_content/src/modpacks/mod.rs b/packages/oneclient_content/src/modpacks/mod.rs new file mode 100644 index 00000000..9a162946 --- /dev/null +++ b/packages/oneclient_content/src/modpacks/mod.rs @@ -0,0 +1,217 @@ +mod checker; +mod curseforge; +mod install; +mod mrpack; +mod update; + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use crate::bundles::BundleFile; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::PackageError; +use crate::packages::types::ExternalFile; +use oneclient_common::domain::{ContentType, GameLoader}; + +pub use update::{ + ModpackRelease, ModpackUpdateStatus, check_modpack_update, cluster_modpack, identify_modpack, +}; + +pub use install::{ + ModpackInstallReport, find_blocked_downloads, import_blocked_files, install_modpack, + store_modpack_archive, +}; + +pub const MODPACK_BUNDLE_NAME: &str = "modpack"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModpackFormat { + Modrinth, + CurseForge, +} + +#[derive(Debug, Clone)] +pub struct ModpackManifest { + pub name: String, + pub version: String, + pub summary: Option, + pub mc_version: String, + pub loader: GameLoader, + pub loader_version: Option, + pub contents: ModpackContents, + pub override_prefixes: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct ModpackContents { + pub files: Vec, + pub direct: HashMap, + pub optional: HashSet, + pub loose: Vec, + pub blocked: Vec, + pub unresolved: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ModpackSummary { + pub mods: usize, + pub resource_packs: usize, + pub shaders: usize, + pub other_files: usize, + pub optional: usize, + pub manual: usize, + pub unresolved: usize, + pub download_bytes: u64, +} + +impl ModpackManifest { + #[must_use] + pub fn summary(&self) -> ModpackSummary { + let contents = &self.contents; + let mut summary = ModpackSummary { + other_files: contents.loose.len(), + optional: contents.optional.len(), + manual: contents.blocked.len(), + unresolved: contents.unresolved.len(), + ..ModpackSummary::default() + }; + + for file in &contents.files { + match file.content_type() { + ContentType::ResourcePack => summary.resource_packs += 1, + ContentType::Shader => summary.shaders += 1, + _ => summary.mods += 1, + } + summary.download_bytes += file.size; + } + summary.download_bytes += contents.loose.iter().map(|file| file.size).sum::(); + summary + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LooseFile { + pub path: String, + pub url: String, + pub sha1: String, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BlockedFile { + pub project_id: String, + pub version_id: String, + pub project_name: String, + pub file_name: String, + pub path: String, + pub sha1: String, + pub size: u64, + pub content_type: ContentType, + pub page_url: Option, +} + +#[tracing::instrument(level = "debug")] +pub async fn detect_format(path: &Path) -> ContentResult { + if zip_entry(path, mrpack::INDEX_ENTRY).await?.is_some() { + return Ok(ModpackFormat::Modrinth); + } + match zip_entry(path, curseforge::MANIFEST_ENTRY).await? { + Some(bytes) if curseforge::is_modpack_manifest(&bytes) => Ok(ModpackFormat::CurseForge), + _ => Err(PackageError::UnsupportedModpackFormat.into()), + } +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn read_modpack(path: &Path, ctx: &ContentCtx) -> ContentResult { + if let Some(bytes) = zip_entry(path, mrpack::INDEX_ENTRY).await? { + let mut manifest = mrpack::parse(&bytes)?; + checker::add_checker_mods(path, &mut manifest, ctx).await; + return Ok(manifest); + } + + let bytes = zip_entry(path, curseforge::MANIFEST_ENTRY) + .await? + .ok_or(PackageError::UnsupportedModpackFormat)?; + curseforge::resolve(&bytes, ctx).await +} + +async fn zip_entry(path: &Path, entry: &str) -> ContentResult>> { + let file = tokio::fs::File::open(path).await?; + match polyio::try_read_zip_entry_bytes(tokio::io::BufReader::new(file), entry).await { + Ok(bytes) => Ok(Some(bytes)), + Err(polyio::IOError::FileNotFoundInZip { .. }) => Ok(None), + Err(err) => Err(err.into()), + } +} + +fn file_sha1(file: &BundleFile) -> &str { + match &file.kind { + crate::bundles::BundleFileKind::Managed { sha1, .. } => sha1, + crate::bundles::BundleFileKind::External { file, .. } => &file.sha1, + } +} + +fn file_name_of(path: &str) -> String { + path.rsplit('/').next().unwrap_or(path).to_lowercase() +} + +fn is_safe_relative_path(path: &str) -> bool { + if path.is_empty() || path.starts_with('/') || path.starts_with('\\') || path.contains(':') { + return false; + } + path.split(['/', '\\']) + .all(|segment| !segment.is_empty() && segment != "." && segment != "..") +} + +fn tracked_folder(folder: &str) -> Option { + match ContentType::from_folder_name(folder)? { + content_type @ (ContentType::Mod | ContentType::ResourcePack | ContentType::Shader) => { + Some(content_type) + } + _ => None, + } +} + +fn tracked_content_type(path: &str) -> Option { + let (folder, file) = path.split_once('/')?; + if file.is_empty() || file.contains('/') { + return None; + } + tracked_folder(folder) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn traversal_and_absolute_paths_are_rejected() { + assert!(is_safe_relative_path("mods/sodium.jar")); + assert!(is_safe_relative_path("config/sodium/options.json")); + assert!(!is_safe_relative_path("../mods/evil.jar")); + assert!(!is_safe_relative_path("mods/../../evil.jar")); + assert!(!is_safe_relative_path("/etc/passwd")); + assert!(!is_safe_relative_path("\\Windows\\evil.dll")); + assert!(!is_safe_relative_path("C:/Windows/evil.dll")); + assert!(!is_safe_relative_path("mods//evil.jar")); + assert!(!is_safe_relative_path("")); + } + + #[test] + fn only_top_level_content_files_are_tracked() { + assert_eq!(tracked_content_type("mods/a.jar"), Some(ContentType::Mod)); + assert_eq!( + tracked_content_type("resourcepacks/a.zip"), + Some(ContentType::ResourcePack) + ); + assert_eq!( + tracked_content_type("shaderpacks/a.zip"), + Some(ContentType::Shader) + ); + assert_eq!(tracked_content_type("mods/sub/a.jar"), None); + assert_eq!(tracked_content_type("config/a.json"), None); + assert_eq!(tracked_content_type("datapacks/a.zip"), None); + assert_eq!(tracked_content_type("a.jar"), None); + } +} diff --git a/packages/oneclient_content/src/modpacks/mrpack.rs b/packages/oneclient_content/src/modpacks/mrpack.rs new file mode 100644 index 00000000..a1c2d7f7 --- /dev/null +++ b/packages/oneclient_content/src/modpacks/mrpack.rs @@ -0,0 +1,325 @@ +use std::collections::HashMap; +use std::path::Path; + +use serde::Deserialize; + +use super::{ + LooseFile, ModpackContents, ModpackManifest, is_safe_relative_path, tracked_content_type, +}; +use crate::bundles::polymrpack::{mrpack_file_kind, scan_dependencies}; +use crate::bundles::{BundleFile, BundleFileKind, BundleFileType}; +use crate::error::ContentResult; +use crate::packages::PackageError; +use crate::packages::types::ExternalFile; +use oneclient_common::domain::GameLoader; + +pub(super) const INDEX_ENTRY: &str = "modrinth.index.json"; + +const OVERRIDES: &str = "overrides/"; +const CLIENT_OVERRIDES: &str = "client-overrides/"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MrpackManifest { + #[serde(default)] + name: String, + #[serde(default)] + version_id: String, + #[serde(default)] + summary: Option, + #[serde(default)] + files: Vec, + #[serde(default)] + dependencies: HashMap, +} + +#[derive(Deserialize)] +struct MrpackFileEntry { + path: String, + hashes: MrpackHashes, + #[serde(default)] + env: Option, + #[serde(default)] + downloads: Vec, + #[serde(rename = "fileSize", default)] + file_size: u64, +} + +#[derive(Deserialize)] +struct MrpackHashes { + sha1: String, +} + +#[derive(Deserialize)] +struct MrpackEnv { + #[serde(default)] + client: Option, +} + +impl MrpackFileEntry { + fn client_env(&self) -> Option<&str> { + self.env.as_ref().and_then(|env| env.client.as_deref()) + } + + fn client_side(&self) -> bool { + self.client_env() != Some("unsupported") + } + + fn optional(&self) -> bool { + self.client_env() == Some("optional") + } +} + +pub(super) fn parse(bytes: &[u8]) -> ContentResult { + let manifest: MrpackManifest = serde_json::from_slice(bytes)?; + let (mc_version, loader, loader_version) = parse_dependencies(&manifest.dependencies)?; + + let mut contents = ModpackContents::default(); + + for entry in manifest.files { + if !entry.client_side() { + continue; + } + + let path = entry.path.replace('\\', "/"); + if !is_safe_relative_path(&path) { + tracing::warn!(path = %entry.path, "skipping modpack file with an unsafe path"); + contents.unresolved.push(entry.path); + continue; + } + + let sha1 = entry.hashes.sha1.to_ascii_lowercase(); + + if let Some(content_type) = tracked_content_type(&path) + && let Some(kind) = mrpack_file_kind(&path, &entry.downloads, &sha1, entry.file_size) + { + if let (BundleFileKind::Managed { .. }, Some(url)) = (&kind, entry.downloads.first()) { + contents.direct.insert( + sha1.clone(), + ExternalFile { + name: file_name(&path), + url: url.clone(), + sha1: sha1.clone(), + size: entry.file_size, + content_type, + }, + ); + } + if entry.optional() { + contents.optional.insert(kind.package_id()); + } + contents.files.push(BundleFile { + enabled: true, + hidden: false, + path, + size: entry.file_size, + file_type: BundleFileType::Normal, + kind, + }); + continue; + } + + match entry.downloads.into_iter().next() { + Some(url) => contents.loose.push(LooseFile { + path, + url, + sha1, + size: entry.file_size, + }), + None => { + tracing::warn!(path = %path, "modpack file has no download"); + contents.unresolved.push(path); + } + } + } + + Ok(ModpackManifest { + name: manifest.name, + version: manifest.version_id, + summary: manifest + .summary + .filter(|summary| !summary.trim().is_empty()), + mc_version, + loader, + loader_version, + contents, + override_prefixes: vec![OVERRIDES.to_string(), CLIENT_OVERRIDES.to_string()], + }) +} + +fn file_name(path: &str) -> String { + Path::new(path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(path) + .to_string() +} + +fn parse_dependencies( + deps: &HashMap, +) -> ContentResult<(String, GameLoader, Option)> { + let scanned = scan_dependencies(deps, GameLoader::is_modded); + let mc_version = scanned + .mc_version + .ok_or(PackageError::UnsupportedModpackFormat)?; + Ok(match scanned.loader { + Some((loader, version)) => (mc_version, loader, Some(version)), + None => (mc_version, GameLoader::Vanilla, None), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bundles::BundleFileKind; + use oneclient_common::domain::ProviderId; + + fn index(files: serde_json::Value, dependencies: serde_json::Value) -> Vec { + serde_json::to_vec(&serde_json::json!({ + "formatVersion": 1, + "game": "minecraft", + "versionId": "1.2.0", + "name": "Example Pack", + "files": files, + "dependencies": dependencies, + })) + .unwrap() + } + + #[test] + fn a_plain_mrpack_parses_into_tracked_and_loose_files() { + let bytes = index( + serde_json::json!([ + { + "path": "mods/sodium.jar", + "hashes": {"sha1": "AAAA", "sha512": "x"}, + "downloads": ["https://cdn.modrinth.com/data/AANobbMI/versions/tFw0iWAk/sodium.jar"], + "fileSize": 10 + }, + { + "path": "mods/extra.jar", + "hashes": {"sha1": "bbbb"}, + "downloads": ["https://github.com/a/b/releases/download/1/extra.jar"], + "fileSize": 20 + }, + { + "path": "config/thing/defaults.json", + "hashes": {"sha1": "cccc"}, + "downloads": ["https://example.invalid/defaults.json"], + "fileSize": 30 + }, + { + "path": "mods/pick-me.jar", + "hashes": {"sha1": "ffff"}, + "env": {"client": "optional", "server": "optional"}, + "downloads": ["https://example.invalid/pick-me.jar"], + "fileSize": 60 + }, + { + "path": "mods/server-only.jar", + "hashes": {"sha1": "dddd"}, + "env": {"client": "unsupported", "server": "required"}, + "downloads": ["https://example.invalid/server-only.jar"], + "fileSize": 40 + }, + { + "path": "../escape.jar", + "hashes": {"sha1": "eeee"}, + "downloads": ["https://example.invalid/escape.jar"], + "fileSize": 50 + } + ]), + serde_json::json!({"minecraft": "1.21.1", "fabric-loader": "0.16.5"}), + ); + + let manifest = parse(&bytes).unwrap(); + + assert_eq!(manifest.name, "Example Pack"); + assert_eq!(manifest.version, "1.2.0"); + assert_eq!(manifest.mc_version, "1.21.1"); + assert_eq!(manifest.loader, GameLoader::Fabric); + assert_eq!(manifest.loader_version.as_deref(), Some("0.16.5")); + + assert_eq!(manifest.contents.files.len(), 3); + assert!(manifest.contents.optional.contains("ffff")); + assert_eq!(manifest.contents.optional.len(), 1); + assert_eq!( + manifest.contents.files[0].kind, + BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: "AANobbMI".into(), + version_id: "tFw0iWAk".into(), + sha1: "aaaa".into(), + } + ); + assert!(matches!( + manifest.contents.files[1].kind, + BundleFileKind::External { .. } + )); + assert_eq!( + manifest + .contents + .direct + .get("aaaa") + .map(|file| file.url.as_str()), + Some("https://cdn.modrinth.com/data/AANobbMI/versions/tFw0iWAk/sodium.jar") + ); + assert_eq!( + manifest + .contents + .direct + .get("aaaa") + .map(|file| file.name.as_str()), + Some("sodium.jar") + ); + assert!(!manifest.contents.direct.contains_key("bbbb")); + + assert_eq!(manifest.contents.loose.len(), 1); + assert_eq!( + manifest.contents.loose[0].path, + "config/thing/defaults.json" + ); + assert_eq!( + manifest.contents.unresolved, + vec!["../escape.jar".to_string()] + ); + assert_eq!( + manifest.override_prefixes, + vec!["overrides/".to_string(), "client-overrides/".to_string()] + ); + } + + #[test] + fn a_pack_without_a_loader_is_vanilla() { + let bytes = index( + serde_json::json!([]), + serde_json::json!({"minecraft": "1.21.1"}), + ); + + let manifest = parse(&bytes).unwrap(); + + assert_eq!(manifest.loader, GameLoader::Vanilla); + assert_eq!(manifest.loader_version, None); + } + + #[test] + fn neoforge_and_quilt_keys_are_recognised() { + for (key, loader) in [ + ("neoforge", GameLoader::NeoForge), + ("forge", GameLoader::Forge), + ("quilt-loader", GameLoader::Quilt), + ] { + let bytes = index( + serde_json::json!([]), + serde_json::json!({"minecraft": "1.21.1", key: "1.0"}), + ); + assert_eq!(parse(&bytes).unwrap().loader, loader, "{key}"); + } + } + + #[test] + fn a_pack_without_a_minecraft_version_is_rejected() { + let bytes = index(serde_json::json!([]), serde_json::json!({})); + assert!(parse(&bytes).is_err()); + } +} diff --git a/packages/oneclient_content/src/modpacks/update.rs b/packages/oneclient_content/src/modpacks/update.rs new file mode 100644 index 00000000..08dc5621 --- /dev/null +++ b/packages/oneclient_content/src/modpacks/update.rs @@ -0,0 +1,212 @@ +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::models::{ClusterRow, ProviderReleaseRow}; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::store::{self, PackageStore}; +use crate::packages::types::{ReleaseType, VersionSummary}; +use oneclient_common::domain::{GameLoader, ProviderId}; + +const VERSIONS_LIMIT: usize = 500; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ModpackRelease { + pub provider: ProviderId, + pub project_id: String, + pub version_id: String, + pub name: String, + pub version: String, + pub published_at: Option, +} + +#[derive(Debug, Clone)] +pub enum ModpackUpdateStatus { + Unpublished, + UpToDate, + Available(VersionSummary), +} + +impl ModpackRelease { + fn from_row(row: ProviderReleaseRow) -> Option { + Some(Self { + provider: ProviderId::from_repr(u8::try_from(row.provider).ok()?)?, + project_id: row.project_id, + version_id: row.version_id, + name: row.display_name, + version: row.display_version, + published_at: row.published_at, + }) + } +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn identify_modpack( + hash: &str, + ctx: &ContentCtx, +) -> ContentResult> { + if let Some(row) = artifact_dao::get_release_by_hash(&ctx.db, hash).await? { + return Ok(ModpackRelease::from_row(row)); + } + + let Some((provider, version)) = ctx.providers.lookup_version(hash, ctx).await? else { + return Ok(None); + }; + store::record_release(provider, &version, hash, ctx).await?; + + Ok(artifact_dao::get_release_by_hash(&ctx.db, hash) + .await? + .and_then(ModpackRelease::from_row)) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn cluster_modpack( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + linked_release(&cluster, ctx).await +} + +async fn linked_release( + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> ContentResult> { + match cluster.linked_modpack_hash.as_deref() { + Some(hash) => identify_modpack(hash, ctx).await, + None => Ok(None), + } +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn check_modpack_update( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let Some(current) = linked_release(&cluster, ctx).await? else { + return Ok(ModpackUpdateStatus::Unpublished); + }; + + let provider = ctx.providers.get(current.provider)?; + let versions = provider + .list_versions( + ¤t.project_id, + Some(&cluster.mc_version), + None, + 0, + VERSIONS_LIMIT, + ctx, + ) + .await? + .items; + + let loader = cluster_loader(cluster.mc_loader); + let versions = versions + .into_iter() + .filter(|version| { + version.loaders.is_empty() + || loader.is_none_or(|loader| version.loaders.contains(&loader)) + }) + .collect(); + + Ok(match newest(versions, ¤t) { + Some(latest) => ModpackUpdateStatus::Available(latest), + None => ModpackUpdateStatus::UpToDate, + }) +} + +fn cluster_loader(repr: i64) -> Option { + u8::try_from(repr).ok().and_then(GameLoader::from_repr) +} + +fn newest(versions: Vec, current: &ModpackRelease) -> Option { + let current_published = versions + .iter() + .find(|version| version.version_id == current.version_id) + .map(|version| version.published) + .or_else(|| { + current + .published_at + .as_deref() + .and_then(|at| chrono::DateTime::parse_from_rfc3339(at).ok()) + .map(|at| at.with_timezone(&chrono::Utc)) + }); + + let newer = versions.into_iter().filter(|version| { + version.version_id != current.version_id + && current_published.is_none_or(|published| version.published > published) + }); + + let (releases, others): (Vec<_>, Vec<_>) = + newer.partition(|version| matches!(version.release_type, ReleaseType::Release)); + + let latest = + |list: Vec| list.into_iter().max_by_key(|version| version.published); + latest(releases).or_else(|| latest(others)) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + + fn version(id: &str, day: u32, release_type: ReleaseType) -> VersionSummary { + VersionSummary { + version_id: id.into(), + project_id: "pack".into(), + name: id.into(), + version_number: id.into(), + published: Utc.with_ymd_and_hms(2026, 9, day, 0, 0, 0).unwrap(), + release_type, + game_versions: Vec::new(), + loaders: Vec::new(), + downloads: 0, + file_size: 0, + dependencies: Vec::new(), + } + } + + fn current(id: &str) -> ModpackRelease { + ModpackRelease { + provider: ProviderId::Modrinth, + project_id: "pack".into(), + version_id: id.into(), + name: "Pack".into(), + version: id.into(), + published_at: None, + } + } + + #[test] + fn the_newest_release_wins_over_a_newer_beta() { + let versions = vec![ + version("v1", 1, ReleaseType::Release), + version("v2", 5, ReleaseType::Release), + version("v3-beta", 9, ReleaseType::Beta), + ]; + + let latest = newest(versions, ¤t("v1")).unwrap(); + assert_eq!(latest.version_id, "v2"); + } + + #[test] + fn nothing_newer_means_no_update() { + let versions = vec![ + version("v1", 1, ReleaseType::Release), + version("v2", 5, ReleaseType::Release), + ]; + + assert!(newest(versions, ¤t("v2")).is_none()); + } + + #[test] + fn a_beta_is_offered_when_no_release_is_newer() { + let versions = vec![ + version("v2", 5, ReleaseType::Release), + version("v3-beta", 9, ReleaseType::Beta), + ]; + + let latest = newest(versions, ¤t("v2")).unwrap(); + assert_eq!(latest.version_id, "v3-beta"); + } +} diff --git a/packages/oneclient_content/src/packages/activity.rs b/packages/oneclient_content/src/packages/activity.rs index 24bcbbc4..7cbf6259 100644 --- a/packages/oneclient_content/src/packages/activity.rs +++ b/packages/oneclient_content/src/packages/activity.rs @@ -2,19 +2,13 @@ //! of one package are legal rows //! the newest stays enabled the rest are switched off rather than unlinked -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; -use oneclient_common::domain::{ContentType, ProviderId}; -use oneclient_db::dao::applied_migration as migration_dao; -use oneclient_db::dao::artifact as artifact_dao; -use oneclient_db::dao::bundle as bundle_catalog_dao; -use oneclient_db::dao::cluster_bundle as bundle_dao; +use oneclient_common::domain::ProviderId; use crate::ctx::ContentCtx; use crate::error::ContentResult; -use crate::packages::dependencies::base_game_version; -use crate::packages::FileIdentity; -use crate::packages::store::{PackageStore, record_release}; +use crate::packages::store::PackageStore; use crate::packages::types::LinkedArtifactInfo; struct Copy { @@ -71,9 +65,7 @@ fn duplicates_to_disable(linked: &[LinkedArtifactInfo]) -> Vec { /// Local files and a bundle's external files have no project to group on so /// they are left out /// two of those are two packages not two copies -fn group_duplicates( - linked: &[LinkedArtifactInfo], -) -> Vec<((ProviderId, String), Vec)> { +fn group_duplicates(linked: &[LinkedArtifactInfo]) -> Vec<((ProviderId, String), Vec)> { let mut by_project: HashMap<(ProviderId, String), Vec> = HashMap::new(); for info in linked { @@ -95,171 +87,6 @@ fn group_duplicates( by_project.into_iter().collect() } -#[tracing::instrument(level = "debug", skip(ctx))] -pub async fn disable_foreign_game_versions( - cluster_id: i64, - mc_version: &str, - mc_loader: i64, - ctx: &ContentCtx, -) -> ContentResult> { - if mc_version.is_empty() { - return Ok(Vec::new()); - } - - let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; - - // bundle installs skip the compatibility check so launch must trust them too - let live_bundles: HashSet = - bundle_catalog_dao::list_visible_for_version_loader(&ctx.db, mc_version, mc_loader) - .await? - .into_iter() - .filter_map(|row| row.name) - .collect(); - - let from_bundle: HashSet = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id) - .await? - .into_iter() - .filter(|row| { - row.bundle_name - .as_deref() - .is_some_and(|name| live_bundles.contains(name)) - }) - .map(|row| row.hash) - .collect(); - - let candidates: Vec<&LinkedArtifactInfo> = linked - .iter() - .filter(|info| { - info.enabled && info.content_type == ContentType::Mod && !from_bundle.contains(&info.hash) - }) - .collect(); - - refresh_stated_game_versions(cluster_id, &candidates, mc_version, ctx).await; - - let mut switched_off = Vec::new(); - - for info in candidates { - match switch_off_if_foreign(cluster_id, info, mc_version, ctx).await { - Ok(true) => switched_off.push( - info.display_name - .clone() - .unwrap_or_else(|| info.file_name.clone()), - ), - Ok(false) => {} - Err(err) => tracing::warn!( - cluster_id, - hash = %info.hash, - %err, - "could not switch off a mod built for another game version" - ), - } - } - - Ok(switched_off) -} - -const FOREIGN_VERSION_REPAIR: &str = "disable-foreign-game-version"; - -fn foreign_repair_id(cluster_id: i64, mc_version: &str, hash: &str) -> String { - format!("{FOREIGN_VERSION_REPAIR}:{cluster_id}:{mc_version}:{hash}") -} - -async fn refresh_stated_game_versions( - cluster_id: i64, - candidates: &[&LinkedArtifactInfo], - mc_version: &str, - ctx: &ContentCtx, -) { - let mut stale = Vec::new(); - - for info in candidates { - match migration_dao::is_applied(&ctx.db, &foreign_repair_id(cluster_id, mc_version, &info.hash)).await { - Ok(true) => continue, - Ok(false) => {} - Err(err) => { - tracing::debug!(hash = %info.hash, %err, "could not read the repair marker"); - continue; - } - } - - match artifact_dao::list_release_game_versions(&ctx.db, &info.hash).await { - Ok(stated) if built_for_another_game_version(&stated, mc_version) => { - stale.push(FileIdentity::from_sha1(&info.hash)); - } - Ok(_) => {} - Err(err) => tracing::debug!(hash = %info.hash, %err, "could not read the stated game versions"), - } - } - - if stale.is_empty() { - return; - } - - let found = match ctx.providers.lookup_versions(&stale, ctx).await { - Ok(found) => found, - // Offline or the provider is down, so the stored rows are all there is - Err(err) => { - tracing::debug!(%err, "could not refresh the stated game versions"); - return; - } - }; - - for (sha1, (provider, version)) in found { - if let Err(err) = record_release(provider, &version, &sha1, ctx).await { - tracing::debug!(hash = %sha1, %err, "could not store the refreshed game versions"); - } - } -} - -async fn switch_off_if_foreign( - cluster_id: i64, - info: &LinkedArtifactInfo, - mc_version: &str, - ctx: &ContentCtx, -) -> ContentResult { - let repair_id = foreign_repair_id(cluster_id, mc_version, &info.hash); - if migration_dao::is_applied(&ctx.db, &repair_id).await? { - return Ok(false); - } - - let stated = artifact_dao::list_release_game_versions(&ctx.db, &info.hash).await?; - if !built_for_another_game_version(&stated, mc_version) { - return Ok(false); - } - - tracing::info!( - cluster_id, - hash = %info.hash, - built_for = ?stated, - "switching off a mod built for another game version" - ); - - PackageStore::set_artifact_enabled_to(cluster_id, &info.hash, false, ctx).await?; - migration_dao::mark_applied(&ctx.db, &repair_id).await?; - - Ok(true) -} - -fn built_for_another_game_version(stated: &[String], mc_version: &str) -> bool { - !stated.is_empty() - && !stated.iter().any(|versions| { - let built_for: Vec = serde_json::from_str(versions).unwrap_or_default(); - covers_game_version(&built_for, mc_version) - }) -} - -fn covers_game_version(stated: &[String], mc_version: &str) -> bool { - let wanted = base_game_version(mc_version); - stated.is_empty() - || stated.iter().any(|version| { - let built_for = base_game_version(version); - built_for == wanted - || wanted - .strip_prefix(built_for) - .is_some_and(|rest| rest.starts_with('.')) - }) -} - /// `published_at` is RFC 3339 so string order matches time order /// undated copies lose to dated ones and an all-undated group still picks one fn newest<'a>(copies: impl IntoIterator) -> Option { @@ -316,7 +143,11 @@ mod tests { ("middle", false, Some("2026-03-01T00:00:00Z")), ])); - assert_eq!(picked.as_deref(), Some("new"), "being enabled does not win it"); + assert_eq!( + picked.as_deref(), + Some("new"), + "being enabled does not win it" + ); } #[test] @@ -333,69 +164,9 @@ mod tests { fn an_undated_group_still_picks_one() { let picked = newest(&copies(&[("a", false, None), ("b", false, None)])); - assert!(picked.is_some(), "a group with no dates must not go unresolved"); - } - - #[test] - fn only_a_stated_mismatch_switches_a_mod_off() { - let foreign = |rows: &[&str]| { - built_for_another_game_version( - &rows.iter().map(|r| (*r).to_string()).collect::>(), - "26.1.2", - ) - }; - - assert!(!foreign(&[]), "a jar no provider ever described is left alone"); - assert!(!foreign(&["[]"]), "an empty list is the provider saying nothing"); - assert!(!foreign(&["[\"1.21.11"]), "a list that will not parse says nothing"); - assert!(!foreign(&["[\"26.1.2\"]"])); assert!( - !foreign(&["[\"1.21.11\",\"26.1.2\"]"]), - "a multi-version build stays" - ); - assert!( - !foreign(&["[\"1.21.11\"]", "[\"26.1.2\"]"]), - "one release row out of two is enough to keep it" - ); - assert!( - foreign(&["[\"1.21.11\"]"]), - "the jars the old stash dragged in have to go" - ); - assert!( - !foreign(&["[\"26.1\"]"]), - "a build for the release this cluster was migrated from stays" - ); - assert!( - foreign(&["[\"1.21.1\"]"]), - "a shorter version that is not a component prefix is still foreign" - ); - } - - #[test] - fn a_pre_release_tag_is_the_same_game_version() { - let foreign = |rows: &[&str], mc_version: &str| { - built_for_another_game_version( - &rows.iter().map(|r| (*r).to_string()).collect::>(), - mc_version, - ) - }; - - assert!( - !foreign(&["[\"26.3-rc-1\"]"], "26.3"), - "a jar built against the release candidate runs on the release" - ); - assert!(!foreign(&["[\"26.3-snapshot-7\",\"26.3-pre-2\"]"], "26.3")); - assert!( - !foreign(&["[\"26.3\"]"], "26.3-snapshot-10"), - "and a snapshot cluster keeps the release build" - ); - assert!( - !foreign(&["[\"26.3-rc-1\"]"], "26.3.1"), - "the component prefix still applies once the tag is off" - ); - assert!( - foreign(&["[\"26.2-rc-1\"]"], "26.3"), - "stripping the tag must not blur two game versions together" + picked.is_some(), + "a group with no dates must not go unresolved" ); } @@ -440,7 +211,12 @@ mod tests { fn the_user_disabling_the_newest_copy_is_left_standing() { let disable = duplicates_to_disable(&[ info(Some("sodium"), "chosen", true, Some("2026-01-01T00:00:00Z")), - info(Some("sodium"), "newest", false, Some("2026-06-01T00:00:00Z")), + info( + Some("sodium"), + "newest", + false, + Some("2026-06-01T00:00:00Z"), + ), ]); assert!( diff --git a/packages/oneclient_content/src/packages/bad_mods.rs b/packages/oneclient_content/src/packages/bad_mods.rs new file mode 100644 index 00000000..20d489ee --- /dev/null +++ b/packages/oneclient_content/src/packages/bad_mods.rs @@ -0,0 +1,701 @@ +use std::collections::HashSet; +use std::sync::{Arc, PoisonError, RwLock}; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Deserializer}; + +use oneclient_common::paths; +use oneclient_db::models::ClusterRow; +use oneclient_net::{EtagPolicy, fetch_cached}; + +use super::dependencies::pick_version; +use super::{ContentType, FileIdentity, ProjectDetail, ProviderId, VersionDetail, VersionLookup}; +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; + +#[derive(Debug, Clone, Default, Deserialize)] +pub struct BadModList { + #[serde(rename = "bad-mods", default, deserialize_with = "lenient_list")] + pub bad_mods: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct BadMod { + #[serde(default, deserialize_with = "non_blank")] + pub hash: Option, + #[serde(default)] + pub project_ids: ProjectIds, + #[serde(default, deserialize_with = "non_blank")] + pub name: Option, + #[serde(default, deserialize_with = "non_blank")] + pub author: Option, + #[serde(default, deserialize_with = "non_blank")] + pub explanation: Option, + #[serde(default, deserialize_with = "lenient_list")] + pub alternatives: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct BadModAlternative { + #[serde(default, deserialize_with = "non_blank")] + pub hash: Option, + #[serde(default)] + pub project_ids: ProjectIds, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +pub struct ProjectIds { + #[serde(default, deserialize_with = "project_id")] + pub modrinth: Option, + #[serde(default, deserialize_with = "project_id")] + pub curseforge: Option, +} + +impl ProjectIds { + pub fn get(&self, provider: ProviderId) -> Option<&str> { + match provider { + ProviderId::Modrinth => self.modrinth.as_deref(), + ProviderId::CurseForge => self.curseforge.as_deref(), + ProviderId::Local => None, + } + } + + pub fn is_empty(&self) -> bool { + self.modrinth.is_none() && self.curseforge.is_none() + } +} + +fn lenient_list<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: DeserializeOwned, +{ + let raw = Option::>::deserialize(deserializer)?.unwrap_or_default(); + + Ok(raw + .into_iter() + .enumerate() + .filter_map(|(index, value)| match serde_json::from_value(value) { + Ok(item) => Some(item), + Err(err) => { + tracing::warn!(index, %err, "skipping malformed bad mods entry"); + None + } + }) + .collect()) +} + +fn blank_to_none(value: String) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +fn non_blank<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + Ok(Option::::deserialize(deserializer)?.and_then(blank_to_none)) +} + +fn project_id<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + #[derive(Deserialize)] + #[serde(untagged)] + enum RawId { + Text(String), + Number(u64), + } + + Ok(match Option::::deserialize(deserializer)? { + Some(RawId::Text(id)) => blank_to_none(id), + Some(RawId::Number(id)) => Some(id.to_string()), + None => None, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedAlternative { + pub provider: ProviderId, + pub project_id: String, + pub name: String, + pub version_number: Option, + pub icon_url: Option, +} + +impl BadModList { + pub fn check(&self, project: &ProjectDetail, version: &VersionDetail) -> Option<&BadMod> { + if project.content_type != ContentType::Mod { + return None; + } + + let authors: Vec<&str> = std::iter::once(project.author.as_str()) + .chain(project.members.iter().map(|member| member.name.as_str())) + .collect(); + + version + .primary_file() + .and_then(|file| self.find(&file.sha1)) + .or_else(|| self.find_project(project.provider, &project.id)) + .or_else(|| self.find_name_and_author(&project.name, &authors)) + } + + pub fn find(&self, sha1: &str) -> Option<&BadMod> { + let sha1 = polyio::normalize_hash(sha1); + self.bad_mods.iter().find(|entry| { + entry + .hash + .as_deref() + .is_some_and(|hash| polyio::normalize_hash(hash) == sha1) + }) + } + + fn find_project(&self, provider: ProviderId, project_id: &str) -> Option<&BadMod> { + self.bad_mods + .iter() + .find(|entry| entry.project_ids.get(provider) == Some(project_id)) + } + + fn find_name_and_author(&self, name: &str, authors: &[&str]) -> Option<&BadMod> { + self.bad_mods.iter().find(|entry| { + if entry.hash.is_some() || !entry.project_ids.is_empty() { + return false; + } + let (Some(entry_name), Some(entry_author)) = + (entry.name.as_deref(), entry.author.as_deref()) + else { + return false; + }; + same_text(entry_name, name) + && authors.iter().any(|author| same_text(entry_author, author)) + }) + } +} + +fn same_text(a: &str, b: &str) -> bool { + a.trim().to_lowercase() == b.trim().to_lowercase() +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn fetch_bad_mods(ctx: &ContentCtx) -> ContentResult { + let url = format!("{}/oneclient/bad-mods.json", ctx.net.config().meta_url_base); + let cache_path = paths::caches_dir()?.join("bad-mods.json"); + + let Some(fetched) = fetch_cached(&ctx.net, &url, &cache_path, EtagPolicy::CommitNow).await? + else { + return Err(ContentError::InvalidData { + reason: "bad mods list is unavailable and not cached".to_string(), + }); + }; + + Ok(fetched.json()?) +} + +static BAD_MODS: RwLock>> = RwLock::new(None); + +pub async fn refresh_bad_mods(ctx: &ContentCtx) -> ContentResult> { + let list = Arc::new(fetch_bad_mods(ctx).await?); + *BAD_MODS.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::clone(&list)); + Ok(list) +} + +pub async fn load_bad_mods(ctx: &ContentCtx) -> Arc { + let loaded = BAD_MODS + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + if let Some(list) = loaded { + return list; + } + + refresh_bad_mods(ctx).await.unwrap_or_else(|err| { + tracing::warn!(%err, "bad mods list unavailable, installs are not screened"); + Arc::default() + }) +} + +const EXPLANATIONS_DIR: &str = "/oneclient/bad_mods_mds/"; + +fn explanation_file_name(path: &str) -> Option<&str> { + let name = path.strip_prefix(EXPLANATIONS_DIR)?; + let valid = name.ends_with(".md") + && name.len() > ".md".len() + && !name.contains("..") + && !name.contains(['/', '\\', '?', '#']); + valid.then_some(name) +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn fetch_explanation(entry: &BadMod, ctx: &ContentCtx) -> Option { + let path = entry.explanation.as_deref()?; + let Some(file_name) = explanation_file_name(path) else { + tracing::warn!( + path, + "ignoring bad mod explanation outside {EXPLANATIONS_DIR}" + ); + return None; + }; + + let url = format!("{}{path}", ctx.net.config().meta_url_base); + let cache_path = match paths::caches_dir() { + Ok(dir) => dir.join("bad_mods_mds").join(file_name), + Err(err) => { + tracing::warn!(%err, "cannot resolve the cache dir for bad mod explanations"); + return None; + } + }; + + match fetch_cached(&ctx.net, &url, &cache_path, EtagPolicy::CommitNow).await { + Ok(Some(fetched)) => Some(fetched.text()).filter(|text| !text.trim().is_empty()), + Ok(None) => { + tracing::warn!(path, "bad mod explanation is unavailable and not cached"); + None + } + Err(err) => { + tracing::warn!(%err, path, "failed to fetch bad mod explanation"); + None + } + } +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn resolve_alternatives( + entry: &BadMod, + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> Vec { + let by_hash = lookup_alternative_hashes(entry, ctx).await; + + let hits: Vec<(&BadModAlternative, Option<&VersionDetail>)> = entry + .alternatives + .iter() + .map(|alternative| { + let hit = alternative + .hash + .as_deref() + .and_then(|hash| by_hash.get(&polyio::normalize_hash(hash))); + (alternative, hit) + }) + .collect(); + + let mut modrinth_ids = HashSet::new(); + let mut curseforge_ids = HashSet::new(); + for (alternative, hit) in &hits { + match hit { + Some(version) => { + modrinth_ids.insert(version.project_id.clone()); + } + None => { + if let Some(id) = alternative.project_ids.modrinth.as_deref() { + if is_modrinth_id(id) { + modrinth_ids.insert(id.to_string()); + } else { + tracing::warn!(id, "ignoring malformed Modrinth project id"); + } + } + if let Some(id) = alternative.project_ids.curseforge.as_deref() { + curseforge_ids.insert(id.to_string()); + } + } + } + } + + let (modrinth, curseforge) = tokio::join!( + fetch_projects(ProviderId::Modrinth, modrinth_ids, ctx), + fetch_projects(ProviderId::CurseForge, curseforge_ids, ctx), + ); + + let resolved = futures_util::future::join_all(hits.into_iter().map(|(alternative, hit)| { + resolve_alternative(alternative, hit, &modrinth, &curseforge, cluster, ctx) + })) + .await; + + let mut seen = HashSet::new(); + resolved + .into_iter() + .flatten() + .filter(|alternative| seen.insert((alternative.provider, alternative.project_id.clone()))) + .collect() +} + +async fn lookup_alternative_hashes(entry: &BadMod, ctx: &ContentCtx) -> VersionLookup { + let mut seen = HashSet::new(); + let identities: Vec = entry + .alternatives + .iter() + .filter_map(|alt| alt.hash.as_deref()) + .map(FileIdentity::from_sha1) + .filter(|identity| seen.insert(identity.sha1.clone())) + .collect(); + + if identities.is_empty() { + return VersionLookup::new(); + } + + let lookup = match ctx.providers.get(ProviderId::Modrinth) { + Ok(provider) => provider.lookup_versions(&identities, ctx).await, + Err(err) => Err(err), + }; + + lookup.unwrap_or_else(|err| { + tracing::warn!(%err, "failed to look up bad mod alternatives by hash"); + VersionLookup::new() + }) +} + +fn is_modrinth_id(id: &str) -> bool { + id.chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) +} + +async fn fetch_projects( + provider_id: ProviderId, + ids: HashSet, + ctx: &ContentCtx, +) -> Vec { + if ids.is_empty() { + return Vec::new(); + } + + let ids: Vec = ids.into_iter().collect(); + let fetched = match ctx.providers.get(provider_id) { + Ok(provider) => provider.get_projects(&ids, ctx).await, + Err(err) => Err(err), + }; + + fetched.unwrap_or_else(|err| { + tracing::warn!(%err, ?provider_id, "failed to fetch alternative projects"); + Vec::new() + }) +} + +fn find_project<'a>(projects: &'a [ProjectDetail], id: &str) -> Option<&'a ProjectDetail> { + projects + .iter() + .find(|project| project.id == id || project.slug.eq_ignore_ascii_case(id)) +} + +async fn resolve_alternative( + alternative: &BadModAlternative, + hit: Option<&VersionDetail>, + modrinth: &[ProjectDetail], + curseforge: &[ProjectDetail], + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> Option { + if let Some(version) = hit { + let (name, icon_url) = find_project(modrinth, &version.project_id) + .map(|project| (project.name.clone(), project.icon_url.clone())) + .unwrap_or_else(|| (version.name.clone(), None)); + + return Some(ResolvedAlternative { + provider: ProviderId::Modrinth, + project_id: version.project_id.clone(), + name, + version_number: Some(version.version_number.clone()), + icon_url, + }); + } + + for (provider_id, projects) in [ + (ProviderId::Modrinth, modrinth), + (ProviderId::CurseForge, curseforge), + ] { + let Some(project) = alternative + .project_ids + .get(provider_id) + .and_then(|id| find_project(projects, id)) + else { + continue; + }; + return Some(with_newest_version(provider_id, project, cluster, ctx).await); + } + + tracing::warn!( + ?alternative, + "bad mod alternative not found by hash or project id" + ); + None +} + +async fn with_newest_version( + provider_id: ProviderId, + project: &ProjectDetail, + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> ResolvedAlternative { + let picked = match ctx.providers.get(provider_id) { + Ok(provider) => pick_version(provider, &project.id, cluster, ctx).await, + Err(err) => Err(err), + }; + + let version_number = match picked { + Ok(pick) => pick.map(|pick| pick.version_number), + Err(err) => { + tracing::warn!(%err, ?provider_id, project_id = %project.id, "failed to pick alternative version"); + None + } + }; + + ResolvedAlternative { + provider: provider_id, + project_id: project.id.clone(), + name: project.name.clone(), + version_number, + icon_url: project.icon_url.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FULL: &[u8] = br#"{ + "bad-mods": [ + { + "hash": "28b67391892582747bd17a7525318a954e54c80a", + "project-ids": { "modrinth": "1bokaNcj", "curseforge": "263420" }, + "name": "Xaero's Minimap", + "author": "xaero96", + "alternatives": [ + { + "hash": "d1741855c0e2b433f615c25e61c211b594dc525d", + "project-ids": { "modrinth": "HXF82T3G", "curseforge": "220318" } + }, + { + "hash": "04739b04815269648fb885526b2c2ff2266e0cc5", + "project-ids": { "modrinth": "OhduvhIc" } + } + ] + } + ] + }"#; + + #[test] + fn parses_full_entry() { + let list: BadModList = serde_json::from_slice(FULL).unwrap(); + let entry = &list.bad_mods[0]; + + assert_eq!( + entry.hash.as_deref(), + Some("28b67391892582747bd17a7525318a954e54c80a") + ); + assert_eq!( + entry.project_ids.get(ProviderId::Modrinth), + Some("1bokaNcj") + ); + assert_eq!( + entry.project_ids.get(ProviderId::CurseForge), + Some("263420") + ); + assert_eq!(entry.name.as_deref(), Some("Xaero's Minimap")); + assert_eq!(entry.author.as_deref(), Some("xaero96")); + + assert_eq!(entry.alternatives.len(), 2); + assert_eq!( + entry.alternatives[1].project_ids.get(ProviderId::Modrinth), + Some("OhduvhIc") + ); + assert_eq!(entry.alternatives[1].project_ids.curseforge, None); + } + + #[test] + fn every_field_is_optional() { + let list: BadModList = serde_json::from_slice(br#"{}"#).unwrap(); + assert!(list.bad_mods.is_empty()); + + let list: BadModList = serde_json::from_slice( + br#"{"bad-mods":[{"name":"X","author":"Y","alternatives":[{}]}]}"#, + ) + .unwrap(); + let entry = &list.bad_mods[0]; + assert_eq!(entry.hash, None); + assert!(entry.project_ids.is_empty()); + assert_eq!(entry.alternatives[0].hash, None); + assert!(entry.alternatives[0].project_ids.is_empty()); + } + + #[test] + fn blank_values_count_as_missing() { + let raw = br#"{"bad-mods":[{"hash":" ","name":"","project-ids":{"modrinth":" ","curseforge":""}}]}"#; + let list: BadModList = serde_json::from_slice(raw).unwrap(); + let entry = &list.bad_mods[0]; + + assert_eq!(entry.hash, None); + assert_eq!(entry.name, None); + assert!(entry.project_ids.is_empty()); + } + + #[test] + fn curseforge_id_accepts_a_number() { + let raw = br#"{"bad-mods":[{"project-ids":{"curseforge":263420}}]}"#; + let list: BadModList = serde_json::from_slice(raw).unwrap(); + + assert_eq!( + list.bad_mods[0].project_ids.get(ProviderId::CurseForge), + Some("263420") + ); + } + + #[test] + fn find_ignores_case_and_whitespace() { + let list: BadModList = + serde_json::from_slice(br#"{"bad-mods":[{"hash":" ABCdef "},{"name":"X"}]}"#).unwrap(); + + assert!(list.find("abcdef").is_some()); + assert!(list.find("ABCDEF").is_some()); + assert!(list.find("abcde0").is_none()); + } + + #[test] + fn project_id_matches_only_its_provider() { + let list: BadModList = serde_json::from_slice(FULL).unwrap(); + + assert!( + list.find_project(ProviderId::Modrinth, "1bokaNcj") + .is_some() + ); + assert!( + list.find_project(ProviderId::CurseForge, "263420") + .is_some() + ); + assert!( + list.find_project(ProviderId::CurseForge, "1bokaNcj") + .is_none() + ); + assert!(list.find_project(ProviderId::Modrinth, "other").is_none()); + } + + const NAME_ONLY: &[u8] = br#"{"bad-mods":[{"name":"Xaero's Minimap","author":"xaero96"}]}"#; + + #[test] + fn name_and_author_must_both_match() { + let list: BadModList = serde_json::from_slice(NAME_ONLY).unwrap(); + + assert!( + list.find_name_and_author(" xaero's minimap ", &["someone", "XAERO96"]) + .is_some() + ); + assert!( + list.find_name_and_author("Xaero's Minimap", &["someone"]) + .is_none() + ); + assert!( + list.find_name_and_author("Xaero's World Map", &["xaero96"]) + .is_none() + ); + } + + #[test] + fn name_and_author_ignored_when_hash_or_project_id_present() { + let list: BadModList = serde_json::from_slice(FULL).unwrap(); + assert!( + list.find_name_and_author("Xaero's Minimap", &["xaero96"]) + .is_none() + ); + + let with_hash: BadModList = serde_json::from_slice( + br#"{"bad-mods":[{"hash":"aaa","name":"Xaero's Minimap","author":"xaero96"}]}"#, + ) + .unwrap(); + assert!( + with_hash + .find_name_and_author("Xaero's Minimap", &["xaero96"]) + .is_none() + ); + + let with_id: BadModList = serde_json::from_slice( + br#"{"bad-mods":[{"project-ids":{"curseforge":"1"},"name":"Xaero's Minimap","author":"xaero96"}]}"#, + ) + .unwrap(); + assert!( + with_id + .find_name_and_author("Xaero's Minimap", &["xaero96"]) + .is_none() + ); + } + + #[test] + fn malformed_entries_are_skipped_not_fatal() { + let raw = br#"{"bad-mods":[ + {"hash":"aaa","alternatives":[{"hash":5},{"hash":"bbb"}]}, + {"hash":123}, + {"project-ids":"oops"}, + "not an object", + {"hash":"ccc"} + ]}"#; + let list: BadModList = serde_json::from_slice(raw).unwrap(); + + assert_eq!(list.bad_mods.len(), 2); + assert_eq!(list.bad_mods[0].alternatives.len(), 1); + assert_eq!( + list.bad_mods[0].alternatives[0].hash.as_deref(), + Some("bbb") + ); + assert!(list.find("ccc").is_some()); + } + + #[test] + fn null_list_is_empty() { + let list: BadModList = serde_json::from_slice(br#"{"bad-mods":null}"#).unwrap(); + assert!(list.bad_mods.is_empty()); + } + + #[test] + fn modrinth_ids_reject_url_breaking_characters() { + assert!(is_modrinth_id("1bokaNcj")); + assert!(is_modrinth_id("xaeros-minimap")); + assert!(!is_modrinth_id("bad id")); + assert!(!is_modrinth_id("a\"],[\"b")); + } + + #[test] + fn parses_explanation_path() { + let raw = br#"{"bad-mods":[{"hash":"aaa","explanation":"/oneclient/bad_mods_mds/test-file.md"},{"hash":"bbb","explanation":" "}]}"#; + let list: BadModList = serde_json::from_slice(raw).unwrap(); + + assert_eq!( + list.bad_mods[0].explanation.as_deref(), + Some("/oneclient/bad_mods_mds/test-file.md") + ); + assert_eq!(list.bad_mods[1].explanation, None); + } + + #[test] + fn explanation_must_be_an_md_file_in_the_folder() { + assert_eq!( + explanation_file_name("/oneclient/bad_mods_mds/test-file.md"), + Some("test-file.md") + ); + assert_eq!(explanation_file_name("/oneclient/tos.json"), None); + assert_eq!( + explanation_file_name("/oneclient/bad_mods_mds/notes.txt"), + None + ); + assert_eq!(explanation_file_name("/oneclient/bad_mods_mds/.md"), None); + assert_eq!( + explanation_file_name("/oneclient/bad_mods_mds/../tos.md"), + None + ); + assert_eq!( + explanation_file_name("/oneclient/bad_mods_mds/sub/file.md"), + None + ); + assert_eq!( + explanation_file_name("/oneclient/bad_mods_mds/a.md?x=1"), + None + ); + assert_eq!( + explanation_file_name("oneclient/bad_mods_mds/test-file.md"), + None + ); + assert_eq!( + explanation_file_name("https://evil.example/oneclient/bad_mods_mds/x.md"), + None + ); + } + + #[test] + fn name_without_author_never_matches() { + let list: BadModList = serde_json::from_slice(br#"{"bad-mods":[{"name":"X"}]}"#).unwrap(); + + assert!(list.find_name_and_author("X", &[""]).is_none()); + } +} diff --git a/packages/oneclient_content/src/packages/dependencies.rs b/packages/oneclient_content/src/packages/dependencies.rs index 1a9af701..19e12317 100644 --- a/packages/oneclient_content/src/packages/dependencies.rs +++ b/packages/oneclient_content/src/packages/dependencies.rs @@ -8,7 +8,7 @@ use crate::error::ContentResult; use crate::packages::provider::PackageProvider; use crate::packages::store::PackageStore; use crate::packages::types::{ - DependencyKind, ProjectDetail, ReleaseType, VersionDependency, VersionDetail, VersionSummary, + DependencyKind, ProjectDetail, ReleaseType, VersionDependency, VersionDetail, VersionSummary, }; /// Caps the transitive walk so a provider serving a cycle can't spin forever @@ -18,357 +18,425 @@ const VERSION_WINDOW: usize = 50; #[derive(Debug, Clone)] pub struct ResolvedDependency { - pub project: ProjectDetail, - pub version: VersionDetail, + pub project: ProjectDetail, + pub version: VersionDetail, } #[derive(Debug, Clone, Default)] pub struct DependencyResolution { - /// Ordered breadth-first from the root - /// already-present ones are filtered out - pub install: Vec, - pub unresolved: Vec, + /// Ordered breadth-first from the root + /// already-present ones are filtered out + pub install: Vec, + pub unresolved: Vec, } impl DependencyResolution { - pub fn is_empty(&self) -> bool { - self.install.is_empty() && self.unresolved.is_empty() - } + pub fn is_empty(&self) -> bool { + self.install.is_empty() && self.unresolved.is_empty() + } } #[tracing::instrument(level = "debug", skip(root, ctx), fields(project_id = %root.project_id, version_id = %root.version_id))] pub async fn resolve_required( - provider_id: ProviderId, - root: &VersionDetail, - cluster_id: i64, - ctx: &ContentCtx, + provider_id: ProviderId, + root: &VersionDetail, + cluster_id: i64, + ctx: &ContentCtx, ) -> ContentResult { - let mut resolution = DependencyResolution::default(); - if !root.dependencies.iter().any(is_required) { - return Ok(resolution); - } + let mut resolution = DependencyResolution::default(); + if !root.dependencies.iter().any(is_required) { + return Ok(resolution); + } - let provider = ctx.providers.get(provider_id)?; - let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let provider = ctx.providers.get(provider_id)?; + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; - // Seeded with installed projects and the root against refetches and self-referencing graphs - let mut seen = installed_project_ids(provider_id, cluster_id, ctx).await?; - seen.insert(root.project_id.clone()); + // Seeded with installed projects and the root against refetches and self-referencing graphs + let mut seen = installed_project_ids(provider_id, cluster_id, ctx).await?; + seen.insert(root.project_id.clone()); - let mut queue: VecDeque<(VersionDependency, usize)> = root - .dependencies - .iter() - .filter(|dep| is_required(dep)) - .map(|dep| (dep.clone(), 1)) - .collect(); + let mut queue: VecDeque<(VersionDependency, usize)> = root + .dependencies + .iter() + .filter(|dep| is_required(dep)) + .map(|dep| (dep.clone(), 1)) + .collect(); - while let Some((dep, depth)) = queue.pop_front() { - if let Some(project_id) = &dep.project_id - && seen.contains(project_id) - { - continue; - } + while let Some((dep, depth)) = queue.pop_front() { + if let Some(project_id) = &dep.project_id + && seen.contains(project_id) + { + continue; + } - let resolved = match resolve_one(provider, &dep, &cluster, ctx).await { - Ok(Some(resolved)) => resolved, - Ok(None) => { - tracing::warn!(?dep, "no compatible version for dependency"); - resolution.unresolved.push(dependency_label(&dep)); - continue; - } - Err(err) => { - tracing::warn!(?dep, %err, "failed to resolve dependency"); - resolution.unresolved.push(dependency_label(&dep)); - continue; - } - }; + let resolved = match resolve_one(provider, &dep, &cluster, ctx).await { + Ok(Some(resolved)) => resolved, + Ok(None) => { + tracing::warn!(?dep, "no compatible version for dependency"); + resolution.unresolved.push(dependency_label(&dep)); + continue; + } + Err(err) => { + tracing::warn!(?dep, %err, "failed to resolve dependency"); + resolution.unresolved.push(dependency_label(&dep)); + continue; + } + }; - // A version-pinned dependency only reveals its project once fetched so dedupe again here - if !seen.insert(resolved.version.project_id.clone()) { - continue; - } + // A version-pinned dependency only reveals its project once fetched so dedupe again here + if !seen.insert(resolved.version.project_id.clone()) { + continue; + } - if depth < MAX_DEPTH { - for next in resolved.version.dependencies.iter().filter(|d| is_required(d)) { - queue.push_back((next.clone(), depth + 1)); - } - } + if depth < MAX_DEPTH { + for next in resolved + .version + .dependencies + .iter() + .filter(|d| is_required(d)) + { + queue.push_back((next.clone(), depth + 1)); + } + } - resolution.install.push(resolved); - } + resolution.install.push(resolved); + } - Ok(resolution) + Ok(resolution) } fn is_required(dep: &VersionDependency) -> bool { - dep.kind == DependencyKind::Required + dep.kind == DependencyKind::Required } fn dependency_label(dep: &VersionDependency) -> String { - dep.project_id - .clone() - .or_else(|| dep.version_id.clone()) - .unwrap_or_else(|| "unknown".to_string()) + dep.project_id + .clone() + .or_else(|| dep.version_id.clone()) + .unwrap_or_else(|| "unknown".to_string()) } -async fn resolve_one( - provider: &dyn PackageProvider, - dep: &VersionDependency, - cluster: &ClusterRow, - ctx: &ContentCtx, +pub(crate) async fn resolve_one( + provider: &dyn PackageProvider, + dep: &VersionDependency, + cluster: &ClusterRow, + ctx: &ContentCtx, ) -> ContentResult> { - // A pinned version wins even when newer exists - // providers ignore the project id once the version id is known - let version = match &dep.version_id { - Some(version_id) => { - provider - .get_version( - dep.project_id.as_deref().unwrap_or_default(), - version_id, - ctx, - ) - .await? - } - None => { - let Some(project_id) = dep.project_id.as_deref() else { - return Ok(None); - }; - let Some(pick) = pick_version(provider, project_id, cluster, ctx).await? else { - return Ok(None); - }; - provider.get_version(project_id, &pick.version_id, ctx).await? - } - }; + // A pinned version wins even when newer exists + // providers ignore the project id once the version id is known + let version = match &dep.version_id { + Some(version_id) => { + provider + .get_version( + dep.project_id.as_deref().unwrap_or_default(), + version_id, + ctx, + ) + .await? + } + None => { + let Some(project_id) = dep.project_id.as_deref() else { + return Ok(None); + }; + let Some(pick) = pick_version(provider, project_id, cluster, ctx).await? else { + return Ok(None); + }; + provider + .get_version(project_id, &pick.version_id, ctx) + .await? + } + }; - let project = provider.get_project(&version.project_id, ctx).await?; - Ok(Some(ResolvedDependency { project, version })) + let project = provider.get_project(&version.project_id, ctx).await?; + Ok(Some(ResolvedDependency { project, version })) } /// Falls back to the newest prerelease when no release fits the cluster pub(crate) async fn pick_version( - provider: &dyn PackageProvider, - project_id: &str, - cluster: &ClusterRow, - ctx: &ContentCtx, + provider: &dyn PackageProvider, + project_id: &str, + cluster: &ClusterRow, + ctx: &ContentCtx, ) -> ContentResult> { - let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla); - let loader_filter = loader.is_modded().then_some(loader); + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla); + let loader_filter = loader.is_modded().then_some(loader); - let mut candidates = provider - .list_versions( - project_id, - Some(&cluster.mc_version), - loader_filter, - 0, - VERSION_WINDOW, - ctx, - ) - .await? - .items; + let mut candidates = provider + .list_versions( + project_id, + Some(&cluster.mc_version), + loader_filter, + 0, + VERSION_WINDOW, + ctx, + ) + .await? + .items; - // Resource packs shaders and datapacks declare no loader so a loader-filtered query is empty - if candidates.is_empty() && loader_filter.is_some() { - candidates = provider - .list_versions(project_id, Some(&cluster.mc_version), None, 0, VERSION_WINDOW, ctx) - .await? - .items; - } + // Resource packs shaders and datapacks declare no loader so a loader-filtered query is empty + if candidates.is_empty() && loader_filter.is_some() { + candidates = provider + .list_versions( + project_id, + Some(&cluster.mc_version), + None, + 0, + VERSION_WINDOW, + ctx, + ) + .await? + .items; + } - Ok(choose_version(candidates, cluster, loader)) + Ok(choose_version(candidates, cluster, loader)) } fn choose_version( - mut candidates: Vec, - cluster: &ClusterRow, - loader: GameLoader, + mut candidates: Vec, + cluster: &ClusterRow, + loader: GameLoader, ) -> Option { - candidates.retain(|version| fits_cluster(version, cluster, loader)); - // Neither provider promises an order so sort rather than trust the page - candidates.sort_by_key(|version| std::cmp::Reverse(version.published)); + candidates.retain(|version| fits_cluster(version, cluster, loader)); + // Neither provider promises an order so sort rather than trust the page + candidates.sort_by_key(|version| std::cmp::Reverse(version.published)); - candidates - .iter() - .find(|version| matches!(version.release_type, ReleaseType::Release)) - .or_else(|| candidates.first()) - .cloned() + candidates + .iter() + .find(|version| matches!(version.release_type, ReleaseType::Release)) + .or_else(|| candidates.first()) + .cloned() } #[must_use] pub(crate) fn base_game_version(version: &str) -> &str { - version - .split_once('-') - .filter(|(base, _)| base.contains('.')) - .map_or(version, |(base, _)| base) + version + .split_once('-') + .filter(|(base, _)| base.contains('.')) + .map_or(version, |(base, _)| base) } #[must_use] pub(crate) fn supports_game_version(game_versions: &[String], mc_version: &str) -> bool { - let wanted = base_game_version(mc_version); - game_versions.is_empty() || game_versions.iter().any(|v| base_game_version(v) == wanted) + let wanted = base_game_version(mc_version); + game_versions.is_empty() || game_versions.iter().any(|v| base_game_version(v) == wanted) } fn fits_cluster(version: &VersionSummary, cluster: &ClusterRow, loader: GameLoader) -> bool { - if !supports_game_version(&version.game_versions, &cluster.mc_version) { - return false; - } + if !supports_game_version(&version.game_versions, &cluster.mc_version) { + return false; + } - version.loaders.is_empty() || version.loaders.iter().any(|l| loader.compatible_with(*l)) + version.loaders.is_empty() || version.loaders.iter().any(|l| loader.compatible_with(*l)) } async fn installed_project_ids( - provider_id: ProviderId, - cluster_id: i64, - ctx: &ContentCtx, + provider_id: ProviderId, + cluster_id: i64, + ctx: &ContentCtx, ) -> ContentResult> { - Ok(PackageStore::list_linked_artifacts(cluster_id, ctx) - .await? - .into_iter() - .filter(|linked| linked.provider == Some(provider_id)) - .filter_map(|linked| linked.project_id) - .collect()) + Ok(PackageStore::list_linked_artifacts(cluster_id, ctx) + .await? + .into_iter() + .filter(|linked| linked.provider == Some(provider_id)) + .filter_map(|linked| linked.project_id) + .collect()) } /// False for modpacks they bring their own file list pub fn resolves_dependencies(content_type: ContentType) -> bool { - !matches!(content_type, ContentType::Modpack) + !matches!(content_type, ContentType::Modpack) } #[cfg(test)] mod tests { - use chrono::{TimeZone, Utc}; + use chrono::{TimeZone, Utc}; - use super::*; + use super::*; - #[test] - fn a_version_is_offered_only_for_the_game_version_it_names() { - let offered = |stated: &[&str], mc_version: &str| { - supports_game_version( - &stated.iter().map(|v| (*v).to_string()).collect::>(), - mc_version, - ) - }; + #[test] + fn a_version_is_offered_only_for_the_game_version_it_names() { + let offered = |stated: &[&str], mc_version: &str| { + supports_game_version( + &stated.iter().map(|v| (*v).to_string()).collect::>(), + mc_version, + ) + }; - assert!(offered(&[], "26.3"), "an undescribed version is offered"); - assert!(offered(&["26.3"], "26.3")); - assert!( - offered(&["26.3-rc-1"], "26.3"), - "a pre-release tag names the same game version" - ); - assert!(offered(&["26.3"], "26.3-snapshot-10")); - assert!( - !offered(&["26.1"], "26.1.2"), - "strict where the launch-time check is lenient: #830 stopped 26.1 \ + assert!(offered(&[], "26.3"), "an undescribed version is offered"); + assert!(offered(&["26.3"], "26.3")); + assert!( + offered(&["26.3-rc-1"], "26.3"), + "a pre-release tag names the same game version" + ); + assert!(offered(&["26.3"], "26.3-snapshot-10")); + assert!( + !offered(&["26.1"], "26.1.2"), + "strict where the launch-time check is lenient: #830 stopped 26.1 \ builds being offered to a 26.1.2 cluster" - ); - assert!(!offered(&["1.21.11"], "26.1.2")); - assert!( - !offered(&["rd-132211"], "rd-160052"), - "pre-classic names the build after the dash, so there is no tag to strip" - ); - } + ); + assert!(!offered(&["1.21.11"], "26.1.2")); + assert!( + !offered(&["rd-132211"], "rd-160052"), + "pre-classic names the build after the dash, so there is no tag to strip" + ); + } - fn summary( - version_id: &str, - release_type: ReleaseType, - day: u32, - loaders: Vec, - game_versions: Vec<&str>, - ) -> VersionSummary { - VersionSummary { - version_id: version_id.into(), - project_id: "p".into(), - name: version_id.into(), - version_number: version_id.into(), - published: Utc.with_ymd_and_hms(2025, 1, day, 0, 0, 0).unwrap(), - release_type, - game_versions: game_versions.into_iter().map(Into::into).collect(), - loaders, - downloads: 0, - file_size: 0, - } - } + fn summary( + version_id: &str, + release_type: ReleaseType, + day: u32, + loaders: Vec, + game_versions: Vec<&str>, + ) -> VersionSummary { + VersionSummary { + version_id: version_id.into(), + project_id: "p".into(), + name: version_id.into(), + version_number: version_id.into(), + published: Utc.with_ymd_and_hms(2025, 1, day, 0, 0, 0).unwrap(), + release_type, + game_versions: game_versions.into_iter().map(Into::into).collect(), + loaders, + downloads: 0, + file_size: 0, + dependencies: Vec::new(), + } + } - fn cluster(loader: GameLoader, mc_version: &str) -> ClusterRow { - ClusterRow { - id: 1, - name: "c".into(), - folder_name: "c".into(), - setting_profile_name: None, - mc_version: mc_version.into(), - mc_loader: loader as i64, - stage: 0, - mc_loader_version: None, - created_at: None, - last_played: None, - overall_played: None, - linked_modpack_hash: None, - } - } + fn cluster(loader: GameLoader, mc_version: &str) -> ClusterRow { + ClusterRow { + id: 1, + name: "c".into(), + folder_name: "c".into(), + setting_profile_name: None, + mc_version: mc_version.into(), + mc_loader: loader as i64, + stage: 0, + mc_loader_version: None, + created_at: None, + last_played: None, + overall_played: None, + linked_modpack_hash: None, + kind: 0, + user_created: 0, + description: None, + tags: "[]".into(), + cover_path: None, + } + } - #[test] - fn prefers_the_newest_release_over_a_newer_prerelease() { - let cluster = cluster(GameLoader::Fabric, "1.21.4"); - let candidates = vec![ - summary("old", ReleaseType::Release, 1, vec![GameLoader::Fabric], vec!["1.21.4"]), - summary("beta", ReleaseType::Beta, 3, vec![GameLoader::Fabric], vec!["1.21.4"]), - summary("new", ReleaseType::Release, 2, vec![GameLoader::Fabric], vec!["1.21.4"]), - ]; + #[test] + fn prefers_the_newest_release_over_a_newer_prerelease() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![ + summary( + "old", + ReleaseType::Release, + 1, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + summary( + "beta", + ReleaseType::Beta, + 3, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + summary( + "new", + ReleaseType::Release, + 2, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + ]; - let pick = choose_version(candidates, &cluster, GameLoader::Fabric); - assert_eq!(pick.unwrap().version_id, "new"); - } + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "new"); + } - #[test] - fn falls_back_to_a_prerelease_when_no_release_fits() { - let cluster = cluster(GameLoader::Fabric, "1.21.4"); - let candidates = vec![ - summary("alpha", ReleaseType::Alpha, 1, vec![GameLoader::Fabric], vec!["1.21.4"]), - summary("beta", ReleaseType::Beta, 2, vec![GameLoader::Fabric], vec!["1.21.4"]), - ]; + #[test] + fn falls_back_to_a_prerelease_when_no_release_fits() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![ + summary( + "alpha", + ReleaseType::Alpha, + 1, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + summary( + "beta", + ReleaseType::Beta, + 2, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + ]; - let pick = choose_version(candidates, &cluster, GameLoader::Fabric); - assert_eq!(pick.unwrap().version_id, "beta"); - } + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "beta"); + } - #[test] - fn drops_versions_the_store_would_reject() { - let cluster = cluster(GameLoader::Fabric, "1.21.4"); - let candidates = vec![ - summary("forge", ReleaseType::Release, 3, vec![GameLoader::Forge], vec!["1.21.4"]), - summary("old-mc", ReleaseType::Release, 2, vec![GameLoader::Fabric], vec!["1.20.1"]), - summary("fits", ReleaseType::Release, 1, vec![GameLoader::Fabric], vec!["1.21.4"]), - ]; + #[test] + fn drops_versions_the_store_would_reject() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![ + summary( + "forge", + ReleaseType::Release, + 3, + vec![GameLoader::Forge], + vec!["1.21.4"], + ), + summary( + "old-mc", + ReleaseType::Release, + 2, + vec![GameLoader::Fabric], + vec!["1.20.1"], + ), + summary( + "fits", + ReleaseType::Release, + 1, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + ]; - let pick = choose_version(candidates, &cluster, GameLoader::Fabric); - assert_eq!(pick.unwrap().version_id, "fits"); - } + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "fits"); + } - #[test] - fn keeps_loaderless_content_for_a_modded_cluster() { - let cluster = cluster(GameLoader::Fabric, "1.21.4"); - let candidates = vec![summary( - "pack", - ReleaseType::Release, - 1, - Vec::new(), - vec!["1.21.4"], - )]; + #[test] + fn keeps_loaderless_content_for_a_modded_cluster() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![summary( + "pack", + ReleaseType::Release, + 1, + Vec::new(), + vec!["1.21.4"], + )]; - let pick = choose_version(candidates, &cluster, GameLoader::Fabric); - assert_eq!(pick.unwrap().version_id, "pack"); - } + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "pack"); + } - #[test] - fn no_compatible_version_resolves_to_nothing() { - let cluster = cluster(GameLoader::Fabric, "1.21.4"); - let candidates = vec![summary( - "forge", - ReleaseType::Release, - 1, - vec![GameLoader::Forge], - vec!["1.21.4"], - )]; + #[test] + fn no_compatible_version_resolves_to_nothing() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![summary( + "forge", + ReleaseType::Release, + 1, + vec![GameLoader::Forge], + vec!["1.21.4"], + )]; - assert!(choose_version(candidates, &cluster, GameLoader::Fabric).is_none()); - } + assert!(choose_version(candidates, &cluster, GameLoader::Fabric).is_none()); + } } diff --git a/packages/oneclient_content/src/packages/error.rs b/packages/oneclient_content/src/packages/error.rs index b1120825..4ac1cfda 100644 --- a/packages/oneclient_content/src/packages/error.rs +++ b/packages/oneclient_content/src/packages/error.rs @@ -1,38 +1,36 @@ use thiserror::Error; -use oneclient_common::domain::ProviderId; use super::types::PackageBody; +use oneclient_common::domain::ProviderId; #[derive(Debug, Error)] pub enum PackageError { - #[error("no primary download file on version")] - NoPrimaryFile, - #[error("package is not a modpack")] - NotModpack, - #[error("unsupported modpack format")] - UnsupportedModpackFormat, - #[error("modpack install had {failed} of {total} file failures")] - PartialModpackInstall { failed: u64, total: u64 }, - #[error("missing API key for provider {0:?}")] - MissingApiKey(ProviderId), - #[error("unsupported package body type")] - UnsupportedBody(PackageBody), - #[error("package is not compatible with cluster Minecraft version")] - IncompatibleMcVersion, - #[error("package is not compatible with cluster loader")] - IncompatibleLoader, - #[error("cluster {0} not found")] - ClusterNotFound(i64), - #[error("artifact file missing at {0}")] - ArtifactMissing(String), - #[error("hash mismatch: expected {expected}, got {actual}")] - HashMismatch { expected: String, actual: String }, - #[error("provider {0:?} does not support this operation")] - UnsupportedProvider(ProviderId), - #[error("package provider {0:?} is not registered")] - ProviderNotRegistered(ProviderId), - #[error("invalid local file: {0}")] - InvalidLocalFile(String), + #[error("no primary download file on version")] + NoPrimaryFile, + #[error("package is not a modpack")] + NotModpack, + #[error("unsupported modpack format")] + UnsupportedModpackFormat, + #[error("missing API key for provider {0:?}")] + MissingApiKey(ProviderId), + #[error("unsupported package body type")] + UnsupportedBody(PackageBody), + #[error("package is not compatible with cluster Minecraft version")] + IncompatibleMcVersion, + #[error("package is not compatible with cluster loader")] + IncompatibleLoader, + #[error("cluster {0} not found")] + ClusterNotFound(i64), + #[error("artifact file missing at {0}")] + ArtifactMissing(String), + #[error("hash mismatch: expected {expected}, got {actual}")] + HashMismatch { expected: String, actual: String }, + #[error("provider {0:?} does not support this operation")] + UnsupportedProvider(ProviderId), + #[error("package provider {0:?} is not registered")] + ProviderNotRegistered(ProviderId), + #[error("invalid local file: {0}")] + InvalidLocalFile(String), } pub type PackageResult = Result; diff --git a/packages/oneclient_content/src/packages/fabric_version.rs b/packages/oneclient_content/src/packages/fabric_version.rs new file mode 100644 index 00000000..1c8cb67b --- /dev/null +++ b/packages/oneclient_content/src/packages/fabric_version.rs @@ -0,0 +1,167 @@ +//! Version ordering as Fabric Loader does it, ported from its `VersionParser`, + +use std::cmp::Ordering; + +struct Semantic<'a> { + components: Vec, + prerelease: Option<&'a str>, + build: Option<&'a str>, +} + +impl Semantic<'_> { + fn friendly(&self) -> String { + let mut out = self + .components + .iter() + .map(ToString::to_string) + .collect::>() + .join("."); + if let Some(prerelease) = self.prerelease { + out.push('-'); + out.push_str(prerelease); + } + if let Some(build) = self.build { + out.push('+'); + out.push_str(build); + } + out + } +} + +fn parse(version: &str) -> Option> { + let (version, build) = match version.split_once('+') { + Some((version, build)) => (version, Some(build)), + None => (version, None), + }; + let (version, prerelease) = match version.split_once('-') { + Some((version, prerelease)) => (version, Some(prerelease)), + None => (version, None), + }; + + if prerelease.is_some_and(|prerelease| !is_dot_separated_id(prerelease)) { + return None; + } + + let components = version + .split('.') + .map(|component| component.parse::().ok()) + .collect::>>()?; + + Some(Semantic { + components, + prerelease, + build, + }) +} + +fn is_dot_separated_id(text: &str) -> bool { + text.is_empty() + || text.split('.').all(|part| { + !part.is_empty() && part.bytes().all(|b| b == b'-' || b.is_ascii_alphanumeric()) + }) +} + +fn is_unsigned_integer(text: &str) -> bool { + text == "0" + || (!text.is_empty() && !text.starts_with('0') && text.bytes().all(|b| b.is_ascii_digit())) +} + +fn compare_strings(left: &str, right: &str) -> Ordering { + left.encode_utf16().cmp(right.encode_utf16()) +} + +fn compare_prereleases(left: &str, right: &str) -> Ordering { + let mut left = left.split('.').filter(|part| !part.is_empty()); + let mut right = right.split('.').filter(|part| !part.is_empty()); + + loop { + let (a, b) = match (left.next(), right.next()) { + (Some(a), Some(b)) => (a, b), + (Some(_), None) => return Ordering::Greater, + (None, Some(_)) => return Ordering::Less, + (None, None) => return Ordering::Equal, + }; + + let by_kind = match (is_unsigned_integer(a), is_unsigned_integer(b)) { + (true, true) => a.len().cmp(&b.len()), + (true, false) => return Ordering::Less, + (false, true) => return Ordering::Greater, + (false, false) => Ordering::Equal, + }; + let ordering = by_kind.then_with(|| compare_strings(a, b)); + if ordering != Ordering::Equal { + return ordering; + } + } +} + +#[must_use] +pub fn compare(left: &str, right: &str) -> Ordering { + let (left, right) = match (parse(left), parse(right)) { + (Some(left), Some(right)) => (left, right), + (parsed_left, parsed_right) => { + let friendly = |parsed: Option>, raw: &str| { + parsed.map_or_else(|| raw.to_owned(), |semantic| semantic.friendly()) + }; + return compare_strings(&friendly(parsed_left, left), &friendly(parsed_right, right)); + } + }; + + for index in 0..left.components.len().max(right.components.len()) { + let component = + |semantic: &Semantic<'_>| semantic.components.get(index).copied().unwrap_or(0); + let ordering = component(&left).cmp(&component(&right)); + if ordering != Ordering::Equal { + return ordering; + } + } + + match (left.prerelease, right.prerelease) { + (Some(left), Some(right)) => compare_prereleases(left, right), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => Ordering::Equal, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions_order_as_fabric_loader_orders_them() { + use Ordering::{Equal, Greater, Less}; + + for (left, right, expected) in [ + ("0.141.4+1.21.11", "0.141.6+1.21.11", Less), + ("1.13.12+kotlin.2.4.0", "1.14.1+kotlin.2.4.20", Less), + ("0.141.10", "0.141.9", Greater), + ("1.0", "1.0.0", Equal), + ("1.0.0+a", "1.0.0+b", Equal), + ("1.0.0-beta.1", "1.0.0", Less), + ("1.0.0-beta.2", "1.0.0-beta.10", Less), + ("1.0.0-beta", "1.0.0-beta.1", Less), + ("1.0.0-1", "1.0.0-alpha", Less), + ( + "0.4.0-alpha.0.27+1.21.11", + "0.4.0-alpha.0.3+1.21.11", + Greater, + ), + ("1.01", "1.1", Equal), + ("1.0.0-", "1.0.0", Less), + ("1.0.0-", "1.0.0-0", Less), + ("1.0.0-rc", "1.0.0-rc-1", Less), + ("1.99999999999", "1.200", Greater), + ("1..5", "1.0.5", Less), + ("v1.10", "v1.9", Less), + ("1.2", "1.2a", Less), + ] { + assert_eq!(compare(left, right), expected, "{left} vs {right}"); + assert_eq!( + compare(right, left), + expected.reverse(), + "{right} vs {left}" + ); + } + } +} diff --git a/packages/oneclient_content/src/packages/file_identity.rs b/packages/oneclient_content/src/packages/file_identity.rs index a6002012..e277c2c8 100644 --- a/packages/oneclient_content/src/packages/file_identity.rs +++ b/packages/oneclient_content/src/packages/file_identity.rs @@ -1,8 +1,4 @@ -use std::path::Path; - - -use crate::error::ContentResult; -use polyio::{normalize_hash, sha1_bytes}; +use polyio::normalize_hash; #[derive(Debug, Clone, PartialEq, Eq)] pub struct FileIdentity { @@ -11,30 +7,12 @@ pub struct FileIdentity { } impl FileIdentity { - pub fn from_bytes(bytes: &[u8]) -> Self { - Self { - sha1: sha1_bytes(bytes), - cf_fingerprint: Some(curseforge_fingerprint(bytes)), - } - } - - #[tracing::instrument(level = "debug", skip(path))] - pub async fn from_path(path: impl AsRef) -> ContentResult { - let bytes = polyio::read(path).await?; - Ok(Self::from_bytes(&bytes)) - } - pub fn from_sha1(sha1: impl AsRef) -> Self { Self { sha1: normalize_hash(sha1.as_ref()), cf_fingerprint: None, } } - - pub fn with_curseforge_fingerprint(mut self, fingerprint: u32) -> Self { - self.cf_fingerprint = Some(fingerprint); - self - } } pub fn curseforge_fingerprint(bytes: &[u8]) -> u32 { diff --git a/packages/oneclient_content/src/packages/local_manifest.rs b/packages/oneclient_content/src/packages/local_manifest.rs index 619b72a2..6c76e6c3 100644 --- a/packages/oneclient_content/src/packages/local_manifest.rs +++ b/packages/oneclient_content/src/packages/local_manifest.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use std::path::Path; +use oneclient_common::domain::GameLoader; use serde::Deserialize; const MAX_DESCRIPTION: usize = 600; @@ -16,333 +17,480 @@ const MANIFESTS: [&str; 5] = [FABRIC, QUILT, NEOFORGE, FORGE, LEGACY_FORGE]; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct JarManifest { - pub name: Option, - pub description: Option, - pub authors: Vec, - pub icon_entry: Option, + pub name: Option, + pub description: Option, + pub authors: Vec, + pub icon_entry: Option, } impl JarManifest { - #[must_use] - pub fn is_empty(&self) -> bool { - self.name.is_none() - && self.description.is_none() - && self.authors.is_empty() - && self.icon_entry.is_none() - } + #[must_use] + pub fn is_empty(&self) -> bool { + self.name.is_none() + && self.description.is_none() + && self.authors.is_empty() + && self.icon_entry.is_none() + } - #[must_use] - pub fn author_line(&self) -> String { - self.authors.join(", ") - } + #[must_use] + pub fn author_line(&self) -> String { + self.authors.join(", ") + } - fn fill_from(&mut self, other: Self) { - if self.name.is_none() { - self.name = other.name; - } - if self.description.is_none() { - self.description = other.description; - } - if self.authors.is_empty() { - self.authors = other.authors; - } - if self.icon_entry.is_none() { - self.icon_entry = other.icon_entry; - } - } + fn fill_from(&mut self, other: Self) { + if self.name.is_none() { + self.name = other.name; + } + if self.description.is_none() { + self.description = other.description; + } + if self.authors.is_empty() { + self.authors = other.authors; + } + if self.icon_entry.is_none() { + self.icon_entry = other.icon_entry; + } + } } #[tracing::instrument(level = "debug", fields(jar = %jar.display()))] pub async fn read_jar_manifest(jar: &Path) -> JarManifest { - let entries = match polyio::read_zip_file_entries(jar, |name| MANIFESTS.contains(&name)).await { - Ok(entries) => entries, - Err(err) => { - tracing::debug!("could not read {}: {err}", jar.display()); - return JarManifest::default(); - } - }; + let entries = match polyio::read_zip_file_entries(jar, |name| MANIFESTS.contains(&name)).await { + Ok(entries) => entries, + Err(err) => { + tracing::debug!("could not read {}: {err}", jar.display()); + return JarManifest::default(); + } + }; - let mut out = JarManifest::default(); - for wanted in MANIFESTS { - let Some((_, bytes)) = entries.iter().find(|(name, _)| name == wanted) else { - continue; - }; - let Ok(text) = std::str::from_utf8(bytes) else { - continue; - }; + let mut out = JarManifest::default(); + for wanted in MANIFESTS { + let Some((_, bytes)) = entries.iter().find(|(name, _)| name == wanted) else { + continue; + }; + let Ok(text) = std::str::from_utf8(bytes) else { + continue; + }; - let parsed = match wanted { - FABRIC => parse_fabric(text), - QUILT => parse_quilt(text), - NEOFORGE | FORGE => parse_forge(text), - _ => parse_legacy_forge(text), - }; + let parsed = match wanted { + FABRIC => parse_fabric(text), + QUILT => parse_quilt(text), + NEOFORGE | FORGE => parse_forge(text), + _ => parse_legacy_forge(text), + }; - match parsed { - Some(parsed) => out.fill_from(parsed), - None => tracing::debug!("{wanted} in {} did not parse", jar.display()), - } - } + match parsed { + Some(parsed) => out.fill_from(parsed), + None => tracing::debug!("{wanted} in {} did not parse", jar.display()), + } + } - out + out +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JarModId { + pub id: String, + pub version: Option, +} + +#[tracing::instrument(level = "debug", fields(jar = %jar.display()))] +pub async fn read_jar_mod_id(jar: &Path) -> Option { + let entries = polyio::read_zip_file_entries(jar, |name| MANIFESTS.contains(&name)) + .await + .inspect_err(|err| tracing::debug!("could not read {}: {err}", jar.display())) + .ok()?; + + MANIFESTS.into_iter().find_map(|wanted| { + let (_, bytes) = entries.iter().find(|(name, _)| name == wanted)?; + parse_mod(wanted, std::str::from_utf8(bytes).ok()?) + }) +} + +fn parse_mod(manifest: &str, text: &str) -> Option { + let (id, version) = match manifest { + NEOFORGE | FORGE => { + let id = toml::from_str::(text) + .ok()? + .get("mods")? + .get(0)? + .get("modId")? + .as_str()? + .to_owned(); + (id, None) + } + _ => { + let json: serde_json::Value = serde_json::from_str(text).ok()?; + let pointers: &[&str] = match manifest { + FABRIC => &["/id"], + QUILT => &["/quilt_loader/id"], + _ => &["/0/modid", "/modList/0/modid"], + }; + let id = pointers + .iter() + .find_map(|pointer| json.pointer(pointer)?.as_str())? + .to_owned(); + let version = (manifest == FABRIC) + .then(|| json.pointer("/version")?.as_str().map(str::to_owned)) + .flatten(); + (id, version) + } + }; + + Some(JarModId { + id: clean(Some(id)).filter(|id| !is_placeholder_id(id))?, + version: clean(version).filter(|version| !version.contains('$')), + }) +} + +fn is_placeholder_id(id: &str) -> bool { + id.contains('$') || id.eq_ignore_ascii_case("examplemod") +} + +#[tracing::instrument(level = "debug", fields(jar = %jar.display()))] +pub async fn read_jar_loader(jar: &Path) -> Option { + let entries = polyio::read_zip_file_entries(jar, |name| MANIFESTS.contains(&name)) + .await + .inspect_err(|err| tracing::debug!("could not read {}: {err}", jar.display())) + .ok()?; + let has = |wanted: &str| entries.iter().any(|(name, _)| name == wanted); + + let fabric_family = has(FABRIC) || has(QUILT); + let forge_family = has(FORGE) || has(LEGACY_FORGE) || has(NEOFORGE); + if fabric_family && forge_family { + return None; + } + + [ + (FABRIC, GameLoader::Fabric), + (QUILT, GameLoader::Quilt), + (FORGE, GameLoader::Forge), + (LEGACY_FORGE, GameLoader::Forge), + (NEOFORGE, GameLoader::NeoForge), + ] + .into_iter() + .find(|(wanted, _)| has(wanted)) + .map(|(_, loader)| loader) } #[tracing::instrument(level = "debug", fields(jar = %jar.display()))] pub async fn read_jar_icon(jar: &Path, entry: &str) -> Option> { - let entry = entry.to_string(); - let found = polyio::read_zip_file_entries(jar, |name| name == entry) - .await - .inspect_err(|err| tracing::debug!("no icon in {}: {err}", jar.display())) - .ok()?; + let entry = entry.to_string(); + let found = polyio::read_zip_file_entries(jar, |name| name == entry) + .await + .inspect_err(|err| tracing::debug!("no icon in {}: {err}", jar.display())) + .ok()?; - found.into_iter().next().map(|(_, bytes)| bytes) + found.into_iter().next().map(|(_, bytes)| bytes) } #[must_use] pub fn parse_fabric(text: &str) -> Option { - #[derive(Deserialize)] - struct FabricMod { - name: Option, - description: Option, - #[serde(default)] - authors: Vec, - icon: Option, - } + #[derive(Deserialize)] + struct FabricMod { + name: Option, + description: Option, + #[serde(default)] + authors: Vec, + icon: Option, + } - let parsed: FabricMod = serde_json::from_str(text).ok()?; + let parsed: FabricMod = serde_json::from_str(text).ok()?; - Some(JarManifest { - name: clean(parsed.name), - description: clean_description(parsed.description), - authors: people(parsed.authors), - icon_entry: parsed.icon.and_then(Icon::path).and_then(entry_path), - }) + Some(JarManifest { + name: clean(parsed.name), + description: clean_description(parsed.description), + authors: people(parsed.authors), + icon_entry: parsed.icon.and_then(Icon::path).and_then(entry_path), + }) } #[must_use] pub fn parse_quilt(text: &str) -> Option { - #[derive(Deserialize)] - struct QuiltMod { - quilt_loader: QuiltLoader, - } + #[derive(Deserialize)] + struct QuiltMod { + quilt_loader: QuiltLoader, + } - #[derive(Deserialize)] - struct QuiltLoader { - metadata: Option, - } + #[derive(Deserialize)] + struct QuiltLoader { + metadata: Option, + } - #[derive(Deserialize)] - struct QuiltMetadata { - name: Option, - description: Option, - #[serde(default)] - contributors: BTreeMap, - icon: Option, - } + #[derive(Deserialize)] + struct QuiltMetadata { + name: Option, + description: Option, + #[serde(default)] + contributors: BTreeMap, + icon: Option, + } - let parsed: QuiltMod = serde_json::from_str(text).ok()?; - let metadata = parsed.quilt_loader.metadata?; + let parsed: QuiltMod = serde_json::from_str(text).ok()?; + let metadata = parsed.quilt_loader.metadata?; - Some(JarManifest { - name: clean(metadata.name), - description: clean_description(metadata.description), - authors: metadata - .contributors - .into_keys() - .filter_map(|name| clean(Some(name))) - .collect(), - icon_entry: metadata.icon.and_then(Icon::path).and_then(entry_path), - }) + Some(JarManifest { + name: clean(metadata.name), + description: clean_description(metadata.description), + authors: metadata + .contributors + .into_keys() + .filter_map(|name| clean(Some(name))) + .collect(), + icon_entry: metadata.icon.and_then(Icon::path).and_then(entry_path), + }) } #[must_use] pub fn parse_forge(text: &str) -> Option { - #[derive(Deserialize)] - struct ForgeManifest { - #[serde(default)] - mods: Vec, - /// Some older jars put it above the mod list rather than inside it - #[serde(rename = "logoFile")] - logo_file: Option, - } + #[derive(Deserialize)] + struct ForgeManifest { + #[serde(default)] + mods: Vec, + /// Some older jars put it above the mod list rather than inside it + #[serde(rename = "logoFile")] + logo_file: Option, + } - #[derive(Deserialize)] - struct ForgeMod { - #[serde(rename = "displayName")] - display_name: Option, - description: Option, - authors: Option, - credits: Option, - #[serde(rename = "logoFile")] - logo_file: Option, - } + #[derive(Deserialize)] + struct ForgeMod { + #[serde(rename = "displayName")] + display_name: Option, + description: Option, + authors: Option, + credits: Option, + #[serde(rename = "logoFile")] + logo_file: Option, + } - let parsed: ForgeManifest = toml::from_str(text).ok()?; - let first = parsed.mods.into_iter().next()?; + let parsed: ForgeManifest = toml::from_str(text).ok()?; + let first = parsed.mods.into_iter().next()?; - Some(JarManifest { - name: clean(first.display_name), - description: clean_description(first.description), - authors: first - .authors - .or(first.credits) - .map(StringOrList::into_people) - .unwrap_or_default(), - icon_entry: first.logo_file.or(parsed.logo_file).and_then(entry_path), - }) + Some(JarManifest { + name: clean(first.display_name), + description: clean_description(first.description), + authors: first + .authors + .or(first.credits) + .map(StringOrList::into_people) + .unwrap_or_default(), + icon_entry: first.logo_file.or(parsed.logo_file).and_then(entry_path), + }) } #[must_use] pub fn parse_legacy_forge(text: &str) -> Option { - #[derive(Deserialize)] - #[serde(untagged)] - enum McModInfo { - Bare(Vec), - Wrapped { - #[serde(rename = "modList")] - mod_list: Vec, - }, - } + #[derive(Deserialize)] + #[serde(untagged)] + enum McModInfo { + Bare(Vec), + Wrapped { + #[serde(rename = "modList")] + mod_list: Vec, + }, + } - #[derive(Deserialize)] - struct LegacyMod { - name: Option, - description: Option, - #[serde(rename = "authorList", default)] - author_list: Vec, - credits: Option, - #[serde(rename = "logoFile")] - logo_file: Option, - } + #[derive(Deserialize)] + struct LegacyMod { + name: Option, + description: Option, + #[serde(rename = "authorList", default)] + author_list: Vec, + credits: Option, + #[serde(rename = "logoFile")] + logo_file: Option, + } - let mods = match serde_json::from_str::(text).ok()? { - McModInfo::Bare(mods) | McModInfo::Wrapped { mod_list: mods } => mods, - }; - let first = mods.into_iter().next()?; + let mods = match serde_json::from_str::(text).ok()? { + McModInfo::Bare(mods) | McModInfo::Wrapped { mod_list: mods } => mods, + }; + let first = mods.into_iter().next()?; - let authors: Vec = first - .author_list - .into_iter() - .filter_map(|name| clean(Some(name))) - .collect(); + let authors: Vec = first + .author_list + .into_iter() + .filter_map(|name| clean(Some(name))) + .collect(); - Some(JarManifest { - name: clean(first.name), - description: clean_description(first.description), - authors: if authors.is_empty() { - clean(first.credits).into_iter().collect() - } else { - authors - }, - icon_entry: first.logo_file.and_then(entry_path), - }) + Some(JarManifest { + name: clean(first.name), + description: clean_description(first.description), + authors: if authors.is_empty() { + clean(first.credits).into_iter().collect() + } else { + authors + }, + icon_entry: first.logo_file.and_then(entry_path), + }) } #[derive(Deserialize)] #[serde(untagged)] enum Icon { - Path(String), - Sizes(BTreeMap), + Path(String), + Sizes(BTreeMap), } impl Icon { - fn path(self) -> Option { - match self { - Self::Path(path) => Some(path), - Self::Sizes(sizes) => sizes - .into_iter() - .max_by_key(|(size, _)| size.parse::().unwrap_or(0)) - .map(|(_, path)| path), - } - } + fn path(self) -> Option { + match self { + Self::Path(path) => Some(path), + Self::Sizes(sizes) => sizes + .into_iter() + .max_by_key(|(size, _)| size.parse::().unwrap_or(0)) + .map(|(_, path)| path), + } + } } fn people(list: Vec) -> Vec { - list.into_iter() - .filter_map(|person| match person { - serde_json::Value::String(name) => clean(Some(name)), - serde_json::Value::Object(mut fields) => match fields.remove("name") { - Some(serde_json::Value::String(name)) => clean(Some(name)), - _ => None, - }, - _ => None, - }) - .collect() + list.into_iter() + .filter_map(|person| match person { + serde_json::Value::String(name) => clean(Some(name)), + serde_json::Value::Object(mut fields) => match fields.remove("name") { + Some(serde_json::Value::String(name)) => clean(Some(name)), + _ => None, + }, + _ => None, + }) + .collect() } #[derive(Deserialize)] #[serde(untagged)] enum StringOrList { - One(String), - Many(Vec), + One(String), + Many(Vec), } impl StringOrList { - fn into_people(self) -> Vec { - match self { - Self::One(text) => clean(Some(text)).into_iter().collect(), - Self::Many(list) => list.into_iter().filter_map(|name| clean(Some(name))).collect(), - } - } + fn into_people(self) -> Vec { + match self { + Self::One(text) => clean(Some(text)).into_iter().collect(), + Self::Many(list) => list + .into_iter() + .filter_map(|name| clean(Some(name))) + .collect(), + } + } } fn clean(value: Option) -> Option { - let value = value?.trim().to_string(); - (!value.is_empty()).then_some(value) + let value = value?.trim().to_string(); + (!value.is_empty()).then_some(value) } fn clean_description(value: Option) -> Option { - let raw = clean(value)?; + let raw = clean(value)?; - // Collapsing whitespace only shortens so a window this wide still holds - // words to spare and a megabyte long blurb is never copied whole - let end = raw - .char_indices() - .nth(MAX_DESCRIPTION_WINDOW) - .map_or(raw.len(), |(idx, _)| idx); + // Collapsing whitespace only shortens so a window this wide still holds + // words to spare and a megabyte long blurb is never copied whole + let end = raw + .char_indices() + .nth(MAX_DESCRIPTION_WINDOW) + .map_or(raw.len(), |(idx, _)| idx); - let mut text = raw[..end].split_whitespace().collect::>().join(" "); - if text.is_empty() { - return None; - } + let mut text = raw[..end].split_whitespace().collect::>().join(" "); + if text.is_empty() { + return None; + } - if text.chars().count() > MAX_DESCRIPTION { - let cut = text - .char_indices() - .nth(MAX_DESCRIPTION) - .map_or(text.len(), |(idx, _)| idx); - text.truncate(cut); - text.push('…'); - } else if end < raw.len() { - text.push('…'); - } + if text.chars().count() > MAX_DESCRIPTION { + let cut = text + .char_indices() + .nth(MAX_DESCRIPTION) + .map_or(text.len(), |(idx, _)| idx); + text.truncate(cut); + text.push('…'); + } else if end < raw.len() { + text.push('…'); + } - Some(text) + Some(text) } fn entry_path(raw: String) -> Option { - let path = raw - .trim() - .replace('\\', "/") - .trim_start_matches('/') - .trim_start_matches("./") - .to_string(); + let path = raw + .trim() + .replace('\\', "/") + .trim_start_matches('/') + .trim_start_matches("./") + .to_string(); - if path.is_empty() || path.split('/').any(|part| part == "..") { - return None; - } + if path.is_empty() || path.split('/').any(|part| part == "..") { + return None; + } - Some(path) + Some(path) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn fabric_reads_every_field() { - let parsed = parse_fabric( - r#"{ + fn parse_mod_id(manifest: &str, text: &str) -> Option { + parse_mod(manifest, text).map(|parsed| parsed.id) + } + + fn parse_mod_version(manifest: &str, text: &str) -> Option { + parse_mod(manifest, text)?.version + } + + #[test] + fn every_loader_names_its_mod_id() { + assert_eq!( + parse_mod_id(FABRIC, r#"{"id":"argentum","name":"Argentum"}"#).as_deref(), + Some("argentum") + ); + assert_eq!( + parse_mod_id(QUILT, r#"{"quilt_loader":{"id":"qsl"}}"#).as_deref(), + Some("qsl") + ); + assert_eq!( + parse_mod_id(FORGE, "[[mods]]\nmodId = \"jei\"\n").as_deref(), + Some("jei") + ); + assert_eq!( + parse_mod_id(LEGACY_FORGE, r#"[{"modid":"patcher"}]"#).as_deref(), + Some("patcher") + ); + assert_eq!( + parse_mod_id(LEGACY_FORGE, r#"{"modList":[{"modid":"oneconfig"}]}"#).as_deref(), + Some("oneconfig") + ); + assert_eq!(parse_mod_id(FABRIC, r#"{"id":" "}"#), None); + + assert_eq!( + parse_mod_version(FABRIC, r#"{"id":"fabric-api","version":"0.141.6+1.21.11"}"#) + .as_deref(), + Some("0.141.6+1.21.11") + ); + assert_eq!( + parse_mod_version(QUILT, r#"{"quilt_loader":{"id":"qsl","version":"1.2.3"}}"#), + None + ); + assert_eq!( + parse_mod_version(FABRIC, r#"{"id":"a","version":"${version}"}"#), + None + ); + assert_eq!( + parse_mod_version(FORGE, "[[mods]]\nmodId = \"jei\"\nversion = \"1\"\n"), + None + ); + assert_eq!( + parse_mod_id(LEGACY_FORGE, r#"[{"modid":"examplemod"}]"#), + None + ); + assert_eq!( + parse_mod_id(LEGACY_FORGE, r#"[{"modid":"${modid}"}]"#), + None + ); + } + + #[test] + fn fabric_reads_every_field() { + let parsed = parse_fabric( + r#"{ "schemaVersion": 1, "id": "sodium", "name": "Sodium", @@ -350,41 +498,41 @@ mod tests { "authors": ["JellySquid", {"name": "Someone", "contact": {}}], "icon": "assets/sodium/icon.png" }"#, - ) - .expect("valid fabric manifest"); + ) + .expect("valid fabric manifest"); - assert_eq!(parsed.name.as_deref(), Some("Sodium")); - assert_eq!( - parsed.description.as_deref(), - Some("A modern rendering engine"), - "hard wrapping collapses so the card lays out its own lines" - ); - assert_eq!(parsed.author_line(), "JellySquid, Someone"); - assert_eq!(parsed.icon_entry.as_deref(), Some("assets/sodium/icon.png")); - } + assert_eq!(parsed.name.as_deref(), Some("Sodium")); + assert_eq!( + parsed.description.as_deref(), + Some("A modern rendering engine"), + "hard wrapping collapses so the card lays out its own lines" + ); + assert_eq!(parsed.author_line(), "JellySquid, Someone"); + assert_eq!(parsed.icon_entry.as_deref(), Some("assets/sodium/icon.png")); + } - #[test] - fn fabric_icon_map_takes_the_largest() { - let parsed = parse_fabric( - r#"{"id": "x", "icon": {"32": "small.png", "128": "big.png", "64": "mid.png"}}"#, - ) - .expect("valid fabric manifest"); + #[test] + fn fabric_icon_map_takes_the_largest() { + let parsed = parse_fabric( + r#"{"id": "x", "icon": {"32": "small.png", "128": "big.png", "64": "mid.png"}}"#, + ) + .expect("valid fabric manifest"); - assert_eq!(parsed.icon_entry.as_deref(), Some("big.png")); - } + assert_eq!(parsed.icon_entry.as_deref(), Some("big.png")); + } - #[test] - fn fabric_survives_an_author_shaped_oddly() { - let parsed = parse_fabric(r#"{"id": "x", "name": "X", "authors": [["nested"], "Real"]}"#) - .expect("one odd author must not sink the manifest"); + #[test] + fn fabric_survives_an_author_shaped_oddly() { + let parsed = parse_fabric(r#"{"id": "x", "name": "X", "authors": [["nested"], "Real"]}"#) + .expect("one odd author must not sink the manifest"); - assert_eq!(parsed.author_line(), "Real"); - } + assert_eq!(parsed.author_line(), "Real"); + } - #[test] - fn quilt_reads_contributor_names() { - let parsed = parse_quilt( - r#"{ + #[test] + fn quilt_reads_contributor_names() { + let parsed = parse_quilt( + r#"{ "schema_version": 1, "quilt_loader": { "id": "example", @@ -396,18 +544,21 @@ mod tests { } } }"#, - ) - .expect("valid quilt manifest"); + ) + .expect("valid quilt manifest"); - assert_eq!(parsed.name.as_deref(), Some("Example")); - assert_eq!(parsed.author_line(), "Alice, Bob"); - assert_eq!(parsed.icon_entry.as_deref(), Some("assets/example/icon.png")); - } + assert_eq!(parsed.name.as_deref(), Some("Example")); + assert_eq!(parsed.author_line(), "Alice, Bob"); + assert_eq!( + parsed.icon_entry.as_deref(), + Some("assets/example/icon.png") + ); + } - #[test] - fn forge_reads_the_first_mod_entry() { - let parsed = parse_forge( - r#" + #[test] + fn forge_reads_the_first_mod_entry() { + let parsed = parse_forge( + r#" modLoader="javafml" loaderVersion="[47,)" license="MIT" @@ -427,19 +578,22 @@ spans lines modId="forge" mandatory=true "#, - ) - .expect("valid forge manifest"); + ) + .expect("valid forge manifest"); - assert_eq!(parsed.name.as_deref(), Some("Example Mod")); - assert_eq!(parsed.description.as_deref(), Some("A mod that spans lines")); - assert_eq!(parsed.author_line(), "Someone"); - assert_eq!(parsed.icon_entry.as_deref(), Some("examplemod.png")); - } + assert_eq!(parsed.name.as_deref(), Some("Example Mod")); + assert_eq!( + parsed.description.as_deref(), + Some("A mod that spans lines") + ); + assert_eq!(parsed.author_line(), "Someone"); + assert_eq!(parsed.icon_entry.as_deref(), Some("examplemod.png")); + } - #[test] - fn forge_falls_back_to_credits_and_a_root_logo() { - let parsed = parse_forge( - r#" + #[test] + fn forge_falls_back_to_credits_and_a_root_logo() { + let parsed = parse_forge( + r#" logoFile="logo.png" [[mods]] @@ -447,82 +601,82 @@ modId="x" displayName="X" credits="Thanks to everyone" "#, - ) - .expect("valid forge manifest"); + ) + .expect("valid forge manifest"); - assert_eq!(parsed.author_line(), "Thanks to everyone"); - assert_eq!(parsed.icon_entry.as_deref(), Some("logo.png")); - } + assert_eq!(parsed.author_line(), "Thanks to everyone"); + assert_eq!(parsed.icon_entry.as_deref(), Some("logo.png")); + } - #[test] - fn legacy_forge_reads_both_shapes() { - let bare = parse_legacy_forge( + #[test] + fn legacy_forge_reads_both_shapes() { + let bare = parse_legacy_forge( r#"[{"modid":"x","name":"X","description":"Old","authorList":["A","B"],"logoFile":"/logo.png"}]"#, ) .expect("valid mcmod.info"); - assert_eq!(bare.name.as_deref(), Some("X")); - assert_eq!(bare.author_line(), "A, B"); - assert_eq!( - bare.icon_entry.as_deref(), - Some("logo.png"), - "the leading slash is jar relative not absolute" - ); + assert_eq!(bare.name.as_deref(), Some("X")); + assert_eq!(bare.author_line(), "A, B"); + assert_eq!( + bare.icon_entry.as_deref(), + Some("logo.png"), + "the leading slash is jar relative not absolute" + ); - let wrapped = parse_legacy_forge( - r#"{"modListVersion":2,"modList":[{"modid":"y","name":"Y","authorList":["C"]}]}"#, - ) - .expect("valid v2 mcmod.info"); + let wrapped = parse_legacy_forge( + r#"{"modListVersion":2,"modList":[{"modid":"y","name":"Y","authorList":["C"]}]}"#, + ) + .expect("valid v2 mcmod.info"); - assert_eq!(wrapped.name.as_deref(), Some("Y")); - } + assert_eq!(wrapped.name.as_deref(), Some("Y")); + } - #[test] - fn a_second_manifest_only_fills_gaps() { - let mut fabric = JarManifest { - name: Some("Fabric Name".into()), - ..JarManifest::default() - }; - fabric.fill_from(JarManifest { - name: Some("Forge Name".into()), - description: Some("From forge".into()), - authors: vec!["Someone".into()], - icon_entry: Some("logo.png".into()), - }); + #[test] + fn a_second_manifest_only_fills_gaps() { + let mut fabric = JarManifest { + name: Some("Fabric Name".into()), + ..JarManifest::default() + }; + fabric.fill_from(JarManifest { + name: Some("Forge Name".into()), + description: Some("From forge".into()), + authors: vec!["Someone".into()], + icon_entry: Some("logo.png".into()), + }); - assert_eq!(fabric.name.as_deref(), Some("Fabric Name")); - assert_eq!(fabric.description.as_deref(), Some("From forge")); - assert_eq!(fabric.author_line(), "Someone"); - assert_eq!(fabric.icon_entry.as_deref(), Some("logo.png")); - } + assert_eq!(fabric.name.as_deref(), Some("Fabric Name")); + assert_eq!(fabric.description.as_deref(), Some("From forge")); + assert_eq!(fabric.author_line(), "Someone"); + assert_eq!(fabric.icon_entry.as_deref(), Some("logo.png")); + } - #[test] - fn traversing_icon_paths_are_refused() { - assert_eq!(entry_path("../../etc/passwd".into()), None); - assert_eq!(entry_path(" ".into()), None); - assert_eq!(entry_path("./icon.png".into()).as_deref(), Some("icon.png")); - } + #[test] + fn traversing_icon_paths_are_refused() { + assert_eq!(entry_path("../../etc/passwd".into()), None); + assert_eq!(entry_path(" ".into()), None); + assert_eq!(entry_path("./icon.png".into()).as_deref(), Some("icon.png")); + } - #[test] - fn long_descriptions_are_cut_on_a_char_boundary() { - let long = "ż".repeat(MAX_DESCRIPTION + 50); - let cut = clean_description(Some(long)).expect("non empty"); + #[test] + fn long_descriptions_are_cut_on_a_char_boundary() { + let long = "ż".repeat(MAX_DESCRIPTION + 50); + let cut = clean_description(Some(long)).expect("non empty"); - assert_eq!( - cut.chars().count(), - MAX_DESCRIPTION + 1, - "the ellipsis is the extra one" - ); - assert!(cut.ends_with('…')); - } + assert_eq!( + cut.chars().count(), + MAX_DESCRIPTION + 1, + "the ellipsis is the extra one" + ); + assert!(cut.ends_with('…')); + } - #[test] - fn garbage_is_not_a_manifest() { - assert!(parse_fabric("not json").is_none()); - assert!(parse_forge("[[mods").is_none()); - assert!( - parse_forge("modLoader=\"javafml\"").is_none(), - "no mod entry to describe" - ); - } + #[test] + fn garbage_is_not_a_manifest() { + assert!(parse_fabric("not json").is_none()); + assert!(parse_forge("[[mods").is_none()); + assert!( + parse_forge("modLoader=\"javafml\"").is_none(), + "no mod entry to describe" + ); + } } diff --git a/packages/oneclient_content/src/packages/markdown.rs b/packages/oneclient_content/src/packages/markdown.rs index 2d5d6b0e..190cb4b5 100644 --- a/packages/oneclient_content/src/packages/markdown.rs +++ b/packages/oneclient_content/src/packages/markdown.rs @@ -179,7 +179,10 @@ mod tests { assert!(out.contains("# Mod"), "{out}"); assert!(out.contains("### Tagline"), "{out}"); - assert!(out.contains("[![Badge](badge.svg)](https://example.com)"), "{out}"); + assert!( + out.contains("[![Badge](badge.svg)](https://example.com)"), + "{out}" + ); assert!(!out.contains("x"), "x"); - assert_eq!(replace_break_tags("a
b"), format!("a{BREAK}b")); + assert_eq!( + replace_break_tags("a
b"), + format!("a{BREAK}b") + ); } } diff --git a/packages/oneclient_content/src/packages/metadata_cache.rs b/packages/oneclient_content/src/packages/metadata_cache.rs index 89a39537..7480ef90 100644 --- a/packages/oneclient_content/src/packages/metadata_cache.rs +++ b/packages/oneclient_content/src/packages/metadata_cache.rs @@ -1,4 +1,3 @@ - use std::collections::HashMap; use chrono::Utc; @@ -7,233 +6,235 @@ use serde::{Deserialize, Serialize}; use oneclient_db::dao::artifact as artifact_dao; use oneclient_db::dao::package_metadata as meta_dao; -use crate::packages::ProviderId; -use oneclient_common::domain::{ContentType, GameLoader}; -use crate::packages::store::artifact_absolute_path; -use crate::packages::types::{PackageBody, ProjectDetail, VersionDetail, VersionFile}; use crate::ctx::ContentCtx; use crate::error::ContentResult; +use crate::packages::ProviderId; +use crate::packages::store::artifact_absolute_path; +use crate::packages::types::{PackageBody, ProjectDetail, VersionDetail, VersionFile}; +use oneclient_common::domain::{ContentType, GameLoader}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CachedPackageMeta { - pub provider: ProviderId, - pub project_id: String, - pub name: String, - pub summary: String, - pub author: String, - pub icon_url: Option, + pub provider: ProviderId, + pub project_id: String, + pub name: String, + pub summary: String, + pub author: String, + pub icon_url: Option, } #[tracing::instrument(level = "debug", skip(ctx, project_ids))] pub async fn read_cached_package_meta( - ctx: &ContentCtx, - provider: ProviderId, - project_ids: &[String], + ctx: &ContentCtx, + provider: ProviderId, + project_ids: &[String], ) -> HashMap { - if project_ids.is_empty() { - return HashMap::new(); - } + if project_ids.is_empty() { + return HashMap::new(); + } - let rows = meta_dao::get_package_metadata_batch(&ctx.db, provider as i64, project_ids) - .await - .unwrap_or_default(); + let rows = meta_dao::get_package_metadata_batch(&ctx.db, provider as i64, project_ids) + .await + .unwrap_or_default(); - rows.into_iter() - .map(|row| { - ( - row.project_id.clone(), - CachedPackageMeta { - provider, - project_id: row.project_id, - name: row.name, - summary: row.summary, - author: row.author, - icon_url: row.icon_url, - }, - ) - }) - .collect() + rows.into_iter() + .map(|row| { + ( + row.project_id.clone(), + CachedPackageMeta { + provider, + project_id: row.project_id, + name: row.name, + summary: row.summary, + author: row.author, + icon_url: row.icon_url, + }, + ) + }) + .collect() } #[tracing::instrument(level = "debug", skip(ctx))] pub async fn cached_project_detail( - ctx: &ContentCtx, - provider: ProviderId, - project_id: &str, - content_type: ContentType, + ctx: &ContentCtx, + provider: ProviderId, + project_id: &str, + content_type: ContentType, ) -> ProjectDetail { - let cached = read_cached_package_meta(ctx, provider, std::slice::from_ref(&project_id.to_string())) - .await; - let meta = cached.get(project_id); + let cached = + read_cached_package_meta(ctx, provider, std::slice::from_ref(&project_id.to_string())) + .await; + let meta = cached.get(project_id); - ProjectDetail { - id: project_id.to_string(), - slug: String::new(), - provider, - content_type, - name: meta - .map(|m| m.name.clone()) - .filter(|n| !n.is_empty()) - .unwrap_or_else(|| project_id.to_string()), - summary: meta.map(|m| m.summary.clone()).unwrap_or_default(), - author: meta.map(|m| m.author.clone()).unwrap_or_default(), - members: Vec::new(), - gallery: Vec::new(), - body: PackageBody::Raw(String::new()), - license: None, - links: Vec::new(), - version_ids: Vec::new(), - game_versions: Vec::new(), - loaders: Vec::new(), - icon_url: meta.and_then(|m| m.icon_url.clone()), - created: Utc::now(), - updated: Utc::now(), - downloads: 0, - } + ProjectDetail { + id: project_id.to_string(), + slug: String::new(), + provider, + content_type, + name: meta + .map(|m| m.name.clone()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| project_id.to_string()), + summary: meta.map(|m| m.summary.clone()).unwrap_or_default(), + author: meta.map(|m| m.author.clone()).unwrap_or_default(), + members: Vec::new(), + gallery: Vec::new(), + body: PackageBody::Raw(String::new()), + license: None, + links: Vec::new(), + version_ids: Vec::new(), + game_versions: Vec::new(), + loaders: Vec::new(), + icon_url: meta.and_then(|m| m.icon_url.clone()), + created: Utc::now(), + updated: Utc::now(), + downloads: 0, + } } #[tracing::instrument(level = "debug", skip(ctx))] pub async fn get_version_cached( - ctx: &ContentCtx, - provider: ProviderId, - project_id: &str, - version_id: &str, + ctx: &ContentCtx, + provider: ProviderId, + project_id: &str, + version_id: &str, ) -> ContentResult { - if let Some(cached) = cached_version_detail(ctx, provider, project_id, version_id).await { - return Ok(cached); - } + if let Some(cached) = cached_version_detail(ctx, provider, project_id, version_id).await { + return Ok(cached); + } - ctx - .providers - .get(provider)? - .get_version(project_id, version_id, ctx) - .await + ctx.providers + .get(provider)? + .get_version(project_id, version_id, ctx) + .await } #[tracing::instrument(level = "debug", skip(ctx))] async fn cached_version_detail( - ctx: &ContentCtx, - provider: ProviderId, - project_id: &str, - version_id: &str, + ctx: &ContentCtx, + provider: ProviderId, + project_id: &str, + version_id: &str, ) -> Option { - let release = - artifact_dao::get_provider_release(&ctx.db, provider as i64, project_id, version_id) - .await - .ok() - .flatten()?; - let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, &release.hash) - .await - .ok() - .flatten()?; + let release = + artifact_dao::get_provider_release(&ctx.db, provider as i64, project_id, version_id) + .await + .ok() + .flatten()?; + let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, &release.hash) + .await + .ok() + .flatten()?; - let path = artifact_absolute_path(&artifact.path).ok()?; - if !path.exists() { - return None; - } + let path = artifact_absolute_path(&artifact.path).ok()?; + if !path.exists() { + return None; + } - let game_versions: Vec = serde_json::from_str(&release.mc_versions).unwrap_or_default(); - let loaders: Vec = serde_json::from_str(&release.mc_loaders).unwrap_or_default(); - let published = release - .published_at - .as_deref() - .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) - .map(|dt| dt.with_timezone(&Utc)) - .unwrap_or_else(Utc::now); + let game_versions: Vec = serde_json::from_str(&release.mc_versions).unwrap_or_default(); + let loaders: Vec = serde_json::from_str(&release.mc_loaders).unwrap_or_default(); + let published = release + .published_at + .as_deref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(Utc::now); - Some(VersionDetail { - version_id: version_id.to_string(), - project_id: project_id.to_string(), - name: release.display_name, - version_number: release.display_version, - changelog: None, - game_versions, - loaders, - published, - downloads: 0, - files: vec![VersionFile { - sha1: release.hash, - url: String::new(), - file_name: artifact.file_name, - primary: true, - size: artifact.size_bytes.unwrap_or(0) as u64, - fingerprint: None, - }], - // Not persisted with the release row callers needing the graph go to the provider - dependencies: Vec::new(), - }) + Some(VersionDetail { + version_id: version_id.to_string(), + project_id: project_id.to_string(), + name: release.display_name, + version_number: release.display_version, + changelog: None, + game_versions, + loaders, + published, + downloads: 0, + files: vec![VersionFile { + sha1: release.hash, + url: String::new(), + file_name: artifact.file_name, + primary: true, + size: artifact.size_bytes.unwrap_or(0) as u64, + fingerprint: None, + }], + // Not persisted with the release row callers needing the graph go to the provider + dependencies: Vec::new(), + }) } #[tracing::instrument(level = "debug", skip(ctx, project_ids))] pub async fn fetch_package_meta( - ctx: &ContentCtx, - provider: ProviderId, - project_ids: &[String], + ctx: &ContentCtx, + provider: ProviderId, + project_ids: &[String], ) -> ContentResult> { - if project_ids.is_empty() { - return Ok(HashMap::new()); - } + if project_ids.is_empty() { + return Ok(HashMap::new()); + } - let provider_repr = provider as i64; - let mut out: HashMap = HashMap::new(); + let provider_repr = provider as i64; + let mut out: HashMap = HashMap::new(); - for row in meta_dao::get_package_metadata_batch(&ctx.db, provider_repr, project_ids).await? - { - out.insert( - row.project_id.clone(), - CachedPackageMeta { - provider, - project_id: row.project_id, - name: row.name, - summary: row.summary, - author: row.author, - icon_url: row.icon_url, - }, - ); - } + for row in meta_dao::get_package_metadata_batch(&ctx.db, provider_repr, project_ids).await? { + out.insert( + row.project_id.clone(), + CachedPackageMeta { + provider, + project_id: row.project_id, + name: row.name, + summary: row.summary, + author: row.author, + icon_url: row.icon_url, + }, + ); + } - if provider == ProviderId::Local { - return Ok(out); - } + if provider == ProviderId::Local { + return Ok(out); + } - let missing: Vec = project_ids - .iter() - .filter(|id| out.get(*id).is_none_or(|m| m.author.is_empty())) - .cloned() - .collect(); - if missing.is_empty() { - return Ok(out); - } + let missing: Vec = project_ids + .iter() + .filter(|id| out.get(*id).is_none_or(|m| m.author.is_empty())) + .cloned() + .collect(); + if missing.is_empty() { + return Ok(out); + } - tracing::debug!(count = missing.len(), "fetching missing package metadata from provider"); + tracing::debug!( + count = missing.len(), + "fetching missing package metadata from provider" + ); - let details = ctx - .providers - .get(provider)? - .get_projects(&missing, ctx) - .await?; + let details = ctx + .providers + .get(provider)? + .get_projects(&missing, ctx) + .await?; - for detail in details { - let meta = CachedPackageMeta { - provider, - project_id: detail.id, - name: detail.name, - summary: detail.summary, - author: detail.author, - icon_url: detail.icon_url, - }; - meta_dao::upsert_package_metadata( - &ctx.db, - provider_repr, - &meta.project_id, - &meta.name, - &meta.summary, - &meta.author, - meta.icon_url.as_deref(), - ) - .await?; - out.insert(meta.project_id.clone(), meta); - } + for detail in details { + let meta = CachedPackageMeta { + provider, + project_id: detail.id, + name: detail.name, + summary: detail.summary, + author: detail.author, + icon_url: detail.icon_url, + }; + meta_dao::upsert_package_metadata( + &ctx.db, + provider_repr, + &meta.project_id, + &meta.name, + &meta.summary, + &meta.author, + meta.icon_url.as_deref(), + ) + .await?; + out.insert(meta.project_id.clone(), meta); + } - Ok(out) + Ok(out) } diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs index b996a0a5..828459d4 100644 --- a/packages/oneclient_content/src/packages/mod.rs +++ b/packages/oneclient_content/src/packages/mod.rs @@ -1,10 +1,12 @@ pub mod activity; +pub mod bad_mods; pub mod dependencies; pub mod error; +pub mod fabric_version; pub mod local_manifest; pub mod metadata_cache; -pub mod modpack; pub mod provider; +pub mod release_migration; pub mod store; pub mod types; pub mod updates; @@ -13,23 +15,27 @@ mod file_identity; pub mod markdown; // Re-exported so `oneclient_core::packages::ContentType` keeps working -pub use oneclient_common::domain::{ContentType, GameLoader, HashAlgorithm, ProviderId}; +pub use activity::reconcile_duplicate_activity; +pub use bad_mods::{ + BadMod, BadModAlternative, BadModList, ProjectIds, ResolvedAlternative, fetch_bad_mods, + fetch_explanation, load_bad_mods, refresh_bad_mods, resolve_alternatives, +}; +pub use dependencies::{ + DependencyResolution, ResolvedDependency, resolve_required, resolves_dependencies, +}; +pub use error::{PackageError, PackageResult}; +pub use file_identity::{FileIdentity, curseforge_fingerprint}; +pub use local_manifest::{JarManifest, read_jar_icon, read_jar_loader, read_jar_manifest}; pub use metadata_cache::{ CachedPackageMeta, cached_project_detail, fetch_package_meta, get_version_cached, read_cached_package_meta, }; -pub use activity::{disable_foreign_game_versions, reconcile_duplicate_activity}; -pub use dependencies::{ - DependencyResolution, ResolvedDependency, resolve_required, resolves_dependencies, -}; -pub use file_identity::{curseforge_fingerprint, FileIdentity}; -pub use local_manifest::{JarManifest, read_jar_icon, read_jar_manifest}; -pub use error::{PackageError, PackageResult}; +pub use oneclient_common::domain::{ContentType, GameLoader, HashAlgorithm, ProviderId}; pub use provider::{PackageProvider, PackageProviderRegistry}; pub use store::{LiveSync, LocalImportReport, PackageStore}; pub use types::*; pub use updates::{ BrowserPackageUpdate, BrowserUpdateCheck, apply_browser_package_update, - cached_browser_package_updates, check_browser_package_updates, - refresh_browser_package_updates, skip_browser_package_update, + cached_browser_package_updates, check_browser_package_updates, refresh_browser_package_updates, + skip_browser_package_update, }; diff --git a/packages/oneclient_content/src/packages/provider/curseforge.rs b/packages/oneclient_content/src/packages/provider/curseforge.rs index f2e439bf..10c510bc 100644 --- a/packages/oneclient_content/src/packages/provider/curseforge.rs +++ b/packages/oneclient_content/src/packages/provider/curseforge.rs @@ -7,16 +7,17 @@ use url::Url; use super::PackageProvider; use super::http::fetch_json; +use crate::ctx::ContentCtx; use crate::error::ContentResult; -use oneclient_common::constants::{CURSEFORGE_API_URL, CURSEFORGE_GAME_ID}; -use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use crate::packages::dependencies::base_game_version; use crate::packages::file_identity::FileIdentity; use crate::packages::types::{ - DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary, - ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup, - VersionSummary, + DependencyKind, GalleryImage, InstalledPackage, PackageBody, Page, ProjectDetail, + ProjectMember, ProjectSummary, ReleaseType, SearchFilters, VersionDependency, VersionDetail, + VersionFile, VersionLookup, VersionSummary, }; -use crate::ctx::ContentCtx; +use oneclient_common::constants::{CURSEFORGE_API_URL, CURSEFORGE_GAME_ID}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; pub struct CurseForgeProvider; @@ -56,7 +57,14 @@ impl PackageProvider for CurseForgeProvider { .to_string(), ); params.append_pair("index", &filters.offset.unwrap_or(0).to_string()); - params.append_pair("sortField", "6"); + params.append_pair( + "sortField", + &filters + .sort + .unwrap_or_default() + .curseforge_sort_field() + .to_string(), + ); params.append_pair("sortOrder", "desc"); let query = filters.normalized_query(); if !query.is_empty() { @@ -75,13 +83,8 @@ impl PackageProvider for CurseForgeProvider { } } - let response: CfPaged> = fetch_json( - &ctx.net, - Method::GET, - url.as_str(), - None, - ) - .await?; + let response: CfPaged> = + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; Ok(Page { offset: response.pagination.index, @@ -130,13 +133,8 @@ impl PackageProvider for CurseForgeProvider { ) -> ContentResult> { let mod_ids: Vec = project_ids.iter().filter_map(|s| s.parse().ok()).collect(); let body = serde_json::json!({ "modIds": mod_ids }); - let response: CfData> = fetch_json( - &ctx.net, - Method::POST, - &api_url("/mods"), - Some(body), - ) - .await?; + let response: CfData> = + fetch_json(&ctx.net, Method::POST, &api_url("/mods"), Some(body)).await?; Ok(response.data.into_iter().map(CfMod::into_detail).collect()) } @@ -160,13 +158,8 @@ impl PackageProvider for CurseForgeProvider { params.append_pair("modLoaderType", &t.to_string()); } } - let response: CfPaged> = fetch_json( - &ctx.net, - Method::GET, - url.as_str(), - None, - ) - .await?; + let response: CfPaged> = + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; let total = response.data.len(); let items = response @@ -200,13 +193,8 @@ impl PackageProvider for CurseForgeProvider { url.query_pairs_mut() .append_pair("gameId", &CURSEFORGE_GAME_ID.to_string()) .append_pair("classId", &cf_class_id(content_type).to_string()); - let response: CfData> = fetch_json( - &ctx.net, - Method::GET, - url.as_str(), - None, - ) - .await?; + let response: CfData> = + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; Ok(response.data.into_iter().map(|c| c.name).collect()) } @@ -235,13 +223,8 @@ impl PackageProvider for CurseForgeProvider { ) -> ContentResult> { let file_ids: Vec = version_ids.iter().filter_map(|s| s.parse().ok()).collect(); let body = serde_json::json!({ "fileIds": file_ids }); - let response: CfData> = fetch_json( - &ctx.net, - Method::POST, - &api_url("/mods/files"), - Some(body), - ) - .await?; + let response: CfData> = + fetch_json(&ctx.net, Method::POST, &api_url("/mods/files"), Some(body)).await?; Ok(response.data.into_iter().map(Into::into).collect()) } @@ -301,6 +284,123 @@ impl PackageProvider for CurseForgeProvider { Ok(out) } + + #[tracing::instrument(level = "debug", skip(self, packages, ctx))] + async fn latest_for_game_version( + &self, + packages: &[InstalledPackage], + mc_version: &str, + loader: GameLoader, + ctx: &ContentCtx, + ) -> ContentResult> { + let mod_ids: Vec = packages + .iter() + .filter_map(|package| package.project_id.parse().ok()) + .collect::>() + .into_iter() + .collect(); + if mod_ids.is_empty() { + return Ok(HashMap::new()); + } + + let response: CfData> = fetch_json( + &ctx.net, + Method::POST, + &api_url("/mods"), + Some(serde_json::json!({ "modIds": mod_ids })), + ) + .await?; + let indexes: HashMap> = response + .data + .into_iter() + .map(|entry| (entry.id.to_string(), entry.latest_files_indexes)) + .collect(); + + let mut picks: Vec<(String, u32)> = Vec::new(); + for package in packages { + let Some(files) = indexes.get(&package.project_id) else { + continue; + }; + if let Some(file_id) = pick_file_index(files, package.content_type, mc_version, loader) + { + picks.push((package.hash.clone(), file_id)); + } + } + if picks.is_empty() { + return Ok(HashMap::new()); + } + + let file_ids: Vec = picks + .iter() + .map(|(_, file_id)| file_id.to_string()) + .collect::>() + .into_iter() + .collect(); + let versions: HashMap = self + .get_versions(&file_ids, ctx) + .await? + .into_iter() + .map(|version| (version.version_id.clone(), version)) + .collect(); + + Ok(picks + .into_iter() + .filter_map(|(hash, file_id)| { + versions + .get(&file_id.to_string()) + .map(|version| (hash, version.clone())) + }) + .collect()) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfModFiles { + id: u32, + #[serde(default)] + latest_files_indexes: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfFileIndex { + game_version: String, + file_id: u32, + release_type: u8, + #[serde(default)] + mod_loader: Option, +} + +fn pick_file_index( + files: &[CfFileIndex], + content_type: ContentType, + mc_version: &str, + loader: GameLoader, +) -> Option { + let wanted = base_game_version(mc_version); + let loader_fits = |index: &CfFileIndex| { + if content_type != ContentType::Mod || !loader.is_modded() { + return true; + } + index + .mod_loader + .and_then(|raw| cf_loader_to_game(CfLoader::from(raw))) + .is_some_and(|other| loader.compatible_with(other)) + }; + + let fitting: Vec<&CfFileIndex> = files + .iter() + .filter(|index| base_game_version(&index.game_version) == wanted) + .filter(|index| loader_fits(index)) + .collect(); + + fitting + .iter() + .filter(|index| index.release_type == 1) + .map(|index| index.file_id) + .max() + .or_else(|| fitting.iter().map(|index| index.file_id).max()) } #[derive(Deserialize)] @@ -315,11 +415,7 @@ struct CfPaged { } #[tracing::instrument(level = "debug", skip(detail, ctx))] -async fn fetch_body( - project_id: &str, - detail: &ProjectDetail, - ctx: &ContentCtx, -) -> PackageBody { +async fn fetch_body(project_id: &str, detail: &ProjectDetail, ctx: &ContentCtx) -> PackageBody { let fallback = || { detail .links @@ -358,7 +454,10 @@ fn html_to_markdown(html: &str) -> Option { fn absolutize_links(markdown: &str) -> String { markdown - .replace("](/linkout?remoteUrl=", "](https://www.curseforge.com/linkout?remoteUrl=") + .replace( + "](/linkout?remoteUrl=", + "](https://www.curseforge.com/linkout?remoteUrl=", + ) .replace("](/", "](https://www.curseforge.com/") } @@ -396,6 +495,8 @@ struct CfMod { struct CfScreenshot { url: String, #[serde(default)] + thumbnail_url: Option, + #[serde(default)] title: Option, } @@ -460,7 +561,8 @@ struct CfFile { #[serde(default)] hashes: Vec, file_fingerprint: u32, - download_url: String, + #[serde(default)] + download_url: Option, file_length: u64, #[serde(default)] dependencies: Vec, @@ -513,7 +615,7 @@ struct CfHash { enum CfClass { Mod = 6, ResourcePack = 12, - DataPack = 4472, + DataPack = 6945, Shader = 6552, Modpack = 4471, } @@ -536,6 +638,12 @@ impl From for ProjectSummary { .logo .and_then(|l| l.thumbnail_url.or(l.url)) .filter(|s| !s.is_empty()), + banner_url: m + .screenshots + .into_iter() + .next() + .map(|s| s.thumbnail_url.filter(|t| !t.is_empty()).unwrap_or(s.url)) + .filter(|s| !s.is_empty()), downloads: m.download_count, created: m.date_created, updated: m.date_modified, @@ -575,14 +683,11 @@ impl CfMod { .map(|s| GalleryImage { url: s.url, title: s.title.filter(|t| !t.is_empty()), + featured: false, }) .collect(), license: None, - links: self - .links - .as_ref() - .map(cf_links) - .unwrap_or_default(), + links: self.links.as_ref().map(cf_links).unwrap_or_default(), body: PackageBody::Raw(String::new()), version_ids: Vec::new(), game_versions: Vec::new(), @@ -619,6 +724,7 @@ impl From for VersionSummary { .collect(), downloads: f.download_count, file_size: f.file_length, + dependencies: f.dependencies.into_iter().map(Into::into).collect(), } } } @@ -648,7 +754,7 @@ impl From for VersionDetail { downloads: f.download_count, files: vec![VersionFile { sha1, - url: f.download_url, + url: f.download_url.unwrap_or_default(), file_name: f.file_name, primary: true, size: f.file_length, @@ -663,7 +769,7 @@ fn cf_class_id(t: ContentType) -> u32 { match t { ContentType::Mod => 6, ContentType::ResourcePack => 12, - ContentType::DataPack => 4472, + ContentType::DataPack => 6945, ContentType::Shader => 6552, ContentType::Modpack => 4471, ContentType::World => 6, @@ -699,7 +805,7 @@ impl From for CfClass { match value { 6 => Self::Mod, 12 => Self::ResourcePack, - 4472 => Self::DataPack, + 6945 => Self::DataPack, 6552 => Self::Shader, 4471 => Self::Modpack, _ => Self::Mod, @@ -727,7 +833,6 @@ fn cf_loader_type(loader: GameLoader) -> Option { } } - #[cfg(test)] mod tests { use super::*; @@ -774,4 +879,26 @@ mod tests { assert_eq!(version.dependencies[2].kind, DependencyKind::Incompatible); assert_eq!(version.dependencies[3].kind, DependencyKind::Embedded); } + + #[test] + fn a_distribution_blocked_file_still_parses() { + let raw = serde_json::json!({ + "id": 1, + "modId": 2, + "displayName": "Blocked", + "fileName": "blocked.jar", + "releaseType": 1, + "fileDate": "2025-01-01T00:00:00Z", + "downloadCount": 10, + "fileFingerprint": 123, + "downloadUrl": null, + "fileLength": 100 + }); + + let version: VersionDetail = serde_json::from_value::(raw) + .expect("curseforge file") + .into(); + + assert_eq!(version.files[0].url, ""); + } } diff --git a/packages/oneclient_content/src/packages/provider/http.rs b/packages/oneclient_content/src/packages/provider/http.rs index ed17abbb..5b67bc78 100644 --- a/packages/oneclient_content/src/packages/provider/http.rs +++ b/packages/oneclient_content/src/packages/provider/http.rs @@ -1,9 +1,9 @@ use reqwest::Method; use serde::de::DeserializeOwned; -use oneclient_net::{RequestClient, RequestError, ResponseNotifyOptions, ResponseOptions}; -use oneclient_events::GroupedProgressChild; use crate::ctx::ContentCtx; +use oneclient_events::GroupedProgressChild; +use oneclient_net::{RequestClient, RequestError, ResponseNotifyOptions, ResponseOptions}; #[tracing::instrument(level = "debug", skip(client, url, body))] pub async fn fetch_json( diff --git a/packages/oneclient_content/src/packages/provider/mod.rs b/packages/oneclient_content/src/packages/provider/mod.rs index 725e5bdd..06584e4a 100644 --- a/packages/oneclient_content/src/packages/provider/mod.rs +++ b/packages/oneclient_content/src/packages/provider/mod.rs @@ -7,14 +7,14 @@ pub use curseforge::CurseForgeProvider; pub use modrinth::ModrinthProvider; pub use registry::PackageProviderRegistry; -use oneclient_common::domain::ProviderId; -use crate::packages::file_identity::FileIdentity; use super::types::{ - Page, ProjectDetail, ProjectSummary, SearchFilters, VersionDetail, VersionLookup, - VersionSummary, + InstalledPackage, Page, ProjectDetail, ProjectSummary, SearchFilters, VersionDetail, + VersionLookup, VersionSummary, }; -use crate::error::ContentResult; use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::file_identity::FileIdentity; +use oneclient_common::domain::ProviderId; #[async_trait::async_trait] pub trait PackageProvider: Send + Sync { @@ -26,11 +26,8 @@ pub trait PackageProvider: Send + Sync { ctx: &ContentCtx, ) -> ContentResult>; - async fn get_project( - &self, - project_id: &str, - ctx: &ContentCtx, - ) -> ContentResult; + async fn get_project(&self, project_id: &str, ctx: &ContentCtx) + -> ContentResult; /// Populates [`ProjectDetail::body`] /// Costs an extra request on providers that serve descriptions separately @@ -78,6 +75,14 @@ pub trait PackageProvider: Send + Sync { ctx: &ContentCtx, ) -> ContentResult; + async fn latest_for_game_version( + &self, + packages: &[InstalledPackage], + mc_version: &str, + loader: oneclient_common::domain::GameLoader, + ctx: &ContentCtx, + ) -> ContentResult>; + async fn list_categories( &self, _content_type: oneclient_common::domain::ContentType, diff --git a/packages/oneclient_content/src/packages/provider/modrinth.rs b/packages/oneclient_content/src/packages/provider/modrinth.rs index f3c95495..56d0bdc5 100644 --- a/packages/oneclient_content/src/packages/provider/modrinth.rs +++ b/packages/oneclient_content/src/packages/provider/modrinth.rs @@ -10,17 +10,17 @@ use serde::de::DeserializeOwned; use super::PackageProvider; use super::http::fetch_json_with_headers; +use crate::ctx::ContentCtx; use crate::error::ContentResult; -use oneclient_common::constants::MODRINTH_API_URL; -use oneclient_net::{RequestClient, RequestError}; -use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use crate::packages::file_identity::FileIdentity; use crate::packages::types::{ - DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary, - ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup, - VersionSummary, + DependencyKind, GalleryImage, InstalledPackage, PackageBody, Page, ProjectDetail, + ProjectMember, ProjectSummary, ReleaseType, SearchFilters, VersionDependency, VersionDetail, + VersionFile, VersionLookup, VersionSummary, }; -use crate::ctx::ContentCtx; +use oneclient_common::constants::MODRINTH_API_URL; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_net::{RequestClient, RequestError}; pub struct ModrinthProvider; @@ -34,7 +34,14 @@ async fn fetch_json( url: impl reqwest::IntoUrl, body: Option, ) -> Result { - fetch_json_with_headers(client, method, url, body, &client.config().modrinth_headers()).await + fetch_json_with_headers( + client, + method, + url, + body, + &client.config().modrinth_headers(), + ) + .await } #[async_trait::async_trait] @@ -64,7 +71,10 @@ impl PackageProvider for ModrinthProvider { let mut groups: Vec> = Vec::new(); if let Some(content_type) = filters.content_type { - groups.push(vec![format!("project_type:{}", content_type.modrinth_type())]); + groups.push(vec![format!( + "project_type:{}", + content_type.modrinth_type() + )]); } if let Some(versions) = &filters.game_versions { let group: Vec = versions.iter().map(|v| format!("versions:{v}")).collect(); @@ -87,7 +97,10 @@ impl PackageProvider for ModrinthProvider { } } if !groups.is_empty() { - params.append_pair("facets", &serde_json::to_string(&groups).unwrap_or_default()); + params.append_pair( + "facets", + &serde_json::to_string(&groups).unwrap_or_default(), + ); } if let Some(sort) = filters.sort { @@ -117,12 +130,15 @@ impl PackageProvider for ModrinthProvider { game_versions: Vec, downloads: u64, icon_url: Option, + #[serde(default)] + featured_gallery: Option, + #[serde(default)] + gallery: Vec, date_created: DateTime, date_modified: DateTime, } - let response: Response = - fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; + let response: Response = fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; Ok(Page { offset: response.offset, @@ -140,6 +156,10 @@ impl PackageProvider for ModrinthProvider { summary: h.description, author: h.author, icon_url: h.icon_url, + banner_url: h + .featured_gallery + .or_else(|| h.gallery.into_iter().next()) + .filter(|url| !url.is_empty()), downloads: h.downloads, created: h.date_created, updated: h.date_modified, @@ -162,8 +182,7 @@ impl PackageProvider for ModrinthProvider { ) -> ContentResult { let project_url = v2(&format!("/project/{project_id}")); let members_url = v2(&format!("/project/{project_id}/members")); - let project_fut = - fetch_json::(&ctx.net, Method::GET, &project_url, None); + let project_fut = fetch_json::(&ctx.net, Method::GET, &project_url, None); let members_fut = fetch_json::>(&ctx.net, Method::GET, &members_url, None); let (raw, members) = tokio::join!(project_fut, members_fut); @@ -186,9 +205,11 @@ impl PackageProvider for ModrinthProvider { name: String, project_type: String, } - let tags: Vec = - fetch_json(&ctx.net, Method::GET, &v2("/tag/category"), None).await?; - let want = content_type.modrinth_type(); + let tags: Vec = fetch_json(&ctx.net, Method::GET, &v2("/tag/category"), None).await?; + let want = match content_type { + ContentType::DataPack => ContentType::Mod.modrinth_type(), + other => other.modrinth_type(), + }; Ok(tags .into_iter() .filter(|t| t.project_type == want) @@ -345,13 +366,8 @@ impl PackageProvider for ModrinthProvider { "algorithm": "sha1" }); - let fetched: HashMap = fetch_json( - &ctx.net, - Method::POST, - &v2("/version_files"), - Some(body), - ) - .await?; + let fetched: HashMap = + fetch_json(&ctx.net, Method::POST, &v2("/version_files"), Some(body)).await?; let mut out = HashMap::new(); for identity in identities { @@ -362,6 +378,102 @@ impl PackageProvider for ModrinthProvider { Ok(out) } + + #[tracing::instrument(level = "debug", skip(self, packages, ctx))] + async fn latest_for_game_version( + &self, + packages: &[InstalledPackage], + mc_version: &str, + loader: GameLoader, + ctx: &ContentCtx, + ) -> ContentResult> { + let mut groups: HashMap> = HashMap::new(); + for package in packages { + groups + .entry(package.content_type) + .or_default() + .push(package.hash.clone()); + } + + let mut out = HashMap::new(); + for (content_type, hashes) in groups { + let loaders = modrinth_loader_filter(content_type, loader); + + let releases = + latest_version_files(ctx, &hashes, mc_version, loaders.as_deref(), true).await?; + let missing: Vec = hashes + .iter() + .filter(|hash| !releases.contains_key(*hash)) + .cloned() + .collect(); + out.extend(releases); + + if !missing.is_empty() { + out.extend( + latest_version_files(ctx, &missing, mc_version, loaders.as_deref(), false) + .await?, + ); + } + } + + Ok(out) + } +} + +fn modrinth_loader_filter(content_type: ContentType, loader: GameLoader) -> Option> { + if content_type != ContentType::Mod || !loader.is_modded() { + return None; + } + Some( + [ + GameLoader::Forge, + GameLoader::NeoForge, + GameLoader::Quilt, + GameLoader::Fabric, + GameLoader::Ornithe, + ] + .into_iter() + .filter(|other| loader.compatible_with(*other)) + .map(|other| other.modrinth_name().to_string()) + .collect(), + ) +} + +async fn latest_version_files( + ctx: &ContentCtx, + hashes: &[String], + mc_version: &str, + loaders: Option<&[String]>, + releases_only: bool, +) -> ContentResult> { + if hashes.is_empty() { + return Ok(HashMap::new()); + } + + let mut body = serde_json::json!({ + "hashes": hashes, + "algorithm": "sha1", + "game_versions": [mc_version], + }); + if let Some(loaders) = loaders { + body["loaders"] = serde_json::json!(loaders); + } + if releases_only { + body["version_types"] = serde_json::json!(["release"]); + } + + let fetched: HashMap = fetch_json( + &ctx.net, + Method::POST, + &v2("/version_files/update"), + Some(body), + ) + .await?; + + Ok(fetched + .into_iter() + .map(|(hash, version)| (polyio::normalize_hash(&hash), version.into())) + .collect()) } #[tracing::instrument(level = "debug", skip(client, team_ids))] @@ -430,6 +542,8 @@ struct ModrinthGalleryItem { raw_url: Option, #[serde(default)] title: Option, + #[serde(default)] + featured: bool, } #[derive(Deserialize)] @@ -459,7 +573,10 @@ impl ModrinthMember { } fn apply_modrinth_members(detail: &mut ProjectDetail, members: Vec) { - detail.members = members.into_iter().map(ModrinthMember::into_member).collect(); + detail.members = members + .into_iter() + .map(ModrinthMember::into_member) + .collect(); if detail.author.is_empty() { detail.author = detail .members @@ -518,11 +635,9 @@ impl ModrinthProject { .gallery .into_iter() .map(|g| GalleryImage { - url: g - .raw_url - .filter(|url| !url.is_empty()) - .unwrap_or(g.url), + url: g.raw_url.filter(|url| !url.is_empty()).unwrap_or(g.url), title: g.title.filter(|t| !t.is_empty()), + featured: g.featured, }) .collect(), license, @@ -649,6 +764,7 @@ impl From for VersionSummary { .collect(), downloads: v.downloads, file_size, + dependencies: v.dependencies.into_iter().map(Into::into).collect(), } } } @@ -685,7 +801,6 @@ fn parse_release_type(s: &str) -> ReleaseType { } } - #[cfg(test)] mod tests { use super::*; diff --git a/packages/oneclient_content/src/packages/provider/registry.rs b/packages/oneclient_content/src/packages/provider/registry.rs index f7b2cfa7..058568aa 100644 --- a/packages/oneclient_content/src/packages/provider/registry.rs +++ b/packages/oneclient_content/src/packages/provider/registry.rs @@ -1,15 +1,14 @@ use std::collections::HashMap; use std::sync::Arc; - use super::{CurseForgeProvider, ModrinthProvider, PackageProvider}; +use crate::ctx::ContentCtx; use crate::error::ContentResult; -use oneclient_common::domain::ProviderId; use crate::packages::error::PackageError; use crate::packages::file_identity::FileIdentity; use crate::packages::store::artifact_absolute_path; use crate::packages::types::{ProviderVersionLookup, VersionDetail}; -use crate::ctx::ContentCtx; +use oneclient_common::domain::ProviderId; #[derive(Clone)] pub struct PackageProviderRegistry { diff --git a/packages/oneclient_content/src/packages/release_migration.rs b/packages/oneclient_content/src/packages/release_migration.rs new file mode 100644 index 00000000..4018bf62 --- /dev/null +++ b/packages/oneclient_content/src/packages/release_migration.rs @@ -0,0 +1,1007 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::{Arc, Mutex}; + +use futures_util::StreamExt; +use tokio::sync::OnceCell; + +use chrono::Utc; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::cluster_bundle as bundle_dao; +use oneclient_db::dao::release_migration_waitlist as waitlist_dao; +use oneclient_db::models::{ClusterRow, ReleaseMigrationWaitlistRow, SeenStatus}; +use oneclient_events::GroupedProgressChild; + +use crate::bundles::{BundlesManager, enabled_bundle_projects}; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::dependencies::{ + ResolvedDependency, resolve_one, resolves_dependencies, supports_game_version, +}; +use crate::packages::store::PackageStore; +use crate::packages::types::{ + DependencyKind, InstalledPackage, ProjectDetail, VersionDependency, VersionDetail, +}; +use crate::packages::updates::{Candidate, browser_installed_any}; + +pub const MIGRATED_CONTENT_TYPES: [ContentType; 3] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, +]; + +const DEPENDENCY_DEPTH: usize = 4; + +const SOURCE_CONCURRENCY: usize = 3; +const DEPENDENCY_CONCURRENCY: usize = 6; + +#[derive(Debug, Clone)] +pub struct ReleaseMigrationPackage { + pub source_hash: String, + pub enabled: bool, + pub provider: ProviderId, + pub project_id: String, + pub content_type: ContentType, + pub display_name: String, + pub version: VersionDetail, + pub version_name: String, + pub size: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SkipReason { + NoCompatibleVersion, + ProviderUnavailable, + MissingDependency, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseMigrationSkip { + pub source_hash: String, + pub enabled: bool, + pub provider: ProviderId, + pub project_id: String, + pub content_type: ContentType, + pub display_name: String, + pub reason: SkipReason, +} + +#[derive(Debug, Clone)] +pub struct ReleaseMigrationDependency { + pub provider: ProviderId, + pub project: ProjectDetail, + pub version: VersionDetail, + pub required_by: Vec, +} + +impl ReleaseMigrationDependency { + #[must_use] + pub fn version_name(&self) -> String { + version_label(&self.version) + } + + #[must_use] + pub fn size(&self) -> u64 { + self.version.primary_file().map_or(0, |file| file.size) + } + + #[must_use] + pub fn is_needed_by(&self, selected_hashes: &HashSet) -> bool { + self.required_by + .iter() + .any(|hash| selected_hashes.contains(hash)) + } + + #[must_use] + pub fn enabled_for(&self, packages: &[ReleaseMigrationPackage]) -> bool { + packages + .iter() + .any(|package| package.enabled && self.required_by.contains(&package.source_hash)) + } +} + +#[derive(Debug, Clone, Default)] +pub struct ReleaseMigrationPlan { + pub source_cluster_id: i64, + pub target_cluster_id: i64, + pub packages: Vec, + pub dependencies: Vec, + pub unavailable: Vec, + pub unreachable: bool, +} + +impl ReleaseMigrationPlan { + #[must_use] + pub fn offered(&self, content_type: ContentType) -> usize { + self.packages + .iter() + .filter(|package| package.content_type == content_type) + .count() + } +} + +fn version_label(version: &VersionDetail) -> String { + if version.version_number.is_empty() { + version.name.clone() + } else { + version.version_number.clone() + } +} + +async fn migratable_candidates(cluster_id: i64, ctx: &ContentCtx) -> ContentResult> { + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let tracked = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + let bundle_projects: HashSet = tracked + .iter() + .filter_map(|row| row.package_id.clone()) + .collect(); + let bundle_hashes: HashSet = tracked.into_iter().map(|row| row.hash).collect(); + + Ok( + browser_installed_any(&linked, &bundle_hashes, &bundle_projects) + .into_iter() + .filter(|candidate| MIGRATED_CONTENT_TYPES.contains(&candidate.content_type)) + .collect(), + ) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn has_migratable_packages(cluster_id: i64, ctx: &ContentCtx) -> ContentResult { + Ok(!migratable_candidates(cluster_id, ctx).await?.is_empty()) +} + +#[tracing::instrument(level = "debug", skip(bundles, ctx))] +pub async fn has_packages_to_migrate( + source_cluster_id: i64, + target_cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult { + let candidates = migratable_candidates(source_cluster_id, ctx).await?; + if candidates.is_empty() { + return Ok(false); + } + let present = target_projects(target_cluster_id, bundles, ctx).await?; + Ok(candidates + .iter() + .any(|candidate| !present.contains(&candidate.project_id))) +} + +async fn target_projects( + target_cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult> { + let mut projects: HashSet = PackageStore::list_linked_artifacts(target_cluster_id, ctx) + .await? + .into_iter() + .filter_map(|linked| linked.project_id) + .collect(); + projects.extend( + bundle_dao::list_bundle_tracked(&ctx.db, target_cluster_id) + .await? + .into_iter() + .filter_map(|row| row.package_id), + ); + match enabled_bundle_projects(target_cluster_id, bundles, ctx).await { + Ok(from_bundles) => projects.extend(from_bundles), + Err(err) => tracing::warn!( + target_cluster_id, + error = %err, + "could not read the target's bundles, treating only installed packages as present" + ), + } + Ok(projects) +} + +fn fits_target( + version: &VersionDetail, + content_type: ContentType, + mc_version: &str, + loader: GameLoader, +) -> bool { + if version.primary_file().is_none() + || !supports_game_version(&version.game_versions, mc_version) + { + return false; + } + content_type != ContentType::Mod + || !loader.is_modded() + || version.loaders.is_empty() + || version + .loaders + .iter() + .any(|other| loader.compatible_with(*other)) +} + +type Lookup = Option; +type LookupCells = HashMap<(ProviderId, String), Arc>>; + +struct Unreachable; + +#[derive(Default)] +pub struct DependencyCache { + entries: Mutex, +} + +fn dependency_key(dep: &VersionDependency) -> Option { + dep.version_id + .as_ref() + .map(|version_id| format!("version:{version_id}")) + .or_else(|| { + dep.project_id + .as_ref() + .map(|project_id| format!("project:{project_id}")) + }) +} + +impl DependencyCache { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + fn cell(&self, provider_id: ProviderId, key: String) -> Arc> { + Arc::clone( + self.entries + .lock() + .unwrap() + .entry((provider_id, key)) + .or_default(), + ) + } + + async fn lookup( + &self, + provider_id: ProviderId, + dep: &VersionDependency, + target: &ClusterRow, + ctx: &ContentCtx, + ) -> Result { + let Some(key) = dependency_key(dep) else { + return Ok(None); + }; + + let cell = self.cell(provider_id, key); + cell.get_or_try_init(|| async { + let provider = match ctx.providers.get(provider_id) { + Ok(provider) => provider, + Err(_) => return Ok(None), + }; + match resolve_one(provider, dep, target, ctx).await { + Ok(resolved) => Ok(resolved), + Err(err) if err.is_transient() => Err(Unreachable), + Err(err) => { + tracing::warn!(?dep, %err, "could not resolve a dependency for release migration"); + Ok(None) + } + } + }) + .await + .cloned() + } + + async fn resolve( + &self, + provider_id: ProviderId, + root: &VersionDetail, + target: &ClusterRow, + present: &HashSet, + ctx: &ContentCtx, + ) -> Result>, Unreachable> { + let mut seen: HashSet = HashSet::new(); + seen.insert(root.project_id.clone()); + + let mut queue: VecDeque<(VersionDependency, usize)> = root + .dependencies + .iter() + .filter(|dep| dep.kind == DependencyKind::Required) + .map(|dep| (dep.clone(), 1)) + .collect(); + + let mut found = Vec::new(); + while let Some((dep, depth)) = queue.pop_front() { + if let Some(project_id) = &dep.project_id + && (seen.contains(project_id) || present.contains(project_id)) + { + continue; + } + + let Some(resolved) = self.lookup(provider_id, &dep, target, ctx).await? else { + return Ok(None); + }; + + let project_id = resolved.version.project_id.clone(); + if present.contains(&project_id) || !seen.insert(project_id) { + continue; + } + + if depth < DEPENDENCY_DEPTH { + for next in resolved + .version + .dependencies + .iter() + .filter(|next| next.kind == DependencyKind::Required) + { + queue.push_back((next.clone(), depth + 1)); + } + } + + found.push(resolved); + } + + Ok(Some(found)) + } +} + +#[tracing::instrument(level = "debug", skip(source_cluster_ids, bundles, ctx))] +pub async fn plan_release_migrations( + source_cluster_ids: &[i64], + target_cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> Vec<(i64, ContentResult)> { + let cache = DependencyCache::new(); + + futures_util::stream::iter(source_cluster_ids.iter().copied().map(|source_cluster_id| { + let cache = &cache; + async move { + let plan = plan_release_migration_cached( + source_cluster_id, + target_cluster_id, + cache, + bundles, + ctx, + ) + .await; + (source_cluster_id, plan) + } + })) + .buffer_unordered(SOURCE_CONCURRENCY) + .collect::>() + .await +} + +#[tracing::instrument(level = "debug", skip(cache, bundles, ctx))] +async fn plan_release_migration_cached( + source_cluster_id: i64, + target_cluster_id: i64, + cache: &DependencyCache, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult { + let target = PackageStore::get_cluster(target_cluster_id, ctx).await?; + let present = target_projects(target_cluster_id, bundles, ctx).await?; + + let candidates: Vec = migratable_candidates(source_cluster_id, ctx) + .await? + .into_iter() + .filter(|candidate| !present.contains(&candidate.project_id)) + .collect(); + + let evaluation = evaluate_candidates(candidates, &target, &present, cache, ctx).await; + + let mut plan = ReleaseMigrationPlan { + source_cluster_id, + target_cluster_id, + packages: evaluation.packages, + dependencies: evaluation.dependencies, + unavailable: evaluation.unavailable, + unreachable: evaluation.unreachable, + }; + + plan.packages + .sort_by_key(|package| package.display_name.to_lowercase()); + plan.dependencies + .sort_by_key(|dependency| dependency.project.name.to_lowercase()); + plan.unavailable + .sort_by_key(|skip| skip.display_name.to_lowercase()); + + Ok(plan) +} + +#[derive(Default)] +struct Evaluation { + packages: Vec, + dependencies: Vec, + unavailable: Vec, + unreachable: bool, +} + +async fn evaluate_candidates( + candidates: Vec, + target: &ClusterRow, + present: &HashSet, + cache: &DependencyCache, + ctx: &ContentCtx, +) -> Evaluation { + let loader = GameLoader::from_repr(target.mc_loader as u8).unwrap_or(GameLoader::Vanilla); + let mut evaluation = Evaluation::default(); + + let mut by_provider: HashMap> = HashMap::new(); + for candidate in candidates { + by_provider + .entry(candidate.provider) + .or_default() + .push(candidate); + } + + let mut offered: Vec = Vec::new(); + + for (provider_id, candidates) in by_provider { + let installed: Vec = candidates + .iter() + .map(|candidate| InstalledPackage { + hash: candidate.hash.clone(), + project_id: candidate.project_id.clone(), + content_type: candidate.content_type, + }) + .collect(); + + let found = match ctx.providers.get(provider_id) { + Ok(provider) => { + provider + .latest_for_game_version(&installed, &target.mc_version, loader, ctx) + .await + } + Err(err) => Err(err), + }; + + let found = match found { + Ok(found) => found, + Err(err) if err.is_transient() => { + tracing::warn!(provider = ?provider_id, error = %err, "release migration check could not reach the provider"); + evaluation.unreachable = true; + continue; + } + Err(err) => { + tracing::warn!(provider = ?provider_id, error = %err, "release migration check failed for the provider"); + evaluation + .unavailable + .extend( + candidates + .into_iter() + .map(|candidate| ReleaseMigrationSkip { + source_hash: candidate.hash, + enabled: candidate.enabled, + provider: provider_id, + project_id: candidate.project_id, + content_type: candidate.content_type, + display_name: candidate.display_name, + reason: SkipReason::ProviderUnavailable, + }), + ); + continue; + } + }; + + for candidate in candidates { + let version = found.get(&candidate.hash).filter(|version| { + fits_target(version, candidate.content_type, &target.mc_version, loader) + }); + + match version { + Some(version) => offered.push(ReleaseMigrationPackage { + source_hash: candidate.hash, + enabled: candidate.enabled, + provider: provider_id, + project_id: candidate.project_id, + content_type: candidate.content_type, + display_name: candidate.display_name, + version: version.clone(), + version_name: version_label(version), + size: version.primary_file().map_or(0, |file| file.size), + }), + None => evaluation.unavailable.push(ReleaseMigrationSkip { + source_hash: candidate.hash, + enabled: candidate.enabled, + provider: provider_id, + project_id: candidate.project_id, + content_type: candidate.content_type, + display_name: candidate.display_name, + reason: SkipReason::NoCompatibleVersion, + }), + } + } + } + + if evaluation.unreachable { + return evaluation; + } + + let resolved_packages = + futures_util::stream::iter(offered.into_iter().map(|package| async move { + let resolved = if resolves_dependencies(package.content_type) { + cache + .resolve(package.provider, &package.version, target, present, ctx) + .await + } else { + Ok(Some(Vec::new())) + }; + (package, resolved) + })) + .buffer_unordered(DEPENDENCY_CONCURRENCY) + .collect::>() + .await; + + let mut dependencies: Vec = Vec::new(); + for (package, resolved) in resolved_packages { + let resolved = match resolved { + Ok(resolved) => resolved, + Err(Unreachable) => { + evaluation.unreachable = true; + return evaluation; + } + }; + + let Some(resolved) = resolved else { + evaluation.unavailable.push(ReleaseMigrationSkip { + source_hash: package.source_hash, + enabled: package.enabled, + provider: package.provider, + project_id: package.project_id, + content_type: package.content_type, + display_name: package.display_name, + reason: SkipReason::MissingDependency, + }); + continue; + }; + + for dependency in resolved { + match dependencies.iter_mut().find(|existing| { + existing.provider == package.provider + && existing.project.id == dependency.project.id + }) { + Some(existing) => existing.required_by.push(package.source_hash.clone()), + None => dependencies.push(ReleaseMigrationDependency { + provider: package.provider, + project: dependency.project, + version: dependency.version, + required_by: vec![package.source_hash.clone()], + }), + } + } + + evaluation.packages.push(package); + } + + let libraries: HashSet = evaluation + .packages + .iter() + .filter(|package| { + dependencies.iter().any(|dependency| { + dependency.provider == package.provider + && dependency.project.id == package.project_id + }) + }) + .map(|package| package.source_hash.clone()) + .collect(); + + evaluation + .packages + .retain(|package| !libraries.contains(&package.source_hash)); + for dependency in &mut dependencies { + dependency + .required_by + .retain(|hash| !libraries.contains(hash)); + } + dependencies.retain(|dependency| !dependency.required_by.is_empty()); + + evaluation.dependencies = dependencies; + evaluation +} + +pub const WAITLIST_DAYS: i64 = 30; + +#[tracing::instrument(level = "debug", skip(skips, ctx))] +pub async fn add_to_waitlist( + target_cluster_id: i64, + skips: &[ReleaseMigrationSkip], + ctx: &ContentCtx, +) -> ContentResult<()> { + let now = Utc::now(); + let added_at = now.to_rfc3339(); + let expires_at = (now + chrono::Duration::days(WAITLIST_DAYS)).to_rfc3339(); + + for skip in skips { + waitlist_dao::upsert( + &ctx.db, + target_cluster_id, + skip.provider as i64, + &skip.project_id, + skip.content_type as i64, + &skip.source_hash, + &skip.display_name, + &added_at, + &expires_at, + skip.enabled, + ) + .await?; + } + + Ok(()) +} + +#[derive(Debug, Clone)] +pub struct WaitlistInstall { + pub cluster_id: i64, + pub cluster_name: String, + pub mc_version: String, + pub names: Vec, +} + +#[tracing::instrument(level = "debug", skip(bundles, is_running, ctx))] +pub async fn process_waitlist( + bundles: &BundlesManager, + is_running: impl Fn(i64) -> bool, + ctx: &ContentCtx, +) -> ContentResult> { + let expired = waitlist_dao::delete_expired(&ctx.db, &Utc::now().to_rfc3339()).await?; + if expired > 0 { + tracing::info!( + expired, + "dropped expired release migration waitlist entries" + ); + } + + let mut by_cluster: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + let rows = waitlist_dao::list_all(&ctx.db).await?; + tracing::info!( + entries = rows.len(), + expired, + "checking the release migration waitlist for new builds" + ); + for row in rows { + by_cluster + .entry(row.target_cluster_id) + .or_default() + .push(row); + } + + let cache = DependencyCache::new(); + let mut installs = Vec::new(); + + for (cluster_id, rows) in by_cluster { + if is_running(cluster_id) { + tracing::info!( + cluster_id, + waiting = rows.len(), + "skipping the waitlist check for a running cluster" + ); + continue; + } + let target = match PackageStore::get_cluster(cluster_id, ctx).await { + Ok(target) => target, + Err(err) => { + tracing::warn!(cluster_id, %err, "could not read a waitlisted cluster, skipping it"); + continue; + } + }; + let present = match target_projects(cluster_id, bundles, ctx).await { + Ok(present) => present, + Err(err) => { + tracing::warn!(cluster_id, %err, "could not read a waitlisted cluster's packages, skipping it"); + continue; + } + }; + + let mut candidates = Vec::new(); + for row in rows { + if present.contains(&row.project_id) { + tracing::info!( + cluster_id, + package = %row.display_name, + "waitlisted package is already in the cluster, dropping it" + ); + waitlist_dao::delete(&ctx.db, cluster_id, row.provider, &row.project_id).await?; + continue; + } + let (Some(provider), Some(content_type)) = ( + ProviderId::from_repr(row.provider as u8), + ContentType::from_repr(row.content_type as u8), + ) else { + waitlist_dao::delete(&ctx.db, cluster_id, row.provider, &row.project_id).await?; + continue; + }; + candidates.push(Candidate { + hash: row.source_hash, + provider, + content_type, + project_id: row.project_id, + version_id: String::new(), + display_name: row.display_name, + display_version: String::new(), + published_at: None, + enabled: row.enabled != 0, + }); + } + if candidates.is_empty() { + continue; + } + + let waitlisted: HashSet<(ProviderId, String)> = candidates + .iter() + .map(|candidate| (candidate.provider, candidate.project_id.clone())) + .collect(); + + let checking: Vec = candidates + .iter() + .map(|candidate| candidate.display_name.clone()) + .collect(); + tracing::info!( + cluster_id, + cluster = %target.name, + mc_version = %target.mc_version, + packages = ?checking, + "checking waitlisted packages for {} builds", + target.mc_version + ); + + let evaluation = evaluate_candidates(candidates, &target, &present, &cache, ctx).await; + if evaluation.unreachable { + tracing::warn!( + cluster_id, + "a provider could not be reached, keeping the waitlist for the next launch" + ); + break; + } + + tracing::info!( + cluster_id, + mc_version = %target.mc_version, + available = ?evaluation + .packages + .iter() + .map(|package| format!("{} {}", package.display_name, package.version_name)) + .collect::>(), + dependencies = ?evaluation + .dependencies + .iter() + .map(|dependency| dependency.project.name.clone()) + .collect::>(), + still_waiting = ?evaluation + .unavailable + .iter() + .map(|skip| format!("{} ({:?})", skip.display_name, skip.reason)) + .collect::>(), + "waitlist check finished for {} builds", + target.mc_version + ); + + let mut names = Vec::new(); + let mut blocked: HashSet = HashSet::new(); + + for dependency in &evaluation.dependencies { + let enabled = dependency.enabled_for(&evaluation.packages); + match apply_release_migration_dependency(cluster_id, dependency, enabled, None, ctx) + .await + { + Ok(_) => { + if waitlisted.contains(&(dependency.provider, dependency.project.id.clone())) { + waitlist_dao::delete( + &ctx.db, + cluster_id, + dependency.provider as i64, + &dependency.project.id, + ) + .await?; + names.push(dependency.project.name.clone()); + } + } + Err(err) => { + tracing::warn!(dependency = %dependency.project.name, %err, "waitlisted package dependency failed to install"); + blocked.extend(dependency.required_by.iter().cloned()); + } + } + } + + for package in &evaluation.packages { + if blocked.contains(&package.source_hash) { + continue; + } + match apply_release_migration_package(cluster_id, package, None, ctx).await { + Ok(_) => { + waitlist_dao::delete( + &ctx.db, + cluster_id, + package.provider as i64, + &package.project_id, + ) + .await?; + names.push(package.display_name.clone()); + } + Err(err) => { + tracing::warn!(package = %package.display_name, %err, "waitlisted package failed to install") + } + } + } + + if !names.is_empty() { + tracing::info!( + cluster_id, + cluster = %target.name, + installed = ?names, + "installed waitlisted packages that gained a {} build", + target.mc_version + ); + installs.push(WaitlistInstall { + cluster_id, + cluster_name: target.name.clone(), + mc_version: target.mc_version.clone(), + names, + }); + } + } + + Ok(installs) +} + +#[tracing::instrument(level = "debug", skip(dependency, child, ctx), fields(project_id = %dependency.project.id))] +pub async fn apply_release_migration_dependency( + target_cluster_id: i64, + dependency: &ReleaseMigrationDependency, + enabled: bool, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + let (installed, _) = PackageStore::install_to_cluster( + dependency.provider, + &dependency.project, + &dependency.version, + target_cluster_id, + true, + false, + child, + ctx, + ) + .await?; + + artifact_dao::set_seen_status(&ctx.db, target_cluster_id, &installed.hash, SeenStatus::New) + .await?; + + if !enabled { + PackageStore::set_artifact_enabled_to(target_cluster_id, &installed.hash, false, ctx) + .await?; + } + + Ok(installed.hash) +} + +#[tracing::instrument(level = "debug", skip(package, child, ctx), fields(project_id = %package.project_id))] +pub async fn apply_release_migration_package( + target_cluster_id: i64, + package: &ReleaseMigrationPackage, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + let project = ctx + .providers + .get(package.provider)? + .get_project(&package.project_id, ctx) + .await?; + + let (installed, _) = PackageStore::install_to_cluster( + package.provider, + &project, + &package.version, + target_cluster_id, + true, + false, + child, + ctx, + ) + .await?; + + artifact_dao::set_seen_status(&ctx.db, target_cluster_id, &installed.hash, SeenStatus::New) + .await?; + + if !package.enabled { + PackageStore::set_artifact_enabled_to(target_cluster_id, &installed.hash, false, ctx) + .await?; + } + + Ok(installed.hash) +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + + use super::*; + use crate::packages::types::VersionFile; + + fn version(game_versions: &[&str], loaders: Vec) -> VersionDetail { + VersionDetail { + version_id: "v".into(), + project_id: "p".into(), + name: "name".into(), + version_number: "1.0".into(), + changelog: None, + game_versions: game_versions.iter().map(|v| (*v).to_string()).collect(), + loaders, + published: Utc::now(), + downloads: 0, + files: vec![VersionFile { + sha1: "a".into(), + url: "u".into(), + file_name: "f.jar".into(), + primary: true, + size: 1, + fingerprint: None, + }], + dependencies: Vec::new(), + } + } + + #[test] + fn a_mod_for_another_loader_is_not_offered() { + let forge = version(&["26.3"], vec![GameLoader::Forge]); + assert!(!fits_target( + &forge, + ContentType::Mod, + "26.3", + GameLoader::Fabric + )); + } + + #[test] + fn a_mod_for_an_older_game_version_is_not_offered() { + let old = version(&["26.2"], vec![GameLoader::Fabric]); + assert!(!fits_target( + &old, + ContentType::Mod, + "26.3", + GameLoader::Fabric + )); + } + + #[test] + fn resource_packs_ignore_the_cluster_loader() { + let pack = version(&["26.3"], vec![GameLoader::Vanilla]); + assert!(fits_target( + &pack, + ContentType::ResourcePack, + "26.3", + GameLoader::Fabric + )); + } + + #[test] + fn a_version_without_files_is_not_offered() { + let mut empty = version(&["26.3"], vec![GameLoader::Fabric]); + empty.files.clear(); + assert!(!fits_target( + &empty, + ContentType::Mod, + "26.3", + GameLoader::Fabric + )); + } + + #[test] + fn packages_keep_a_stable_order_however_the_checks_finish() { + let mut packages = vec!["Zoomify", "blur", "Sodium"]; + packages.sort_by_key(|name| name.to_lowercase()); + assert_eq!(packages, vec!["blur", "Sodium", "Zoomify"]); + } + + #[test] + fn pinned_versions_and_projects_cache_separately() { + let pinned = VersionDependency { + project_id: Some("fabric-api".into()), + version_id: Some("v1".into()), + kind: DependencyKind::Required, + }; + let loose = VersionDependency { + project_id: Some("fabric-api".into()), + version_id: None, + kind: DependencyKind::Required, + }; + assert_eq!(dependency_key(&pinned).as_deref(), Some("version:v1")); + assert_eq!( + dependency_key(&loose).as_deref(), + Some("project:fabric-api") + ); + } +} diff --git a/packages/oneclient_content/src/packages/store/download.rs b/packages/oneclient_content/src/packages/store/download.rs index 6c12290a..8c61ebac 100644 --- a/packages/oneclient_content/src/packages/store/download.rs +++ b/packages/oneclient_content/src/packages/store/download.rs @@ -2,12 +2,12 @@ use oneclient_db::dao::artifact as artifact_dao; use oneclient_db::models::ArtifactRow; use super::paths::{artifact_absolute_path, cache_file_path, relative_cache_path}; +use crate::ctx::ContentCtx; use crate::error::ContentResult; -use polyio::{normalize_hash, sha1_file}; +use crate::packages::types::{ExternalFile, VersionDetail, VersionFile}; use oneclient_common::domain::{ContentType, ProviderId}; use oneclient_events::GroupedProgressChild; -use crate::packages::types::{ExternalFile, VersionDetail, VersionFile}; -use crate::ctx::ContentCtx; +use polyio::{normalize_hash, sha1_file}; #[tracing::instrument(level = "debug", skip(child, ctx))] pub async fn ensure_artifact_file( @@ -31,7 +31,9 @@ pub async fn ensure_artifact_file( dest, Some(&expected), 0, - child.cloned().map(oneclient_net::ResponseNotifyOptions::grouped), + child + .cloned() + .map(oneclient_net::ResponseNotifyOptions::grouped), ) .await?; diff --git a/packages/oneclient_content/src/packages/store/gc.rs b/packages/oneclient_content/src/packages/store/gc.rs index 5a7b78a6..f3b73a76 100644 --- a/packages/oneclient_content/src/packages/store/gc.rs +++ b/packages/oneclient_content/src/packages/store/gc.rs @@ -15,264 +15,270 @@ const RECENT_GRACE: Duration = Duration::from_secs(60 * 60); #[derive(Debug, Default, Clone, Copy)] pub struct GcReport { - pub artifacts_removed: usize, - pub files_removed: usize, - pub bytes_freed: u64, + pub artifacts_removed: usize, + pub files_removed: usize, + pub bytes_freed: u64, } #[tracing::instrument(level = "debug", skip(ctx))] pub async fn evict_if_unused(hash: &str, ctx: &ContentCtx) -> ContentResult { - // Read the row first once it is deleted there is no way back to the file - let row = artifact_dao::get_artifact_by_hash(&ctx.db, hash).await?; + // Read the row first once it is deleted there is no way back to the file + let row = artifact_dao::get_artifact_by_hash(&ctx.db, hash).await?; - if !artifact_dao::delete_artifact_if_unused(&ctx.db, hash).await? { - return Ok(false); - } + if !artifact_dao::delete_artifact_if_unused(&ctx.db, hash).await? { + return Ok(false); + } - if let Some(row) = row - && let Ok(path) = artifact_absolute_path(&row.path) - { - remove_cached_file(&path).await; - } + if let Some(row) = row + && let Ok(path) = artifact_absolute_path(&row.path) + { + remove_cached_file(&path).await; + } - Ok(true) + Ok(true) } #[tracing::instrument(level = "debug", skip(ctx))] pub async fn collect_unused_artifacts(ctx: &ContentCtx) -> ContentResult { - let mut report = GcReport::default(); + let mut report = GcReport::default(); - for row in artifact_dao::list_unused_artifacts(&ctx.db).await? { - let path = artifact_absolute_path(&row.path).ok(); + for row in artifact_dao::list_unused_artifacts(&ctx.db).await? { + let path = artifact_absolute_path(&row.path).ok(); - // An install inserts the row then links it - // in between it looks unused - if let Some(path) = &path - && is_recent(path).await - { - continue; - } + // An install inserts the row then links it + // in between it looks unused + if let Some(path) = &path + && is_recent(path).await + { + continue; + } - if !artifact_dao::delete_artifact_if_unused(&ctx.db, &row.hash).await? { - continue; - } - report.artifacts_removed += 1; + if !artifact_dao::delete_artifact_if_unused(&ctx.db, &row.hash).await? { + continue; + } + report.artifacts_removed += 1; - if let Some(path) = path { - report.bytes_freed += file_len(&path).await; - remove_cached_file(&path).await; - } - } + if let Some(path) = path { + report.bytes_freed += file_len(&path).await; + remove_cached_file(&path).await; + } + } - if report.artifacts_removed > 0 { - tracing::info!( - artifacts = report.artifacts_removed, - mb_freed = report.bytes_freed / 1_000_000, - "reclaimed unused packages" - ); - } + if report.artifacts_removed > 0 { + tracing::info!( + artifacts = report.artifacts_removed, + mb_freed = report.bytes_freed / 1_000_000, + "reclaimed unused packages" + ); + } - Ok(report) + Ok(report) } pub async fn find_unreferenced_files(ctx: &ContentCtx) -> ContentResult> { - let cache_root = paths::packages_cache_dir()?; - if !polyio::try_exists(&cache_root).await.unwrap_or(false) { - return Ok(Vec::new()); - } + let cache_root = paths::packages_cache_dir()?; + if !polyio::try_exists(&cache_root).await.unwrap_or(false) { + return Ok(Vec::new()); + } - let known: HashSet = artifact_dao::list_artifact_paths(&ctx.db) - .await? - .into_iter() - .collect(); + let known: HashSet = artifact_dao::list_artifact_paths(&ctx.db) + .await? + .into_iter() + .collect(); - // An empty artifacts table means a lost index not a cache full of garbage - // the other reading would offer to wipe every package the user has - if known.is_empty() { - tracing::debug!("no artifacts indexed; treating the package cache as fully referenced"); - return Ok(Vec::new()); - } + // An empty artifacts table means a lost index not a cache full of garbage + // the other reading would offer to wipe every package the user has + if known.is_empty() { + tracing::debug!("no artifacts indexed; treating the package cache as fully referenced"); + return Ok(Vec::new()); + } - let mut found = Vec::new(); - for file in walk_files(&cache_root).await { - let Ok(relative) = relative_cache_path(&file) else { - continue; - }; - if known.contains(&relative) || is_recent(&file).await { - continue; - } + let mut found = Vec::new(); + for file in walk_files(&cache_root).await { + let Ok(relative) = relative_cache_path(&file) else { + continue; + }; + if known.contains(&relative) || is_recent(&file).await { + continue; + } - let size = file_len(&file).await; - found.push((file, size)); - } + let size = file_len(&file).await; + found.push((file, size)); + } - Ok(found) + Ok(found) } /// Reasons from the filesystem not the database so it can misread an install whose database was reset #[tracing::instrument(level = "debug", skip(ctx))] pub async fn remove_unreferenced_files(ctx: &ContentCtx) -> ContentResult { - let cache_root = paths::packages_cache_dir()?; - let mut report = GcReport::default(); + let cache_root = paths::packages_cache_dir()?; + let mut report = GcReport::default(); - for (file, size) in find_unreferenced_files(ctx).await? { - if polyio::remove_file(&file).await.is_ok() { - report.files_removed += 1; - report.bytes_freed += size; - prune_empty_parents(&file, &cache_root).await; - } - } + for (file, size) in find_unreferenced_files(ctx).await? { + if polyio::remove_file(&file).await.is_ok() { + report.files_removed += 1; + report.bytes_freed += size; + prune_empty_parents(&file, &cache_root).await; + } + } - if report.files_removed > 0 { - tracing::info!( - files = report.files_removed, - mb_freed = report.bytes_freed / 1_000_000, - "removed unreferenced cache files" - ); - } + if report.files_removed > 0 { + tracing::info!( + files = report.files_removed, + mb_freed = report.bytes_freed / 1_000_000, + "removed unreferenced cache files" + ); + } - Ok(report) + Ok(report) } async fn remove_cached_file(path: &Path) { - if polyio::remove_file(path).await.is_err() { - return; - } + if polyio::remove_file(path).await.is_err() { + return; + } - if let Ok(root) = paths::packages_cache_dir() { - prune_empty_parents(path, &root).await; - } + if let Ok(root) = paths::packages_cache_dir() { + prune_empty_parents(path, &root).await; + } } async fn prune_empty_parents(file: &Path, root: &Path) { - let mut dir = file.parent().map(Path::to_path_buf); + let mut dir = file.parent().map(Path::to_path_buf); - while let Some(current) = dir { - if current == root || !current.starts_with(root) { - return; - } + while let Some(current) = dir { + if current == root || !current.starts_with(root) { + return; + } - // `remove_dir` refuses a non-empty directory which is exactly the stop condition - if polyio::remove_dir(¤t).await.is_err() { - return; - } + // `remove_dir` refuses a non-empty directory which is exactly the stop condition + if polyio::remove_dir(¤t).await.is_err() { + return; + } - dir = current.parent().map(Path::to_path_buf); - } + dir = current.parent().map(Path::to_path_buf); + } } async fn is_recent(path: &Path) -> bool { - let Ok(meta) = polyio::stat(path).await else { - // Cannot tell how old it is so leave it be - return true; - }; + let Ok(meta) = polyio::stat(path).await else { + // Cannot tell how old it is so leave it be + return true; + }; - meta.modified() - .ok() - .and_then(|modified| SystemTime::now().duration_since(modified).ok()) - .is_none_or(|age| age < RECENT_GRACE) + meta.modified() + .ok() + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .is_none_or(|age| age < RECENT_GRACE) } async fn file_len(path: &Path) -> u64 { - polyio::stat(path).await.map(|m| m.len()).unwrap_or(0) + polyio::stat(path).await.map(|m| m.len()).unwrap_or(0) } async fn walk_files(root: &Path) -> Vec { - let mut files = Vec::new(); - let mut stack = vec![root.to_path_buf()]; + let mut files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; - while let Some(dir) = stack.pop() { - let Ok(mut entries) = polyio::read_dir(&dir).await else { - continue; - }; + while let Some(dir) = stack.pop() { + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; - while let Ok(Some(entry)) = entries.next_entry().await { - let Ok(file_type) = entry.file_type().await else { - continue; - }; + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; - if file_type.is_dir() { - stack.push(entry.path()); - } else if file_type.is_file() { - files.push(entry.path()); - } - } - } + if file_type.is_dir() { + stack.push(entry.path()); + } else if file_type.is_file() { + files.push(entry.path()); + } + } + } - files + files } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[tokio::test] - async fn empty_parents_go_but_the_root_stays() { - let root = polyio::testing::ScratchDir::new("gc_prune"); - let cache = root.join("packages"); - let nested = cache.join("mod").join("modrinth").join("proj").join("v1"); - polyio::create_dir_all(&nested).await.unwrap(); + #[tokio::test] + async fn empty_parents_go_but_the_root_stays() { + let root = polyio::testing::ScratchDir::new("gc_prune"); + let cache = root.join("packages"); + let nested = cache.join("mod").join("modrinth").join("proj").join("v1"); + polyio::create_dir_all(&nested).await.unwrap(); - let file = nested.join("a.jar"); - polyio::write(&file, b"jar".as_slice()).await.unwrap(); - polyio::remove_file(&file).await.unwrap(); + let file = nested.join("a.jar"); + polyio::write(&file, b"jar".as_slice()).await.unwrap(); + polyio::remove_file(&file).await.unwrap(); - prune_empty_parents(&file, &cache).await; + prune_empty_parents(&file, &cache).await; - assert!(!cache.join("mod").exists(), "empty chain should be gone"); - assert!(cache.exists(), "the cache root itself always stays"); + assert!(!cache.join("mod").exists(), "empty chain should be gone"); + assert!(cache.exists(), "the cache root itself always stays"); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[tokio::test] - async fn a_sibling_stops_the_prune() { - let root = polyio::testing::ScratchDir::new("gc_prune_sibling"); - let cache = root.join("packages"); - let version = cache.join("mod").join("modrinth").join("proj").join("v1"); - polyio::create_dir_all(&version).await.unwrap(); + #[tokio::test] + async fn a_sibling_stops_the_prune() { + let root = polyio::testing::ScratchDir::new("gc_prune_sibling"); + let cache = root.join("packages"); + let version = cache.join("mod").join("modrinth").join("proj").join("v1"); + polyio::create_dir_all(&version).await.unwrap(); - let gone = version.join("a.jar"); - polyio::write(&gone, b"jar".as_slice()).await.unwrap(); - polyio::write(version.join("b.jar"), b"jar".as_slice()) - .await - .unwrap(); - polyio::remove_file(&gone).await.unwrap(); + let gone = version.join("a.jar"); + polyio::write(&gone, b"jar".as_slice()).await.unwrap(); + polyio::write(version.join("b.jar"), b"jar".as_slice()) + .await + .unwrap(); + polyio::remove_file(&gone).await.unwrap(); - prune_empty_parents(&gone, &cache).await; + prune_empty_parents(&gone, &cache).await; - assert!(version.exists(), "a directory with another file survives"); + assert!(version.exists(), "a directory with another file survives"); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[tokio::test] - async fn fresh_files_are_spared() { - let root = polyio::testing::ScratchDir::new("gc_recent"); - polyio::create_dir_all(root.path()).await.unwrap(); + #[tokio::test] + async fn fresh_files_are_spared() { + let root = polyio::testing::ScratchDir::new("gc_recent"); + polyio::create_dir_all(root.path()).await.unwrap(); - let file = root.join("downloading.jar"); - polyio::write(&file, b"partial".as_slice()).await.unwrap(); + let file = root.join("downloading.jar"); + polyio::write(&file, b"partial".as_slice()).await.unwrap(); - assert!(is_recent(&file).await, "a just-written file is not garbage"); + assert!(is_recent(&file).await, "a just-written file is not garbage"); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[tokio::test] - async fn walk_finds_files_at_every_depth() { - let root = polyio::testing::ScratchDir::new("gc_walk"); - polyio::create_dir_all(root.join("a").join("b")).await.unwrap(); - polyio::write(root.join("top.jar"), b"1".as_slice()).await.unwrap(); - polyio::write(root.join("a").join("mid.jar"), b"2".as_slice()).await.unwrap(); - polyio::write(root.join("a").join("b").join("deep.jar"), b"3".as_slice()) - .await - .unwrap(); + #[tokio::test] + async fn walk_finds_files_at_every_depth() { + let root = polyio::testing::ScratchDir::new("gc_walk"); + polyio::create_dir_all(root.join("a").join("b")) + .await + .unwrap(); + polyio::write(root.join("top.jar"), b"1".as_slice()) + .await + .unwrap(); + polyio::write(root.join("a").join("mid.jar"), b"2".as_slice()) + .await + .unwrap(); + polyio::write(root.join("a").join("b").join("deep.jar"), b"3".as_slice()) + .await + .unwrap(); - let found = walk_files(root.path()).await; + let found = walk_files(root.path()).await; - assert_eq!(found.len(), 3, "found: {found:?}"); + assert_eq!(found.len(), 3, "found: {found:?}"); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } } diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs index d1f1e432..5dd6ebc0 100644 --- a/packages/oneclient_content/src/packages/store/link.rs +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -1,421 +1,475 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; +use oneclient_db::DbPool; +use oneclient_db::dao::game_session as session_dao; use oneclient_db::models::{ArtifactRow, ClusterRow}; +use crate::error::ContentResult; use oneclient_common::domain::ContentType; use oneclient_common::paths; -use crate::error::ContentResult; use super::manifest; use super::paths::artifact_absolute_path; const STAGING_SUFFIX: &str = ".oneclient-tmp"; +pub(crate) fn shares_content(cluster: &ClusterRow, content_type: ContentType) -> bool { + content_type.is_global() && !cluster.is_isolated() +} + /// What a live add actually did, so a caller can say so instead of promising #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LiveSync { - /// The file is in the running game's folder now - Applied, - /// It could not be put there; it arrives at the next launch instead - Deferred, - /// Nothing to do: this content type never reloads, or no session owns the folder - Skipped, + /// The file is in the running game's folder now + Applied, + /// It could not be put there; it arrives at the next launch instead + Deferred, + /// Nothing to do: this content type never reloads, or no session owns the folder + Skipped, } /// so a rerun that died mid-way leaves atleast one stale file, that next rerun clears anyways fn staging_path(dest: &Path) -> PathBuf { - let mut name = OsString::from("."); - name.push(dest.file_name().unwrap_or_else(|| "entry".as_ref())); - name.push(STAGING_SUFFIX); + let mut name = OsString::from("."); + name.push(dest.file_name().unwrap_or_else(|| "entry".as_ref())); + name.push(STAGING_SUFFIX); - dest.with_file_name(name) + dest.with_file_name(name) } /// A launcher killed between the copy and the rename leaves one of these behind /// and every other pass over these folders skips dotfiles #[tracing::instrument(level = "debug")] pub async fn sweep_staging_files(dir: &Path) { - let Ok(mut entries) = polyio::read_dir(dir).await else { - return; - }; + let Ok(mut entries) = polyio::read_dir(dir).await else { + return; + }; - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - let Some(name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; - if !name.starts_with('.') || !name.ends_with(STAGING_SUFFIX) { - continue; - } + if !name.starts_with('.') || !name.ends_with(STAGING_SUFFIX) { + continue; + } - if let Err(err) = remove_entry(&path).await { - tracing::debug!(file = name, error = %err, "failed to clear a stale staging file"); - } - } + if let Err(err) = remove_entry(&path).await { + tracing::debug!(file = name, error = %err, "failed to clear a stale staging file"); + } + } } /// written to a staging name and renamed into place so the destination is never missing even for a moment #[tracing::instrument(level = "debug")] pub async fn link_or_copy(src: &Path, dest: &Path) -> ContentResult<()> { - if let Some(parent) = dest.parent() { - polyio::create_dir_all(parent).await?; - } + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await?; + } - let staging = staging_path(dest); - remove_entry(&staging).await?; + let staging = staging_path(dest); + remove_entry(&staging).await?; - if polyio::symlink_file(src, &staging).await.is_err() { - polyio::copy(src, &staging).await?; - } + if polyio::symlink_file(src, &staging).await.is_err() { + polyio::copy(src, &staging).await?; + } - if let Err(err) = polyio::rename(&staging, dest).await { - remove_entry(&staging).await.ok(); - return Err(err.into()); - } + if let Err(err) = polyio::rename(&staging, dest).await { + remove_entry(&staging).await.ok(); + return Err(err.into()); + } - Ok(()) + Ok(()) } pub async fn remove_entry(path: &Path) -> ContentResult<()> { - if polyio::symlink_metadata(path).await.is_err() { - return Ok(()); - } + if polyio::symlink_metadata(path).await.is_err() { + return Ok(()); + } - polyio::remove_file(path).await?; - Ok(()) + polyio::remove_file(path).await?; + Ok(()) +} + +pub async fn drop_unmanaged_mod( + cluster: &ClusterRow, + content_type: ContentType, + file_name: &str, + hash: &str, + db: &DbPool, +) { + if content_type != ContentType::Mod || session_owns(cluster, db).await { + return; + } + let Ok(dir) = paths::cluster_dir(&cluster.folder_name) else { + return; + }; + if manifest::mods_live_in_cluster(&dir).await { + return; + } + + let path = dir.join(content_type.folder_name()).join(file_name); + let linked = polyio::sha1_file(&path) + .await + .is_ok_and(|disk| polyio::normalize_hash(&disk) == hash); + if linked && let Err(err) = remove_entry(&path).await { + tracing::debug!(file = file_name, error = %err, "could not drop the unlinked mod from the cluster folder"); + } } async fn materialized_root( - cluster: &ClusterRow, - content_type: ContentType, + cluster: &ClusterRow, + content_type: ContentType, ) -> Option<(PathBuf, &'static str)> { - if content_type.is_global() { - return paths::shared_minecraft_dir() - .ok() - .map(|dir| (dir, manifest::GLOBAL_MANIFEST_NAME)); - } + if shares_content(cluster, content_type) { + return paths::shared_minecraft_dir() + .ok() + .map(|dir| (dir, manifest::GLOBAL_MANIFEST_NAME)); + } - if content_type == ContentType::Mod - && let Ok(dir) = paths::cluster_dir(&cluster.folder_name) - && manifest::mods_live_in_cluster(&dir).await - { - return Some((dir, manifest::MODS_MANIFEST_NAME)); - } + if content_type == ContentType::Mod + && let Ok(dir) = paths::cluster_dir(&cluster.folder_name) + && manifest::mods_live_in_cluster(&dir).await + { + return Some((dir, manifest::MODS_MANIFEST_NAME)); + } - paths::cluster_game_dir(&cluster.folder_name) - .ok() - .map(|dir| (dir, manifest::MANIFEST_NAME)) + paths::cluster_game_dir(&cluster.folder_name, cluster.is_isolated()) + .ok() + .map(|dir| (dir, manifest::MANIFEST_NAME)) } -async fn session_owns(cluster: &ClusterRow) -> bool { - let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name) else { - return false; - }; +async fn session_owns(cluster: &ClusterRow, db: &DbPool) -> bool { + let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name, cluster.is_isolated()) else { + return false; + }; - manifest::load(&game_dir, manifest::MANIFEST_NAME) - .await - .is_some_and(|session| session.cluster_id == cluster.id) + let owned = manifest::load(&game_dir, manifest::MANIFEST_NAME) + .await + .is_some_and(|session| session.cluster_id == cluster.id); + + if !owned || !cluster.is_isolated() { + return owned; + } + + match session_dao::unfinished_sessions(db).await { + Ok(sessions) => sessions + .iter() + .any(|session| session.cluster_id == cluster.id), + Err(err) => { + tracing::debug!(error = %err, "cannot tell whether the instance is running; treating it as running"); + true + } + } } -#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +#[tracing::instrument(level = "debug", skip(cluster, db), fields(cluster_id = cluster.id))] pub async fn try_unlink_materialized( - cluster: &ClusterRow, - content_type: ContentType, - file_name: &str, + cluster: &ClusterRow, + content_type: ContentType, + file_name: &str, + db: &DbPool, ) -> LiveSync { - if !content_type.reloads_in_game() && session_owns(cluster).await { - tracing::debug!( - file = file_name, - "file will be moved at next launch due to an active session" - ); - return LiveSync::Deferred; - } + if !content_type.reloads_in_game() && session_owns(cluster, db).await { + tracing::debug!( + file = file_name, + "file will be moved at next launch due to an active session" + ); + return LiveSync::Deferred; + } - let Some((root, manifest_name)) = materialized_root(cluster, content_type).await else { - return LiveSync::Deferred; - }; + let Some((root, manifest_name)) = materialized_root(cluster, content_type).await else { + return LiveSync::Deferred; + }; - let _guard = manifest::lock().await; + let _guard = manifest::lock().await; - let Some(mut loaded) = manifest::load(&root, manifest_name).await else { - return LiveSync::Skipped; - }; + let Some(mut loaded) = manifest::load(&root, manifest_name).await else { + return LiveSync::Skipped; + }; - let relative = manifest::entry_path(content_type.folder_name(), file_name); - let ours = if content_type.is_global() { - loaded.contains(&relative) - } else { - loaded.owns(cluster.id, &relative) - }; + let relative = manifest::entry_path(content_type.folder_name(), file_name); + let ours = if shares_content(cluster, content_type) { + loaded.contains(&relative) + } else { + loaded.owns(cluster.id, &relative) + }; - if !ours { - return LiveSync::Skipped; - } + if !ours { + return LiveSync::Skipped; + } - let path = root.join(&relative); - if let Err(err) = remove_entry(&path).await { - tracing::debug!( - file = file_name, - error = %err, - "could not drop package from the game folder now; it goes at the next launch" - ); - return LiveSync::Deferred; - } + let path = root.join(&relative); + if let Err(err) = remove_entry(&path).await { + tracing::debug!( + file = file_name, + error = %err, + "could not drop package from the game folder now; it goes at the next launch" + ); + return LiveSync::Deferred; + } - loaded.entries.retain(|entry| entry.path != relative); - manifest::save(&root, manifest_name, &loaded).await; + loaded.entries.retain(|entry| entry.path != relative); + manifest::save(&root, manifest_name, &loaded).await; - LiveSync::Applied + LiveSync::Applied } #[tracing::instrument(level = "debug", skip(cluster, artifact), fields(cluster_id = cluster.id, hash = %artifact.hash))] pub async fn try_link_materialized( - cluster: &ClusterRow, - artifact: &ArtifactRow, - file_name: &str, + cluster: &ClusterRow, + artifact: &ArtifactRow, + file_name: &str, ) -> LiveSync { - let Some(content_type) = ContentType::from_repr(artifact.content_type as u8) else { - return LiveSync::Skipped; - }; + let Some(content_type) = ContentType::from_repr(artifact.content_type as u8) else { + return LiveSync::Skipped; + }; - if !content_type.reloads_in_game() { - return LiveSync::Skipped; - } + if !content_type.reloads_in_game() { + return LiveSync::Skipped; + } - let Some((root, manifest_name)) = materialized_root(cluster, content_type).await else { - return LiveSync::Deferred; - }; + let Some((root, manifest_name)) = materialized_root(cluster, content_type).await else { + return LiveSync::Deferred; + }; - let Ok(src) = artifact_absolute_path(&artifact.path) else { - return LiveSync::Deferred; - }; + let Ok(src) = artifact_absolute_path(&artifact.path) else { + return LiveSync::Deferred; + }; - if !polyio::try_exists(&src).await.unwrap_or(false) { - tracing::warn!(hash = %artifact.hash, "cached artifact missing; leaving it to the next launch"); - return LiveSync::Deferred; - } + if !polyio::try_exists(&src).await.unwrap_or(false) { + tracing::warn!(hash = %artifact.hash, "cached artifact missing; leaving it to the next launch"); + return LiveSync::Deferred; + } - let _guard = manifest::lock().await; + let _guard = manifest::lock().await; - // No manifest means nothing is playing out of this folder right now - let Some(mut loaded) = manifest::load(&root, manifest_name).await else { - return LiveSync::Skipped; - }; + // No manifest means nothing is playing out of this folder right now + let Some(mut loaded) = manifest::load(&root, manifest_name).await else { + return LiveSync::Skipped; + }; - if !content_type.is_global() && loaded.cluster_id != cluster.id { - return LiveSync::Deferred; - } + if !shares_content(cluster, content_type) && loaded.cluster_id != cluster.id { + return LiveSync::Deferred; + } - let relative = manifest::entry_path(content_type.folder_name(), file_name); - let dest = root.join(&relative); + let relative = manifest::entry_path(content_type.folder_name(), file_name); + let dest = root.join(&relative); - let ours = if content_type.is_global() { - loaded.contains(&relative) - } else { - loaded.owns(cluster.id, &relative) - }; + let ours = if shares_content(cluster, content_type) { + loaded.contains(&relative) + } else { + loaded.owns(cluster.id, &relative) + }; - // Same rule as the unlink path: never write over a file we did not place - if !ours && polyio::symlink_metadata(&dest).await.is_ok() { - tracing::debug!( - file = file_name, - "a file we do not own already sits in the game folder; leaving it to the next launch" - ); - return LiveSync::Deferred; - } + // Same rule as the unlink path: never write over a file we did not place + if !ours && polyio::symlink_metadata(&dest).await.is_ok() { + tracing::debug!( + file = file_name, + "a file we do not own already sits in the game folder; leaving it to the next launch" + ); + return LiveSync::Deferred; + } - if let Err(err) = link_or_copy(&src, &dest).await { - tracing::debug!( - file = file_name, - error = %err, - "could not add the pack to the running game; it goes in at the next launch" - ); - return LiveSync::Deferred; - } + if let Err(err) = link_or_copy(&src, &dest).await { + tracing::debug!( + file = file_name, + error = %err, + "could not add the pack to the running game; it goes in at the next launch" + ); + return LiveSync::Deferred; + } - loaded.entries.retain(|entry| entry.path != relative); - loaded.entries.push(manifest::ManifestEntry { - path: relative, - hash: artifact.hash.clone(), - }); - manifest::save(&root, manifest_name, &loaded).await; + loaded.entries.retain(|entry| entry.path != relative); + loaded.entries.push(manifest::ManifestEntry { + path: relative, + hash: artifact.hash.clone(), + }); + manifest::save(&root, manifest_name, &loaded).await; - LiveSync::Applied + LiveSync::Applied } #[cfg(test)] mod tests { - use super::*; + use super::*; - fn cluster() -> ClusterRow { - ClusterRow { - id: 1, - name: "Test".into(), - folder_name: "test".into(), - setting_profile_name: None, - mc_version: "1.21.1".into(), - mc_loader: 0, - stage: 0, - mc_loader_version: None, - created_at: None, - last_played: None, - overall_played: None, - linked_modpack_hash: None, - } - } + fn cluster() -> ClusterRow { + ClusterRow { + id: 1, + name: "Test".into(), + folder_name: "test".into(), + setting_profile_name: None, + mc_version: "1.21.1".into(), + mc_loader: 0, + stage: 0, + mc_loader_version: None, + created_at: None, + last_played: None, + overall_played: None, + linked_modpack_hash: None, + kind: 0, + user_created: 0, + description: None, + tags: "[]".into(), + cover_path: None, + } + } - fn artifact(content_type: ContentType) -> ArtifactRow { - ArtifactRow { - hash: "abc".into(), - content_type: content_type as i64, - path: "packages/whatever".into(), - file_name: "thing".into(), - size_bytes: None, - } - } + fn artifact(content_type: ContentType) -> ArtifactRow { + ArtifactRow { + hash: "abc".into(), + content_type: content_type as i64, + path: "packages/whatever".into(), + file_name: "thing".into(), + size_bytes: None, + } + } - #[tokio::test] - async fn a_mod_is_never_added_to_a_running_game() { - assert_eq!( - try_link_materialized(&cluster(), &artifact(ContentType::Mod), "sodium.jar").await, - LiveSync::Skipped - ); - assert_eq!( - try_link_materialized(&cluster(), &artifact(ContentType::World), "world.zip").await, - LiveSync::Skipped - ); - } + #[tokio::test] + async fn a_mod_is_never_added_to_a_running_game() { + assert_eq!( + try_link_materialized(&cluster(), &artifact(ContentType::Mod), "sodium.jar").await, + LiveSync::Skipped + ); + assert_eq!( + try_link_materialized(&cluster(), &artifact(ContentType::World), "world.zip").await, + LiveSync::Skipped + ); + } - #[tokio::test] - async fn a_stale_staging_file_is_swept() { - let root = polyio::testing::ScratchDir::new("sweep_staging"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + #[tokio::test] + async fn a_stale_staging_file_is_swept() { + let root = polyio::testing::ScratchDir::new("sweep_staging"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - let orphan = staging_path(&dir.join("pack.zip")); - polyio::write(&orphan, b"half a copy".as_slice()).await.unwrap(); - polyio::write(dir.join("keep.zip"), b"pack".as_slice()) - .await - .unwrap(); + let orphan = staging_path(&dir.join("pack.zip")); + polyio::write(&orphan, b"half a copy".as_slice()) + .await + .unwrap(); + polyio::write(dir.join("keep.zip"), b"pack".as_slice()) + .await + .unwrap(); - sweep_staging_files(dir).await; + sweep_staging_files(dir).await; - assert!(polyio::symlink_metadata(&orphan).await.is_err()); - assert!(polyio::symlink_metadata(dir.join("keep.zip")).await.is_ok()); + assert!(polyio::symlink_metadata(&orphan).await.is_err()); + assert!(polyio::symlink_metadata(dir.join("keep.zip")).await.is_ok()); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[cfg(not(windows))] - #[tokio::test] - async fn remove_entry_clears_a_dangling_link() { - let root = polyio::testing::ScratchDir::new("dangling_link"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + #[cfg(not(windows))] + #[tokio::test] + async fn remove_entry_clears_a_dangling_link() { + let root = polyio::testing::ScratchDir::new("dangling_link"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - let target = dir.join("target.jar"); - let link = dir.join("link.jar"); - polyio::write(&target, b"jar".as_slice()).await.unwrap(); - polyio::symlink_file(&target, &link).await.unwrap(); - polyio::remove_file(&target).await.unwrap(); + let target = dir.join("target.jar"); + let link = dir.join("link.jar"); + polyio::write(&target, b"jar".as_slice()).await.unwrap(); + polyio::symlink_file(&target, &link).await.unwrap(); + polyio::remove_file(&target).await.unwrap(); - assert!(!link.exists(), "the link resolves to nothing"); - assert!( - polyio::symlink_metadata(&link).await.is_ok(), - "but the link itself is still there" - ); + assert!(!link.exists(), "the link resolves to nothing"); + assert!( + polyio::symlink_metadata(&link).await.is_ok(), + "but the link itself is still there" + ); - remove_entry(&link).await.unwrap(); - assert!(polyio::symlink_metadata(&link).await.is_err()); + remove_entry(&link).await.unwrap(); + assert!(polyio::symlink_metadata(&link).await.is_err()); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[cfg(windows)] - #[tokio::test] - async fn remove_entry_clears_a_hard_link() { - let root = polyio::testing::ScratchDir::new("hard_link"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + #[cfg(windows)] + #[tokio::test] + async fn remove_entry_clears_a_hard_link() { + let root = polyio::testing::ScratchDir::new("hard_link"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - let target = dir.join("target.jar"); - let link = dir.join("link.jar"); - polyio::write(&target, b"jar".as_slice()).await.unwrap(); - polyio::symlink_file(&target, &link).await.unwrap(); - polyio::remove_file(&target).await.unwrap(); + let target = dir.join("target.jar"); + let link = dir.join("link.jar"); + polyio::write(&target, b"jar".as_slice()).await.unwrap(); + polyio::symlink_file(&target, &link).await.unwrap(); + polyio::remove_file(&target).await.unwrap(); - assert!(link.exists(), "the link is still holding the file"); + assert!(link.exists(), "the link is still holding the file"); - remove_entry(&link).await.unwrap(); - assert!(polyio::symlink_metadata(&link).await.is_err()); + remove_entry(&link).await.unwrap(); + assert!(polyio::symlink_metadata(&link).await.is_err()); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[tokio::test] - async fn replacing_a_pack_leaves_only_the_pack() { - let root = polyio::testing::ScratchDir::new("atomic_replace"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + #[tokio::test] + async fn replacing_a_pack_leaves_only_the_pack() { + let root = polyio::testing::ScratchDir::new("atomic_replace"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - let old = dir.join("old.zip"); - let new = dir.join("new.zip"); - polyio::write(&old, b"old".as_slice()).await.unwrap(); - polyio::write(&new, b"new".as_slice()).await.unwrap(); + let old = dir.join("old.zip"); + let new = dir.join("new.zip"); + polyio::write(&old, b"old".as_slice()).await.unwrap(); + polyio::write(&new, b"new".as_slice()).await.unwrap(); - let packs = dir.join("resourcepacks"); - let dest = packs.join("pack.zip"); + let packs = dir.join("resourcepacks"); + let dest = packs.join("pack.zip"); - link_or_copy(&old, &dest).await.unwrap(); - link_or_copy(&new, &dest).await.unwrap(); + link_or_copy(&old, &dest).await.unwrap(); + link_or_copy(&new, &dest).await.unwrap(); - assert_eq!(polyio::read_to_string(&dest).await.unwrap(), "new"); + assert_eq!(polyio::read_to_string(&dest).await.unwrap(), "new"); - let mut names = Vec::new(); - let mut entries = polyio::read_dir(&packs).await.unwrap(); - while let Ok(Some(entry)) = entries.next_entry().await { - names.push(entry.file_name().to_string_lossy().into_owned()); - } + let mut names = Vec::new(); + let mut entries = polyio::read_dir(&packs).await.unwrap(); + while let Ok(Some(entry)) = entries.next_entry().await { + names.push(entry.file_name().to_string_lossy().into_owned()); + } - assert_eq!(names, vec!["pack.zip".to_string()]); + assert_eq!(names, vec!["pack.zip".to_string()]); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - /// A crash between the write and the rename must not wedge the next attempt - #[tokio::test] - async fn a_stale_staging_file_is_cleared() { - let root = polyio::testing::ScratchDir::new("stale_staging"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + /// A crash between the write and the rename must not wedge the next attempt + #[tokio::test] + async fn a_stale_staging_file_is_cleared() { + let root = polyio::testing::ScratchDir::new("stale_staging"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - let src = dir.join("src.zip"); - let dest = dir.join("pack.zip"); - polyio::write(&src, b"real".as_slice()).await.unwrap(); - polyio::write(staging_path(&dest), b"junk".as_slice()) - .await - .unwrap(); + let src = dir.join("src.zip"); + let dest = dir.join("pack.zip"); + polyio::write(&src, b"real".as_slice()).await.unwrap(); + polyio::write(staging_path(&dest), b"junk".as_slice()) + .await + .unwrap(); - link_or_copy(&src, &dest).await.unwrap(); + link_or_copy(&src, &dest).await.unwrap(); - assert_eq!(polyio::read_to_string(&dest).await.unwrap(), "real"); - assert!( - polyio::symlink_metadata(staging_path(&dest)).await.is_err(), - "the staging file is consumed by the rename" - ); + assert_eq!(polyio::read_to_string(&dest).await.unwrap(), "real"); + assert!( + polyio::symlink_metadata(staging_path(&dest)).await.is_err(), + "the staging file is consumed by the rename" + ); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[tokio::test] - async fn remove_entry_is_fine_with_nothing_there() { - let root = polyio::testing::ScratchDir::new("remove_missing"); - polyio::create_dir_all(root.path()).await.unwrap(); + #[tokio::test] + async fn remove_entry_is_fine_with_nothing_there() { + let root = polyio::testing::ScratchDir::new("remove_missing"); + polyio::create_dir_all(root.path()).await.unwrap(); - remove_entry(&root.join("never_existed.jar")).await.unwrap(); + remove_entry(&root.join("never_existed.jar")).await.unwrap(); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } } diff --git a/packages/oneclient_content/src/packages/store/manifest.rs b/packages/oneclient_content/src/packages/store/manifest.rs index ec7bbe5e..a2250555 100644 --- a/packages/oneclient_content/src/packages/store/manifest.rs +++ b/packages/oneclient_content/src/packages/store/manifest.rs @@ -9,184 +9,188 @@ pub const GLOBAL_MANIFEST_NAME: &str = ".oneclient-global.json"; const MANIFEST_VERSION: u32 = 1; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ManifestEntry { - pub path: String, - pub hash: String, + pub path: String, + pub hash: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MaterializedManifest { - pub version: u32, - pub cluster_id: i64, - pub entries: Vec, + pub version: u32, + pub cluster_id: i64, + pub entries: Vec, } impl MaterializedManifest { - #[must_use] - pub fn new(cluster_id: i64, entries: Vec) -> Self { - Self { - version: MANIFEST_VERSION, - cluster_id, - entries, - } - } + #[must_use] + pub fn new(cluster_id: i64, entries: Vec) -> Self { + Self { + version: MANIFEST_VERSION, + cluster_id, + entries, + } + } - #[must_use] - pub fn paths(&self) -> HashSet<&str> { - self.entries.iter().map(|e| e.path.as_str()).collect() - } + #[must_use] + pub fn paths(&self) -> HashSet<&str> { + self.entries.iter().map(|e| e.path.as_str()).collect() + } - #[must_use] - pub fn contains(&self, relative_path: &str) -> bool { - self.entries.iter().any(|e| e.path == relative_path) - } + #[must_use] + pub fn contains(&self, relative_path: &str) -> bool { + self.entries.iter().any(|e| e.path == relative_path) + } - /// The cluster check is what makes deleting out of the shared game directory safe - #[must_use] - pub fn owns(&self, cluster_id: i64, relative_path: &str) -> bool { - self.cluster_id == cluster_id && self.contains(relative_path) - } + /// The cluster check is what makes deleting out of the shared game directory safe + #[must_use] + pub fn owns(&self, cluster_id: i64, relative_path: &str) -> bool { + self.cluster_id == cluster_id && self.contains(relative_path) + } } #[must_use] pub fn manifest_path(dir: &Path, name: &str) -> PathBuf { - dir.join(name) + dir.join(name) } /// An unparseable manifest reads as `None` not an error /// stashing links as user content is recoverable refusing to launch is not pub async fn load(dir: &Path, name: &str) -> Option { - let raw = polyio::read_to_string(manifest_path(dir, name)).await.ok()?; + let raw = polyio::read_to_string(manifest_path(dir, name)) + .await + .ok()?; - match serde_json::from_str::(&raw) { - Ok(manifest) if manifest.version == MANIFEST_VERSION => Some(manifest), - Ok(manifest) => { - tracing::warn!( - version = manifest.version, - "ignoring materialized manifest written by a different launcher version" - ); - None - } - Err(err) => { - tracing::warn!(error = %err, "materialized manifest is unreadable; ignoring"); - None - } - } + match serde_json::from_str::(&raw) { + Ok(manifest) if manifest.version == MANIFEST_VERSION => Some(manifest), + Ok(manifest) => { + tracing::warn!( + version = manifest.version, + "ignoring materialized manifest written by a different launcher version" + ); + None + } + Err(err) => { + tracing::warn!(error = %err, "materialized manifest is unreadable; ignoring"); + None + } + } } pub async fn save(dir: &Path, name: &str, manifest: &MaterializedManifest) { - let body = match serde_json::to_vec_pretty(manifest) { - Ok(body) => body, - Err(err) => { - tracing::warn!(error = %err, "failed to encode materialized manifest"); - return; - } - }; + let body = match serde_json::to_vec_pretty(manifest) { + Ok(body) => body, + Err(err) => { + tracing::warn!(error = %err, "failed to encode materialized manifest"); + return; + } + }; - if let Err(err) = polyio::write(manifest_path(dir, name), body).await { - tracing::warn!(error = %err, "failed to write materialized manifest"); - } + if let Err(err) = polyio::write(manifest_path(dir, name), body).await { + tracing::warn!(error = %err, "failed to write materialized manifest"); + } } pub async fn clear(dir: &Path, name: &str) { - polyio::remove_file(manifest_path(dir, name)).await.ok(); + polyio::remove_file(manifest_path(dir, name)).await.ok(); } static MANIFEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); pub async fn lock() -> tokio::sync::MutexGuard<'static, ()> { - MANIFEST_LOCK.lock().await + MANIFEST_LOCK.lock().await } #[must_use] pub fn entry_path(content_folder: &str, file_name: &str) -> String { - format!("{content_folder}/{file_name}") + format!("{content_folder}/{file_name}") } // whether this cluster keeps its mods in its own folder rather than in the game directory pub async fn mods_live_in_cluster(cluster_dir: &Path) -> bool { - load(cluster_dir, MODS_MANIFEST_NAME).await.is_some() + load(cluster_dir, MODS_MANIFEST_NAME).await.is_some() } #[cfg(test)] mod tests { - use super::*; + use super::*; - fn manifest() -> MaterializedManifest { - MaterializedManifest::new( - 7, - vec![ManifestEntry { - path: "mods/sodium.jar".into(), - hash: "abc".into(), - }], - ) - } + fn manifest() -> MaterializedManifest { + MaterializedManifest::new( + 7, + vec![ManifestEntry { + path: "mods/sodium.jar".into(), + hash: "abc".into(), + }], + ) + } - #[test] - fn ownership_is_scoped_to_the_cluster_that_wrote_it() { - let manifest = manifest(); + #[test] + fn ownership_is_scoped_to_the_cluster_that_wrote_it() { + let manifest = manifest(); - assert!(manifest.owns(7, "mods/sodium.jar")); - assert!(!manifest.owns(8, "mods/sodium.jar")); - assert!(!manifest.owns(7, "mods/iris.jar")); - } + assert!(manifest.owns(7, "mods/sodium.jar")); + assert!(!manifest.owns(8, "mods/sodium.jar")); + assert!(!manifest.owns(7, "mods/iris.jar")); + } - #[tokio::test] - async fn round_trips_through_the_game_dir() { - let root = polyio::testing::ScratchDir::new("manifest_round_trip"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + #[tokio::test] + async fn round_trips_through_the_game_dir() { + let root = polyio::testing::ScratchDir::new("manifest_round_trip"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - assert!(load(dir, MANIFEST_NAME).await.is_none(), "no manifest yet"); + assert!(load(dir, MANIFEST_NAME).await.is_none(), "no manifest yet"); - save(dir, MANIFEST_NAME, &manifest()).await; - let loaded = load(dir, MANIFEST_NAME).await.expect("manifest should load"); - assert_eq!(loaded.cluster_id, 7); - assert!(loaded.contains("mods/sodium.jar")); + save(dir, MANIFEST_NAME, &manifest()).await; + let loaded = load(dir, MANIFEST_NAME) + .await + .expect("manifest should load"); + assert_eq!(loaded.cluster_id, 7); + assert!(loaded.contains("mods/sodium.jar")); - clear(dir, MANIFEST_NAME).await; - assert!( - load(dir, MANIFEST_NAME).await.is_none(), - "cleared manifest should be gone" - ); + clear(dir, MANIFEST_NAME).await; + assert!( + load(dir, MANIFEST_NAME).await.is_none(), + "cleared manifest should be gone" + ); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[tokio::test] - async fn the_two_manifests_do_not_collide() { - let root = polyio::testing::ScratchDir::new("manifest_two_names"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + #[tokio::test] + async fn the_two_manifests_do_not_collide() { + let root = polyio::testing::ScratchDir::new("manifest_two_names"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - save(dir, MODS_MANIFEST_NAME, &manifest()).await; + save(dir, MODS_MANIFEST_NAME, &manifest()).await; - assert!( - load(dir, MODS_MANIFEST_NAME).await.is_some(), - "the mods manifest is there" - ); - assert!( - load(dir, MANIFEST_NAME).await.is_none(), - "and it is not mistaken for the game-dir one" - ); + assert!( + load(dir, MODS_MANIFEST_NAME).await.is_some(), + "the mods manifest is there" + ); + assert!( + load(dir, MANIFEST_NAME).await.is_none(), + "and it is not mistaken for the game-dir one" + ); - clear(dir, MODS_MANIFEST_NAME).await; + clear(dir, MODS_MANIFEST_NAME).await; - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } - #[tokio::test] - async fn garbage_manifest_reads_as_absent() { - let root = polyio::testing::ScratchDir::new("manifest_garbage"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); - polyio::write(manifest_path(dir, MANIFEST_NAME), b"not json".as_slice()) - .await - .unwrap(); + #[tokio::test] + async fn garbage_manifest_reads_as_absent() { + let root = polyio::testing::ScratchDir::new("manifest_garbage"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + polyio::write(manifest_path(dir, MANIFEST_NAME), b"not json".as_slice()) + .await + .unwrap(); - assert!(load(dir, MANIFEST_NAME).await.is_none()); + assert!(load(dir, MANIFEST_NAME).await.is_none()); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } } diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs index 35f58a1f..3ce59eaf 100644 --- a/packages/oneclient_content/src/packages/store/mod.rs +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -10,8 +10,8 @@ pub use gc::{ remove_unreferenced_files, }; pub use link::{ - LiveSync, link_or_copy, remove_entry, sweep_staging_files, try_link_materialized, - try_unlink_materialized, + LiveSync, drop_unmanaged_mod, link_or_copy, remove_entry, sweep_staging_files, + try_link_materialized, try_unlink_materialized, }; pub use paths::{artifact_absolute_path, cache_file_path, relative_cache_path}; @@ -23,15 +23,17 @@ use oneclient_db::models::{ArtifactRow, ClusterRow, SeenStatus}; use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; // `paths` alone is this module's own cache-path helpers -use oneclient_common::paths as common_paths; use super::error::PackageError; use super::file_identity::FileIdentity; +use super::types::{ + CachedArtifact, LinkedArtifactInfo, ProjectDetail, ProviderReleaseInfo, VersionDetail, +}; use super::{local_manifest, metadata_cache}; -use super::types::{CachedArtifact, ProjectDetail, ProviderReleaseInfo, VersionDetail, LinkedArtifactInfo}; -use polyio::{normalize_hash, sha1_file}; -use oneclient_events::GroupedProgressChild; use crate::ctx::ContentCtx; use crate::error::{ContentError, ContentResult}; +use oneclient_common::paths as common_paths; +use oneclient_events::GroupedProgressChild; +use polyio::{normalize_hash, sha1_file}; use std::path::{Path, PathBuf}; pub struct PackageStore; @@ -46,10 +48,7 @@ pub struct LocalImportReport { impl PackageStore { #[tracing::instrument(level = "debug", skip(ctx))] - pub async fn get_cluster( - cluster_id: i64, - ctx: &ContentCtx, - ) -> ContentResult { + pub async fn get_cluster(cluster_id: i64, ctx: &ContentCtx) -> ContentResult { cluster_dao::get_by_id(&ctx.db, cluster_id) .await? .ok_or(PackageError::ClusterNotFound(cluster_id).into()) @@ -91,7 +90,7 @@ impl PackageStore { .await } - #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments)] #[tracing::instrument(skip(project, version, child, ctx), fields(project_id = %project.id, version_id = %version.version_id))] pub async fn install_to_cluster( provider_id: ProviderId, @@ -110,15 +109,9 @@ impl PackageStore { ensure_compatible(project, version, &cluster)?; } - let artifact = Self::download_and_cache( - provider_id, - project, - version, - force_download, - child, - ctx, - ) - .await?; + let artifact = + Self::download_and_cache(provider_id, project, version, force_download, child, ctx) + .await?; let enabled = Self::link_artifact(&artifact, &cluster, None, ctx).await?; @@ -181,11 +174,7 @@ impl PackageStore { /// `packages` must not depend on `bundles` and its override reconciliation /// does not apply here #[tracing::instrument(level = "debug", skip(ctx))] - async fn unlink_superseded( - cluster_id: i64, - hash: &str, - ctx: &ContentCtx, - ) -> ContentResult<()> { + async fn unlink_superseded(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult<()> { let cluster = Self::get_cluster(cluster_id, ctx).await?; let content_type = artifact_dao::get_artifact_by_hash(&ctx.db, hash) .await? @@ -194,8 +183,19 @@ impl PackageStore { artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; + if let (Some(content_type), Some(link)) = (content_type, &link) { + let name = &link.cluster_file_name; + drop_unmanaged_mod(&cluster, content_type, name, hash, &ctx.db).await; + } + if let (Some(content_type), Some(link)) = (content_type, link) - && link::try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await + && link::try_unlink_materialized( + &cluster, + content_type, + &link.cluster_file_name, + &ctx.db, + ) + .await == LiveSync::Deferred { return Ok(()); @@ -340,9 +340,11 @@ impl PackageStore { .await? .ok_or(PackageError::ArtifactMissing(hash.to_string()))?; - let content_type = ContentType::from_repr(artifact.content_type as u8) - .ok_or_else(|| ContentError::InvalidData { - reason: format!("unknown content type {}", artifact.content_type), + let content_type = + ContentType::from_repr(artifact.content_type as u8).ok_or_else(|| { + ContentError::InvalidData { + reason: format!("unknown content type {}", artifact.content_type), + } })?; let enabled = target.unwrap_or(link.enabled == 0); @@ -360,7 +362,7 @@ impl PackageStore { ) .await?; - if content_type.is_global() { + if link::shares_content(&cluster, content_type) { artifact_dao::set_enabled_for_hash(&ctx.db, hash, i64::from(enabled)).await?; } @@ -369,9 +371,10 @@ impl PackageStore { let live = if enabled { link::try_link_materialized(&cluster, &artifact, &file_name).await } else { - link::try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; + link::try_unlink_materialized(&cluster, content_type, &link.cluster_file_name, &ctx.db) + .await; if link.cluster_file_name != file_name { - link::try_unlink_materialized(&cluster, content_type, &file_name).await; + link::try_unlink_materialized(&cluster, content_type, &file_name, &ctx.db).await; } LiveSync::Skipped }; @@ -437,9 +440,7 @@ impl PackageStore { ) -> ContentResult { let provider = ctx.providers.get(provider_id)?; - let version = provider - .get_version(project_id, version_id, ctx) - .await?; + let version = provider.get_version(project_id, version_id, ctx).await?; let project = provider.get_project(project_id, ctx).await?; @@ -454,6 +455,18 @@ async fn store_local_file( content_type: ContentType, cluster: &ClusterRow, ctx: &ContentCtx, +) -> ContentResult { + ensure_takes_mods(content_type, cluster)?; + + let row = cache_local_file(path, content_type, ctx).await?; + PackageStore::link_artifact(&row, cluster, None, ctx).await?; + Ok(row) +} + +pub async fn cache_local_file( + path: &Path, + content_type: ContentType, + ctx: &ContentCtx, ) -> ContentResult { let file_name = path .file_name() @@ -463,8 +476,9 @@ async fn store_local_file( let hash = normalize_hash(&sha1_file(path).await?); - if let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, &hash).await? { - PackageStore::link_artifact(&row, cluster, None, ctx).await?; + if let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, &hash).await? + && artifact_absolute_path(&row.path)?.exists() + { return Ok(row); } @@ -483,7 +497,7 @@ async fn store_local_file( let size = polyio::stat(&dest).await?.len(); let stored_path = relative_cache_path(&dest)?; - let row = artifact_dao::insert_artifact( + artifact_dao::insert_artifact( &ctx.db, &hash, content_type as i64, @@ -491,10 +505,8 @@ async fn store_local_file( &file_name, Some(size as i64), ) - .await?; - - PackageStore::link_artifact(&row, cluster, None, ctx).await?; - Ok(row) + .await + .map_err(Into::into) } /// A dropped file arrives as a file name and nothing else so it is worth @@ -657,10 +669,7 @@ async fn store_local_icon(hash: &str, jar: &std::path::Path, entry: &str) -> Opt } #[tracing::instrument(level = "debug", skip(row, ctx), fields(hash = %row.hash))] -async fn row_to_cached( - row: ArtifactRow, - ctx: &ContentCtx, -) -> ContentResult { +async fn row_to_cached(row: ArtifactRow, ctx: &ContentCtx) -> ContentResult { let path = artifact_absolute_path(&row.path)?; let release = artifact_dao::get_release_by_hash(&ctx.db, &row.hash) .await? @@ -689,6 +698,8 @@ fn ensure_compatible( version: &VersionDetail, cluster: &ClusterRow, ) -> ContentResult<()> { + ensure_takes_mods(project.content_type, cluster)?; + if project.provider == ProviderId::Local { return Ok(()); } @@ -717,6 +728,17 @@ fn ensure_compatible( Ok(()) } +fn ensure_takes_mods(content_type: ContentType, cluster: &ClusterRow) -> ContentResult<()> { + let vanilla = GameLoader::from_repr(cluster.mc_loader as u8) + .is_none_or(|loader| loader == GameLoader::Vanilla); + + if content_type == ContentType::Mod && vanilla && cluster.is_isolated() { + return Err(PackageError::IncompatibleLoader.into()); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -777,6 +799,11 @@ mod tests { last_played: None, overall_played: None, linked_modpack_hash: None, + kind: 0, + user_created: 0, + description: None, + tags: "[]".into(), + cover_path: None, } } diff --git a/packages/oneclient_content/src/packages/types.rs b/packages/oneclient_content/src/packages/types.rs index d1cc044b..141aabee 100644 --- a/packages/oneclient_content/src/packages/types.rs +++ b/packages/oneclient_content/src/packages/types.rs @@ -11,238 +11,265 @@ pub const DEFAULT_PAGE_SIZE: usize = 24; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SearchFilters { - pub query: Option, - pub offset: Option, - pub limit: Option, - pub content_type: Option, - pub game_versions: Option>, - pub loaders: Option>, - pub categories: Option>, - pub sort: Option, + pub query: Option, + pub offset: Option, + pub limit: Option, + pub content_type: Option, + pub game_versions: Option>, + pub loaders: Option>, + pub categories: Option>, + pub sort: Option, } impl SearchFilters { - /// Providers rank the raw string so " sodium " and "sodium" score differently - #[must_use] - pub fn normalized_query(&self) -> String { - self.query.as_deref().map(normalize_query).unwrap_or_default() - } + /// Providers rank the raw string so " sodium " and "sodium" score differently + #[must_use] + pub fn normalized_query(&self) -> String { + self.query + .as_deref() + .map(normalize_query) + .unwrap_or_default() + } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum SearchSort { - #[default] - Relevance, - Downloads, - Newest, - Updated, + #[default] + Relevance, + Downloads, + Newest, + Updated, } impl SearchSort { - pub fn modrinth_index(self) -> &'static str { - match self { - Self::Relevance => "relevance", - Self::Downloads => "downloads", - Self::Newest => "newest", - Self::Updated => "updated", - } - } + pub fn modrinth_index(self) -> &'static str { + match self { + Self::Relevance => "relevance", + Self::Downloads => "downloads", + Self::Newest => "newest", + Self::Updated => "updated", + } + } + + pub fn curseforge_sort_field(self) -> u32 { + match self { + Self::Relevance => 1, + Self::Downloads => 6, + Self::Newest => 11, + Self::Updated => 3, + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Page { - pub offset: usize, - pub limit: usize, - pub total: usize, - pub items: Vec, + pub offset: usize, + pub limit: usize, + pub total: usize, + pub items: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ProjectSummary { - pub id: String, - pub slug: String, - pub provider: ProviderId, - pub content_type: ContentType, - pub name: String, - pub summary: String, - pub author: String, - pub icon_url: Option, - pub downloads: u64, - pub created: DateTime, - pub updated: DateTime, - pub loaders: Vec, - pub game_versions: Vec, + pub id: String, + pub slug: String, + pub provider: ProviderId, + pub content_type: ContentType, + pub name: String, + pub summary: String, + pub author: String, + pub icon_url: Option, + #[serde(default)] + pub banner_url: Option, + pub downloads: u64, + pub created: DateTime, + pub updated: DateTime, + pub loaders: Vec, + pub game_versions: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum PackageBody { - Url(String), - Raw(String), + Url(String), + Raw(String), } impl PackageBody { - pub fn raw(markdown: impl AsRef) -> Self { - Self::Raw(crate::packages::markdown::normalize_markdown(markdown.as_ref())) - } + pub fn raw(markdown: impl AsRef) -> Self { + Self::Raw(crate::packages::markdown::normalize_markdown( + markdown.as_ref(), + )) + } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectMember { - pub name: String, - pub role: String, - pub url: Option, - pub avatar_url: Option, + pub name: String, + pub role: String, + pub url: Option, + pub avatar_url: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GalleryImage { - pub url: String, - pub title: Option, + pub url: String, + pub title: Option, + #[serde(default)] + pub featured: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectDetail { - pub id: String, - pub slug: String, - pub provider: ProviderId, - pub content_type: ContentType, - pub name: String, - pub summary: String, - pub author: String, - pub members: Vec, - pub gallery: Vec, - pub body: PackageBody, - pub license: Option, - pub links: Vec<(String, String)>, - pub version_ids: Vec, - pub game_versions: Vec, - pub loaders: Vec, - pub icon_url: Option, - pub created: DateTime, - pub updated: DateTime, - pub downloads: u64, + pub id: String, + pub slug: String, + pub provider: ProviderId, + pub content_type: ContentType, + pub name: String, + pub summary: String, + pub author: String, + pub members: Vec, + pub gallery: Vec, + pub body: PackageBody, + pub license: Option, + pub links: Vec<(String, String)>, + pub version_ids: Vec, + pub game_versions: Vec, + pub loaders: Vec, + pub icon_url: Option, + pub created: DateTime, + pub updated: DateTime, + pub downloads: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VersionSummary { - pub version_id: String, - pub project_id: String, - pub name: String, - pub version_number: String, - pub published: DateTime, - pub release_type: ReleaseType, - pub game_versions: Vec, - pub loaders: Vec, - pub downloads: u64, - pub file_size: u64, + pub version_id: String, + pub project_id: String, + pub name: String, + pub version_number: String, + pub published: DateTime, + pub release_type: ReleaseType, + pub game_versions: Vec, + pub loaders: Vec, + pub downloads: u64, + pub file_size: u64, + #[serde(default)] + pub dependencies: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ReleaseType { - #[default] - Release, - Beta, - Alpha, + #[default] + Release, + Beta, + Alpha, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VersionDetail { - pub version_id: String, - pub project_id: String, - pub name: String, - pub version_number: String, - pub changelog: Option, - pub game_versions: Vec, - pub loaders: Vec, - pub published: DateTime, - pub downloads: u64, - pub files: Vec, - /// Only [`DependencyKind::Required`] entries are pulled in automatically - #[serde(default)] - pub dependencies: Vec, + pub version_id: String, + pub project_id: String, + pub name: String, + pub version_number: String, + pub changelog: Option, + pub game_versions: Vec, + pub loaders: Vec, + pub published: DateTime, + pub downloads: u64, + pub files: Vec, + /// Only [`DependencyKind::Required`] entries are pulled in automatically + #[serde(default)] + pub dependencies: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum DependencyKind { - Required, - Optional, - Incompatible, - Embedded, + Required, + Optional, + Incompatible, + Embedded, } /// At least one id is set Modrinth may pin a version CurseForge only names the project #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct VersionDependency { - pub project_id: Option, - pub version_id: Option, - pub kind: DependencyKind, + pub project_id: Option, + pub version_id: Option, + pub kind: DependencyKind, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VersionFile { - pub sha1: String, - pub url: String, - pub file_name: String, - pub primary: bool, - pub size: u64, - pub fingerprint: Option, + pub sha1: String, + pub url: String, + pub file_name: String, + pub primary: bool, + pub size: u64, + pub fingerprint: Option, } impl VersionDetail { - pub fn primary_file(&self) -> Option<&VersionFile> { - self.files.iter().find(|f| f.primary).or(self.files.first()) - } + pub fn primary_file(&self) -> Option<&VersionFile> { + self.files.iter().find(|f| f.primary).or(self.files.first()) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ExternalFile { - pub name: String, - pub url: String, - pub sha1: String, - pub size: u64, - pub content_type: ContentType, + pub name: String, + pub url: String, + pub sha1: String, + pub size: u64, + pub content_type: ContentType, } #[derive(Debug, Clone)] pub struct CachedArtifact { - pub hash: String, - pub content_type: ContentType, - pub path: std::path::PathBuf, - pub file_name: String, - pub size_bytes: Option, - pub release: Option, + pub hash: String, + pub content_type: ContentType, + pub path: std::path::PathBuf, + pub file_name: String, + pub size_bytes: Option, + pub release: Option, } #[derive(Debug, Clone)] pub struct ProviderReleaseInfo { - pub provider: ProviderId, - pub project_id: String, - pub version_id: String, - pub display_name: String, - pub display_version: String, - pub mc_versions: Vec, - pub loaders: Vec, + pub provider: ProviderId, + pub project_id: String, + pub version_id: String, + pub display_name: String, + pub display_version: String, + pub mc_versions: Vec, + pub loaders: Vec, } pub type VersionLookup = HashMap; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstalledPackage { + pub hash: String, + pub project_id: String, + pub content_type: ContentType, +} pub type ProviderVersionLookup = HashMap; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LinkedArtifactInfo { - pub hash: String, - pub cluster_file_name: String, - pub enabled: bool, - pub content_type: ContentType, - pub file_name: String, - pub project_id: Option, - pub version_id: Option, - pub display_name: Option, - pub display_version: Option, - pub provider: Option, - /// Provider publish time RFC 3339 - pub published_at: Option, - /// Drives the new/updated badge in the package manager - pub seen_status: SeenStatus, + pub hash: String, + pub cluster_file_name: String, + pub enabled: bool, + pub content_type: ContentType, + pub file_name: String, + pub project_id: Option, + pub version_id: Option, + pub display_name: Option, + pub display_version: Option, + pub provider: Option, + /// Provider publish time RFC 3339 + pub published_at: Option, + /// Drives the new/updated badge in the package manager + pub seen_status: SeenStatus, } diff --git a/packages/oneclient_content/src/packages/updates.rs b/packages/oneclient_content/src/packages/updates.rs index 771913f1..e7effafc 100644 --- a/packages/oneclient_content/src/packages/updates.rs +++ b/packages/oneclient_content/src/packages/updates.rs @@ -10,7 +10,7 @@ use std::collections::HashSet; use chrono::{DateTime, Utc}; use futures_util::StreamExt; -use oneclient_common::domain::ProviderId; +use oneclient_common::domain::{ContentType, ProviderId}; use oneclient_db::dao::artifact as artifact_dao; use oneclient_db::dao::browser_package_update as update_dao; use oneclient_db::dao::cluster_bundle as bundle_dao; @@ -29,281 +29,301 @@ const UPDATE_CHECK_CONCURRENCY: usize = 6; #[derive(Debug, Clone, PartialEq, Eq)] pub struct BrowserPackageUpdate { - pub cluster_id: i64, - /// The artifact currently linked into the cluster - pub hash: String, - pub provider: ProviderId, - pub project_id: String, - pub installed_version_id: String, - pub installed_version_name: String, - pub latest_version_id: String, - pub latest_version_name: String, - /// Local fallback for a UI with no meta cache entry - pub display_name: String, - /// The user has already declined this exact newer version - pub skipped: bool, + pub cluster_id: i64, + /// The artifact currently linked into the cluster + pub hash: String, + pub provider: ProviderId, + pub project_id: String, + pub installed_version_id: String, + pub installed_version_name: String, + pub latest_version_id: String, + pub latest_version_name: String, + /// Local fallback for a UI with no meta cache entry + pub display_name: String, + /// The user has already declined this exact newer version + pub skipped: bool, } impl BrowserPackageUpdate { - fn from_row(row: BrowserPackageUpdateRow) -> Self { - Self { - cluster_id: row.cluster_id, - hash: row.hash, - provider: ProviderId::from_repr(row.provider as u8).unwrap_or(ProviderId::Modrinth), - project_id: row.project_id, - installed_version_id: row.installed_version_id, - installed_version_name: row.installed_version_name, - latest_version_id: row.latest_version_id, - latest_version_name: row.latest_version_name, - display_name: row.display_name, - skipped: row.skipped != 0, - } - } + fn from_row(row: BrowserPackageUpdateRow) -> Self { + Self { + cluster_id: row.cluster_id, + hash: row.hash, + provider: ProviderId::from_repr(row.provider as u8).unwrap_or(ProviderId::Modrinth), + project_id: row.project_id, + installed_version_id: row.installed_version_id, + installed_version_name: row.installed_version_name, + latest_version_id: row.latest_version_id, + latest_version_name: row.latest_version_name, + display_name: row.display_name, + skipped: row.skipped != 0, + } + } } #[derive(Debug, Clone, Default)] pub struct BrowserUpdateCheck { - pub cluster_id: i64, - pub updates: Vec, - /// Unreachable this run - /// their cached rows are preserved not cleared - pub unchecked: Vec, + pub cluster_id: i64, + pub updates: Vec, + /// Unreachable this run + /// their cached rows are preserved not cleared + pub unchecked: Vec, } impl BrowserUpdateCheck { - #[must_use] - pub fn pending(&self) -> Vec { - self.updates.iter().filter(|u| !u.skipped).cloned().collect() - } + #[must_use] + pub fn pending(&self) -> Vec { + self.updates + .iter() + .filter(|u| !u.skipped) + .cloned() + .collect() + } } -struct Candidate { - hash: String, - provider: ProviderId, - project_id: String, - version_id: String, - display_name: String, - display_version: String, - published_at: Option>, +pub(crate) struct Candidate { + pub(crate) hash: String, + pub(crate) provider: ProviderId, + pub(crate) content_type: ContentType, + pub(crate) project_id: String, + pub(crate) version_id: String, + pub(crate) display_name: String, + pub(crate) display_version: String, + pub(crate) published_at: Option>, + pub(crate) enabled: bool, } /// A missing bundle tracking row is the marker for "the user added this themselves" -fn browser_installed( - linked: &[LinkedArtifactInfo], - bundle_hashes: &HashSet, - bundle_projects: &HashSet, +pub(crate) fn browser_installed( + linked: &[LinkedArtifactInfo], + bundle_hashes: &HashSet, + bundle_projects: &HashSet, ) -> Vec { - linked - .iter() - .filter(|info| info.enabled) - .filter(|info| !bundle_hashes.contains(&info.hash)) - .filter(|info| { - info.project_id - .as_ref() - .is_none_or(|id| !bundle_projects.contains(id)) - }) - .filter_map(|info| { - let provider = info.provider?; - if provider == ProviderId::Local { - return None; - } - Some(Candidate { - hash: info.hash.clone(), - provider, - project_id: info.project_id.clone()?, - version_id: info.version_id.clone()?, - display_name: info - .display_name - .clone() - .unwrap_or_else(|| info.file_name.clone()), - display_version: info.display_version.clone().unwrap_or_default(), - published_at: info.published_at.as_deref().and_then(parse_published), - }) - }) - .collect() + browser_installed_any(linked, bundle_hashes, bundle_projects) + .into_iter() + .filter(|candidate| candidate.enabled) + .collect() +} + +pub(crate) fn browser_installed_any( + linked: &[LinkedArtifactInfo], + bundle_hashes: &HashSet, + bundle_projects: &HashSet, +) -> Vec { + linked + .iter() + .filter(|info| !bundle_hashes.contains(&info.hash)) + .filter(|info| { + info.project_id + .as_ref() + .is_none_or(|id| !bundle_projects.contains(id)) + }) + .filter_map(|info| { + let provider = info.provider?; + if provider == ProviderId::Local { + return None; + } + Some(Candidate { + hash: info.hash.clone(), + provider, + content_type: info.content_type, + project_id: info.project_id.clone()?, + version_id: info.version_id.clone()?, + display_name: info + .display_name + .clone() + .unwrap_or_else(|| info.file_name.clone()), + display_version: info.display_version.clone().unwrap_or_default(), + published_at: info.published_at.as_deref().and_then(parse_published), + enabled: info.enabled, + }) + }) + .collect() } /// An unreachable project lands in [`BrowserUpdateCheck::unchecked`] /// only an unreadable cluster errors #[tracing::instrument(level = "debug", skip(ctx))] pub async fn check_browser_package_updates( - cluster_id: i64, - ctx: &ContentCtx, + cluster_id: i64, + ctx: &ContentCtx, ) -> ContentResult { - let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; - let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; - let tracked = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; - let bundle_projects: HashSet = - tracked.iter().filter_map(|row| row.package_id.clone()).collect(); - let bundle_hashes: HashSet = tracked.into_iter().map(|row| row.hash).collect(); + let tracked = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + let bundle_projects: HashSet = tracked + .iter() + .filter_map(|row| row.package_id.clone()) + .collect(); + let bundle_hashes: HashSet = tracked.into_iter().map(|row| row.hash).collect(); - let candidates = browser_installed(&linked, &bundle_hashes, &bundle_projects); - if candidates.is_empty() { - return Ok(BrowserUpdateCheck { - cluster_id, - ..Default::default() - }); - } + let candidates = browser_installed(&linked, &bundle_hashes, &bundle_projects); + if candidates.is_empty() { + return Ok(BrowserUpdateCheck { + cluster_id, + ..Default::default() + }); + } - let skipped: HashSet = update_dao::list_for_cluster(&ctx.db, cluster_id) - .await? - .into_iter() - .filter(|row| row.skipped != 0) - .map(|row| format!("{}:{}", row.hash, row.latest_version_id)) - .collect(); + let skipped: HashSet = update_dao::list_for_cluster(&ctx.db, cluster_id) + .await? + .into_iter() + .filter(|row| row.skipped != 0) + .map(|row| format!("{}:{}", row.hash, row.latest_version_id)) + .collect(); - let results = futures_util::stream::iter(candidates.into_iter().map(|candidate| { - let cluster = &cluster; - async move { - let outcome = latest_for(&candidate, cluster, ctx).await; - (candidate, outcome) - } - })) - .buffer_unordered(UPDATE_CHECK_CONCURRENCY) - .collect::>() - .await; + let results = futures_util::stream::iter(candidates.into_iter().map(|candidate| { + let cluster = &cluster; + async move { + let outcome = latest_for(&candidate, cluster, ctx).await; + (candidate, outcome) + } + })) + .buffer_unordered(UPDATE_CHECK_CONCURRENCY) + .collect::>() + .await; - let mut check = BrowserUpdateCheck { - cluster_id, - ..Default::default() - }; + let mut check = BrowserUpdateCheck { + cluster_id, + ..Default::default() + }; - for (candidate, outcome) in results { - match outcome { - Ok(Some((latest_version_id, latest_version_name))) => { - let key = format!("{}:{latest_version_id}", candidate.hash); - check.updates.push(BrowserPackageUpdate { - cluster_id, - hash: candidate.hash, - provider: candidate.provider, - project_id: candidate.project_id, - installed_version_id: candidate.version_id, - installed_version_name: candidate.display_version, - latest_version_id, - latest_version_name, - display_name: candidate.display_name, - skipped: skipped.contains(&key), - }); - } - Ok(None) => {} - Err(err) => { - // Offline is the common case and says nothing about the launcher - if err.is_transient() { - tracing::debug!( - project_id = %candidate.project_id, - error = %err, - "update check skipped, provider unreachable" - ); - } else { - tracing::warn!( - project_id = %candidate.project_id, - error = %err, - "update check failed for package" - ); - } - check.unchecked.push(candidate.hash); - } - } - } + for (candidate, outcome) in results { + match outcome { + Ok(Some((latest_version_id, latest_version_name))) => { + let key = format!("{}:{latest_version_id}", candidate.hash); + check.updates.push(BrowserPackageUpdate { + cluster_id, + hash: candidate.hash, + provider: candidate.provider, + project_id: candidate.project_id, + installed_version_id: candidate.version_id, + installed_version_name: candidate.display_version, + latest_version_id, + latest_version_name, + display_name: candidate.display_name, + skipped: skipped.contains(&key), + }); + } + Ok(None) => {} + Err(err) => { + // Offline is the common case and says nothing about the launcher + if err.is_transient() { + tracing::debug!( + project_id = %candidate.project_id, + error = %err, + "update check skipped, provider unreachable" + ); + } else { + tracing::warn!( + project_id = %candidate.project_id, + error = %err, + "update check failed for package" + ); + } + check.unchecked.push(candidate.hash); + } + } + } - Ok(check) + Ok(check) } fn parse_published(raw: &str) -> Option> { - DateTime::parse_from_rfc3339(raw) - .ok() - .map(|at| at.with_timezone(&Utc)) + DateTime::parse_from_rfc3339(raw) + .ok() + .map(|at| at.with_timezone(&Utc)) } fn is_upgrade(installed_published: Option>, latest_published: DateTime) -> bool { - installed_published.is_none_or(|installed| latest_published > installed) + installed_published.is_none_or(|installed| latest_published > installed) } async fn latest_for( - candidate: &Candidate, - cluster: &ClusterRow, - ctx: &ContentCtx, + candidate: &Candidate, + cluster: &ClusterRow, + ctx: &ContentCtx, ) -> ContentResult> { - let provider = ctx.providers.get(candidate.provider)?; - let Some(latest) = pick_version(provider, &candidate.project_id, cluster, ctx).await? else { - return Ok(None); - }; + let provider = ctx.providers.get(candidate.provider)?; + let Some(latest) = pick_version(provider, &candidate.project_id, cluster, ctx).await? else { + return Ok(None); + }; - if latest.version_id == candidate.version_id { - return Ok(None); - } + if latest.version_id == candidate.version_id { + return Ok(None); + } - if !is_upgrade(candidate.published_at, latest.published) { - tracing::debug!( - project_id = %candidate.project_id, - installed = %candidate.version_id, - offered = %latest.version_id, - "update not offered, the installed version is not older than the pick" - ); - return Ok(None); - } + if !is_upgrade(candidate.published_at, latest.published) { + tracing::debug!( + project_id = %candidate.project_id, + installed = %candidate.version_id, + offered = %latest.version_id, + "update not offered, the installed version is not older than the pick" + ); + return Ok(None); + } - let name = if latest.version_number.is_empty() { - latest.name - } else { - latest.version_number - }; + let name = if latest.version_number.is_empty() { + latest.name + } else { + latest.version_number + }; - Ok(Some((latest.version_id, name))) + Ok(Some((latest.version_id, name))) } /// Unreported rows are dropped except unreachable ones which keep their last /// known state #[tracing::instrument(level = "debug", skip(ctx))] pub async fn refresh_browser_package_updates( - cluster_id: i64, - ctx: &ContentCtx, + cluster_id: i64, + ctx: &ContentCtx, ) -> ContentResult { - let check = check_browser_package_updates(cluster_id, ctx).await?; + let check = check_browser_package_updates(cluster_id, ctx).await?; - let mut keep: Vec = check.unchecked.clone(); - let mut stored = Vec::with_capacity(check.updates.len()); + let mut keep: Vec = check.unchecked.clone(); + let mut stored = Vec::with_capacity(check.updates.len()); - for update in &check.updates { - let row = update_dao::upsert( - &ctx.db, - cluster_id, - &update.hash, - update.provider as i64, - &update.project_id, - &update.installed_version_id, - &update.installed_version_name, - &update.latest_version_id, - &update.latest_version_name, - &update.display_name, - ) - .await?; - keep.push(row.hash.clone()); - // The row is the authority on `skipped` for this exact version - stored.push(BrowserPackageUpdate::from_row(row)); - } + for update in &check.updates { + let row = update_dao::upsert( + &ctx.db, + cluster_id, + &update.hash, + update.provider as i64, + &update.project_id, + &update.installed_version_id, + &update.installed_version_name, + &update.latest_version_id, + &update.latest_version_name, + &update.display_name, + ) + .await?; + keep.push(row.hash.clone()); + // The row is the authority on `skipped` for this exact version + stored.push(BrowserPackageUpdate::from_row(row)); + } - update_dao::retain_hashes(&ctx.db, cluster_id, &keep).await?; + update_dao::retain_hashes(&ctx.db, cluster_id, &keep).await?; - Ok(BrowserUpdateCheck { - cluster_id, - updates: stored, - unchecked: check.unchecked, - }) + Ok(BrowserUpdateCheck { + cluster_id, + updates: stored, + unchecked: check.unchecked, + }) } /// Never touches the network #[tracing::instrument(level = "debug", skip(ctx))] pub async fn cached_browser_package_updates( - cluster_id: i64, - ctx: &ContentCtx, + cluster_id: i64, + ctx: &ContentCtx, ) -> ContentResult> { - Ok(update_dao::list_for_cluster(&ctx.db, cluster_id) - .await? - .into_iter() - .map(BrowserPackageUpdate::from_row) - .collect()) + Ok(update_dao::list_for_cluster(&ctx.db, cluster_id) + .await? + .into_iter() + .map(BrowserPackageUpdate::from_row) + .collect()) } /// The row stays and the package still reads as out of date @@ -311,305 +331,308 @@ pub async fn cached_browser_package_updates( /// appears #[tracing::instrument(level = "debug", skip(ctx))] pub async fn skip_browser_package_update( - cluster_id: i64, - hash: &str, - ctx: &ContentCtx, + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, ) -> ContentResult<()> { - update_dao::set_skipped(&ctx.db, cluster_id, hash, true).await?; - Ok(()) + update_dao::set_skipped(&ctx.db, cluster_id, hash, true).await?; + Ok(()) } /// Download first unlink second so a failed download leaves the cluster as it was #[tracing::instrument(level = "debug", skip(update, child, ctx), fields(cluster_id = update.cluster_id, project_id = %update.project_id))] pub async fn apply_browser_package_update( - update: &BrowserPackageUpdate, - child: Option<&GroupedProgressChild>, - ctx: &ContentCtx, + update: &BrowserPackageUpdate, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, ) -> ContentResult { - // Never touch anything a bundle owns however this was called - if bundle_dao::get_bundle_tracked(&ctx.db, update.cluster_id, &update.hash) - .await? - .is_some() - { - return Err(ContentError::InvalidData { - reason: format!( - "{} is provided by a bundle and is not updated by the browser flow", - update.display_name - ), - }); - } + // Never touch anything a bundle owns however this was called + if bundle_dao::get_bundle_tracked(&ctx.db, update.cluster_id, &update.hash) + .await? + .is_some() + { + return Err(ContentError::InvalidData { + reason: format!( + "{} is provided by a bundle and is not updated by the browser flow", + update.display_name + ), + }); + } - let provider = ctx.providers.get(update.provider)?; - let project = provider.get_project(&update.project_id, ctx).await?; - let version = provider - .get_version(&update.project_id, &update.latest_version_id, ctx) - .await?; + let provider = ctx.providers.get(update.provider)?; + let project = provider.get_project(&update.project_id, ctx).await?; + let version = provider + .get_version(&update.project_id, &update.latest_version_id, ctx) + .await?; - let enabled_before = artifact_dao::get_cluster_artifact(&ctx.db, update.cluster_id, &update.hash) - .await? - .map(|link| link.enabled != 0); + let enabled_before = + artifact_dao::get_cluster_artifact(&ctx.db, update.cluster_id, &update.hash) + .await? + .map(|link| link.enabled != 0); - install_new_dependencies(update, &project, &version, ctx).await; + install_new_dependencies(update.provider, update.cluster_id, &project, &version, ctx).await; - // Compatibility is not re-checked - // a provider disagreeing at install time would strand the user on a build - // they cannot move off - let (installed, _) = PackageStore::install_to_cluster( - update.provider, - &project, - &version, - update.cluster_id, - true, - false, - child, - ctx, - ) - .await?; + // Compatibility is not re-checked + // a provider disagreeing at install time would strand the user on a build + // they cannot move off + let (installed, _) = PackageStore::install_to_cluster( + update.provider, + &project, + &version, + update.cluster_id, + true, + false, + child, + ctx, + ) + .await?; - // Provider handed back the already-linked file - // leave the link untouched - if installed.hash == update.hash { - update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?; - return Ok(installed.hash); - } + // Provider handed back the already-linked file + // leave the link untouched + if installed.hash == update.hash { + update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?; + return Ok(installed.hash); + } - // A package switched off stays off - // the fresh link starts enabled so this is a toggle rather than a set - if enabled_before == Some(false) { - PackageStore::set_artifact_enabled(update.cluster_id, &installed.hash, ctx).await?; - } + // A package switched off stays off + // the fresh link starts enabled so this is a toggle rather than a set + if enabled_before == Some(false) { + PackageStore::set_artifact_enabled(update.cluster_id, &installed.hash, ctx).await?; + } - artifact_dao::set_seen_status( - &ctx.db, - update.cluster_id, - &installed.hash, - SeenStatus::Updated, - ) - .await?; + artifact_dao::set_seen_status( + &ctx.db, + update.cluster_id, + &installed.hash, + SeenStatus::Updated, + ) + .await?; - update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?; + update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?; - Ok(installed.hash) + Ok(installed.hash) } -#[tracing::instrument(level = "debug", skip(update, project, version, ctx), fields(cluster_id = update.cluster_id))] -async fn install_new_dependencies( - update: &BrowserPackageUpdate, - project: &crate::packages::types::ProjectDetail, - version: &crate::packages::types::VersionDetail, - ctx: &ContentCtx, +#[tracing::instrument(level = "debug", skip(project, version, ctx))] +pub(crate) async fn install_new_dependencies( + provider: ProviderId, + cluster_id: i64, + project: &crate::packages::types::ProjectDetail, + version: &crate::packages::types::VersionDetail, + ctx: &ContentCtx, ) { - if !resolves_dependencies(project.content_type) { - return; - } + if !resolves_dependencies(project.content_type) { + return; + } - let resolution = match resolve_required(update.provider, version, update.cluster_id, ctx).await { - Ok(resolution) => resolution, - Err(err) => { - tracing::warn!(%err, "dependency resolution failed, updating the package alone"); - return; - } - }; + let resolution = match resolve_required(provider, version, cluster_id, ctx).await { + Ok(resolution) => resolution, + Err(err) => { + tracing::warn!(%err, "dependency resolution failed, updating the package alone"); + return; + } + }; - for missing in &resolution.unresolved { - tracing::warn!(dependency = %missing, "no compatible version for a new dependency"); - } + for missing in &resolution.unresolved { + tracing::warn!(dependency = %missing, "no compatible version for a new dependency"); + } - for dependency in &resolution.install { - match PackageStore::install_to_cluster( - update.provider, - &dependency.project, - &dependency.version, - update.cluster_id, - true, - false, - None, - ctx, - ) - .await - { - Ok((artifact, _)) => { - tracing::info!( - dependency = %dependency.project.name, - "installed a dependency the newer version needs" - ); - if let Err(err) = artifact_dao::set_seen_status( - &ctx.db, - update.cluster_id, - &artifact.hash, - SeenStatus::New, - ) - .await - { - tracing::debug!(%err, "could not mark a new dependency as new"); - } - } - Err(err) => tracing::warn!( - dependency = %dependency.project.name, - %err, - "failed to install a dependency the newer version needs" - ), - } - } + for dependency in &resolution.install { + match PackageStore::install_to_cluster( + provider, + &dependency.project, + &dependency.version, + cluster_id, + true, + false, + None, + ctx, + ) + .await + { + Ok((artifact, _)) => { + tracing::info!( + dependency = %dependency.project.name, + "installed a dependency the newer version needs" + ); + if let Err(err) = artifact_dao::set_seen_status( + &ctx.db, + cluster_id, + &artifact.hash, + SeenStatus::New, + ) + .await + { + tracing::debug!(%err, "could not mark a new dependency as new"); + } + } + Err(err) => tracing::warn!( + dependency = %dependency.project.name, + %err, + "failed to install a dependency the newer version needs" + ), + } + } } #[cfg(test)] mod tests { - use super::*; - use oneclient_common::domain::ContentType; + use super::*; - fn at(raw: &str) -> DateTime { - parse_published(raw).expect("test dates are rfc 3339") - } + fn at(raw: &str) -> DateTime { + parse_published(raw).expect("test dates are rfc 3339") + } - fn linked( - hash: &str, - provider: Option, - project_id: Option<&str>, - version_id: Option<&str>, - ) -> LinkedArtifactInfo { - LinkedArtifactInfo { - hash: hash.into(), - cluster_file_name: format!("{hash}.jar"), - enabled: true, - content_type: ContentType::Mod, - file_name: format!("{hash}.jar"), - project_id: project_id.map(Into::into), - version_id: version_id.map(Into::into), - display_name: None, - display_version: None, - provider, - published_at: None, - seen_status: SeenStatus::Seen, - } - } + fn linked( + hash: &str, + provider: Option, + project_id: Option<&str>, + version_id: Option<&str>, + ) -> LinkedArtifactInfo { + LinkedArtifactInfo { + hash: hash.into(), + cluster_file_name: format!("{hash}.jar"), + enabled: true, + content_type: ContentType::Mod, + file_name: format!("{hash}.jar"), + project_id: project_id.map(Into::into), + version_id: version_id.map(Into::into), + display_name: None, + display_version: None, + provider, + published_at: None, + seen_status: SeenStatus::Seen, + } + } - #[test] - fn bundle_provided_packages_are_never_candidates() { - let linked = vec![linked( - "a", - Some(ProviderId::Modrinth), - Some("sodium"), - Some("v1"), - )]; - let bundles: HashSet = ["a".to_string()].into_iter().collect(); + #[test] + fn bundle_provided_packages_are_never_candidates() { + let linked = vec![linked( + "a", + Some(ProviderId::Modrinth), + Some("sodium"), + Some("v1"), + )]; + let bundles: HashSet = ["a".to_string()].into_iter().collect(); - assert!( - browser_installed(&linked, &bundles, &HashSet::new()).is_empty(), - "a bundle-tracked artifact must stay out of the browser update flow" - ); - } + assert!( + browser_installed(&linked, &bundles, &HashSet::new()).is_empty(), + "a bundle-tracked artifact must stay out of the browser update flow" + ); + } - #[test] - fn browser_installed_remote_packages_are_candidates() { - let linked = vec![linked( - "a", - Some(ProviderId::Modrinth), - Some("sodium"), - Some("v1"), - )]; + #[test] + fn browser_installed_remote_packages_are_candidates() { + let linked = vec![linked( + "a", + Some(ProviderId::Modrinth), + Some("sodium"), + Some("v1"), + )]; - let found = browser_installed(&linked, &HashSet::new(), &HashSet::new()); - assert_eq!(found.len(), 1); - assert_eq!(found[0].project_id, "sodium"); - assert_eq!(found[0].version_id, "v1"); - } + let found = browser_installed(&linked, &HashSet::new(), &HashSet::new()); + assert_eq!(found.len(), 1); + assert_eq!(found[0].project_id, "sodium"); + assert_eq!(found[0].version_id, "v1"); + } - #[test] - fn local_imports_are_not_candidates() { - let linked = vec![ - linked("a", Some(ProviderId::Local), Some("x"), Some("v1")), - linked("b", None, None, None), - ]; + #[test] + fn local_imports_are_not_candidates() { + let linked = vec![ + linked("a", Some(ProviderId::Local), Some("x"), Some("v1")), + linked("b", None, None, None), + ]; - assert!( - browser_installed(&linked, &HashSet::new(), &HashSet::new()).is_empty(), - "a file with no provider release has no version to compare" - ); - } + assert!( + browser_installed(&linked, &HashSet::new(), &HashSet::new()).is_empty(), + "a file with no provider release has no version to compare" + ); + } - #[test] - fn a_remote_package_with_no_recorded_version_is_not_a_candidate() { - let linked = vec![linked("a", Some(ProviderId::Modrinth), Some("sodium"), None)]; + #[test] + fn a_remote_package_with_no_recorded_version_is_not_a_candidate() { + let linked = vec![linked( + "a", + Some(ProviderId::Modrinth), + Some("sodium"), + None, + )]; - assert!( - browser_installed(&linked, &HashSet::new(), &HashSet::new()).is_empty(), - "without an installed version id there is nothing to compare against" - ); - } + assert!( + browser_installed(&linked, &HashSet::new(), &HashSet::new()).is_empty(), + "without an installed version id there is nothing to compare against" + ); + } - #[test] - fn pending_hides_versions_the_user_declined() { - let make = |hash: &str, skipped: bool| BrowserPackageUpdate { - cluster_id: 1, - hash: hash.into(), - provider: ProviderId::Modrinth, - project_id: "sodium".into(), - installed_version_id: "v1".into(), - installed_version_name: "1.0".into(), - latest_version_id: "v2".into(), - latest_version_name: "2.0".into(), - display_name: "Sodium".into(), - skipped, - }; + #[test] + fn pending_hides_versions_the_user_declined() { + let make = |hash: &str, skipped: bool| BrowserPackageUpdate { + cluster_id: 1, + hash: hash.into(), + provider: ProviderId::Modrinth, + project_id: "sodium".into(), + installed_version_id: "v1".into(), + installed_version_name: "1.0".into(), + latest_version_id: "v2".into(), + latest_version_name: "2.0".into(), + display_name: "Sodium".into(), + skipped, + }; - let check = BrowserUpdateCheck { - cluster_id: 1, - updates: vec![make("a", true), make("b", false)], - unchecked: Vec::new(), - }; + let check = BrowserUpdateCheck { + cluster_id: 1, + updates: vec![make("a", true), make("b", false)], + unchecked: Vec::new(), + }; - let pending = check.pending(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0].hash, "b"); - } + let pending = check.pending(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].hash, "b"); + } - #[test] - fn a_newer_pick_is_an_upgrade() { - assert!(is_upgrade( - Some(at("2026-01-01T00:00:00Z")), - at("2026-02-01T00:00:00Z") - )); - } + #[test] + fn a_newer_pick_is_an_upgrade() { + assert!(is_upgrade( + Some(at("2026-01-01T00:00:00Z")), + at("2026-02-01T00:00:00Z") + )); + } - #[test] - fn a_pick_older_than_the_installed_build_is_not_an_upgrade() { - assert!( - !is_upgrade( - Some(at("2026-02-01T00:00:00Z")), - at("2026-01-01T00:00:00Z") - ), - "a hand-installed build newer than the provider pick must not be offered a downgrade" - ); - } + #[test] + fn a_pick_older_than_the_installed_build_is_not_an_upgrade() { + assert!( + !is_upgrade(Some(at("2026-02-01T00:00:00Z")), at("2026-01-01T00:00:00Z")), + "a hand-installed build newer than the provider pick must not be offered a downgrade" + ); + } - #[test] - fn a_pick_published_at_the_same_moment_is_not_an_upgrade() { - assert!(!is_upgrade( - Some(at("2026-02-01T00:00:00Z")), - at("2026-02-01T00:00:00Z") - )); - } + #[test] + fn a_pick_published_at_the_same_moment_is_not_an_upgrade() { + assert!(!is_upgrade( + Some(at("2026-02-01T00:00:00Z")), + at("2026-02-01T00:00:00Z") + )); + } - #[test] - fn an_unknown_installed_date_still_offers_the_update() { - assert!(is_upgrade(None, at("2026-02-01T00:00:00Z"))); - } + #[test] + fn an_unknown_installed_date_still_offers_the_update() { + assert!(is_upgrade(None, at("2026-02-01T00:00:00Z"))); + } - #[test] - fn candidates_carry_the_installed_publish_date() { - let mut info = linked("a", Some(ProviderId::Modrinth), Some("sodium"), Some("v1")); - info.published_at = Some("2026-02-01T00:00:00Z".into()); + #[test] + fn candidates_carry_the_installed_publish_date() { + let mut info = linked("a", Some(ProviderId::Modrinth), Some("sodium"), Some("v1")); + info.published_at = Some("2026-02-01T00:00:00Z".into()); - let found = browser_installed(&[info], &HashSet::new(), &HashSet::new()); - assert_eq!(found[0].published_at, Some(at("2026-02-01T00:00:00Z"))); - } - #[test] - fn an_unparseable_publish_date_leaves_the_candidate_undated() { - let mut info = linked("a", Some(ProviderId::Modrinth), Some("sodium"), Some("v1")); - info.published_at = Some("not a date".into()); + let found = browser_installed(&[info], &HashSet::new(), &HashSet::new()); + assert_eq!(found[0].published_at, Some(at("2026-02-01T00:00:00Z"))); + } + #[test] + fn an_unparseable_publish_date_leaves_the_candidate_undated() { + let mut info = linked("a", Some(ProviderId::Modrinth), Some("sodium"), Some("v1")); + info.published_at = Some("not a date".into()); - let found = browser_installed(&[info], &HashSet::new(), &HashSet::new()); - assert_eq!(found[0].published_at, None); - } + let found = browser_installed(&[info], &HashSet::new(), &HashSet::new()); + assert_eq!(found[0].published_at, None); + } } diff --git a/packages/oneclient_content/tests/file_identity.rs b/packages/oneclient_content/tests/file_identity.rs index 6c8aadd0..8c6e6d83 100644 --- a/packages/oneclient_content/tests/file_identity.rs +++ b/packages/oneclient_content/tests/file_identity.rs @@ -1,39 +1,15 @@ -use oneclient_content::packages::{curseforge_fingerprint, FileIdentity}; +use oneclient_content::packages::curseforge_fingerprint; #[test] fn curseforge_fingerprint_ignores_padding_whitespace() { - let a = curseforge_fingerprint(b"hello"); - let b = curseforge_fingerprint(b"\t\n\r hello \n\t\r"); - assert_eq!(a, b); -} - -#[test] -fn curseforge_fingerprint_empty_is_stable() { - assert_eq!(curseforge_fingerprint(&[]), curseforge_fingerprint(&[])); + let a = curseforge_fingerprint(b"hello"); + let b = curseforge_fingerprint(b"\t\n\r hello \n\t\r"); + assert_eq!(a, b); } #[test] fn curseforge_fingerprint_differs_for_content() { - let a = curseforge_fingerprint(b"hello"); - let b = curseforge_fingerprint(b"world"); - assert_ne!(a, b); -} - -#[test] -fn file_identity_from_bytes_sets_sha1_and_cf_fingerprint() { - let id = FileIdentity::from_bytes(b"test jar content"); - assert_eq!(id.sha1.len(), 40); - assert!(id.cf_fingerprint.is_some()); -} - -#[test] -fn file_identity_from_sha1_has_no_cf_fingerprint() { - let id = FileIdentity::from_sha1("a".repeat(40)); - assert!(id.cf_fingerprint.is_none()); -} - -#[test] -fn file_identity_with_fingerprint_roundtrip() { - let id = FileIdentity::from_sha1("abc").with_curseforge_fingerprint(12345); - assert_eq!(id.cf_fingerprint, Some(12345)); + let a = curseforge_fingerprint(b"hello"); + let b = curseforge_fingerprint(b"world"); + assert_ne!(a, b); } diff --git a/packages/oneclient_core/Cargo.toml b/packages/oneclient_core/Cargo.toml index 4d290aa2..839bae6a 100644 --- a/packages/oneclient_core/Cargo.toml +++ b/packages/oneclient_core/Cargo.toml @@ -17,7 +17,7 @@ path = "src/lib.rs" default = [] [dev-dependencies] -async-tungstenite = {version = "=0.32.1", features = ["tokio-runtime"]} +async-tungstenite = {version = "=0.35.0", features = ["tokio-runtime"]} polyio = {workspace = true, features = ["testing"]} [dependencies] @@ -73,6 +73,8 @@ digest.workspace = true md-5.workspace = true sha1.workspace = true sha2.workspace = true +sha1_0_10 = { package = "sha1", version = "0.10", features = ["oid"] } +rsa = { version = "0.9", features = ["sha1", "pem"] } # util base64.workspace = true @@ -92,7 +94,6 @@ sysinfo.workspace = true trash.workspace = true url.workspace = true uuid.workspace = true -rsa = { version = "0.9", features = ["sha1", "pem"] } [target.'cfg(target_os = "linux")'.dependencies] libc.workspace = true diff --git a/packages/oneclient_core/examples/auth_msa.rs b/packages/oneclient_core/examples/auth_msa.rs index d7915f6b..c2093e4f 100644 --- a/packages/oneclient_core/examples/auth_msa.rs +++ b/packages/oneclient_core/examples/auth_msa.rs @@ -1,7 +1,6 @@ - use std::process::Command; -use oneclient_core::{dev, logger, LauncherResult}; +use oneclient_core::{LauncherResult, dev, logger}; #[tokio::main] async fn main() -> LauncherResult<()> { @@ -25,7 +24,10 @@ async fn main() -> LauncherResult<()> { "Signed in as {} ({})\n token expires: {}", account.username, account.id, account.expires ); - println!("Account saved to {}", oneclient_common::paths::auth_file()?.display()); + println!( + "Account saved to {}", + oneclient_common::paths::auth_file()?.display() + ); Ok(()) } @@ -42,7 +44,9 @@ fn try_open_browser(url: &str) { match result { Ok(_) => println!("Opened verification page in your default browser.\n"), Err(err) => { - println!("Could not open browser automatically ({err}). Open the URL above manually.\n"); + println!( + "Could not open browser automatically ({err}). Open the URL above manually.\n" + ); } } } diff --git a/packages/oneclient_core/examples/bundles_sync.rs b/packages/oneclient_core/examples/bundles_sync.rs index eccb554d..bf9e3b58 100644 --- a/packages/oneclient_core/examples/bundles_sync.rs +++ b/packages/oneclient_core/examples/bundles_sync.rs @@ -1,6 +1,5 @@ - -use oneclient_core::dev; use oneclient_common::domain::GameLoader; +use oneclient_core::dev; #[tokio::main] async fn main() -> Result<(), Box> { diff --git a/packages/oneclient_core/examples/cluster_manage.rs b/packages/oneclient_core/examples/cluster_manage.rs index 53790711..ba287d81 100644 --- a/packages/oneclient_core/examples/cluster_manage.rs +++ b/packages/oneclient_core/examples/cluster_manage.rs @@ -1,42 +1,47 @@ - +use oneclient_cluster::profiles::list_named_profiles; +use oneclient_common::domain::GameLoader; use oneclient_core::clusters::CreateClusterOptions; use oneclient_core::dev; -use oneclient_common::domain::GameLoader; -use oneclient_cluster::profiles::list_named_profiles; #[tokio::main] async fn main() -> Result<(), Box> { - tracing_subscriber::fmt::init(); + tracing_subscriber::fmt::init(); - let state = dev::ephemeral_state().await?; - let global = state.settings.read().global_game_settings.clone(); + let state = dev::ephemeral_state().await?; + let global = state.settings.read().global_game_settings.clone(); - let cluster = state.clusters.create( - &global, - CreateClusterOptions::new("Example 1.21.1 Fabric", "1.21.1", GameLoader::Fabric).mem_max(3072), - ) - .await?; + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Example 1.21.1 Fabric", "1.21.1", GameLoader::Fabric) + .mem_max(3072), + ) + .await?; - println!("Created cluster #{} at folder '{}'", cluster.id, cluster.folder_name); - println!(" setting profile: {:?}", cluster.setting_profile_name); - println!(" created at: {:?}", cluster.created_at); + println!( + "Created cluster #{} at folder '{}'", + cluster.id, cluster.folder_name + ); + println!(" setting profile: {:?}", cluster.setting_profile_name); + println!(" created at: {:?}", cluster.created_at); - let profile = state.clusters.resolve_settings(&global, &cluster).await?; - println!( - " resolved mem_max: {:?}, force_fullscreen: {:?}", - profile.mem_max, profile.force_fullscreen - ); + let profile = state.clusters.resolve_settings(&global, &cluster).await?; + println!( + " resolved mem_max: {:?}, force_fullscreen: {:?}", + profile.mem_max, profile.force_fullscreen + ); - for c in state.clusters.list().await? { - println!(" - {} ({}, {})", c.name, c.mc_version, c.mc_loader); - } + for c in state.clusters.list().await? { + println!(" - {} ({}, {})", c.name, c.mc_version, c.mc_loader); + } - for p in list_named_profiles(&state.services.db).await? { - println!("Profile {} (mem_max: {:?})", p.name, p.mem_max); - } + for p in list_named_profiles(&state.services.db).await? { + println!("Profile {} (mem_max: {:?})", p.name, p.mem_max); + } - state.clusters.delete(cluster.id, true).await?; - println!("Deleted cluster #{}", cluster.id); + state.clusters.delete(cluster.id, true).await?; + println!("Deleted cluster #{}", cluster.id); - Ok(()) + Ok(()) } diff --git a/packages/oneclient_core/examples/download_game.rs b/packages/oneclient_core/examples/download_game.rs index 045ec845..41b81c08 100644 --- a/packages/oneclient_core/examples/download_game.rs +++ b/packages/oneclient_core/examples/download_game.rs @@ -1,8 +1,8 @@ #![recursion_limit = "256"] -use oneclient_core::clusters::CreateClusterOptions; -use oneclient_core::dev; use oneclient_common::domain::GameLoader; use oneclient_core::LauncherResult; +use oneclient_core::clusters::CreateClusterOptions; +use oneclient_core::dev; #[tokio::main] async fn main() -> LauncherResult<()> { @@ -19,24 +19,34 @@ async fn main() -> LauncherResult<()> { let global = state.settings.read().global_game_settings.clone(); - let cluster = state.clusters.create( - &global, - CreateClusterOptions { - name: format!("download-{mc_version}"), - mc_version: mc_version.to_string(), - mc_loader: loader, - mc_loader_version: loader_version.map(str::to_string), - mem_max: None, - }, - ) - .await?; + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions { + name: format!("download-{mc_version}"), + mc_version: mc_version.to_string(), + mc_loader: loader, + mc_loader_version: loader_version.map(str::to_string), + mem_max: None, + kind: oneclient_cluster::ClusterKind::OneClient, + user_created: false, + modpack: false, + description: None, + tags: Vec::new(), + }, + ) + .await?; println!( "Preparing cluster {} ({} {:?})...", cluster.name, cluster.mc_version, cluster.mc_loader ); - let ready = oneclient_core::clusters::prepare_cluster_locked(&state, cluster.id, false, true, true, None).await?; + let ready = oneclient_core::clusters::prepare_cluster_locked( + &state, cluster.id, false, true, true, None, + ) + .await?; println!("Cluster ready at {}", ready.dir()?.display()); Ok(()) diff --git a/packages/oneclient_core/examples/launch_game.rs b/packages/oneclient_core/examples/launch_game.rs index 3da7091f..2c435b24 100644 --- a/packages/oneclient_core/examples/launch_game.rs +++ b/packages/oneclient_core/examples/launch_game.rs @@ -1,9 +1,9 @@ #![recursion_limit = "256"] +use oneclient_common::domain::GameLoader; use oneclient_core::LauncherResult; use oneclient_core::clusters::CreateClusterOptions; use oneclient_core::dev; -use oneclient_common::domain::GameLoader; #[tokio::main] async fn main() -> LauncherResult<()> { @@ -17,16 +17,24 @@ async fn main() -> LauncherResult<()> { .and_then(|s| s.parse().ok()) .unwrap_or(GameLoader::Vanilla); - let account = state.auth.add_offline_account("Example".to_string()).await?; - println!("Using offline account {} ({})", account.username, account.id); + let account = state + .auth + .add_offline_account("Example".to_string()) + .await?; + println!( + "Using offline account {} ({})", + account.username, account.id + ); let global = state.settings.read().global_game_settings.clone(); - let cluster = state.clusters.create( - &global, - CreateClusterOptions::new(format!("launch-{mc_version}"), mc_version, loader), - ) - .await?; + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new(format!("launch-{mc_version}"), mc_version, loader), + ) + .await?; println!("Launching {} ({mc_version} {loader:?})...", cluster.name); let game = oneclient_core::launch_cluster(&state, cluster.id, &account, true).await?; diff --git a/packages/oneclient_core/examples/notifications.rs b/packages/oneclient_core/examples/notifications.rs index e7c6da98..432cbaf9 100644 --- a/packages/oneclient_core/examples/notifications.rs +++ b/packages/oneclient_core/examples/notifications.rs @@ -1,5 +1,4 @@ - -use oneclient_events::{GroupedProgressSession, EventBus}; +use oneclient_events::{EventBus, GroupedProgressSession}; use tokio::sync::mpsc; use uuid::Uuid; @@ -9,7 +8,11 @@ async fn main() { let events = EventBus::new(tx); events.notify("Welcome").body("Launcher started").send(); - events.notify("Heads up").body("Example error notification").error().send(); + events + .notify("Heads up") + .body("Example error notification") + .error() + .send(); let download = Uuid::new_v4(); for current in (0..=100).step_by(25) { diff --git a/packages/oneclient_core/examples/package_project.rs b/packages/oneclient_core/examples/package_project.rs index eb4f9662..857e4e34 100644 --- a/packages/oneclient_core/examples/package_project.rs +++ b/packages/oneclient_core/examples/package_project.rs @@ -1,72 +1,59 @@ - use std::env; use oneclient_common::domain::ProviderId; -use oneclient_core::{dev, logger, LauncherResult}; +use oneclient_core::{LauncherResult, dev, logger}; #[tokio::main] async fn main() -> LauncherResult<()> { - logger::init_debug()?; + logger::init_debug()?; - let mut args = env::args().skip(1); - let provider_name = args.next().unwrap_or_else(|| "modrinth".into()); - let project_ref = args.next().unwrap_or_else(|| "sodium".into()); - let mc_version = args.next(); + let mut args = env::args().skip(1); + let provider_name = args.next().unwrap_or_else(|| "modrinth".into()); + let project_ref = args.next().unwrap_or_else(|| "sodium".into()); + let mc_version = args.next(); - let provider_id = parse_provider(&provider_name)?; - let env = dev::ephemeral_services().await?.content(); - let provider = env.providers.get(provider_id)?; + let provider_id = parse_provider(&provider_name)?; + let env = dev::ephemeral_services().await?.content(); + let provider = env.providers.get(provider_id)?; - let project = provider.get_project(&project_ref, &env).await?; + let project = provider.get_project(&project_ref, &env).await?; - println!( - "{:?} project: {} ({})", - project.provider, project.name, project.id - ); - println!(" {}\n", project.summary); + println!( + "{:?} project: {} ({})", + project.provider, project.name, project.id + ); + println!(" {}\n", project.summary); - let versions = provider - .list_versions( - &project.id, - mc_version.as_deref(), - None, - 0, - 5, - &env, - ) - .await?; + let versions = provider + .list_versions(&project.id, mc_version.as_deref(), None, 0, 5, &env) + .await?; - println!("Versions (up to {}):", versions.items.len()); - for v in &versions.items { - println!( - " {} - {} ({})", - v.name, v.version_number, v.version_id - ); - } + println!("Versions (up to {}):", versions.items.len()); + for v in &versions.items { + println!(" {} - {} ({})", v.name, v.version_number, v.version_id); + } - if let Some(version) = versions.items.first() { - let detail = provider - .get_version(&project.id, &version.version_id, &env) - .await?; - if let Some(file) = detail.primary_file() { - println!( - "\nPrimary file: {} sha1={} cf_fp={:?}", - file.file_name, file.sha1, file.fingerprint - ); - } - } + if let Some(version) = versions.items.first() { + let detail = provider + .get_version(&project.id, &version.version_id, &env) + .await?; + if let Some(file) = detail.primary_file() { + println!( + "\nPrimary file: {} sha1={} cf_fp={:?}", + file.file_name, file.sha1, file.fingerprint + ); + } + } - Ok(()) + Ok(()) } fn parse_provider(name: &str) -> LauncherResult { - match name.to_lowercase().as_str() { - "modrinth" | "mr" => Ok(ProviderId::Modrinth), - "curseforge" | "cf" => Ok(ProviderId::CurseForge), - other => Err(oneclient_core::LauncherError::InvalidSettingsProfile { - reason: format!( - "unknown provider {other:?}; use modrinth or curseforge" - ), - }), - } + match name.to_lowercase().as_str() { + "modrinth" | "mr" => Ok(ProviderId::Modrinth), + "curseforge" | "cf" => Ok(ProviderId::CurseForge), + other => Err(oneclient_core::LauncherError::InvalidSettingsProfile { + reason: format!("unknown provider {other:?}; use modrinth or curseforge"), + }), + } } diff --git a/packages/oneclient_core/examples/package_search.rs b/packages/oneclient_core/examples/package_search.rs index 7a326a06..ba89859c 100644 --- a/packages/oneclient_core/examples/package_search.rs +++ b/packages/oneclient_core/examples/package_search.rs @@ -1,4 +1,3 @@ - use std::env; use oneclient_common::domain::{ContentType, ProviderId}; diff --git a/packages/oneclient_core/src/changelog.rs b/packages/oneclient_core/src/changelog.rs index ee0667d4..00d36b2b 100644 --- a/packages/oneclient_core/src/changelog.rs +++ b/packages/oneclient_core/src/changelog.rs @@ -1,67 +1,26 @@ - - -use oneclient_common::paths; -use oneclient_net::{EtagPolicy, fetch_cached}; -use oneclient_net::RequestClient; use crate::{LauncherError, LauncherResult}; +use oneclient_common::paths; +use oneclient_net::RequestClient; +use oneclient_net::{EtagPolicy, fetch_cached}; -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ChangelogGroup { +const CHANGELOG_URL: &str = + "https://github.com/Polyfrost/OneLauncher/releases/latest/download/changelog.json"; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub struct ChangelogEntry { pub version: String, - pub changes: Vec, -} - -pub fn parse_changelog(data: &str) -> Vec { - let mut groups = Vec::new(); - - for line in data.lines() { - if let Some(version) = line.strip_prefix("# ") { - groups.push(ChangelogGroup { - version: version.trim().to_string(), - changes: Vec::new(), - }); - } else if let Some(change) = line.strip_prefix("- ") { - if let Some(group) = groups.last_mut() { - group.changes.push(change.trim().to_string()); - } - } else if line.trim() == "###" { - break; - } - } - - groups + pub body: String, } #[tracing::instrument(level = "debug", skip(net))] -pub async fn fetch_changelog(net: &RequestClient) -> LauncherResult { - let url = format!( - "{}/oneclient/CHANGE_LOG.md", - net.config().meta_url_base - ); - let cache_path = paths::caches_dir()?.join("CHANGE_LOG.md"); +pub async fn fetch_changelog(net: &RequestClient) -> LauncherResult> { + let cache_path = paths::caches_dir()?.join("changelog.json"); - let fetched = fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow) + let fetched = fetch_cached(net, CHANGELOG_URL, &cache_path, EtagPolicy::CommitNow) .await? .ok_or_else(|| LauncherError::InvalidSettingsProfile { reason: "changelog is unavailable and not cached".to_string(), })?; - Ok(fetched.text()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_changelog_groups_versions_and_bullets() { - let data = "# 2.0.0\n\n- Faster UI\n- Bug fixes\n# 1.9.0\n- Older change\n###\nignored\n"; - let groups = parse_changelog(data); - - assert_eq!(groups.len(), 2); - assert_eq!(groups[0].version, "2.0.0"); - assert_eq!(groups[0].changes, vec!["Faster UI", "Bug fixes"]); - assert_eq!(groups[1].version, "1.9.0"); - assert_eq!(groups[1].changes, vec!["Older change"]); - } + Ok(fetched.json()?) } diff --git a/packages/oneclient_core/src/clusters/java_override.rs b/packages/oneclient_core/src/clusters/java_override.rs new file mode 100644 index 00000000..6b10e40e --- /dev/null +++ b/packages/oneclient_core/src/clusters/java_override.rs @@ -0,0 +1,119 @@ +use std::sync::Arc; + +use oneclient_cluster::ProfileUpdate; +use oneclient_cluster::profiles::get_profile_or_default; +use oneclient_common::patch::Patch; +use oneclient_db::dao::applied_migration as migration_dao; +use oneclient_events::GroupedProgressSession; + +use crate::LauncherResult; +use crate::state::LauncherState; + +const BUNDLE_JAVA_OVERRIDE: &str = "bundle-java-override"; + +#[tracing::instrument(skip(state, progress))] +pub async fn apply_bundle_java_override( + state: &Arc, + cluster_id: i64, + search_for_java: bool, + auto_install_java: bool, + progress: Option<&GroupedProgressSession>, +) -> LauncherResult<()> { + let db = &state.services.db; + let cluster = state.clusters.get(cluster_id).await?; + if !cluster.uses_bundles() { + return Ok(()); + } + let Some(profile_name) = cluster.setting_profile_name.as_deref() else { + return Ok(()); + }; + + let archives = state + .bundles + .archives_for( + &state.services.content(), + &cluster.mc_version, + cluster.mc_loader, + ) + .await?; + if archives.is_empty() { + return Ok(()); + } + + let taken = oneclient_content::bundles::taken_bundle_names( + cluster_id, + &archives, + &state.services.content(), + ) + .await?; + let mut overrides = archives + .iter() + .filter(|archive| taken.contains(&archive.manifest.name)) + .filter_map(|archive| { + archive + .manifest + .java_version_override + .map(|major| (archive.manifest.name.as_str(), major)) + }); + + let Some((bundle, major)) = overrides.next() else { + return Ok(()); + }; + + let marker = format!("{BUNDLE_JAVA_OVERRIDE}:{cluster_id}:{major}"); + if migration_dao::is_applied(db, &marker).await? { + return Ok(()); + } + + for (other, other_major) in overrides { + if other_major != major { + tracing::warn!( + cluster_id, + bundle, + major, + other, + other_major, + "bundles disagree on the java version override; using the first" + ); + } + } + + let global = state.settings.read().global_game_settings.clone(); + let profile = get_profile_or_default(db, &global, Some(profile_name)).await?; + + if profile.java_path.is_none() { + let runtime = state + .java + .prepare(major, search_for_java, auto_install_java, progress) + .await?; + + state + .clusters + .update_profile( + cluster_id, + ProfileUpdate { + java_path: Patch::Set(runtime.absolute_path.clone()), + ..Default::default() + }, + ) + .await?; + + tracing::info!( + cluster_id, + bundle, + major, + java_path = %runtime.absolute_path, + "applied bundle java version override" + ); + } else { + tracing::debug!( + cluster_id, + bundle, + major, + "cluster already has a java runtime; leaving the bundle override unapplied" + ); + } + + migration_dao::mark_applied(db, &marker).await?; + Ok(()) +} diff --git a/packages/oneclient_core/src/clusters/migrate.rs b/packages/oneclient_core/src/clusters/migrate.rs index 3c344f6b..b0c7a4f0 100644 --- a/packages/oneclient_core/src/clusters/migrate.rs +++ b/packages/oneclient_core/src/clusters/migrate.rs @@ -1,13 +1,18 @@ use std::path::{Path, PathBuf}; -use std::str::FromStr; -use oneclient_db::dao::{applied_migration as migration_dao, cluster as cluster_dao}; +use oneclient_common::domain::ContentType; +use oneclient_content::packages::PackageStore; +use oneclient_db::dao::cluster::ClusterMigration; +use oneclient_db::dao::{ + applied_migration as migration_dao, bundle as bundle_catalog_dao, cluster as cluster_dao, + cluster_bundle as bundle_dao, +}; use oneclient_db::models::ClusterRow; use crate::LauncherResult; -use oneclient_common::domain::GameLoader; +use crate::clusters::ClusterStage; use crate::state::LauncherState; -use crate::versions::RemoteMigration; +use crate::versions::{MigrationNode, RemoteMigration, cyclic_migration_ids}; #[tracing::instrument(skip(state))] pub async fn apply_remote_migrations(state: &LauncherState) -> LauncherResult { @@ -16,9 +21,20 @@ pub async fn apply_remote_migrations(state: &LauncherState) -> LauncherResult migrated += 1, Ok(false) => {} @@ -41,12 +57,22 @@ pub async fn apply_remote_migrations(state: &LauncherState) -> LauncherResult LauncherResult { let db = &state.services.db; + let names_loader = rule.to.loader.is_some(); - if migration_dao::is_applied(db, &rule.id).await? { + if !names_loader && migration_dao::is_applied(db, &rule.id).await? { return Ok(false); } - let Ok(loader) = GameLoader::from_str(&rule.from.loader) else { + let Some((from, to)) = rule.endpoints() else { + if names_loader { + tracing::warn!( + migration_id = %rule.id, + from = %rule.from.loader, + to = ?rule.to.loader, + "unknown loader in migration rule; skipping" + ); + return Ok(false); + } tracing::warn!( migration_id = %rule.id, loader = %rule.from.loader, @@ -56,27 +82,38 @@ async fn apply_rule(state: &LauncherState, rule: &RemoteMigration) -> LauncherRe return Ok(false); }; - if rule.from.mc_version == rule.to.mc_version { + if from == to { tracing::warn!(migration_id = %rule.id, "migration is a no-op; retiring rule"); migration_dao::mark_applied(db, &rule.id).await?; return Ok(false); } let Some(source) = - cluster_dao::find_by_version_loader(db, &rule.from.mc_version, loader as i64).await? + cluster_dao::find_by_version_loader(db, &from.mc_version, from.loader as i64).await? else { migration_dao::mark_applied(db, &rule.id).await?; return Ok(false); }; - if cluster_dao::find_by_version_loader(db, &rule.to.mc_version, loader as i64) + if cluster_dao::is_provision_dismissed(db, &to.mc_version, to.loader as i64).await? { + tracing::info!( + migration_id = %rule.id, + to = %to.mc_version, + to_loader = %to.loader, + "target version was dismissed; skipping migration" + ); + return Ok(false); + } + + if cluster_dao::find_by_version_loader(db, &to.mc_version, to.loader as i64) .await? .is_some() { tracing::warn!( migration_id = %rule.id, - from = %rule.from.mc_version, - to = %rule.to.mc_version, + from = %from.mc_version, + to = %to.mc_version, + to_loader = %to.loader, "target cluster already exists; skipping migration" ); return Ok(false); @@ -91,33 +128,123 @@ async fn apply_rule(state: &LauncherState, rule: &RemoteMigration) -> LauncherRe return Ok(false); } - migrate_cluster(state, rule, &source).await?; + if from.loader != to.loader + && !(target_loader_supports(state, rule, &to).await + && target_has_bundles(state, rule, &source, &to).await?) + { + return Ok(false); + } + + if !migrate_cluster(state, rule, &source, &from, &to).await? { + return Ok(false); + } migration_dao::mark_applied(db, &rule.id).await?; tracing::info!( migration_id = %rule.id, cluster_id = source.id, - from = %rule.from.mc_version, - to = %rule.to.mc_version, + from = %from.mc_version, + to = %to.mc_version, + from_loader = %from.loader, + to_loader = %to.loader, "migrated cluster" ); Ok(true) } +async fn target_loader_supports( + state: &LauncherState, + rule: &RemoteMigration, + to: &MigrationNode, +) -> bool { + let mut metadata = state.metadata.lock().await; + match metadata + .loader_supports_version(&state.services.mc(), to.loader, &to.mc_version) + .await + { + Ok(true) => true, + Ok(false) => { + tracing::warn!( + migration_id = %rule.id, + loader = %to.loader, + mc_version = %to.mc_version, + "target loader has no build for this version; skipping migration" + ); + false + } + Err(err) => { + tracing::warn!( + migration_id = %rule.id, + loader = %to.loader, + error = %err, + "could not read the target loader versions; retrying next launch" + ); + false + } + } +} + +async fn target_has_bundles( + state: &LauncherState, + rule: &RemoteMigration, + source: &ClusterRow, + to: &MigrationNode, +) -> LauncherResult { + if rule.allow_without_bundles || source.is_isolated() { + return Ok(true); + } + + let bundles = bundle_catalog_dao::list_visible_for_version_loader( + &state.services.db, + &to.mc_version, + to.loader as i64, + ) + .await?; + + if bundles.is_empty() { + tracing::warn!( + migration_id = %rule.id, + loader = %to.loader, + mc_version = %to.mc_version, + "target has no bundles; skipping migration" + ); + return Ok(false); + } + + Ok(true) +} + async fn migrate_cluster( state: &LauncherState, rule: &RemoteMigration, source: &ClusterRow, -) -> LauncherResult<()> { - let from = &rule.from.mc_version; - let to = &rule.to.mc_version; + from: &MigrationNode, + to: &MigrationNode, +) -> LauncherResult { + let current = cluster_dao::get_by_id(&state.services.db, source.id).await?; + if !current + .as_ref() + .is_some_and(|row| row_unchanged(source, row)) + { + tracing::info!( + migration_id = %rule.id, + cluster_id = source.id, + "cluster changed since the migration pass read it; skipping" + ); + return Ok(false); + } let clusters_dir = oneclient_common::paths::clusters_dir()?; let old_dir = clusters_dir.join(&source.folder_name); - let new_folder = resolve_new_folder(&clusters_dir, &source.folder_name, from, to).await?; - let new_name = retarget_version_prefix(&source.name, from, to); + let new_folder = resolve_new_folder( + &clusters_dir, + &source.folder_name, + retarget_identity(&source.folder_name, from, to), + ) + .await?; + let new_name = retarget_identity(&source.name, from, to); let mut renamed: Option<(PathBuf, PathBuf)> = None; if let Some(folder) = &new_folder { @@ -139,17 +266,30 @@ async fn migrate_cluster( renamed.is_some(), ); - match cluster_dao::migrate_version( - &state.services.db, - source.id, - to, - new_name.as_deref(), - folder_for_db, - ) - .await - { - Ok(_) => Ok(()), - Err(err) => { + let changes_loader = from.loader != to.loader; + let migration = if changes_loader { + ClusterMigration { + mc_version: &to.mc_version, + mc_loader: to.loader as i64, + mc_loader_version: None, + stage: ClusterStage::NotReady as i64, + name: new_name.as_deref(), + folder_name: folder_for_db, + } + } else { + ClusterMigration { + mc_version: &to.mc_version, + mc_loader: source.mc_loader, + mc_loader_version: source.mc_loader_version.as_deref(), + stage: source.stage, + name: new_name.as_deref(), + folder_name: folder_for_db, + } + }; + + match cluster_dao::migrate_version_if_unchanged(&state.services.db, source, migration).await { + Ok(Some(_)) => {} + outcome => { if let Some((old_dir, new_dir)) = renamed && let Err(rollback) = polyio::rename(&new_dir, &old_dir).await { @@ -159,12 +299,84 @@ async fn migrate_cluster( from = ?new_dir, to = ?old_dir, error = %rollback, - "failed to roll back cluster directory rename after database error" + "failed to roll back cluster directory rename" ); } - Err(err.into()) + if let Err(err) = outcome { + return Err(err.into()); + } + tracing::info!( + migration_id = %rule.id, + cluster_id = source.id, + "cluster changed while migrating; rolled back" + ); + return Ok(false); } } + + if let Err(err) = state.clusters.refresh_identity(source.id).await { + tracing::warn!( + migration_id = %rule.id, + cluster_id = source.id, + error = %err, + "could not update the instance file after migrating" + ); + } + + if changes_loader && !to.loader.compatible_with(from.loader) { + match disable_mods(state, source.id).await { + Ok(disabled) => tracing::info!( + migration_id = %rule.id, + cluster_id = source.id, + disabled, + "disabled mods built for the previous loader" + ), + Err(err) => tracing::warn!( + migration_id = %rule.id, + cluster_id = source.id, + error = %err, + "could not disable mods built for the previous loader" + ), + } + } + + Ok(true) +} + +fn row_unchanged(expected: &ClusterRow, current: &ClusterRow) -> bool { + current.mc_version == expected.mc_version + && current.mc_loader == expected.mc_loader + && current.folder_name == expected.folder_name +} + +async fn disable_mods(state: &LauncherState, cluster_id: i64) -> LauncherResult { + let ctx = state.services.content(); + let linked = PackageStore::list_linked_artifacts(cluster_id, &ctx).await?; + let mut disabled = 0; + + for info in linked + .iter() + .filter(|info| info.content_type == ContentType::Mod) + { + if info.enabled { + if let Err(err) = + PackageStore::set_artifact_enabled_to(cluster_id, &info.hash, false, &ctx).await + { + tracing::warn!( + cluster_id, + hash = %info.hash, + error = %err, + "could not disable a mod built for the previous loader" + ); + continue; + } + disabled += 1; + } + + bundle_dao::clear_bundle_tracking(&state.services.db, cluster_id, &info.hash).await?; + } + + Ok(disabled) } fn folder_for_db<'a>(current: &'a str, new_folder: Option<&'a str>, renamed: bool) -> &'a str { @@ -177,10 +389,9 @@ fn folder_for_db<'a>(current: &'a str, new_folder: Option<&'a str>, renamed: boo async fn resolve_new_folder( clusters_dir: &Path, folder_name: &str, - from: &str, - to: &str, + candidate: Option, ) -> LauncherResult> { - let Some(candidate) = retarget_version_prefix(folder_name, from, to) else { + let Some(candidate) = candidate else { tracing::debug!( folder = %folder_name, "folder name is not in generated form; leaving it in place" @@ -200,6 +411,24 @@ async fn resolve_new_folder( Ok(Some(candidate)) } +fn retarget_identity(value: &str, from: &MigrationNode, to: &MigrationNode) -> Option { + if from.loader == to.loader { + return retarget_version_prefix(value, &from.mc_version, &to.mc_version); + } + + let from_prefix = format!("{} {}", from.mc_version, from.loader); + let rest = value + .get(..from_prefix.len()) + .filter(|prefix| prefix.eq_ignore_ascii_case(&from_prefix)) + .map(|_| &value[from_prefix.len()..])?; + if !(rest.is_empty() || rest.starts_with(' ')) { + return None; + } + + let retargeted = format!("{} {}{rest}", to.mc_version, to.loader); + (retargeted != value).then_some(retargeted) +} + fn retarget_version_prefix(value: &str, from: &str, to: &str) -> Option { let rest = value.strip_prefix(from)?; if !rest.starts_with(' ') { @@ -211,6 +440,79 @@ fn retarget_version_prefix(value: &str, from: &str, to: &str) -> Option #[cfg(test)] mod tests { use super::*; + use oneclient_common::domain::GameLoader; + + #[cfg(debug_assertions)] + #[tokio::test] + async fn remote_migration_does_not_restore_a_deleted_target() { + use crate::clusters::CreateClusterOptions; + use crate::versions::{MigrationSource, MigrationTarget}; + + let state = crate::dev::ephemeral_state().await.unwrap(); + let global = state.settings.read().global_game_settings.clone(); + let mut clusters = Vec::new(); + for version in ["26.1", "26.1.2"] { + clusters.push( + state + .clusters + .create_provisioned( + &global, + CreateClusterOptions::new( + format!("{version} Fabric"), + version, + GameLoader::Fabric, + ), + ) + .await + .unwrap() + .unwrap(), + ); + } + let source = &clusters[0]; + let target = &clusters[1]; + let marker = source.dir().unwrap().join("keep.txt"); + polyio::write(&marker, b"keep").await.unwrap(); + state.clusters.delete(target.id, true).await.unwrap(); + + let mut rule = RemoteMigration { + id: "dismissed-target".to_string(), + from: MigrationSource { + mc_version: source.mc_version.clone(), + loader: "fabric".to_string(), + }, + to: MigrationTarget { + mc_version: target.mc_version.clone(), + loader: None, + }, + allow_without_bundles: false, + }; + + for loader in [None, Some("fabric".to_string())] { + rule.to.loader = loader; + assert!(!apply_rule(&state, &rule).await.unwrap()); + let unchanged = state.clusters.get(source.id).await.unwrap(); + assert_eq!(unchanged.mc_version, source.mc_version); + assert_eq!(unchanged.folder_name, source.folder_name); + assert_eq!(polyio::read(&marker).await.unwrap(), b"keep"); + assert!( + cluster_dao::find_by_version_loader( + &state.services.db, + &target.mc_version, + target.mc_loader as i64, + ) + .await + .unwrap() + .is_none() + ); + } + + rule.to.mc_version = "26.1.3".to_string(); + assert!(apply_rule(&state, &rule).await.unwrap()); + assert_eq!( + state.clusters.get(source.id).await.unwrap().mc_version, + "26.1.3" + ); + } #[test] fn retargets_generated_folder() { @@ -253,6 +555,65 @@ mod tests { assert_eq!(retarget_version_prefix("26.1", "26.1", "26.1.2"), None); } + fn node(mc_version: &str, loader: GameLoader) -> MigrationNode { + MigrationNode { + mc_version: mc_version.to_string(), + loader, + } + } + + #[test] + fn version_rules_keep_the_old_retargeting() { + let from = node("26.1", GameLoader::Fabric); + let to = node("26.1.2", GameLoader::Fabric); + assert_eq!( + retarget_identity("26.1 fabric (1)", &from, &to).as_deref(), + Some("26.1.2 fabric (1)") + ); + assert_eq!( + retarget_identity("26.1 my pack", &from, &to).as_deref(), + Some("26.1.2 my pack") + ); + } + + #[test] + fn loader_rules_retarget_generated_names() { + let from = node("1.20.1", GameLoader::Forge); + let to = node("1.20.1", GameLoader::NeoForge); + assert_eq!( + retarget_identity("1.20.1 Forge", &from, &to).as_deref(), + Some("1.20.1 NeoForge") + ); + assert_eq!( + retarget_identity("1.20.1 forge (2)", &from, &to).as_deref(), + Some("1.20.1 NeoForge (2)") + ); + } + + #[test] + fn loader_rules_leave_custom_names_alone() { + let from = node("1.20.1", GameLoader::Forge); + let to = node("1.20.1", GameLoader::NeoForge); + assert_eq!(retarget_identity("1.20.1 my pack", &from, &to), None); + assert_eq!(retarget_identity("1.20.1 Forgery", &from, &to), None); + assert_eq!(retarget_identity("my cool pack", &from, &to), None); + } + + #[test] + fn loader_rules_with_the_same_display_name_do_not_rename() { + let from = node("1.8.9", GameLoader::Fabric); + let to = node("1.8.9", GameLoader::Ornithe); + assert_eq!(retarget_identity("1.8.9 Fabric", &from, &to), None); + } + + #[test] + fn only_fabric_to_quilt_keeps_mods() { + assert!(GameLoader::Quilt.compatible_with(GameLoader::Fabric)); + assert!(!GameLoader::Fabric.compatible_with(GameLoader::Quilt)); + assert!(!GameLoader::NeoForge.compatible_with(GameLoader::Forge)); + assert!(!GameLoader::Vanilla.compatible_with(GameLoader::Fabric)); + } + #[test] fn row_claims_new_folder_only_when_the_directory_moved() { assert_eq!( diff --git a/packages/oneclient_core/src/clusters/mod.rs b/packages/oneclient_core/src/clusters/mod.rs index 37be52b9..883ba3b7 100644 --- a/packages/oneclient_core/src/clusters/mod.rs +++ b/packages/oneclient_core/src/clusters/mod.rs @@ -1,16 +1,28 @@ +mod java_override; mod migrate; +mod modpack; pub(crate) mod prepare; mod provision; +mod release_migration; mod unlink_legacy; +pub use java_override::apply_bundle_java_override; pub use migrate::apply_remote_migrations; +pub use modpack::{ + ModpackCluster, ModpackSource, PreparedModpack, create_modpack_instance, + install_modpack_instance, prepare_modpack, repair_modpack_cluster, update_modpack_cluster, +}; pub use prepare::{ estimate_cluster_download, prepare_cluster, prepare_cluster_locked, required_java_major, }; pub use provision::{ensure_from_bundles, ensure_from_versions}; +pub use release_migration::{ + OfferLookup, ReleaseMigrationOffer, can_migrate_manually, copy_configs, manual_migration_offer, + rank_migration_sources, record_new_versions, release_migration_offer, +}; pub use unlink_legacy::{SweepReport, unlink_legacy_cluster_content}; pub use oneclient_cluster::{ - Cluster, ClusterError, ClusterLinkTarget, ClusterManager, ClusterStage, ClusterUpdate, - CreateClusterOptions, + Cluster, ClusterError, ClusterKind, ClusterLinkTarget, ClusterManager, ClusterStage, + ClusterUpdate, CreateClusterOptions, }; diff --git a/packages/oneclient_core/src/clusters/modpack.rs b/packages/oneclient_core/src/clusters/modpack.rs new file mode 100644 index 00000000..442c3c15 --- /dev/null +++ b/packages/oneclient_core/src/clusters/modpack.rs @@ -0,0 +1,505 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use oneclient_cluster::naming::{is_allowed_name_char, validate_modpack_instance_name}; +use oneclient_cluster::{Cluster, ClusterKind, ClusterUpdate, CreateClusterOptions}; +use oneclient_common::domain::{GameLoader, ProviderId}; +use oneclient_common::patch::Patch; +use oneclient_content::ContentError; +use oneclient_content::modpacks::{self, ModpackInstallReport, ModpackManifest, ModpackRelease}; +use oneclient_content::packages::store::artifact_absolute_path; +use oneclient_content::packages::{PackageStore, ProjectDetail}; +use oneclient_db::dao::artifact::get_artifact_by_hash; +use oneclient_events::GroupedProgressSession; + +use crate::LauncherResult; +use crate::images::DEFAULT_IMAGE_EDGE; +use crate::state::LauncherState; + +const FALLBACK_NAME: &str = "Modpack"; +const MODPACK_ICON_EDGE: u32 = 256; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModpackSource { + File(PathBuf), + Provider { + provider: ProviderId, + project_id: String, + version_id: String, + }, +} + +#[derive(Debug, Clone)] +pub struct ModpackCluster { + pub cluster: Cluster, + pub report: ModpackInstallReport, +} + +#[derive(Debug, Clone)] +pub struct PreparedModpack { + pub source: ModpackSource, + pub artifact_hash: String, + pub archive_path: PathBuf, + pub manifest: ModpackManifest, + pub instance_name: String, +} + +#[tracing::instrument(skip(state))] +pub async fn prepare_modpack( + state: &Arc, + source: &ModpackSource, +) -> LauncherResult { + let content = state.services.content(); + + let artifact = match source { + ModpackSource::File(path) => modpacks::store_modpack_archive(path, &content).await?, + ModpackSource::Provider { + provider, + project_id, + version_id, + } => PackageStore::resolve_or_download(*provider, project_id, version_id, &content).await?, + }; + let archive_path = artifact_absolute_path(&artifact.path)?; + let manifest = modpacks::read_modpack(&archive_path, &content).await?; + + let taken: Vec = state + .clusters + .list() + .await? + .into_iter() + .map(|cluster| cluster.name) + .collect(); + + Ok(PreparedModpack { + source: source.clone(), + artifact_hash: artifact.hash, + archive_path, + instance_name: unique_name(&instance_name(&manifest.name), &taken), + manifest, + }) +} + +#[tracing::instrument(skip(state, prepared), fields(pack = %prepared.manifest.name))] +pub async fn create_modpack_instance( + state: &Arc, + prepared: &PreparedModpack, +) -> LauncherResult { + let manifest = &prepared.manifest; + let global = state.settings.read().global_game_settings.clone(); + let mut options = CreateClusterOptions::new( + prepared.instance_name.clone(), + manifest.mc_version.clone(), + manifest.loader, + ) + .kind(kind_for(manifest.loader)) + .user_created(true) + .modpack(true); + options.mc_loader_version = manifest.loader_version.clone(); + options.description = manifest.summary.clone(); + + let cluster = state.clusters.create(&global, options).await?; + let linked = state + .clusters + .update( + cluster.id, + ClusterUpdate { + linked_modpack_hash: Patch::Set(prepared.artifact_hash.clone()), + ..Default::default() + }, + ) + .await; + + match linked { + Ok(cluster) => Ok(cluster), + Err(err) => { + if let Err(cleanup) = state.clusters.delete(cluster.id, true).await { + tracing::warn!(cluster_id = cluster.id, error = %cleanup, "could not remove the unlinked modpack instance"); + } + Err(err.into()) + } + } +} + +#[tracing::instrument(skip(state, prepared, progress), fields(pack = %prepared.manifest.name))] +pub async fn install_modpack_instance( + state: &Arc, + cluster_id: i64, + prepared: &PreparedModpack, + progress: Option<&GroupedProgressSession>, +) -> LauncherResult { + let content = state.services.content(); + let report = modpacks::install_modpack( + &prepared.archive_path, + &prepared.manifest, + cluster_id, + progress, + &content, + ) + .await?; + + match modpacks::identify_modpack(&prepared.artifact_hash, &content).await { + Ok(Some(release)) => { + if let Err(err) = apply_project_details(state, cluster_id, &release).await { + tracing::debug!(cluster_id, error = %err, "could not read the modpack's project page"); + } + } + Ok(None) => {} + Err(err) => { + tracing::debug!(error = %err, "could not identify the modpack on any provider"); + } + } + + let cluster = state.clusters.get(cluster_id).await?; + Ok(ModpackCluster { cluster, report }) +} + +#[tracing::instrument(skip(state, progress))] +pub async fn update_modpack_cluster( + state: &Arc, + cluster_id: i64, + version_id: &str, + progress: Option<&GroupedProgressSession>, +) -> LauncherResult { + let content = state.services.content(); + let cluster = state.clusters.get(cluster_id).await?; + let current = modpacks::cluster_modpack(cluster_id, &content) + .await? + .ok_or_else(|| ContentError::InvalidData { + reason: "This instance's modpack is not published on Modrinth or CurseForge." + .to_string(), + })?; + + let artifact = PackageStore::resolve_or_download( + current.provider, + ¤t.project_id, + version_id, + &content, + ) + .await?; + let archive_path = artifact_absolute_path(&artifact.path)?; + let manifest = modpacks::read_modpack(&archive_path, &content).await?; + + if manifest.mc_version != cluster.mc_version || manifest.loader != cluster.mc_loader { + return Err(ContentError::InvalidData { + reason: format!( + "This version needs Minecraft {} with {}. Install it as a new instance instead.", + manifest.mc_version, manifest.loader + ), + } + .into()); + } + + let report = + modpacks::install_modpack(&archive_path, &manifest, cluster_id, progress, &content).await?; + + let loader_version = match &manifest.loader_version { + Some(version) if cluster.mc_loader_version.as_ref() != Some(version) => { + Patch::Set(version.clone()) + } + _ => Patch::Unchanged, + }; + let cluster = state + .clusters + .update( + cluster_id, + ClusterUpdate { + linked_modpack_hash: Patch::Set(artifact.hash.clone()), + mc_loader_version: loader_version, + ..Default::default() + }, + ) + .await?; + restore_missing_icon(state, &cluster).await; + + Ok(ModpackCluster { cluster, report }) +} + +#[tracing::instrument(skip(state, progress))] +pub async fn repair_modpack_cluster( + state: &Arc, + cluster_id: i64, + progress: Option<&GroupedProgressSession>, +) -> LauncherResult { + let content = state.services.content(); + let cluster = state.clusters.get(cluster_id).await?; + let hash = cluster + .linked_modpack_hash + .clone() + .ok_or_else(|| ContentError::InvalidData { + reason: "This instance was not installed from a modpack.".to_string(), + })?; + + let artifact = get_artifact_by_hash(&state.services.db, &hash) + .await? + .ok_or_else(|| ContentError::InvalidData { + reason: + "The modpack file for this instance is gone. Update the pack to fetch it again." + .to_string(), + })?; + let archive_path = artifact_absolute_path(&artifact.path)?; + let manifest = modpacks::read_modpack(&archive_path, &content).await?; + + let report = + modpacks::install_modpack(&archive_path, &manifest, cluster_id, progress, &content).await?; + restore_missing_icon(state, &cluster).await; + + Ok(ModpackCluster { cluster, report }) +} + +async fn fetch_project( + state: &Arc, + release: &ModpackRelease, +) -> LauncherResult { + let content = state.services.content(); + Ok(content + .providers + .get(release.provider)? + .get_project(&release.project_id, &content) + .await?) +} + +async fn apply_project_details( + state: &Arc, + cluster_id: i64, + release: &ModpackRelease, +) -> LauncherResult<()> { + let project = fetch_project(state, release).await?; + let cluster = state.clusters.get(cluster_id).await?; + + if let Err(err) = apply_project_description(state, &cluster, &project).await { + tracing::debug!(cluster_id, error = %err, "could not set the modpack description"); + } + if let Err(err) = save_project_icon(state, &cluster, &project).await { + tracing::debug!(cluster_id, error = %err, "no icon for the modpack"); + } + if let Err(err) = apply_project_cover(state, &cluster, &project).await { + tracing::debug!(cluster_id, error = %err, "no cover art for the modpack"); + } + Ok(()) +} + +async fn save_project_icon( + state: &Arc, + cluster: &Cluster, + project: &ProjectDetail, +) -> LauncherResult<()> { + let (Some(url), Some(dest)) = ( + project.icon_url.as_deref().filter(|url| !url.is_empty()), + cluster.modpack_icon_file(), + ) else { + return Ok(()); + }; + + let cached = state + .images + .ensure_on_disk(&state.services.requester, url, MODPACK_ICON_EDGE) + .await?; + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await?; + } + polyio::copy(&cached, &dest).await?; + Ok(()) +} + +async fn restore_missing_icon(state: &Arc, cluster: &Cluster) { + if cluster + .modpack_icon_file() + .is_none_or(|icon| icon.is_file()) + { + return; + } + + let content = state.services.content(); + let restored = async { + let Some(release) = modpacks::cluster_modpack(cluster.id, &content).await? else { + return Ok(()); + }; + let project = fetch_project(state, &release).await?; + save_project_icon(state, cluster, &project).await + }; + let result: LauncherResult<()> = restored.await; + if let Err(err) = result { + tracing::debug!(cluster_id = cluster.id, error = %err, "could not restore the modpack icon"); + } +} + +async fn apply_project_description( + state: &Arc, + cluster: &Cluster, + project: &ProjectDetail, +) -> LauncherResult<()> { + let Some(description) = project_description(cluster.description.as_deref(), project) else { + return Ok(()); + }; + + state + .clusters + .update( + cluster.id, + ClusterUpdate { + description: Patch::Set(description), + ..Default::default() + }, + ) + .await?; + Ok(()) +} + +fn project_description(current: Option<&str>, project: &ProjectDetail) -> Option { + if current.is_some_and(|current| !current.trim().is_empty()) { + return None; + } + let summary = project.summary.trim(); + (!summary.is_empty()).then(|| summary.to_string()) +} + +async fn apply_project_cover( + state: &Arc, + cluster: &Cluster, + project: &ProjectDetail, +) -> LauncherResult<()> { + let Some(url) = cover_url(project) else { + return Ok(()); + }; + + let cached = state + .images + .ensure_on_disk(&state.services.requester, &url, DEFAULT_IMAGE_EDGE) + .await?; + state + .clusters + .set_cover_from_file(cluster.id, &cached) + .await?; + Ok(()) +} + +fn cover_url(project: &ProjectDetail) -> Option { + project + .gallery + .iter() + .find(|image| image.featured) + .or_else(|| project.gallery.first()) + .map(|image| image.url.clone()) + .or_else(|| project.icon_url.clone()) + .filter(|url| !url.is_empty()) +} + +fn kind_for(loader: GameLoader) -> ClusterKind { + if loader.is_modded() { + ClusterKind::Modded + } else { + ClusterKind::Vanilla + } +} + +fn unique_name(base: &str, taken: &[String]) -> String { + let is_taken = |candidate: &str| { + taken + .iter() + .any(|name| name.trim().eq_ignore_ascii_case(candidate)) + }; + if !is_taken(base) { + return base.to_string(); + } + + (2..) + .map(|n| format!("{base} ({n})")) + .find(|candidate| !is_taken(candidate)) + .unwrap_or_else(|| base.to_string()) +} + +fn instance_name(pack_name: &str) -> String { + let name = pack_name + .chars() + .filter(|c| is_allowed_name_char(*c)) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + + if validate_modpack_instance_name(&name).is_ok() { + name + } else { + FALLBACK_NAME.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn project(summary: &str) -> ProjectDetail { + ProjectDetail { + id: "pack".into(), + slug: "pack".into(), + provider: ProviderId::Modrinth, + content_type: oneclient_content::packages::ContentType::Modpack, + name: "Pack".into(), + summary: summary.into(), + author: String::new(), + members: Vec::new(), + gallery: Vec::new(), + body: oneclient_content::packages::PackageBody::Raw(String::new()), + license: None, + links: Vec::new(), + version_ids: Vec::new(), + game_versions: Vec::new(), + loaders: Vec::new(), + icon_url: None, + created: chrono::Utc::now(), + updated: chrono::Utc::now(), + downloads: 0, + } + } + + #[test] + fn the_project_summary_only_fills_an_empty_description() { + let summary = " Adventure pack with 300+ mods. "; + + assert_eq!( + project_description(None, &project(summary)), + Some("Adventure pack with 300+ mods.".to_string()) + ); + assert_eq!( + project_description(Some(" "), &project(summary)), + Some("Adventure pack with 300+ mods.".to_string()) + ); + assert_eq!(project_description(Some("Mine"), &project(summary)), None); + assert_eq!(project_description(None, &project(" ")), None); + } + + #[test] + fn pack_names_are_cut_down_to_a_valid_instance_name() { + assert_eq!( + instance_name("Fabulously Optimized"), + "Fabulously Optimized" + ); + assert_eq!( + instance_name("All the Mods 10: To the Sky"), + "All the Mods 10 To the Sky" + ); + assert_eq!(instance_name("Better MC [FORGE]"), "Better MC FORGE"); + assert_eq!(instance_name("✨✨✨"), FALLBACK_NAME); + assert_eq!(instance_name(""), FALLBACK_NAME); + } + + #[test] + fn a_second_copy_of_a_pack_gets_a_numbered_name() { + let taken = vec!["Better MC FABRIC BMC".to_string()]; + assert_eq!( + unique_name("Better MC FABRIC BMC", &taken), + "Better MC FABRIC BMC (2)" + ); + + let taken = vec!["Fabulously".to_string(), "fabulously (2)".to_string()]; + assert_eq!(unique_name("Fabulously", &taken), "Fabulously (3)"); + assert_eq!(unique_name("Fresh", &taken), "Fresh"); + assert!(validate_modpack_instance_name(&unique_name("Fabulously", &taken)).is_ok()); + } + + #[test] + fn a_pack_without_a_loader_makes_a_vanilla_instance() { + assert_eq!(kind_for(GameLoader::Vanilla), ClusterKind::Vanilla); + assert_eq!(kind_for(GameLoader::Fabric), ClusterKind::Modded); + } +} diff --git a/packages/oneclient_core/src/clusters/prepare.rs b/packages/oneclient_core/src/clusters/prepare.rs index d4e9ed51..772bcecc 100644 --- a/packages/oneclient_core/src/clusters/prepare.rs +++ b/packages/oneclient_core/src/clusters/prepare.rs @@ -5,18 +5,18 @@ use interfrost::api::minecraft::{DownloadType, VersionInfo}; use interfrost::api::modded::SidedDataEntry; use tokio::process::Command; -use oneclient_cluster::Cluster; -use oneclient_cluster::ClusterError; -use oneclient_cluster::ClusterStage; use crate::game::{ self, download_minecraft, download_version_info, get_loader_version, resolve_minecraft_version, }; -use oneclient_java::JavaRuntime; -use oneclient_mc::MetadataStore; -use oneclient_events::GroupedProgressSession; -use oneclient_common::paths; use crate::state::{LauncherServices, LauncherState}; use crate::{GameError, LauncherResult}; +use oneclient_cluster::Cluster; +use oneclient_cluster::ClusterError; +use oneclient_cluster::ClusterStage; +use oneclient_common::paths; +use oneclient_events::GroupedProgressSession; +use oneclient_java::JavaRuntime; +use oneclient_mc::MetadataStore; #[tracing::instrument(skip(state, shared_progress))] pub async fn prepare_cluster_locked( @@ -50,6 +50,7 @@ pub async fn prepare_cluster( shared_progress: Option<&GroupedProgressSession>, ) -> LauncherResult { let cluster = state.clusters.get(cluster_id).await?; + state.clusters.ensure_dedicated_marker(&cluster).await?; let continuing = cluster.stage == ClusterStage::Downloading; tracing::info!( @@ -61,7 +62,10 @@ pub async fn prepare_cluster( ); if !continuing { - state.clusters.set_stage(cluster_id, ClusterStage::Downloading).await?; + state + .clusters + .set_stage(cluster_id, ClusterStage::Downloading) + .await?; } let owned = shared_progress.is_none().then(|| { @@ -99,12 +103,18 @@ pub async fn prepare_cluster( tracing::error!(cluster_id, error = %err, "cluster preparation failed"); } if !continuing { - let _ = state.clusters.set_stage(cluster_id, ClusterStage::NotReady).await; + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::NotReady) + .await; } return Err(err); } - let cluster = state.clusters.set_stage(cluster_id, ClusterStage::Ready).await?; + let cluster = state + .clusters + .set_stage(cluster_id, ClusterStage::Ready) + .await?; tracing::debug!(cluster_id, "cluster stage set to Ready"); Ok(cluster) } @@ -133,7 +143,16 @@ async fn game_download_bytes(services: &LauncherServices, info: &VersionInfo) -> let _ = services; // Runs before a Java runtime is resolved so rules are checked against the // host architecture only natives-related edge cases shift - match game::plan_downloads(info, assets_index, std::env::consts::ARCH, false, false).await { + match game::plan_downloads( + info, + assets_index, + std::env::consts::ARCH, + false, + false, + game::NativesMode::Extract, + ) + .await + { Ok(plan) => plan.total_bytes(), Err(err) => { tracing::warn!("download plan failed, estimating full size: {err}"); @@ -174,8 +193,15 @@ pub async fn required_java_major( cluster.mc_loader_version.as_deref(), ) .await?; - download_version_info(&state.services.mc(), None, &version, loader_version.as_ref(), false) - .await? + download_version_info( + &state.services.mc(), + None, + &version, + cluster.mc_loader, + loader_version.as_ref(), + false, + ) + .await? }; Ok(info.java_version.map(|java| java.major_version)) @@ -202,23 +228,33 @@ pub async fn estimate_cluster_download( cluster.mc_loader_version.as_deref(), ) .await?; - download_version_info(&state.services.mc(), None, &version, loader_version.as_ref(), false).await? + download_version_info( + &state.services.mc(), + None, + &version, + cluster.mc_loader, + loader_version.as_ref(), + false, + ) + .await? }; let mut total = game_download_bytes(&state.services, &info).await; if let Some(java) = &info.java_version { - let installed = state.java.list_runtimes() - .await - .unwrap_or_default(); + let installed = state.java.list_runtimes().await.unwrap_or_default(); if !installed.iter().any(|rt| rt.major == java.major_version) { total += JRE_ESTIMATE_BYTES; } } - total += oneclient_content::bundles::enabled_bundle_bytes(cluster_id, bundles, &state.services.content()) - .await - .unwrap_or(0); + total += oneclient_content::bundles::enabled_bundle_bytes( + cluster_id, + bundles, + &state.services.content(), + ) + .await + .unwrap_or(0); Ok(total) } @@ -254,6 +290,7 @@ async fn install_cluster( &state.services.mc(), Some(progress), &version, + cluster.mc_loader, loader_version.as_ref(), force, ) @@ -265,17 +302,26 @@ async fn install_cluster( .map(|v| v.major_version) .ok_or(ClusterError::MissingJavaVersion)?; - let java = if let Some(runtime) = - state.java.runtime_for_profile(profile.java_path.as_deref()).await? + let java = if let Some(runtime) = state + .java + .runtime_for_profile(profile.java_path.as_deref()) + .await? { runtime } else { state .java - .prepare(java_major, search_for_java, auto_install_java, Some(progress)) + .prepare( + java_major, + search_for_java, + auto_install_java, + Some(progress), + ) .await? }; + let natives_mode = crate::game::natives::natives_mode(state, &version_info.id, None); + download_minecraft( &state.services.mc(), progress, @@ -283,6 +329,7 @@ async fn install_cluster( &java.os_arch, minecraft_updated, force, + natives_mode, ) .await?; @@ -305,7 +352,7 @@ async fn run_forge_processors( .join(&version_info.id) .join(format!("{}.jar", version_info.id)); let libraries = paths::libraries_dir()?; - let cluster_dir = cluster.game_dir()?; + let install_root = paths::metadata_dir()?; let Some(data) = &mut version_info.data else { return Ok(()); @@ -336,7 +383,7 @@ async fn run_forge_processors( client => cluster.mc_version.clone(), server => ""; "ROOT": - client => cluster_dir.to_string_lossy(), + client => install_root.to_string_lossy(), server => ""; "LIBRARY_DIR": client => libraries.to_string_lossy(), diff --git a/packages/oneclient_core/src/clusters/provision.rs b/packages/oneclient_core/src/clusters/provision.rs index 767f5f1a..a4fde317 100644 --- a/packages/oneclient_core/src/clusters/provision.rs +++ b/packages/oneclient_core/src/clusters/provision.rs @@ -2,10 +2,10 @@ use std::str::FromStr; use oneclient_db::dao::{bundle as bundle_dao, cluster as cluster_dao}; -use oneclient_common::domain::GameLoader; -use crate::state::LauncherState; -use oneclient_common::version::format_mc_version; use crate::LauncherResult; +use crate::state::LauncherState; +use oneclient_common::domain::GameLoader; +use oneclient_common::version::format_mc_version; use oneclient_cluster::Cluster; use oneclient_cluster::CreateClusterOptions; @@ -45,7 +45,7 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult { tracing::info!( @@ -73,7 +73,10 @@ pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult LauncherResult> { - let metadata = state.versions.metadata(&state.services.requester.config().meta_url_base).await; + let metadata = state + .versions + .metadata(&state.services.requester.config().meta_url_base) + .await; let mut created = Vec::new(); for entry in metadata { @@ -110,7 +113,7 @@ pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult { tracing::info!( diff --git a/packages/oneclient_core/src/clusters/release_migration.rs b/packages/oneclient_core/src/clusters/release_migration.rs new file mode 100644 index 00000000..f6a8233a --- /dev/null +++ b/packages/oneclient_core/src/clusters/release_migration.rs @@ -0,0 +1,457 @@ +use std::cmp::Reverse; +use std::collections::HashSet; +use std::path::Path; + +use oneclient_common::domain::GameLoader; + +use oneclient_cluster::Cluster; +use oneclient_common::version::parse_mc_version; +use oneclient_content::bundles::bundle_override_paths; +use oneclient_content::packages::release_migration::{ + has_migratable_packages, has_packages_to_migrate, +}; + +use crate::LauncherResult; +use crate::state::LauncherState; +use crate::versions::{ReleaseTarget, RemoteMigration}; + +#[derive(Debug, Clone)] +pub struct ReleaseMigrationOffer { + pub release: ReleaseTarget, + pub target: Cluster, + pub sources: Vec, +} + +#[derive(Debug, Clone)] +pub enum OfferLookup { + MissingCluster, + NoSources, + Offer(Box), +} + +fn version_order(mc_version: &str) -> Option<(u32, u32, u32)> { + let parsed = parse_mc_version(mc_version)?; + Some(( + parsed.major, + parsed.minor.unwrap_or(0), + parsed.patch.unwrap_or(0), + )) +} + +fn source_rank( + target: (u32, u32, u32), + source: (u32, u32, u32), +) -> (bool, Reverse<(u32, u32, u32)>) { + (source > target, Reverse(source)) +} + +#[must_use] +pub fn can_migrate_manually(source: GameLoader, target: GameLoader) -> bool { + source == target || (source == GameLoader::Fabric && target == GameLoader::Ornithe) +} + +fn is_migration_source( + candidate: &Cluster, + target: &Cluster, + loader_ok: impl Fn(GameLoader, GameLoader) -> bool, +) -> bool { + candidate.id != target.id && loader_ok(candidate.mc_loader, target.mc_loader) +} + +fn is_migration_destination(target: &ReleaseTarget, rules: &[RemoteMigration]) -> bool { + rules.iter().any(|rule| { + rule.endpoints() + .is_some_and(|(_, to)| to.mc_version == target.mc_version && to.loader == target.loader) + }) +} + +#[tracing::instrument(skip(state))] +pub async fn record_new_versions(state: &LauncherState) -> LauncherResult<()> { + let rules = state.versions.migrations().await; + let mut added: Vec = Vec::new(); + for target in state.versions.take_added_versions() { + if is_migration_destination(&target, &rules) { + continue; + } + if !state.versions.shows_initial_migration(&target).await { + tracing::info!( + mc_version = %target.mc_version, + loader = %target.loader, + "new version opts out of the initial migration offer" + ); + continue; + } + added.push(target); + } + if added.is_empty() { + return Ok(()); + } + + let updated = { + let mut settings = state.settings.write(); + for target in &added { + let key = target.key(); + if !settings.pending_release_migrations.contains(&key) { + settings.pending_release_migrations.push(key); + } + } + settings.clone() + }; + + crate::settings::store::save_settings(&updated).await +} + +#[tracing::instrument(skip(state))] +pub async fn release_migration_offer( + state: &LauncherState, + release: &ReleaseTarget, +) -> LauncherResult { + let clusters = state.clusters.list().await?; + let content = state.services.content(); + + let Some(target) = clusters + .iter() + .filter(|cluster| { + !cluster.user_created + && cluster.mc_loader == release.loader + && cluster.mc_version == release.mc_version + }) + .min_by_key(|cluster| cluster.created_at) + else { + return Ok(OfferLookup::MissingCluster); + }; + + offer_for(target, &clusters, &content).await +} + +#[tracing::instrument(skip(state))] +pub async fn manual_migration_offer( + state: &LauncherState, + target_cluster_id: i64, + source_cluster_id: i64, +) -> LauncherResult { + let clusters = state.clusters.list().await?; + let content = state.services.content(); + + let (Some(target), Some(source)) = ( + clusters + .iter() + .find(|cluster| cluster.id == target_cluster_id), + clusters + .iter() + .find(|cluster| cluster.id == source_cluster_id), + ) else { + return Ok(OfferLookup::MissingCluster); + }; + + if !is_migration_source(source, target, can_migrate_manually) + || !has_packages_to_migrate(source.id, target.id, &state.bundles, &content).await? + { + return Ok(OfferLookup::NoSources); + } + + Ok(OfferLookup::Offer(Box::new(ReleaseMigrationOffer { + release: ReleaseTarget { + mc_version: target.mc_version.clone(), + loader: target.mc_loader, + }, + target: target.clone(), + sources: vec![source.clone()], + }))) +} + +#[must_use] +pub fn rank_migration_sources(target: &Cluster, clusters: &[Cluster]) -> Vec { + let target_order = version_order(&target.mc_version); + let mut sources: Vec = clusters + .iter() + .filter(|cluster| is_migration_source(cluster, target, can_migrate_manually)) + .cloned() + .collect(); + sources.sort_by_key(|cluster| { + ( + cluster.mc_loader != target.mc_loader, + target_order + .zip(version_order(&cluster.mc_version)) + .map(|(target, source)| source_rank(target, source)), + Reverse(cluster.last_played), + ) + }); + sources +} + +async fn offer_for( + target: &Cluster, + clusters: &[Cluster], + content: &oneclient_content::ContentCtx, +) -> LauncherResult { + let Some(target_order) = version_order(&target.mc_version) else { + return Ok(OfferLookup::NoSources); + }; + + let mut sources = Vec::new(); + for cluster in clusters { + if cluster.user_created || !is_migration_source(cluster, target, |from, to| from == to) { + continue; + } + if !version_order(&cluster.mc_version).is_some_and(|order| order != target_order) { + continue; + } + if has_migratable_packages(cluster.id, content).await? { + sources.push(cluster.clone()); + } + } + + if sources.is_empty() { + return Ok(OfferLookup::NoSources); + } + + sources.sort_by_key(|cluster| { + ( + version_order(&cluster.mc_version).map(|order| source_rank(target_order, order)), + Reverse(cluster.last_played), + ) + }); + + Ok(OfferLookup::Offer(Box::new(ReleaseMigrationOffer { + release: ReleaseTarget { + mc_version: target.mc_version.clone(), + loader: target.mc_loader, + }, + target: target.clone(), + sources, + }))) +} + +pub async fn copy_configs( + state: &LauncherState, + source: &Cluster, + target: &Cluster, +) -> LauncherResult { + if !target.uses_dedicated_dir() { + return Ok(0); + } + let from = source.game_dir()?.join("config"); + let to = target.game_dir()?.join("config"); + if from == to || !polyio::try_exists(&from).await? { + return Ok(0); + } + let bundled = bundle_override_paths( + &state.bundles, + &state.services.content(), + &target.mc_version, + target.mc_loader, + ) + .await?; + copy_missing(&from, &to, "config", &bundled).await +} + +async fn copy_missing( + from: &Path, + to: &Path, + rel: &str, + skip: &HashSet, +) -> LauncherResult { + let mut copied = 0; + let mut stack = vec![(from.to_path_buf(), to.to_path_buf(), rel.to_string())]; + while let Some((src, dst, rel)) = stack.pop() { + polyio::create_dir_all(&dst).await?; + let mut entries = polyio::read_dir(&src).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let child = dst.join(&name); + let child_rel = format!("{rel}/{}", name.to_string_lossy()); + let file_type = entry.file_type().await?; + if file_type.is_dir() { + stack.push((entry.path(), child, child_rel)); + } else if file_type.is_file() + && !skip.contains(&child_rel) + && !polyio::try_exists(&child).await? + { + polyio::copy(entry.path(), &child).await?; + copied += 1; + } + } + } + Ok(copied) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn copy_missing_keeps_existing_and_bundled_files() { + let src = polyio::tempdir().await.expect("src dir"); + let dst = polyio::tempdir().await.expect("dst dir"); + let (src, dst) = (src.dir_path(), dst.dir_path()); + + polyio::create_dir_all(src.join("sub")).await.unwrap(); + polyio::write(src.join("a.json"), b"old").await.unwrap(); + polyio::write(src.join("sub/b.json"), b"b").await.unwrap(); + polyio::write(src.join("sub/bundled.json"), b"bundled") + .await + .unwrap(); + polyio::write(dst.join("a.json"), b"keep").await.unwrap(); + + let skip = HashSet::from(["config/sub/bundled.json".to_string()]); + assert_eq!(copy_missing(src, dst, "config", &skip).await.unwrap(), 1); + assert!( + !polyio::try_exists(dst.join("sub/bundled.json")) + .await + .unwrap() + ); + assert_eq!( + polyio::read_to_string(dst.join("a.json")).await.unwrap(), + "keep" + ); + assert_eq!( + polyio::read_to_string(dst.join("sub/b.json")) + .await + .unwrap(), + "b" + ); + } + + #[test] + fn newer_minor_orders_above_older_minor() { + assert!(version_order("26.2") < version_order("26.3")); + assert!(version_order("26.1.2") < version_order("26.2")); + assert!(version_order("1.21.4") < version_order("26.1")); + } + + #[test] + fn upgrades_prefer_the_newest_older_cluster() { + let target = version_order("26.3").unwrap(); + let mut sources = vec!["26.1", "26.4", "26.2", "1.21.4"]; + sources.sort_by_key(|v| source_rank(target, version_order(v).unwrap())); + assert_eq!(sources, vec!["26.2", "26.1", "1.21.4", "26.4"]); + } + + #[test] + fn downgrades_prefer_the_newest_cluster() { + let target = version_order("1.8.9").unwrap(); + let mut sources = vec!["1.21.4", "26.2", "26.1"]; + sources.sort_by_key(|v| source_rank(target, version_order(v).unwrap())); + assert_eq!(sources, vec!["26.2", "26.1", "1.21.4"]); + } + + #[test] + fn a_version_that_existing_clusters_move_to_is_not_a_new_release() { + let rules = vec![RemoteMigration { + id: "x".into(), + from: crate::versions::MigrationSource { + mc_version: "26.1".into(), + loader: "fabric".into(), + }, + to: crate::versions::MigrationTarget { + mc_version: "26.1.2".into(), + loader: None, + }, + allow_without_bundles: false, + }]; + let moved = ReleaseTarget { + mc_version: "26.1.2".into(), + loader: GameLoader::Fabric, + }; + let released = ReleaseTarget { + mc_version: "26.3".into(), + loader: GameLoader::Fabric, + }; + let other_loader = ReleaseTarget { + mc_version: "26.1.2".into(), + loader: GameLoader::NeoForge, + }; + + assert!(is_migration_destination(&moved, &rules)); + assert!(!is_migration_destination(&released, &rules)); + assert!(!is_migration_destination(&other_loader, &rules)); + } + + #[test] + fn a_loader_switch_destination_uses_the_target_loader() { + let rules = vec![RemoteMigration { + id: "x".into(), + from: crate::versions::MigrationSource { + mc_version: "1.20.1".into(), + loader: "forge".into(), + }, + to: crate::versions::MigrationTarget { + mc_version: "1.20.1".into(), + loader: Some("neoforge".into()), + }, + allow_without_bundles: false, + }]; + let destination = ReleaseTarget { + mc_version: "1.20.1".into(), + loader: GameLoader::NeoForge, + }; + let source = ReleaseTarget { + mc_version: "1.20.1".into(), + loader: GameLoader::Forge, + }; + + assert!(is_migration_destination(&destination, &rules)); + assert!(!is_migration_destination(&source, &rules)); + } + + #[test] + fn a_same_version_source_comes_before_older_ones() { + let target = version_order("26.2").unwrap(); + let mut sources = vec!["26.1.2", "26.2", "1.21.11"]; + sources.sort_by_key(|v| source_rank(target, version_order(v).unwrap())); + assert_eq!(sources, vec!["26.2", "26.1.2", "1.21.11"]); + } + + #[test] + fn fabric_sources_can_fill_an_ornithe_cluster_manually() { + assert!(can_migrate_manually( + GameLoader::Fabric, + GameLoader::Ornithe + )); + assert!(can_migrate_manually(GameLoader::Fabric, GameLoader::Fabric)); + assert!(!can_migrate_manually( + GameLoader::Ornithe, + GameLoader::Fabric + )); + assert!(!can_migrate_manually( + GameLoader::Forge, + GameLoader::Ornithe + )); + } + + #[test] + fn a_bare_minor_orders_below_its_patches() { + assert!(version_order("26.3") < version_order("26.3.1")); + } + + fn user_cluster(id: i64, mc_version: &str) -> Cluster { + Cluster { + id, + name: format!("{mc_version} Fabric"), + folder_name: format!("cluster-{id}"), + setting_profile_name: None, + mc_version: mc_version.to_string(), + mc_loader: GameLoader::Fabric, + mc_loader_version: None, + stage: oneclient_cluster::ClusterStage::default(), + created_at: None, + last_played: None, + overall_played: std::time::Duration::ZERO, + linked_modpack_hash: None, + kind: oneclient_cluster::ClusterKind::OneClient, + user_created: true, + description: None, + tags: Vec::new(), + cover_path: None, + } + } + + #[test] + fn manual_migration_lists_user_created_sources() { + let target = user_cluster(1, "26.2"); + let clusters = [target.clone(), user_cluster(2, "26.3")]; + let sources = rank_migration_sources(&target, &clusters); + assert_eq!(sources.iter().map(|c| c.id).collect::>(), [2]); + } +} diff --git a/packages/oneclient_core/src/clusters/unlink_legacy.rs b/packages/oneclient_core/src/clusters/unlink_legacy.rs index 49d94c71..aadc2ed7 100644 --- a/packages/oneclient_core/src/clusters/unlink_legacy.rs +++ b/packages/oneclient_core/src/clusters/unlink_legacy.rs @@ -5,195 +5,194 @@ use oneclient_db::dao::artifact as artifact_dao; use crate::LauncherResult; use crate::state::LauncherState; use oneclient_common::domain::ContentType; -use oneclient_content::packages::store::manifest::{ - self, ManifestEntry, MaterializedManifest, -}; use oneclient_content::packages::store::artifact_absolute_path; +use oneclient_content::packages::store::manifest::{self, ManifestEntry, MaterializedManifest}; const SWEPT_TYPES: [ContentType; 2] = [ContentType::Mod, ContentType::DataPack]; const SWEPT_TYPES_REDIRECTED: [ContentType; 1] = [ContentType::DataPack]; async fn swept_types(cluster_root: &Path) -> &'static [ContentType] { - if manifest::mods_live_in_cluster(cluster_root).await { - &SWEPT_TYPES_REDIRECTED - } else { - &SWEPT_TYPES - } + if manifest::mods_live_in_cluster(cluster_root).await { + &SWEPT_TYPES_REDIRECTED + } else { + &SWEPT_TYPES + } } #[derive(Debug, Default, Clone, Copy)] pub struct SweepReport { - pub removed: usize, - pub adopted: usize, - pub skipped: usize, + pub removed: usize, + pub adopted: usize, + pub skipped: usize, } #[tracing::instrument(skip(state))] pub async fn unlink_legacy_cluster_content(state: &LauncherState) -> LauncherResult { - let mut report = SweepReport::default(); + let mut report = SweepReport::default(); - for cluster in state.clusters.list().await? { - let dedicated = cluster.uses_dedicated_dir(); - let Ok(cluster_root) = cluster.dir() else { - continue; - }; + for cluster in state.clusters.list().await? { + let dedicated = cluster.uses_dedicated_dir(); + let Ok(cluster_root) = cluster.dir() else { + continue; + }; - let swept = swept_types(&cluster_root).await; + let swept = swept_types(&cluster_root).await; - if dedicated { - adopt_dedicated(state, &cluster, &cluster_root, swept, &mut report).await; - } else { - sweep_shared(state, &cluster_root, swept, &mut report).await; - } - } + if dedicated { + adopt_dedicated(state, &cluster, &cluster_root, swept, &mut report).await; + } else { + sweep_shared(state, &cluster_root, swept, &mut report).await; + } + } - if report.removed > 0 || report.adopted > 0 { - tracing::info!( - removed = report.removed, - adopted = report.adopted, - skipped = report.skipped, - "cleaned up legacy cluster content links" - ); - } + if report.removed > 0 || report.adopted > 0 { + tracing::info!( + removed = report.removed, + adopted = report.adopted, + skipped = report.skipped, + "cleaned up legacy cluster content links" + ); + } - Ok(report) + Ok(report) } async fn sweep_shared( - state: &LauncherState, - cluster_root: &Path, - swept: &[ContentType], - report: &mut SweepReport, + state: &LauncherState, + cluster_root: &Path, + swept: &[ContentType], + report: &mut SweepReport, ) { - for content_type in swept { - let dir = cluster_root.join(content_type.folder_name()); - let Ok(mut entries) = polyio::read_dir(&dir).await else { - continue; - }; + for content_type in swept { + let dir = cluster_root.join(content_type.folder_name()); + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - let Ok(file_type) = entry.file_type().await else { - continue; - }; + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Ok(file_type) = entry.file_type().await else { + continue; + }; - // A symlink here is unambiguously ours nothing else writes links here - if file_type.is_symlink() { - polyio::remove_file(&path).await.ok(); - report.removed += 1; - continue; - } + // A symlink here is unambiguously ours nothing else writes links here + if file_type.is_symlink() { + polyio::remove_file(&path).await.ok(); + report.removed += 1; + continue; + } - if !file_type.is_file() { - continue; - } + if !file_type.is_file() { + continue; + } - match cached_artifact_for(state, &path).await { - Some(CacheMatch::Elsewhere) => { - if polyio::remove_file(&path).await.is_ok() { - report.removed += 1; - } - } - // Cache row points at this very file (older recovery pass stored it - // in place) deleting it would destroy the only copy - Some(CacheMatch::IsTheArtifact) => { - tracing::debug!( - path = %path.display(), - "leaving cluster file in place; it is the artifact's only copy" - ); - report.skipped += 1; - } - None => report.skipped += 1, - } - } - } + match cached_artifact_for(state, &path).await { + Some(CacheMatch::Elsewhere) => { + if polyio::remove_file(&path).await.is_ok() { + report.removed += 1; + } + } + // Cache row points at this very file (older recovery pass stored it + // in place) deleting it would destroy the only copy + Some(CacheMatch::IsTheArtifact) => { + tracing::debug!( + path = %path.display(), + "leaving cluster file in place; it is the artifact's only copy" + ); + report.skipped += 1; + } + None => report.skipped += 1, + } + } + } } /// Writes a manifest so later launches can tell adopted content from user files async fn adopt_dedicated( - state: &LauncherState, - cluster: &crate::clusters::Cluster, - cluster_root: &Path, - swept: &[ContentType], - report: &mut SweepReport, + state: &LauncherState, + cluster: &crate::clusters::Cluster, + cluster_root: &Path, + swept: &[ContentType], + report: &mut SweepReport, ) { - let _manifest = manifest::lock().await; - - if manifest::load(cluster_root, manifest::MANIFEST_NAME) - .await - .is_some() - { - return; - } + let _manifest = manifest::lock().await; - let linked = match oneclient_content::packages::PackageStore::list_linked_artifacts( - cluster.id, - &state.services.content(), - ) - .await - { - Ok(linked) => linked, - Err(err) => { - tracing::warn!(cluster_id = cluster.id, error = %err, "cannot adopt dedicated content"); - return; - } - }; + if manifest::load(cluster_root, manifest::MANIFEST_NAME) + .await + .is_some() + { + return; + } - let mut entries = Vec::new(); - for link in linked { - if !link.enabled || !swept.contains(&link.content_type) { - continue; - } + let linked = match oneclient_content::packages::PackageStore::list_linked_artifacts( + cluster.id, + &state.services.content(), + ) + .await + { + Ok(linked) => linked, + Err(err) => { + tracing::warn!(cluster_id = cluster.id, error = %err, "cannot adopt dedicated content"); + return; + } + }; - let relative = - manifest::entry_path(link.content_type.folder_name(), &link.cluster_file_name); - if polyio::symlink_metadata(cluster_root.join(&relative)) - .await - .is_err() - { - continue; - } + let mut entries = Vec::new(); + for link in linked { + if !link.enabled || !swept.contains(&link.content_type) { + continue; + } - entries.push(ManifestEntry { - path: relative, - hash: link.hash, - }); - } + let relative = + manifest::entry_path(link.content_type.folder_name(), &link.cluster_file_name); + if polyio::symlink_metadata(cluster_root.join(&relative)) + .await + .is_err() + { + continue; + } - report.adopted += entries.len(); - manifest::save( - cluster_root, - manifest::MANIFEST_NAME, - &MaterializedManifest::new(cluster.id, entries), - ) - .await; + entries.push(ManifestEntry { + path: relative, + hash: link.hash, + }); + } + + report.adopted += entries.len(); + manifest::save( + cluster_root, + manifest::MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, entries), + ) + .await; } enum CacheMatch { - /// Cache holds this content at its own path the cluster copy is a link - Elsewhere, - /// The artifact row points at this exact file - IsTheArtifact, + /// Cache holds this content at its own path the cluster copy is a link + Elsewhere, + /// The artifact row points at this exact file + IsTheArtifact, } async fn cached_artifact_for(state: &LauncherState, path: &Path) -> Option { - let hash = polyio::sha1_file(path).await.ok()?; - let row = artifact_dao::get_artifact_by_hash(&state.services.db, &polyio::normalize_hash(&hash)) - .await - .ok() - .flatten()?; + let hash = polyio::sha1_file(path).await.ok()?; + let row = + artifact_dao::get_artifact_by_hash(&state.services.db, &polyio::normalize_hash(&hash)) + .await + .ok() + .flatten()?; - let canonical = artifact_absolute_path(&row.path).ok()?; - if same_file(&canonical, path) { - Some(CacheMatch::IsTheArtifact) - } else { - Some(CacheMatch::Elsewhere) - } + let canonical = artifact_absolute_path(&row.path).ok()?; + if same_file(&canonical, path) { + Some(CacheMatch::IsTheArtifact) + } else { + Some(CacheMatch::Elsewhere) + } } fn same_file(a: &Path, b: &Path) -> bool { - match (polyio::canonicalize(a), polyio::canonicalize(b)) { - (Ok(a), Ok(b)) => a == b, - _ => a == b, - } + match (polyio::canonicalize(a), polyio::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } } diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs index 7001dc75..4d2b3f44 100644 --- a/packages/oneclient_core/src/dev.rs +++ b/packages/oneclient_core/src/dev.rs @@ -6,20 +6,18 @@ use tokio::sync::mpsc; use uuid::Uuid; use oneclient_content::packages::provider::PackageProviderRegistry; -use oneclient_net::RequestClient; use oneclient_events::{ - Event, EventBus, GameEvent, GroupedProgressEvent, Level, Notification, ProgressEvent, + Event, EventBus, GameEvent, GroupedProgressEvent, Level, Notification, ProgressEvent, }; +use oneclient_net::RequestClient; -use crate::{ - LauncherResult, LauncherServices, LauncherState, -}; +use crate::{LauncherResult, LauncherServices, LauncherState}; struct GroupedSessionUi { - title: String, - parent: ProgressBar, - children: HashMap, - child_bytes: HashMap, + title: String, + parent: ProgressBar, + children: HashMap, + child_bytes: HashMap, } fn spawn_notification_handler(mut rx: oneclient_events::EventReceiver) { @@ -55,7 +53,12 @@ fn spawn_notification_handler(mut rx: oneclient_events::EventReceiver) { Level::Error => tracing::error!(%title, %body), }); } - Event::Progress(ProgressEvent::Update { id, label, current, total }) => { + Event::Progress(ProgressEvent::Update { + id, + label, + current, + total, + }) => { if current >= total { if let Some(pb) = progress_bars.remove(&id) { pb.finish_with_message(format!("{label} Done!")); @@ -160,10 +163,9 @@ fn spawn_notification_handler(mut rx: oneclient_events::EventReceiver) { } GroupedProgressEvent::End { session_id } => { if let Some(session) = grouped_sessions.remove(&session_id) { - session.parent.finish_with_message(format!( - "{} - complete", - session.title - )); + session + .parent + .finish_with_message(format!("{} - complete", session.title)); } } }, @@ -174,7 +176,10 @@ fn spawn_notification_handler(mut rx: oneclient_events::EventReceiver) { Event::Game(GameEvent::Log { line, .. }) => { mp.suspend(|| tracing::info!("[game] {line}")); } - Event::Game(GameEvent::Failed { cluster_id, message }) => { + Event::Game(GameEvent::Failed { + cluster_id, + message, + }) => { mp.suspend(|| tracing::error!(cluster_id, "launch failed: {message}")); } // No TTY dialog here answer `None` so the waiting task fails @@ -216,104 +221,104 @@ pub async fn initialize() -> LauncherResult> { } async fn ephemeral_root() -> LauncherResult { - let path = std::env::current_dir() - .map_err(crate::LauncherError::StdIoError)? - .join("target") - .join(format!("ephemeral-{}", Uuid::new_v4())); + let path = std::env::current_dir() + .map_err(crate::LauncherError::StdIoError)? + .join("target") + .join(format!("ephemeral-{}", Uuid::new_v4())); + + polyio::create_dir_all(&path).await?; - polyio::create_dir_all(&path).await?; - Ok(path) } pub async fn ephemeral_state() -> LauncherResult> { - let root = ephemeral_root().await?; - oneclient_common::paths::set_launcher_dir(root.clone()); + let root = ephemeral_root().await?; + oneclient_common::paths::set_launcher_dir(root.clone()); - let (tx, rx) = mpsc::unbounded_channel(); - spawn_notification_handler(rx); + let (tx, rx) = mpsc::unbounded_channel(); + spawn_notification_handler(rx); - let db = oneclient_db::connect(root.join("example.db")).await?; - let settings = crate::settings::store::load_settings(None).await; + let db = oneclient_db::connect(root.join("example.db")).await?; + let settings = crate::settings::store::load_settings(None).await; - let services = LauncherServices { - events: EventBus::new(tx), - requester: RequestClient::new(oneclient_net::NetConfig::default())?, - db, - packages: PackageProviderRegistry::new(), - }; - let auth = Arc::new(oneclient_auth::AuthService::with_store( - Default::default(), - services.requester.clone(), - services.events.clone(), - )); + let services = LauncherServices { + events: EventBus::new(tx), + requester: RequestClient::new(oneclient_net::NetConfig::default())?, + db, + packages: PackageProviderRegistry::new(), + }; + let auth = Arc::new(oneclient_auth::AuthService::with_store( + Default::default(), + services.requester.clone(), + services.events.clone(), + )); - let clusters = crate::clusters::ClusterManager::new(services.db.clone()); + let clusters = crate::clusters::ClusterManager::new(services.db.clone()); - let java = oneclient_java::JavaService::new( - Arc::new(crate::java_store::SqlJavaStore::new(services.db.clone())), - services.requester.clone(), - services.events.clone(), - ); + let java = oneclient_java::JavaService::new( + Arc::new(crate::java_store::SqlJavaStore::new(services.db.clone())), + services.requester.clone(), + services.events.clone(), + ); - Ok(Arc::new(LauncherState { - services, - auth, - java, - clusters, - settings: parking_lot::RwLock::new(settings), - metadata: tokio::sync::Mutex::new(oneclient_mc::MetadataStore::new()), - bundles: Arc::new(oneclient_content::bundles::BundlesManager::new()), - versions: Arc::new(crate::versions::VersionsManager::new()), - images: crate::images::ImageCacheStore::new(), - games: crate::game::GameProcessManager::new(), - discord: oneclient_discord::DiscordRpc::spawn(false), - })) + Ok(Arc::new(LauncherState { + services, + auth, + java, + clusters, + settings: parking_lot::RwLock::new(settings), + metadata: tokio::sync::Mutex::new(oneclient_mc::MetadataStore::new()), + bundles: Arc::new(oneclient_content::bundles::BundlesManager::new()), + versions: Arc::new(crate::versions::VersionsManager::new()), + images: crate::images::ImageCacheStore::new(), + games: crate::game::GameProcessManager::new(), + discord: oneclient_discord::DiscordRpc::spawn(false), + })) } pub async fn ephemeral_services() -> LauncherResult { - let root = ephemeral_root().await?; - oneclient_common::paths::set_launcher_dir(root.clone()); - let db = oneclient_db::connect(root.join("example.db")).await?; - let (tx, rx) = mpsc::unbounded_channel(); - spawn_notification_handler(rx); + let root = ephemeral_root().await?; + oneclient_common::paths::set_launcher_dir(root.clone()); + let db = oneclient_db::connect(root.join("example.db")).await?; + let (tx, rx) = mpsc::unbounded_channel(); + spawn_notification_handler(rx); - Ok(LauncherServices { - events: EventBus::new(tx), - requester: RequestClient::new(oneclient_net::NetConfig::default())?, - db, - packages: PackageProviderRegistry::new(), - }) + Ok(LauncherServices { + events: EventBus::new(tx), + requester: RequestClient::new(oneclient_net::NetConfig::default())?, + db, + packages: PackageProviderRegistry::new(), + }) } pub async fn seed_bundle_archive( - state: &LauncherState, - manifest: oneclient_content::bundles::BundleManifest, + state: &LauncherState, + manifest: oneclient_content::bundles::BundleManifest, ) -> LauncherResult<()> { - let disk_path = format!("bundles/{}.mrpack", manifest.name); - let loader = manifest.loader as i64; + let disk_path = format!("bundles/{}.mrpack", manifest.name); + let loader = manifest.loader as i64; - oneclient_db::dao::bundle::upsert_bundle( - &state.services.db, - oneclient_db::models::NewBundle { - remote_path: &disk_path, - mc_version: &manifest.mc_version, - mc_loader: loader, - file_name: &format!("{}.mrpack", manifest.name), - name: Some(&manifest.name), - version_id: Some(&manifest.version_id), - category: Some(&manifest.category), - loader_version: Some(&manifest.loader_version), - disk_path: &disk_path, - hidden: false, - etag: None, - synced_at: None, - }, - ) - .await?; + oneclient_db::dao::bundle::upsert_bundle( + &state.services.db, + oneclient_db::models::NewBundle { + remote_path: &disk_path, + mc_version: &manifest.mc_version, + mc_loader: loader, + file_name: &format!("{}.mrpack", manifest.name), + name: Some(&manifest.name), + version_id: Some(&manifest.version_id), + category: Some(&manifest.category), + loader_version: Some(&manifest.loader_version), + disk_path: &disk_path, + hidden: false, + etag: None, + synced_at: None, + }, + ) + .await?; - let path = oneclient_common::paths::data_dir()?.join(&disk_path); - state.bundles.cache_archive_manifest(path, manifest).await; + let path = oneclient_common::paths::data_dir()?.join(&disk_path); + state.bundles.cache_archive_manifest(path, manifest).await; - Ok(()) + Ok(()) } diff --git a/packages/oneclient_core/src/disable_warnings.rs b/packages/oneclient_core/src/disable_warnings.rs new file mode 100644 index 00000000..8954ea8f --- /dev/null +++ b/packages/oneclient_core/src/disable_warnings.rs @@ -0,0 +1,182 @@ +use std::collections::{HashMap, HashSet}; + +use serde::Deserialize; + +use crate::{LauncherError, LauncherResult}; +use oneclient_common::paths; +use oneclient_net::RequestClient; +use oneclient_net::{EtagPolicy, fetch_cached}; + +const MESSAGES_DIR: &str = "/oneclient/bundles/disable-warnings/"; + +#[derive(Debug, Default, Deserialize)] +struct Manifest { + #[serde(default)] + mods: HashMap, +} + +#[derive(Debug, Deserialize)] +struct Entry { + message: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DisableWarnings { + mods: HashMap, +} + +impl DisableWarnings { + pub fn body_for(&self, project_id: &str) -> Option<&str> { + self.mods.get(project_id).map(String::as_str) + } +} + +fn message_file_name(path: &str) -> Option<&str> { + let name = path.strip_prefix(MESSAGES_DIR)?; + let valid = name.ends_with(".md") + && name.len() > ".md".len() + && !name.contains("..") + && !name.contains(['/', '\\', '?', '#']); + valid.then_some(name) +} + +async fn fetch_message(net: &RequestClient, path: &str) -> Option { + let Some(file_name) = message_file_name(path) else { + tracing::warn!(path, "ignoring disable warning outside {MESSAGES_DIR}"); + return None; + }; + + let url = format!("{}{path}", net.config().meta_url_base); + let cache_path = match paths::caches_dir() { + Ok(dir) => dir.join("disable-warnings").join(file_name), + Err(err) => { + tracing::warn!(%err, "cannot resolve the cache dir for disable warnings"); + return None; + } + }; + + match fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow).await { + Ok(Some(fetched)) => Some(fetched.text()).filter(|body| !body.trim().is_empty()), + Ok(None) => { + tracing::warn!(path, "disable warning is unavailable and not cached"); + None + } + Err(err) => { + tracing::warn!(path, %err, "failed to fetch disable warning"); + None + } + } +} + +fn resolve(manifest: Manifest, bodies: &HashMap) -> DisableWarnings { + let mods = manifest + .mods + .into_iter() + .filter_map(|(project_id, entry)| { + bodies + .get(&entry.message) + .map(|body| (project_id, body.clone())) + }) + .collect(); + + DisableWarnings { mods } +} + +#[tracing::instrument(level = "debug", skip(net))] +pub async fn fetch_disable_warnings(net: &RequestClient) -> LauncherResult { + let url = format!( + "{}/oneclient/bundles/disable-warnings.json", + net.config().meta_url_base + ); + let cache_path = paths::caches_dir()?.join("disable-warnings.json"); + + let fetched = fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow) + .await? + .ok_or_else(|| LauncherError::InvalidSettingsProfile { + reason: "disable warnings are unavailable and not cached".to_string(), + })?; + let manifest: Manifest = fetched.json()?; + + let paths: HashSet<&str> = manifest + .mods + .values() + .map(|entry| entry.message.as_str()) + .collect(); + + let mut bodies = HashMap::new(); + for path in paths { + if let Some(body) = fetch_message(net, path).await { + bodies.insert(path.to_string(), body); + } + } + + Ok(resolve(manifest, &bodies)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(raw: &str) -> Manifest { + serde_json::from_str(raw).unwrap() + } + + fn bodies(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(path, body)| (path.to_string(), body.to_string())) + .collect() + } + + #[test] + fn only_listed_mods_get_a_warning() { + let manifest = parse( + r#"{"mods":{"Iw9mZi4a":{"message":"/oneclient/bundles/disable-warnings/polyplus.md"}}}"#, + ); + let warnings = resolve( + manifest, + &bodies(&[("/oneclient/bundles/disable-warnings/polyplus.md", "Poly+")]), + ); + + assert_eq!(warnings.body_for("Iw9mZi4a"), Some("Poly+")); + assert_eq!(warnings.body_for("AANobbMI"), None); + } + + #[test] + fn unfetched_message_means_no_warning() { + let manifest = parse( + r#"{"mods":{"Iw9mZi4a":{"message":"/oneclient/bundles/disable-warnings/polyplus.md"}}}"#, + ); + let warnings = resolve(manifest, &HashMap::new()); + + assert_eq!(warnings.body_for("Iw9mZi4a"), None); + } + + #[test] + fn empty_manifest_means_no_warnings() { + let warnings = resolve(parse("{}"), &HashMap::new()); + + assert_eq!(warnings.body_for("Iw9mZi4a"), None); + } + + #[test] + fn message_paths_are_confined_to_the_warnings_dir() { + assert_eq!( + message_file_name("/oneclient/bundles/disable-warnings/polyplus.md"), + Some("polyplus.md") + ); + assert_eq!( + message_file_name("/oneclient/bundles/disable-warnings/../x.md"), + None + ); + assert_eq!( + message_file_name("/oneclient/bundles/disable-warnings/a/b.md"), + None + ); + assert_eq!(message_file_name("/oneclient/tos.json"), None); + assert_eq!( + message_file_name("/oneclient/bundles/disable-warnings/.md"), + None + ); + } +} diff --git a/packages/oneclient_core/src/error.rs b/packages/oneclient_core/src/error.rs index e36d89f3..32b61259 100644 --- a/packages/oneclient_core/src/error.rs +++ b/packages/oneclient_core/src/error.rs @@ -103,7 +103,7 @@ impl LauncherError { } /// Whether the install is missing pieces and so is worth repairing - #[must_use] + #[must_use] pub fn indicates_missing_files(&self) -> bool { let mut source: Option<&(dyn std::error::Error + 'static)> = Some(self); @@ -181,8 +181,9 @@ impl SentryExclusion for reqwest::Error { impl SentryExclusion for polyio::IOError { fn is_sentry_excluded(&self) -> bool { match self { - polyio::IOError::IOError(source) - | polyio::IOError::PathIOError { source, .. } => source.is_sentry_excluded(), + polyio::IOError::IOError(source) | polyio::IOError::PathIOError { source, .. } => { + source.is_sentry_excluded() + } _ => false, } } @@ -205,8 +206,7 @@ mod tests { #[test] fn a_missing_asset_directory_asks_for_a_repair() { - let path = - "/Users/someone/Library/Application Support/org.Polyfrost.OneClient-dev/metadata/assets"; + let path = "/Users/someone/Library/Application Support/org.Polyfrost.OneClient-dev/metadata/assets"; let err = not_found(path); assert!(err.indicates_missing_files(), "{err}"); @@ -225,12 +225,8 @@ mod tests { #[test] fn dismissing_a_dialog_is_a_cancellation_not_a_failure() { - assert!( - LauncherError::JavaError(oneclient_java::JavaError::Cancelled).is_cancelled() - ); - assert!( - LauncherError::AuthError(oneclient_auth::AuthError::LoginCancelled).is_cancelled() - ); + assert!(LauncherError::JavaError(oneclient_java::JavaError::Cancelled).is_cancelled()); + assert!(LauncherError::AuthError(oneclient_auth::AuthError::LoginCancelled).is_cancelled()); } /// The whole point of the split anything that is not a dismissal keeps @@ -271,8 +267,6 @@ mod tests { } assert!(!LauncherError::NotInitialized.indicates_missing_files()); - assert!( - !LauncherError::Minecraft("bad manifest".into()).indicates_missing_files() - ); + assert!(!LauncherError::Minecraft("bad manifest".into()).indicates_missing_files()); } } diff --git a/packages/oneclient_core/src/game/analytics.rs b/packages/oneclient_core/src/game/analytics.rs index fd4fdf2a..8acca949 100644 --- a/packages/oneclient_core/src/game/analytics.rs +++ b/packages/oneclient_core/src/game/analytics.rs @@ -28,628 +28,640 @@ const MAX_PERSONAS: usize = 6; #[derive(Debug, Clone, PartialEq)] pub struct DayPlaytime { - pub date: String, - pub secs: i64, + pub date: String, + pub secs: i64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Persona { - Veteran, - Marathoner, - Regular, - Loyalist, - Explorer, - NightOwl, - EarlyBird, - WeekendWarrior, - Gamer, - Sprinter, + Veteran, + Marathoner, + Regular, + Loyalist, + Explorer, + NightOwl, + EarlyBird, + WeekendWarrior, + Gamer, + Sprinter, } impl Persona { - pub fn title(self) -> &'static str { - match self { - Persona::Veteran => "Veteran", - Persona::Marathoner => "Marathoner", - Persona::Regular => "Regular", - Persona::Loyalist => "Loyalist", - Persona::Explorer => "Explorer", - Persona::NightOwl => "Night Owl", - Persona::EarlyBird => "Early Bird", - Persona::WeekendWarrior => "Weekend Warrior", - Persona::Gamer => "Gamer", - Persona::Sprinter => "Sprinter", - } - } + pub fn title(self) -> &'static str { + match self { + Persona::Veteran => "Veteran", + Persona::Marathoner => "Marathoner", + Persona::Regular => "Regular", + Persona::Loyalist => "Loyalist", + Persona::Explorer => "Explorer", + Persona::NightOwl => "Night Owl", + Persona::EarlyBird => "Early Bird", + Persona::WeekendWarrior => "Weekend Warrior", + Persona::Gamer => "Gamer", + Persona::Sprinter => "Sprinter", + } + } - pub fn description(self) -> &'static str { - match self { - Persona::Veteran => "500h+ played", - Persona::Marathoner => "An 8h+ session", - Persona::Regular => "A week straight", - Persona::Loyalist => "Mostly one server", - Persona::Explorer => "10+ servers joined", - Persona::NightOwl => "Mostly after dark", - Persona::EarlyBird => "Mostly before 9am", - Persona::WeekendWarrior => "Mostly weekends", - Persona::Gamer => "5h+ per day played", - Persona::Sprinter => "Short and frequent", - } - } + pub fn description(self) -> &'static str { + match self { + Persona::Veteran => "500h+ played", + Persona::Marathoner => "An 8h+ session", + Persona::Regular => "A week straight", + Persona::Loyalist => "Mostly one server", + Persona::Explorer => "10+ servers joined", + Persona::NightOwl => "Mostly after dark", + Persona::EarlyBird => "Mostly before 9am", + Persona::WeekendWarrior => "Mostly weekends", + Persona::Gamer => "5h+ per day played", + Persona::Sprinter => "Short and frequent", + } + } } fn personas_for(stats: &PlaytimeStats, servers: &[ServerStat]) -> Vec { - if stats.total_secs <= 0 { - return Vec::new(); - } + if stats.total_secs <= 0 { + return Vec::new(); + } - let total = stats.total_secs as f64; - let share = |hours: &[usize]| -> f64 { - hours.iter().map(|&h| stats.per_hour[h]).sum::() as f64 / total - }; - let weekend = WEEKEND_DAYS - .iter() - .map(|&d| stats.per_weekday[d]) - .sum::() as f64 - / total; + let total = stats.total_secs as f64; + let share = |hours: &[usize]| -> f64 { + hours.iter().map(|&h| stats.per_hour[h]).sum::() as f64 / total + }; + let weekend = WEEKEND_DAYS + .iter() + .map(|&d| stats.per_weekday[d]) + .sum::() as f64 + / total; - let server_secs: i64 = servers.iter().map(|s| s.total_secs).sum(); - let top_server_share = servers - .iter() - .map(|s| s.total_secs) - .max() - .filter(|_| server_secs > 0) - .map_or(0.0, |top| top as f64 / server_secs as f64); + let server_secs: i64 = servers.iter().map(|s| s.total_secs).sum(); + let top_server_share = servers + .iter() + .map(|s| s.total_secs) + .max() + .filter(|_| server_secs > 0) + .map_or(0.0, |top| top as f64 / server_secs as f64); - let mut lengths: Vec = stats.session_secs.clone(); - lengths.sort_unstable(); - let median = lengths.get(lengths.len() / 2).copied().unwrap_or(0); + let mut lengths: Vec = stats.session_secs.clone(); + lengths.sort_unstable(); + let median = lengths.get(lengths.len() / 2).copied().unwrap_or(0); - let earned = [ - (Persona::Veteran, stats.total_secs >= VETERAN_SECS), - ( - Persona::Marathoner, - stats.longest_session_secs >= MARATHON_SECS, - ), - (Persona::Regular, stats.longest_streak >= REGULAR_STREAK), - ( - Persona::Loyalist, - servers.len() > 1 && top_server_share >= LOYALIST_SHARE, - ), - (Persona::Explorer, servers.len() >= EXPLORER_SERVERS), - (Persona::NightOwl, share(&NIGHT_HOURS) >= NIGHT_OWL_SHARE), - ( - Persona::EarlyBird, - share(&MORNING_HOURS) >= EARLY_BIRD_SHARE, - ), - (Persona::WeekendWarrior, weekend >= WEEKEND_SHARE), - ( - Persona::Gamer, - stats.avg_secs_per_active_day > GAMER_SECS_PER_DAY, - ), - ( - Persona::Sprinter, - stats.session_count >= SPRINTER_SESSIONS && median < SPRINTER_SECS, - ), - ]; + let earned = [ + (Persona::Veteran, stats.total_secs >= VETERAN_SECS), + ( + Persona::Marathoner, + stats.longest_session_secs >= MARATHON_SECS, + ), + (Persona::Regular, stats.longest_streak >= REGULAR_STREAK), + ( + Persona::Loyalist, + servers.len() > 1 && top_server_share >= LOYALIST_SHARE, + ), + (Persona::Explorer, servers.len() >= EXPLORER_SERVERS), + (Persona::NightOwl, share(&NIGHT_HOURS) >= NIGHT_OWL_SHARE), + ( + Persona::EarlyBird, + share(&MORNING_HOURS) >= EARLY_BIRD_SHARE, + ), + (Persona::WeekendWarrior, weekend >= WEEKEND_SHARE), + ( + Persona::Gamer, + stats.avg_secs_per_active_day > GAMER_SECS_PER_DAY, + ), + ( + Persona::Sprinter, + stats.session_count >= SPRINTER_SESSIONS && median < SPRINTER_SECS, + ), + ]; - earned - .into_iter() - .filter_map(|(persona, earned)| earned.then_some(persona)) - .take(MAX_PERSONAS) - .collect() + earned + .into_iter() + .filter_map(|(persona, earned)| earned.then_some(persona)) + .take(MAX_PERSONAS) + .collect() } pub const WEEKDAY_LABELS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; #[derive(Debug, Clone, PartialEq)] pub struct PlaytimeStats { - pub total_secs: i64, - pub session_count: usize, - pub per_weekday: [i64; 7], - pub per_hour: [i64; 24], - pub daily: Vec, - pub session_secs: Vec, - pub longest_session_secs: i64, - pub active_days: usize, - pub current_streak: usize, - pub longest_streak: usize, - pub avg_secs_per_active_day: f64, - pub peak_hour: Option, - pub peak_weekday: Option, - pub night_share: f64, + pub total_secs: i64, + pub session_count: usize, + pub per_weekday: [i64; 7], + pub per_hour: [i64; 24], + pub daily: Vec, + pub session_secs: Vec, + pub longest_session_secs: i64, + pub active_days: usize, + pub current_streak: usize, + pub longest_streak: usize, + pub avg_secs_per_active_day: f64, + pub peak_hour: Option, + pub peak_weekday: Option, + pub night_share: f64, } impl PlaytimeStats { - pub fn from_spans(spans: &[SessionSpan]) -> Self { - Self::from_spans_on(spans, Local::now().date_naive()) - } + pub fn from_spans(spans: &[SessionSpan]) -> Self { + Self::from_spans_on(spans, Local::now().date_naive()) + } - fn from_spans_on(spans: &[SessionSpan], today: NaiveDate) -> Self { - let mut per_weekday = [0i64; 7]; - let mut per_hour = [0i64; 24]; - let mut total_secs = 0i64; - let mut night_secs = 0i64; - let mut session_count = 0usize; - let mut by_day: BTreeMap = BTreeMap::new(); - let mut session_secs: Vec = Vec::new(); + fn from_spans_on(spans: &[SessionSpan], today: NaiveDate) -> Self { + let mut per_weekday = [0i64; 7]; + let mut per_hour = [0i64; 24]; + let mut total_secs = 0i64; + let mut night_secs = 0i64; + let mut session_count = 0usize; + let mut by_day: BTreeMap = BTreeMap::new(); + let mut session_secs: Vec = Vec::new(); - for span in spans { - let Some(ended) = span.ended_at.as_deref() else { - continue; - }; - let (Ok(start), Ok(end)) = ( - DateTime::parse_from_rfc3339(&span.started_at), - DateTime::parse_from_rfc3339(ended), - ) else { - continue; - }; + for span in spans { + let Some(ended) = span.ended_at.as_deref() else { + continue; + }; + let (Ok(start), Ok(end)) = ( + DateTime::parse_from_rfc3339(&span.started_at), + DateTime::parse_from_rfc3339(ended), + ) else { + continue; + }; - let secs = (end - start).num_seconds(); - if secs <= 0 { - continue; - } + let secs = (end - start).num_seconds(); + if secs <= 0 { + continue; + } - total_secs += secs; - session_count += 1; - session_secs.push(secs); + total_secs += secs; + session_count += 1; + session_secs.push(secs); - for_each_local_hour( - start.with_timezone(&Utc), - end.with_timezone(&Utc), - |at, slice| { - let hour = at.hour() as usize; - per_hour[hour] += slice; - per_weekday[at.weekday().num_days_from_monday() as usize] += slice; - if NIGHT_HOURS.contains(&hour) { - night_secs += slice; - } - *by_day.entry(at.date_naive()).or_insert(0) += slice; - }, - ); - } + for_each_local_hour( + start.with_timezone(&Utc), + end.with_timezone(&Utc), + |at, slice| { + let hour = at.hour() as usize; + per_hour[hour] += slice; + per_weekday[at.weekday().num_days_from_monday() as usize] += slice; + if NIGHT_HOURS.contains(&hour) { + night_secs += slice; + } + *by_day.entry(at.date_naive()).or_insert(0) += slice; + }, + ); + } - let longest_session_secs = session_secs.iter().copied().max().unwrap_or(0); - let dates: Vec = by_day.keys().copied().collect(); - let (current_streak, longest_streak) = streaks(&dates, today); - let daily: Vec = by_day - .iter() - .map(|(date, secs)| DayPlaytime { - date: date.format("%Y-%m-%d").to_string(), - secs: *secs, - }) - .collect(); + let longest_session_secs = session_secs.iter().copied().max().unwrap_or(0); + let dates: Vec = by_day.keys().copied().collect(); + let (current_streak, longest_streak) = streaks(&dates, today); + let daily: Vec = by_day + .iter() + .map(|(date, secs)| DayPlaytime { + date: date.format("%Y-%m-%d").to_string(), + secs: *secs, + }) + .collect(); - let active_days = daily.len(); - let avg_secs_per_active_day = if active_days > 0 { - total_secs as f64 / active_days as f64 - } else { - 0.0 - }; - let night_share = if total_secs > 0 { - night_secs as f64 / total_secs as f64 - } else { - 0.0 - }; + let active_days = daily.len(); + let avg_secs_per_active_day = if active_days > 0 { + total_secs as f64 / active_days as f64 + } else { + 0.0 + }; + let night_share = if total_secs > 0 { + night_secs as f64 / total_secs as f64 + } else { + 0.0 + }; - let peak_hour = argmax(&per_hour); - let peak_weekday = argmax(&per_weekday); + let peak_hour = argmax(&per_hour); + let peak_weekday = argmax(&per_weekday); - Self { - total_secs, - session_count, - per_weekday, - per_hour, - daily, - session_secs, - longest_session_secs, - active_days, - current_streak, - longest_streak, - avg_secs_per_active_day, - peak_hour, - peak_weekday, - night_share, - } - } + Self { + total_secs, + session_count, + per_weekday, + per_hour, + daily, + session_secs, + longest_session_secs, + active_days, + current_streak, + longest_streak, + avg_secs_per_active_day, + peak_hour, + peak_weekday, + night_share, + } + } } fn for_each_local_hour( - start: DateTime, - end: DateTime, - mut f: impl FnMut(DateTime, i64), + start: DateTime, + end: DateTime, + mut f: impl FnMut(DateTime, i64), ) { - let mut cur = start; - while cur < end { - let local = cur.with_timezone(&Local); - let boundary = next_local_hour(local); - let next = if boundary > cur { - boundary.min(end) - } else { - (cur + Duration::hours(1)).min(end) - }; - f(local, (next - cur).num_seconds()); - cur = next; - } + let mut cur = start; + while cur < end { + let local = cur.with_timezone(&Local); + let boundary = next_local_hour(local); + let next = if boundary > cur { + boundary.min(end) + } else { + (cur + Duration::hours(1)).min(end) + }; + f(local, (next - cur).num_seconds()); + cur = next; + } } fn next_local_hour(local: DateTime) -> DateTime { - let naive = local.naive_local(); - let top = match naive.date().and_hms_opt(naive.hour(), 0, 0) { - Some(top) => top + Duration::hours(1), - None => return (local + Duration::hours(1)).with_timezone(&Utc), - }; + let naive = local.naive_local(); + let top = match naive.date().and_hms_opt(naive.hour(), 0, 0) { + Some(top) => top + Duration::hours(1), + None => return (local + Duration::hours(1)).with_timezone(&Utc), + }; - Local - .from_local_datetime(&top) - .earliest() - .map(|at| at.with_timezone(&Utc)) - .unwrap_or_else(|| (local + Duration::hours(1)).with_timezone(&Utc)) + Local + .from_local_datetime(&top) + .earliest() + .map(|at| at.with_timezone(&Utc)) + .unwrap_or_else(|| (local + Duration::hours(1)).with_timezone(&Utc)) } fn streaks(days: &[NaiveDate], today: NaiveDate) -> (usize, usize) { - let mut longest = 0usize; - let mut run = 0usize; - let mut prev: Option = None; + let mut longest = 0usize; + let mut run = 0usize; + let mut prev: Option = None; - for &day in days { - run = match prev { - Some(p) if p == day => run, - Some(p) if p.succ_opt() == Some(day) => run + 1, - _ => 1, - }; - longest = longest.max(run); - prev = Some(day); - } + for &day in days { + run = match prev { + Some(p) if p == day => run, + Some(p) if p.succ_opt() == Some(day) => run + 1, + _ => 1, + }; + longest = longest.max(run); + prev = Some(day); + } - let current = match days.last() { - Some(last) if *last == today || Some(*last) == today.pred_opt() => run, - _ => 0, - }; + let current = match days.last() { + Some(last) if *last == today || Some(*last) == today.pred_opt() => run, + _ => 0, + }; - (current, longest) + (current, longest) } fn argmax(buckets: &[i64]) -> Option { - buckets - .iter() - .enumerate() - .filter(|&(_, &v)| v > 0) - .max_by_key(|&(_, &v)| v) - .map(|(i, _)| i) + buckets + .iter() + .enumerate() + .filter(|&(_, &v)| v > 0) + .max_by_key(|&(_, &v)| v) + .map(|(i, _)| i) } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ServerStat { - pub address: String, - pub port: Option, - pub joins: i64, - pub total_secs: i64, - pub last_played: Option, - pub is_ip: bool, + pub address: String, + pub port: Option, + pub joins: i64, + pub total_secs: i64, + pub last_played: Option, + pub is_ip: bool, } fn address_is_ip(address: &str) -> bool { - address.parse::().is_ok() + address.parse::().is_ok() } pub fn aggregate_servers(rows: &[GameSessionServerRow]) -> Vec { - struct Acc { - port: Option, - joins: i64, - total_secs: i64, - last_played: Option, - } + struct Acc { + port: Option, + joins: i64, + total_secs: i64, + last_played: Option, + } - let mut map: HashMap = HashMap::new(); - for row in rows { - let acc = map.entry(row.address.clone()).or_insert(Acc { - port: row.port, - joins: 0, - total_secs: 0, - last_played: None, - }); - acc.joins += 1; + let mut map: HashMap = HashMap::new(); + for row in rows { + let acc = map.entry(row.address.clone()).or_insert(Acc { + port: row.port, + joins: 0, + total_secs: 0, + last_played: None, + }); + acc.joins += 1; - if let Some(ended) = row.disconnected_at.as_deref() - && let (Ok(start), Ok(end)) = ( - DateTime::parse_from_rfc3339(&row.joined_at), - DateTime::parse_from_rfc3339(ended), - ) { - let secs = (end - start).num_seconds(); - if secs > 0 { - acc.total_secs += secs; - } - } + if let Some(ended) = row.disconnected_at.as_deref() + && let (Ok(start), Ok(end)) = ( + DateTime::parse_from_rfc3339(&row.joined_at), + DateTime::parse_from_rfc3339(ended), + ) + { + let secs = (end - start).num_seconds(); + if secs > 0 { + acc.total_secs += secs; + } + } - if acc - .last_played - .as_deref() - .is_none_or(|prev| row.joined_at.as_str() > prev) - { - acc.last_played = Some(row.joined_at.clone()); - acc.port = row.port; - } - } + if acc + .last_played + .as_deref() + .is_none_or(|prev| row.joined_at.as_str() > prev) + { + acc.last_played = Some(row.joined_at.clone()); + acc.port = row.port; + } + } - let mut servers: Vec = map - .into_iter() - .map(|(address, acc)| ServerStat { - is_ip: address_is_ip(&address), - address, - port: acc.port, - joins: acc.joins, - total_secs: acc.total_secs, - last_played: acc.last_played, - }) - .collect(); + let mut servers: Vec = map + .into_iter() + .map(|(address, acc)| ServerStat { + is_ip: address_is_ip(&address), + address, + port: acc.port, + joins: acc.joins, + total_secs: acc.total_secs, + last_played: acc.last_played, + }) + .collect(); - servers.sort_by(|a, b| { - b.total_secs - .cmp(&a.total_secs) - .then(b.joins.cmp(&a.joins)) - .then(a.address.cmp(&b.address)) - }); - servers + servers.sort_by(|a, b| { + b.total_secs + .cmp(&a.total_secs) + .then(b.joins.cmp(&a.joins)) + .then(a.address.cmp(&b.address)) + }); + servers } #[derive(Debug, Clone, PartialEq)] pub struct Analytics { - pub playtime: PlaytimeStats, - pub servers: Vec, - pub personas: Vec, + pub playtime: PlaytimeStats, + pub servers: Vec, + pub personas: Vec, } impl Analytics { - pub fn new(playtime: PlaytimeStats, servers: Vec) -> Self { - let personas = personas_for(&playtime, &servers); - Self { - playtime, - servers, - personas, - } - } + pub fn new(playtime: PlaytimeStats, servers: Vec) -> Self { + let personas = personas_for(&playtime, &servers); + Self { + playtime, + servers, + personas, + } + } } #[tracing::instrument(level = "debug", skip(db))] pub async fn global_analytics(db: &oneclient_db::DbPool) -> LauncherResult { - let spans = session_dao::all_session_spans(db).await?; - let servers = session_dao::all_session_servers(db).await?; - Ok(Analytics::new( - PlaytimeStats::from_spans(&spans), - aggregate_servers(&servers), - )) + let spans = session_dao::all_session_spans(db).await?; + let servers = session_dao::all_session_servers(db).await?; + Ok(Analytics::new( + PlaytimeStats::from_spans(&spans), + aggregate_servers(&servers), + )) } #[tracing::instrument(level = "debug", skip(db))] -pub async fn cluster_analytics(db: &oneclient_db::DbPool, cluster_id: i64) -> LauncherResult { - let spans = session_dao::session_spans_for_cluster(db, cluster_id).await?; - let servers = session_dao::session_servers_for_cluster(db, cluster_id).await?; - Ok(Analytics::new( - PlaytimeStats::from_spans(&spans), - aggregate_servers(&servers), - )) +pub async fn cluster_analytics( + db: &oneclient_db::DbPool, + cluster_id: i64, +) -> LauncherResult { + let spans = session_dao::session_spans_for_cluster(db, cluster_id).await?; + let servers = session_dao::session_servers_for_cluster(db, cluster_id).await?; + Ok(Analytics::new( + PlaytimeStats::from_spans(&spans), + aggregate_servers(&servers), + )) } #[cfg(test)] mod tests { - use super::*; + use super::*; - fn span(start: &str, end: &str) -> SessionSpan { - SessionSpan { - started_at: start.to_string(), - ended_at: Some(end.to_string()), - } - } + fn span(start: &str, end: &str) -> SessionSpan { + SessionSpan { + started_at: start.to_string(), + ended_at: Some(end.to_string()), + } + } - #[test] - fn empty_has_no_personas() { - let stats = PlaytimeStats::from_spans(&[]); - assert_eq!(stats.total_secs, 0); - assert!(personas_for(&stats, &[]).is_empty()); - assert_eq!(stats.peak_hour, None); - } + #[test] + fn empty_has_no_personas() { + let stats = PlaytimeStats::from_spans(&[]); + assert_eq!(stats.total_secs, 0); + assert!(personas_for(&stats, &[]).is_empty()); + assert_eq!(stats.peak_hour, None); + } - #[test] - fn skips_open_and_negative_spans() { - let spans = vec![ - SessionSpan { - started_at: "2026-01-01T10:00:00+00:00".into(), - ended_at: None, - }, - span("2026-01-01T10:00:00+00:00", "2026-01-01T09:00:00+00:00"), - ]; - let stats = PlaytimeStats::from_spans(&spans); - assert_eq!(stats.session_count, 0); - assert_eq!(stats.total_secs, 0); - } + #[test] + fn skips_open_and_negative_spans() { + let spans = vec![ + SessionSpan { + started_at: "2026-01-01T10:00:00+00:00".into(), + ended_at: None, + }, + span("2026-01-01T10:00:00+00:00", "2026-01-01T09:00:00+00:00"), + ]; + let stats = PlaytimeStats::from_spans(&spans); + assert_eq!(stats.session_count, 0); + assert_eq!(stats.total_secs, 0); + } - fn server(address: &str, joined: &str, disconnected: Option<&str>) -> GameSessionServerRow { - GameSessionServerRow { - session_started_at: "s".into(), - address: address.into(), - port: Some(25565), - joined_at: joined.into(), - disconnected_at: disconnected.map(str::to_string), - } - } + fn server(address: &str, joined: &str, disconnected: Option<&str>) -> GameSessionServerRow { + GameSessionServerRow { + session_started_at: "s".into(), + address: address.into(), + port: Some(25565), + joined_at: joined.into(), + disconnected_at: disconnected.map(str::to_string), + } + } - #[test] - fn ip_addresses_are_flagged_domains_are_not() { - assert!(address_is_ip("192.168.1.10")); - assert!(address_is_ip("::1")); - assert!(!address_is_ip("mc.hypixel.net")); - assert!(!address_is_ip("play.example.com")); - } + #[test] + fn ip_addresses_are_flagged_domains_are_not() { + assert!(address_is_ip("192.168.1.10")); + assert!(address_is_ip("::1")); + assert!(!address_is_ip("mc.hypixel.net")); + assert!(!address_is_ip("play.example.com")); + } - #[test] - fn aggregate_servers_sums_time_and_counts_joins() { - let rows = vec![ - server( - "mc.hypixel.net", - "2026-01-01T10:00:00+00:00", - Some("2026-01-01T11:00:00+00:00"), - ), - server("mc.hypixel.net", "2026-01-02T10:00:00+00:00", None), - server( - "192.168.1.10", - "2026-01-01T12:00:00+00:00", - Some("2026-01-01T12:30:00+00:00"), - ), - ]; + #[test] + fn aggregate_servers_sums_time_and_counts_joins() { + let rows = vec![ + server( + "mc.hypixel.net", + "2026-01-01T10:00:00+00:00", + Some("2026-01-01T11:00:00+00:00"), + ), + server("mc.hypixel.net", "2026-01-02T10:00:00+00:00", None), + server( + "192.168.1.10", + "2026-01-01T12:00:00+00:00", + Some("2026-01-01T12:30:00+00:00"), + ), + ]; - let servers = aggregate_servers(&rows); - assert_eq!(servers.len(), 2); - assert_eq!(servers[0].address, "mc.hypixel.net"); - assert_eq!(servers[0].joins, 2); - assert_eq!(servers[0].total_secs, 3600); - assert!(!servers[0].is_ip); - assert_eq!(servers[1].address, "192.168.1.10"); - assert_eq!(servers[1].joins, 1); - assert_eq!(servers[1].total_secs, 1800); - assert!(servers[1].is_ip); - } + let servers = aggregate_servers(&rows); + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].address, "mc.hypixel.net"); + assert_eq!(servers[0].joins, 2); + assert_eq!(servers[0].total_secs, 3600); + assert!(!servers[0].is_ip); + assert_eq!(servers[1].address, "192.168.1.10"); + assert_eq!(servers[1].joins, 1); + assert_eq!(servers[1].total_secs, 1800); + assert!(servers[1].is_ip); + } - fn day(s: &str) -> NaiveDate { - NaiveDate::parse_from_str(s, "%Y-%m-%d").expect("a date") - } + fn day(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").expect("a date") + } - fn local_span(start: &str, end: &str) -> SessionSpan { - let at = |s: &str| { - Local - .from_local_datetime( - &chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").expect("a time"), - ) - .earliest() - .expect("an unambiguous local time") - .to_rfc3339() - }; - SessionSpan { - started_at: at(start), - ended_at: Some(at(end)), - } - } + fn local_span(start: &str, end: &str) -> SessionSpan { + let at = |s: &str| { + Local + .from_local_datetime( + &chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").expect("a time"), + ) + .earliest() + .expect("an unambiguous local time") + .to_rfc3339() + }; + SessionSpan { + started_at: at(start), + ended_at: Some(at(end)), + } + } - #[test] - fn an_overnight_session_is_split_across_both_days() { - let spans = vec![local_span("2026-07-16 22:00:00", "2026-07-17 04:00:00")]; - let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-17")); + #[test] + fn an_overnight_session_is_split_across_both_days() { + let spans = vec![local_span("2026-07-16 22:00:00", "2026-07-17 04:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-17")); - assert_eq!(stats.total_secs, 6 * 3600); - assert_eq!(stats.session_count, 1, "still one session"); - assert_eq!(stats.daily.len(), 2); - assert_eq!(stats.daily[0].date, "2026-07-16"); - assert_eq!(stats.daily[0].secs, 2 * 3600); - assert_eq!(stats.daily[1].date, "2026-07-17"); - assert_eq!(stats.daily[1].secs, 4 * 3600); - } + assert_eq!(stats.total_secs, 6 * 3600); + assert_eq!(stats.session_count, 1, "still one session"); + assert_eq!(stats.daily.len(), 2); + assert_eq!(stats.daily[0].date, "2026-07-16"); + assert_eq!(stats.daily[0].secs, 2 * 3600); + assert_eq!(stats.daily[1].date, "2026-07-17"); + assert_eq!(stats.daily[1].secs, 4 * 3600); + } - #[test] - fn a_multi_day_session_books_a_full_day_per_day() { - let spans = vec![local_span("2026-07-16 12:00:00", "2026-07-19 12:00:00")]; - let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-19")); + #[test] + fn a_multi_day_session_books_a_full_day_per_day() { + let spans = vec![local_span("2026-07-16 12:00:00", "2026-07-19 12:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-19")); - assert_eq!(stats.daily.len(), 4, "three nights means four days touched"); - assert_eq!(stats.daily[1].secs, 24 * 3600); - assert_eq!(stats.daily[2].secs, 24 * 3600); - assert_eq!( - stats.daily.iter().map(|d| d.secs).sum::(), - stats.total_secs - ); - assert!(stats.per_hour.iter().all(|&h| h > 0)); - } + assert_eq!(stats.daily.len(), 4, "three nights means four days touched"); + assert_eq!(stats.daily[1].secs, 24 * 3600); + assert_eq!(stats.daily[2].secs, 24 * 3600); + assert_eq!( + stats.daily.iter().map(|d| d.secs).sum::(), + stats.total_secs + ); + assert!(stats.per_hour.iter().all(|&h| h > 0)); + } - #[test] - fn weekday_totals_follow_the_hours_played_not_the_start() { - let spans = vec![local_span("2026-07-17 23:00:00", "2026-07-18 09:00:00")]; - let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-18")); + #[test] + fn weekday_totals_follow_the_hours_played_not_the_start() { + let spans = vec![local_span("2026-07-17 23:00:00", "2026-07-18 09:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-18")); - assert_eq!(stats.per_weekday[4], 3600, "Friday keeps only its one hour"); - assert_eq!(stats.per_weekday[5], 9 * 3600); - assert_eq!(stats.peak_weekday, Some(5)); - } + assert_eq!(stats.per_weekday[4], 3600, "Friday keeps only its one hour"); + assert_eq!(stats.per_weekday[5], 9 * 3600); + assert_eq!(stats.peak_weekday, Some(5)); + } - #[test] - fn a_streak_survives_an_empty_today_but_not_an_empty_yesterday() { - let days = [day("2026-07-15"), day("2026-07-16"), day("2026-07-17")]; + #[test] + fn a_streak_survives_an_empty_today_but_not_an_empty_yesterday() { + let days = [day("2026-07-15"), day("2026-07-16"), day("2026-07-17")]; - assert_eq!(streaks(&days, day("2026-07-17")), (3, 3)); - assert_eq!(streaks(&days, day("2026-07-18")), (3, 3), "today is not over"); - assert_eq!(streaks(&days, day("2026-07-19")), (0, 3), "yesterday was missed"); - } + assert_eq!(streaks(&days, day("2026-07-17")), (3, 3)); + assert_eq!( + streaks(&days, day("2026-07-18")), + (3, 3), + "today is not over" + ); + assert_eq!( + streaks(&days, day("2026-07-19")), + (0, 3), + "yesterday was missed" + ); + } - #[test] - fn a_gap_ends_the_run_but_keeps_the_record() { - let days = [ - day("2026-07-01"), - day("2026-07-02"), - day("2026-07-03"), - day("2026-07-10"), - ]; - assert_eq!(streaks(&days, day("2026-07-10")), (1, 3)); - assert_eq!(streaks(&[], day("2026-07-10")), (0, 0)); - } + #[test] + fn a_gap_ends_the_run_but_keeps_the_record() { + let days = [ + day("2026-07-01"), + day("2026-07-02"), + day("2026-07-03"), + day("2026-07-10"), + ]; + assert_eq!(streaks(&days, day("2026-07-10")), (1, 3)); + assert_eq!(streaks(&[], day("2026-07-10")), (0, 0)); + } - #[test] - fn gamer_needs_five_hour_average() { - let spans = vec![local_span("2026-01-01 12:00:00", "2026-01-01 18:00:00")]; - let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-01")); - assert_eq!(stats.active_days, 1); - assert!(personas_for(&stats, &[]).contains(&Persona::Gamer)); - } + #[test] + fn gamer_needs_five_hour_average() { + let spans = vec![local_span("2026-01-01 12:00:00", "2026-01-01 18:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-01")); + assert_eq!(stats.active_days, 1); + assert!(personas_for(&stats, &[]).contains(&Persona::Gamer)); + } - fn stat_server(address: &str, total_secs: i64) -> ServerStat { - ServerStat { - address: address.into(), - port: None, - joins: 1, - total_secs, - last_played: None, - is_ip: false, - } - } + fn stat_server(address: &str, total_secs: i64) -> ServerStat { + ServerStat { + address: address.into(), + port: None, + joins: 1, + total_secs, + last_played: None, + is_ip: false, + } + } - #[test] - fn loyalty_needs_somewhere_else_to_have_been() { - let spans = vec![local_span("2026-01-01 12:00:00", "2026-01-01 18:00:00")]; - let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-01")); + #[test] + fn loyalty_needs_somewhere_else_to_have_been() { + let spans = vec![local_span("2026-01-01 12:00:00", "2026-01-01 18:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-01")); - let only_one = [stat_server("a.example.com", 3600)]; - assert!( - !personas_for(&stats, &only_one).contains(&Persona::Loyalist), - "a single server is loyalty to nothing" - ); + let only_one = [stat_server("a.example.com", 3600)]; + assert!( + !personas_for(&stats, &only_one).contains(&Persona::Loyalist), + "a single server is loyalty to nothing" + ); - let favourite = [ - stat_server("a.example.com", 9000), - stat_server("b.example.com", 1000), - ]; - assert!(personas_for(&stats, &favourite).contains(&Persona::Loyalist)); - } + let favourite = [ + stat_server("a.example.com", 9000), + stat_server("b.example.com", 1000), + ]; + assert!(personas_for(&stats, &favourite).contains(&Persona::Loyalist)); + } - #[test] - fn an_overnight_session_still_reads_as_a_night_owl() { - let spans = vec![local_span("2026-01-01 22:00:00", "2026-01-02 06:00:00")]; - let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-02")); + #[test] + fn an_overnight_session_still_reads_as_a_night_owl() { + let spans = vec![local_span("2026-01-01 22:00:00", "2026-01-02 06:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-02")); - assert!(personas_for(&stats, &[]).contains(&Persona::NightOwl)); - assert!(personas_for(&stats, &[]).contains(&Persona::Marathoner)); - } + assert!(personas_for(&stats, &[]).contains(&Persona::NightOwl)); + assert!(personas_for(&stats, &[]).contains(&Persona::Marathoner)); + } - #[test] - fn no_more_personas_than_fit() { - let mut spans = Vec::new(); - for day_of in 1..=20 { - spans.push(local_span( - &format!("2026-01-{day_of:02} 22:00:00"), - &format!("2026-01-{:02} 08:00:00", day_of + 1), - )); - } - let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-21")); - let servers: Vec = (0..12) - .map(|i| stat_server(&format!("s{i}.example.com"), 3600)) - .collect(); + #[test] + fn no_more_personas_than_fit() { + let mut spans = Vec::new(); + for day_of in 1..=20 { + spans.push(local_span( + &format!("2026-01-{day_of:02} 22:00:00"), + &format!("2026-01-{:02} 08:00:00", day_of + 1), + )); + } + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-21")); + let servers: Vec = (0..12) + .map(|i| stat_server(&format!("s{i}.example.com"), 3600)) + .collect(); - assert_eq!(personas_for(&stats, &servers).len(), MAX_PERSONAS); - } + assert_eq!(personas_for(&stats, &servers).len(), MAX_PERSONAS); + } } diff --git a/packages/oneclient_core/src/game/diagnosis.rs b/packages/oneclient_core/src/game/diagnosis.rs index c58e7625..87dad72e 100644 --- a/packages/oneclient_core/src/game/diagnosis.rs +++ b/packages/oneclient_core/src/game/diagnosis.rs @@ -50,9 +50,7 @@ pub fn diagnose(line: &str) -> Option { return None; } - Some(CrashDiagnosis::CorruptArchive { - file: jar_in(line), - }) + Some(CrashDiagnosis::CorruptArchive { file: jar_in(line) }) } /// Opportunistic some JVMs omit the path entirely and a missing name still @@ -97,7 +95,10 @@ impl CrashWatch { && let Ok(mut found) = self.found.lock() && found.is_none() { - tracing::warn!(?diagnosis, "recognised a repairable crash cause in the game log"); + tracing::warn!( + ?diagnosis, + "recognised a repairable crash cause in the game log" + ); *found = Some(diagnosis); } } @@ -141,7 +142,8 @@ mod tests { #[test] fn a_windows_path_is_reduced_to_its_file_name() { - let line = r"Error: Invalid or corrupt jarfile C:\Users\someone\metadata\libraries\asm-9.7.jar"; + let line = + r"Error: Invalid or corrupt jarfile C:\Users\someone\metadata\libraries\asm-9.7.jar"; assert_eq!( diagnose(line), @@ -194,6 +196,9 @@ mod tests { watch.observe("java.util.zip.ZipException: error in opening zip file"); assert!(watch.take().is_some()); - assert!(watch.take().is_none(), "a diagnosis must not be reported twice"); + assert!( + watch.take().is_none(), + "a diagnosis must not be reported twice" + ); } } diff --git a/packages/oneclient_core/src/game/error.rs b/packages/oneclient_core/src/game/error.rs index 5057273a..82c8bd6a 100644 --- a/packages/oneclient_core/src/game/error.rs +++ b/packages/oneclient_core/src/game/error.rs @@ -47,9 +47,7 @@ pub enum GameError { impl GameError { pub(crate) fn wrapper_spawn(program: &str, err: &std::io::Error) -> Self { let reason = match err.kind() { - std::io::ErrorKind::NotFound => { - "command not found".to_string() - } + std::io::ErrorKind::NotFound => "command not found".to_string(), std::io::ErrorKind::PermissionDenied => { "insufficient permissions or file is not an executable".to_string() } diff --git a/packages/oneclient_core/src/game/fabric.rs b/packages/oneclient_core/src/game/fabric.rs index 611de7ca..a6433deb 100644 --- a/packages/oneclient_core/src/game/fabric.rs +++ b/packages/oneclient_core/src/game/fabric.rs @@ -107,7 +107,11 @@ mod tests { assert!(mods_folder_argument(GameLoader::Fabric, Some("0.16.5"), "-Xmx4G", dir).is_some()); - assert!(!uses_cluster_mods_folder(GameLoader::Fabric, Some("0.16.5"), mine)); + assert!(!uses_cluster_mods_folder( + GameLoader::Fabric, + Some("0.16.5"), + mine + )); assert!(mods_folder_argument(GameLoader::Fabric, Some("0.16.5"), mine, dir).is_none()); } } diff --git a/packages/oneclient_core/src/game/gpu/linux.rs b/packages/oneclient_core/src/game/gpu/linux.rs index 7734a33c..a2ccdb85 100644 --- a/packages/oneclient_core/src/game/gpu/linux.rs +++ b/packages/oneclient_core/src/game/gpu/linux.rs @@ -1,6 +1,4 @@ -use std::path::{Path, PathBuf}; - -const INTEL_INTEGRATED: &str = "0000:00:02.0"; +use std::path::Path; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Driver { @@ -41,53 +39,25 @@ impl Driver { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Gpu { +pub struct Card { pub pci_address: String, - pub boot_vga: Option, + pub name: String, pub driver: Driver, - pub render_node: Option, - pub discrete: bool, } -impl Gpu { - fn nvidia_proprietary(&self) -> bool { - self.driver == Driver::Nvidia - } - - fn can_render(&self) -> bool { - self.render_node.is_some() || self.nvidia_proprietary() - } - - fn priority(&self) -> u32 { - u32::from(self.discrete) * 2 + u32::from(self.boot_vga == Some(true)) - } -} - -pub fn offload_env(gpus: &[Gpu]) -> Vec<(&'static str, String)> { - let Some(best) = gpus - .iter() - .filter(|gpu| gpu.can_render()) - .max_by_key(|gpu| gpu.priority()) - else { - return Vec::new(); - }; - - if !best.discrete || best.boot_vga == Some(true) { - return Vec::new(); - } - - let mut env = if best.nvidia_proprietary() { +pub fn offload_env(card: &Card) -> Vec<(&'static str, String)> { + let mut env = if card.driver == Driver::Nvidia { vec![ ("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()), ("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()), ("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()), ] } else { - let prime = pci_tag(&best.pci_address).unwrap_or_else(|| "1".to_string()); + let prime = format!("pci-{}", card.pci_address.replace([':', '.'], "_")); vec![("DRI_PRIME", prime)] }; - if let Some(icd) = best.driver.vulkan_icd() { + if let Some(icd) = card.driver.vulkan_icd() { env.push(("VK_LOADER_DRIVERS_SELECT", icd.to_string())); } @@ -95,22 +65,25 @@ pub fn offload_env(gpus: &[Gpu]) -> Vec<(&'static str, String)> { } #[cfg(target_os = "linux")] -pub fn detect() -> Vec { - let mut gpus = read_pci_devices(Path::new("/sys/bus/pci/devices")); +pub fn detect() -> Vec { + let ids = [ + "/usr/share/hwdata/pci.ids", + "/usr/share/misc/pci.ids", + "/usr/share/pci.ids", + ] + .iter() + .find_map(|path| std::fs::read_to_string(path).ok()) + .unwrap_or_default(); - for gpu in &mut gpus { - gpu.discrete = probe_discrete(gpu); - } - - gpus + read_pci_devices(Path::new("/sys/bus/pci/devices"), &ids) } -fn read_pci_devices(root: &Path) -> Vec { +fn read_pci_devices(root: &Path, ids: &str) -> Vec { let Ok(entries) = std::fs::read_dir(root) else { return Vec::new(); }; - let mut gpus = Vec::new(); + let mut cards = Vec::new(); for entry in entries.flatten() { let name = entry.file_name(); @@ -120,28 +93,30 @@ fn read_pci_devices(root: &Path) -> Vec { let device = entry.path(); - if read_class(&device.join("class")).is_none_or(|class| class >> 16 != 0x03) { + if read_hex(&device.join("class")).is_none_or(|class| class >> 16 != 0x03) { continue; } - gpus.push(Gpu { + let driver = Driver::from_name(read_link_name(&device.join("driver")).as_deref()); + if driver != Driver::Nvidia && !has_render_node(&device.join("drm")) { + continue; + } + + let vendor = read_hex(&device.join("vendor")).unwrap_or(0); + let model = read_hex(&device.join("device")).unwrap_or(0); + + cards.push(Card { pci_address: address.to_string(), - boot_vga: read_flag(&device.join("boot_vga")), - driver: Driver::from_name(read_link_name(&device.join("driver")).as_deref()), - render_node: render_node(&device.join("drm")), - discrete: false, + name: display_name(ids, vendor, model, address), + driver, }); } - gpus.sort_by(|a, b| a.pci_address.cmp(&b.pci_address)); - gpus + cards.sort_by(|a, b| a.pci_address.cmp(&b.pci_address)); + cards } -fn read_flag(path: &Path) -> Option { - Some(std::fs::read_to_string(path).ok()?.trim() == "1") -} - -fn read_class(path: &Path) -> Option { +fn read_hex(path: &Path) -> Option { let raw = std::fs::read_to_string(path).ok()?; let raw = raw.trim(); u32::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16).ok() @@ -152,373 +127,95 @@ fn read_link_name(path: &Path) -> Option { Some(link.file_name()?.to_str()?.to_string()) } -fn render_node(drm: &Path) -> Option { - let entries = std::fs::read_dir(drm).ok()?; - - entries.flatten().find_map(|entry| { - let name = entry.file_name(); - name.to_str()? - .starts_with("renderD") - .then(|| Path::new("/dev/dri").join(&name)) +fn has_render_node(drm: &Path) -> bool { + std::fs::read_dir(drm).is_ok_and(|entries| { + entries + .flatten() + .any(|entry| entry.file_name().to_string_lossy().starts_with("renderD")) }) } -fn pci_tag(address: &str) -> Option { - let (domain, rest) = address.split_once(':')?; - let (bus, rest) = rest.split_once(':')?; - let (device, function) = rest.split_once('.')?; +fn display_name(ids: &str, vendor: u32, model: u32, address: &str) -> String { + let brand = match vendor { + 0x10de => "NVIDIA", + 0x1002 => "AMD", + 0x8086 => "Intel", + _ => "", + }; - let widths = [(domain, 4), (bus, 2), (device, 2), (function, 1)]; - if widths - .iter() - .any(|(part, width)| part.len() != *width || !part.bytes().all(|b| b.is_ascii_hexdigit())) - { - return None; - } + let model = pci_ids_model(ids, vendor, model).map_or_else( + || format!("GPU {address}"), + |name| { + name.rsplit_once('[') + .and_then(|(_, marketing)| marketing.strip_suffix(']')) + .unwrap_or(name) + .to_string() + }, + ); - Some(format!("pci-{domain}_{bus}_{device}_{function}")) -} - -fn probe_discrete(gpu: &Gpu) -> bool { - match gpu.driver { - Driver::Nvidia => true, - Driver::AmdGpu => answered(gpu, drm::amdgpu_is_discrete(gpu)), - Driver::Xe => answered(gpu, drm::xe_is_discrete(gpu)), - Driver::I915 => gpu.pci_address != INTEL_INTEGRATED, - Driver::Nouveau | Driver::Radeon | Driver::Other => gpu.boot_vga == Some(false), + if brand.is_empty() { + model + } else { + format!("{brand} {model}") } } -fn answered(gpu: &Gpu, probe: std::io::Result) -> bool { - probe.unwrap_or_else(|error| { - tracing::warn!( - card = gpu.pci_address, - ?error, - "no discreteness answer; assuming integrated" - ); - false - }) -} +fn pci_ids_model(ids: &str, vendor: u32, model: u32) -> Option<&str> { + let vendor = format!("{vendor:04x} "); + let model = format!("\t{model:04x} "); -#[cfg(not(target_os = "linux"))] -mod drm { - use super::Gpu; - - fn unavailable() -> std::io::Result { - Err(std::io::Error::from(std::io::ErrorKind::Unsupported)) - } - - pub fn amdgpu_is_discrete(_gpu: &Gpu) -> std::io::Result { - unavailable() - } - - pub fn xe_is_discrete(_gpu: &Gpu) -> std::io::Result { - unavailable() - } -} - -#[cfg(target_os = "linux")] -mod drm { - use std::fs::File; - use std::os::fd::AsRawFd; - - use super::Gpu; - - const DRM_IOCTL_BASE: u32 = b'd' as u32; - const DRM_COMMAND_BASE: u32 = 0x40; - - fn open(gpu: &Gpu) -> std::io::Result> { - let Some(node) = &gpu.render_node else { - return Ok(None); - }; - - std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(node) - .map(Some) - } - - fn check(result: i32) -> std::io::Result<()> { - if result == 0 { - Ok(()) - } else { - Err(std::io::Error::last_os_error()) - } - } - - fn command_write(file: &File, index: u32, data: &T) -> std::io::Result<()> { - let request = libc::_IOW::(DRM_IOCTL_BASE, DRM_COMMAND_BASE + index); - check(unsafe { libc::ioctl(file.as_raw_fd(), request, std::ptr::from_ref(data)) }) - } - - fn command_read_write(file: &File, index: u32, data: &mut T) -> std::io::Result<()> { - let request = libc::_IOWR::(DRM_IOCTL_BASE, DRM_COMMAND_BASE + index); - check(unsafe { libc::ioctl(file.as_raw_fd(), request, std::ptr::from_mut(data)) }) - } - - pub fn amdgpu_is_discrete(gpu: &Gpu) -> std::io::Result { - let Some(file) = open(gpu)? else { - return Ok(false); - }; - - #[repr(C)] - struct InfoDevice { - _before_ids_flags: [u32; 34], - ids_flags: u64, - } - - #[repr(C)] - struct Info { - return_pointer: u64, - return_size: u32, - query: u32, - _query_args: [u32; 4], - } - - let mut result = InfoDevice { - _before_ids_flags: [0; 34], - ids_flags: 0, - }; - - const AMDGPU_INFO_DEV_INFO: u32 = 0x16; - let query = Info { - return_pointer: std::ptr::from_mut(&mut result) as u64, - return_size: size_of_val(&result) as u32, - query: AMDGPU_INFO_DEV_INFO, - _query_args: [0; 4], - }; - - const DRM_AMDGPU_INFO: u32 = 0x05; - command_write(&file, DRM_AMDGPU_INFO, &query)?; - - const AMDGPU_IDS_FLAGS_FUSION: u64 = 0x1; - Ok(result.ids_flags & AMDGPU_IDS_FLAGS_FUSION == 0) - } - - pub fn xe_is_discrete(gpu: &Gpu) -> std::io::Result { - let Some(file) = open(gpu)? else { - return Ok(false); - }; - - #[repr(C)] - struct DeviceQuery { - extensions: u64, - query: u32, - size: u32, - data: u64, - _reserved: [u64; 2], - } - - const DRM_XE_DEVICE_QUERY: u32 = 0x00; - const DRM_XE_DEVICE_QUERY_CONFIG: u32 = 0x2; - - let mut query = DeviceQuery { - extensions: 0, - query: DRM_XE_DEVICE_QUERY_CONFIG, - size: 0, - data: 0, - _reserved: [0; 2], - }; - - command_read_write(&file, DRM_XE_DEVICE_QUERY, &mut query)?; - - const INFO_OFFSET: usize = 1; - const CONFIG_FLAGS: usize = 1; - let words = (query.size as usize).div_ceil(size_of::()); - - if words <= INFO_OFFSET + CONFIG_FLAGS { - tracing::warn!(size = query.size, "xe config too short to hold its flags"); - return Ok(false); - } - - let mut data = vec![0_u64; words]; - query.data = data.as_mut_ptr() as u64; - - command_read_write(&file, DRM_XE_DEVICE_QUERY, &mut query)?; - - const DRM_XE_QUERY_CONFIG_FLAG_HAS_VRAM: u64 = 1 << 0; - Ok(data[INFO_OFFSET + CONFIG_FLAGS] & DRM_XE_QUERY_CONFIG_FLAG_HAS_VRAM != 0) - } + ids.lines() + .skip_while(|line| !line.starts_with(&vendor)) + .skip(1) + .take_while(|line| line.starts_with('\t') || line.starts_with('#')) + .find_map(|line| line.strip_prefix(&model)) } #[cfg(test)] mod tests { use super::*; - const AMD_INTEGRATED: &str = "0000:0c:00.0"; + const IDS: &str = "\ +# comment +10de NVIDIA Corporation +\t25a2 GA107M [GeForce RTX 3050 Mobile] +\t\t1043 1e1f GA107M [GeForce RTX 3050 Mobile] +\t1234 Plain Name +1002 Advanced Micro Devices, Inc. [AMD/ATI] +\t25a2 Not The Nvidia Card +8086 Intel Corporation +"; - fn gpu(address: &str, driver: Driver, boot_vga: Option) -> Gpu { - let discrete = match driver { - Driver::Nvidia => true, - Driver::I915 => address != INTEL_INTEGRATED, - _ => boot_vga == Some(false), - }; - - Gpu { + fn card(address: &str, driver: Driver) -> Card { + Card { pci_address: address.to_string(), - boot_vga, + name: String::new(), driver, - render_node: Some(PathBuf::from("/dev/dri/renderD128")), - discrete, - } - } - - fn nvidia_offload() -> Vec<(&'static str, String)> { - vec![ - ("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()), - ("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()), - ("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()), - ("VK_LOADER_DRIVERS_SELECT", "*nvidia*".to_string()), - ] - } - - fn dri_prime(card: &str, icd: &str) -> Vec<(&'static str, String)> { - vec![ - ("DRI_PRIME", card.to_string()), - ("VK_LOADER_DRIVERS_SELECT", icd.to_string()), - ] - } - - #[test] - fn offload_env_names_the_right_card_for_every_pairing() { - let igpu = || gpu(INTEL_INTEGRATED, Driver::I915, Some(true)); - let dgpu = || gpu("0000:01:00.0", Driver::Nvidia, Some(false)); - - type Case = (&'static str, Vec, Vec<(&'static str, String)>); - let amd = "*radeon*,*amd*"; - - let cases: Vec = vec![ - ("no cards at all", vec![], vec![]), - ( - "a lone amd card", - vec![gpu("0000:01:00.0", Driver::AmdGpu, Some(true))], - vec![], - ), - ( - "a lone nvidia card", - vec![gpu("0000:01:00.0", Driver::Nvidia, Some(true))], - vec![], - ), - ("a lone intel card", vec![igpu()], vec![]), - ( - "an all-amd hybrid never names nvidia", - vec![ - gpu(AMD_INTEGRATED, Driver::AmdGpu, Some(true)), - gpu("0000:01:00.0", Driver::AmdGpu, Some(false)), - ], - dri_prime("pci-0000_01_00_0", amd), - ), - ( - "an intel + amd hybrid never names nvidia", - vec![igpu(), gpu("0000:01:00.0", Driver::AmdGpu, Some(false))], - dri_prime("pci-0000_01_00_0", amd), - ), - ( - "an optimus laptop gets the nvidia variables and no DRI_PRIME", - vec![igpu(), dgpu()], - nvidia_offload(), - ), - ( - "the same laptop on nouveau gets DRI_PRIME and no ICD filter", - vec![igpu(), Gpu { driver: Driver::Nouveau, ..dgpu() }], - vec![("DRI_PRIME", "pci-0000_01_00_0".to_string())], - ), - ( - "an nvidia card without a render node is still a target", - vec![igpu(), Gpu { render_node: None, ..dgpu() }], - nvidia_offload(), - ), - ( - "an unbound card is never the target", - vec![igpu(), Gpu { driver: Driver::Other, render_node: None, ..dgpu() }], - vec![], - ), - ( - "a bmc display adapter is skipped for the real card behind it", - vec![ - igpu(), - Gpu { render_node: None, ..gpu("0000:03:00.0", Driver::Other, Some(false)) }, - gpu("0000:c1:00.0", Driver::AmdGpu, Some(false)), - ], - dri_prime("pci-0000_c1_00_0", amd), - ), - ( - "the discrete card wins over a renderable card at a lower address", - vec![ - igpu(), - Gpu { discrete: false, ..gpu("0000:03:00.0", Driver::AmdGpu, Some(false)) }, - gpu("0000:c1:00.0", Driver::AmdGpu, Some(false)), - ], - dri_prime("pci-0000_c1_00_0", amd), - ), - ( - "nothing happens when the discrete card already drives the display", - vec![ - gpu("0000:01:00.0", Driver::Nvidia, Some(true)), - gpu(INTEL_INTEGRATED, Driver::I915, Some(false)), - ], - vec![], - ), - ( - "the driver still finds the discrete card when nothing publishes boot_vga", - vec![ - gpu(INTEL_INTEGRATED, Driver::I915, None), - gpu("0000:01:00.0", Driver::Nvidia, None), - ], - nvidia_offload(), - ), - ( - "a malformed pci address falls back to the ordinal", - vec![ - gpu(AMD_INTEGRATED, Driver::AmdGpu, Some(true)), - gpu("not-an-address", Driver::AmdGpu, Some(false)), - ], - dri_prime("1", amd), - ), - ]; - - for (name, gpus, expected) in cases { - assert_eq!(offload_env(&gpus), expected, "{name}"); } } #[test] - fn pci_tags_match_the_shape_mesa_builds() { - assert_eq!(pci_tag("0000:01:00.0").as_deref(), Some("pci-0000_01_00_0")); - assert_eq!(pci_tag("10000:af:1f.7").as_deref(), None, "domain is 4 wide"); - assert_eq!(pci_tag("0000:1:00.0"), None, "bus is 2 wide"); - assert_eq!(pci_tag("0000:01:00"), None, "no function"); - assert_eq!(pci_tag("0000:0g:00.0"), None, "not hex"); - } - - #[test] - fn a_driver_that_will_not_answer_is_never_called_discrete() { - let mut apu = gpu(AMD_INTEGRATED, Driver::AmdGpu, Some(false)); - apu.discrete = probe_discrete(&apu); - assert!(!apu.discrete); - - let mut dgpu = gpu("0000:03:00.0", Driver::AmdGpu, Some(true)); - dgpu.discrete = probe_discrete(&dgpu); - assert!(!dgpu.discrete); - - assert!( - offload_env(&[apu, dgpu]).is_empty(), - "knowing nothing, leave the renderer alone" + fn offload_env_names_the_chosen_card() { + assert_eq!( + offload_env(&card("0000:01:00.0", Driver::Nvidia)), + vec![ + ("__NV_PRIME_RENDER_OFFLOAD", "1".to_string()), + ("__VK_LAYER_NV_optimus", "NVIDIA_only".to_string()), + ("__GLX_VENDOR_LIBRARY_NAME", "nvidia".to_string()), + ("VK_LOADER_DRIVERS_SELECT", "*nvidia*".to_string()), + ], ); - } - - #[test] - fn the_cards_we_can_place_without_asking_the_driver() { - assert!(probe_discrete(&gpu("0000:01:00.0", Driver::Nvidia, Some(false)))); - assert!(!probe_discrete(&gpu(INTEL_INTEGRATED, Driver::I915, Some(true)))); - assert!( - probe_discrete(&gpu("0000:03:00.0", Driver::I915, Some(false))), - "an arc board does not sit at the integrated address" + assert_eq!( + offload_env(&card("0000:c1:00.0", Driver::AmdGpu)), + vec![ + ("DRI_PRIME", "pci-0000_c1_00_0".to_string()), + ("VK_LOADER_DRIVERS_SELECT", "*radeon*,*amd*".to_string()), + ], ); - assert!( - probe_discrete(&gpu("0000:01:00.0", Driver::Nouveau, Some(false))), - "no query, so boot_vga is all we have" + assert_eq!( + offload_env(&card("0000:01:00.0", Driver::Nouveau)), + vec![("DRI_PRIME", "pci-0000_01_00_0".to_string())], + "nouveau gets no ICD filter", ); } @@ -533,10 +230,11 @@ mod tests { std::fs::write(path, contents).unwrap(); } - fn write_card(bus: &Path, address: &str, boot_vga: &str, driver: &str) { + fn write_card(bus: &Path, address: &str, vendor: &str, model: &str, driver: &str) { let device = bus.join(address); - write(&device.join("boot_vga"), &format!("{boot_vga}\n")); write(&device.join("class"), "0x030000\n"); + write(&device.join("vendor"), &format!("0x{vendor}\n")); + write(&device.join("device"), &format!("0x{model}\n")); let target = bus.join("drivers").join(driver); std::fs::create_dir_all(&target).unwrap(); @@ -544,58 +242,41 @@ mod tests { } #[test] - fn reads_boot_flag_driver_and_render_node_out_of_a_sysfs_tree() { + fn lists_the_cards_that_can_render_out_of_a_sysfs_tree() { let scratch = polyio::testing::ScratchDir::new("gpu-sysfs"); let bus = scratch.join("devices"); - write_card(&bus, INTEL_INTEGRATED, "1", "i915"); + write_card(&bus, "0000:00:02.0", "8086", "9a49", "i915"); write( - &bus.join(INTEL_INTEGRATED) + &bus.join("0000:00:02.0") .join("drm") .join("renderD128") .join("dev"), "226:128\n", ); - write_card(&bus, "0000:01:00.0", "0", "nvidia"); - - let gpus = read_pci_devices(&bus); - - assert_eq!(gpus.len(), 2); - assert_eq!(gpus[0].pci_address, INTEL_INTEGRATED); - assert_eq!(gpus[0].boot_vga, Some(true)); - assert_eq!(gpus[0].driver, Driver::I915); - assert_eq!( - gpus[0].render_node, - Some(PathBuf::from("/dev/dri/renderD128")), - "the node the ioctl probes open" - ); - assert_eq!(gpus[1].boot_vga, Some(false)); - assert_eq!(gpus[1].driver, Driver::Nvidia); - assert_eq!(gpus[1].render_node, None, "no nvidia-drm in this tree"); - - assert!(gpus.iter().all(|gpu| !gpu.discrete)); - } - - #[test] - fn skips_everything_on_the_bus_that_is_not_a_display_controller() { - let scratch = polyio::testing::ScratchDir::new("gpu-bus"); - let bus = scratch.join("devices"); - - write_card(&bus, "0000:01:00.0", "1", "amdgpu"); + write_card(&bus, "0000:01:00.0", "10de", "25a2", "nvidia"); + write_card(&bus, "0000:03:00.0", "1a03", "2000", "ast"); write(&bus.join("0000:01:00.1").join("class"), "0x040300\n"); write(&bus.join("0000:02:00.0").join("class"), "0x020000\n"); - std::fs::create_dir_all(bus.join("0000:03:00.0")).unwrap(); + std::fs::create_dir_all(bus.join("0000:04:00.0")).unwrap(); - let gpus = read_pci_devices(&bus); + let gpus = read_pci_devices(&bus, IDS); - assert_eq!(gpus.len(), 1, "one card, counted once"); - assert_eq!(gpus[0].driver, Driver::AmdGpu); + assert_eq!( + gpus.len(), + 2, + "no bmc adapter, audio, nic or classless device" + ); + assert_eq!(gpus[0].pci_address, "0000:00:02.0"); + assert_eq!(gpus[0].driver, Driver::I915); + assert_eq!(gpus[1].driver, Driver::Nvidia, "needs no render node"); + assert_eq!(gpus[1].name, "NVIDIA GeForce RTX 3050 Mobile"); } #[test] fn a_missing_pci_tree_is_not_an_error() { let scratch = polyio::testing::ScratchDir::new("gpu-missing"); - assert!(read_pci_devices(&scratch.join("nope")).is_empty()); + assert!(read_pci_devices(&scratch.join("nope"), "").is_empty()); } } diff --git a/packages/oneclient_core/src/game/gpu/mod.rs b/packages/oneclient_core/src/game/gpu/mod.rs index e6d37f06..16ee6dd2 100644 --- a/packages/oneclient_core/src/game/gpu/mod.rs +++ b/packages/oneclient_core/src/game/gpu/mod.rs @@ -1,30 +1,69 @@ #[cfg(any(target_os = "linux", test))] mod linux; -use tokio::process::Command; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Gpu { + pub id: String, + pub name: String, +} + +#[cfg(windows)] +const WINDOWS_PREFERENCES: [(&str, &str, u8); 2] = [ + ("integrated", "Integrated GPU", 1), + ("dedicated", "Dedicated GPU", 2), +]; + +pub fn detect() -> Vec { + cfg_select! { + target_os = "linux" => linux::detect() + .into_iter() + .map(|card| Gpu { + id: card.pci_address, + name: card.name, + }) + .collect(), + windows => WINDOWS_PREFERENCES + .iter() + .map(|(id, name, _)| Gpu { + id: (*id).to_string(), + name: (*name).to_string(), + }) + .collect(), + _ => Vec::new(), + } +} #[allow(unused_variables)] -pub async fn prefer_discrete(command: &mut Command, java_path: &str) { +pub async fn select(command: &mut tokio::process::Command, java_path: &str, id: &str) { #[cfg(target_os = "linux")] { - let gpus = linux::detect(); - let env = linux::offload_env(&gpus); - - if env.is_empty() { - tracing::info!( - gpus = gpus.len(), - "discrete GPU was requested but nothing here is a valid offload target; \ - leaving the renderer alone" - ); + let cards = tokio::task::spawn_blocking(linux::detect) + .await + .unwrap_or_default(); + let Some(card) = cards.into_iter().find(|card| card.pci_address == id) else { + tracing::warn!(id, "the selected GPU is gone; leaving the renderer alone"); return; - } + }; - for (key, value) in env { - tracing::debug!(key, value, "offloading the game to the discrete GPU"); + for (key, value) in linux::offload_env(&card) { + tracing::debug!( + key, + value, + gpu = card.name, + "rendering the game on the selected GPU" + ); command.env(key, value); } } #[cfg(windows)] - oneclient_java::prefer_dedicated_gpu(std::path::Path::new(java_path)).await; + { + let Some((_, _, preference)) = WINDOWS_PREFERENCES.iter().find(|(known, ..)| *known == id) + else { + tracing::warn!(id, "unknown GPU preference; leaving the renderer alone"); + return; + }; + + oneclient_java::set_gpu_preference(std::path::Path::new(java_path), *preference).await; + } } diff --git a/packages/oneclient_core/src/game/heal.rs b/packages/oneclient_core/src/game/heal.rs index 71571762..edadfd55 100644 --- a/packages/oneclient_core/src/game/heal.rs +++ b/packages/oneclient_core/src/game/heal.rs @@ -8,7 +8,7 @@ const PROBE_LEN: usize = 4096; const MAX_PROBE_BYTES: u64 = 32 * 1024 * 1024; -const BULK_NAMES: [&[u8]; 4] = [b"cache", b"repo", b"backup", b"logs"]; +const BULK_NAMES: [&str; 4] = ["cache", "repo", "backup", "log"]; const NEVER_PROBED: [&str; 8] = [ "db", "sqlite", "sqlite3", "wal", "shm", "journal", "lock", "idx", @@ -88,12 +88,8 @@ fn sweep_dir(root: &Path, recurse: bool) -> usize { let path = entry.path(); - if names_bulk_data(&path) { - continue; - } - if file_type.is_dir() { - if recurse { + if recurse && !names_bulk_data(&path) { stack.push(path); } continue; @@ -127,12 +123,13 @@ fn names_bulk_data(path: &Path) -> bool { path.file_name() .and_then(|name| name.to_str()) .is_some_and(|name| { - let name = name.as_bytes(); - - BULK_NAMES.iter().any(|needle| { - name.windows(needle.len()) - .any(|window| window.eq_ignore_ascii_case(needle)) - }) + name.split(|c: char| !c.is_ascii_alphanumeric()) + .any(|word| { + let word = word.strip_suffix(['s', 'S']).unwrap_or(word); + BULK_NAMES + .iter() + .any(|needle| word.eq_ignore_ascii_case(needle)) + }) }) } @@ -140,7 +137,11 @@ fn worth_probing(path: &Path) -> bool { !path .extension() .and_then(|ext| ext.to_str()) - .is_some_and(|ext| NEVER_PROBED.iter().any(|skip| ext.eq_ignore_ascii_case(skip))) + .is_some_and(|ext| { + NEVER_PROBED + .iter() + .any(|skip| ext.eq_ignore_ascii_case(skip)) + }) } fn is_zeroed(path: &Path) -> bool { @@ -171,3 +172,34 @@ fn is_zeroed(path: &Path) -> bool { read_so_far > 0 } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bulk_names_match_whole_words_only() { + for bulk in [ + "cache", + "logs", + "backups", + "catharsis-repo-cache", + "meowdding-repo-cache", + "config_backup.json", + ] { + assert!(names_bulk_data(Path::new(bulk)), "{bulk} should be skipped"); + } + + for swept in [ + "NoChatReports", + "NCR-Common.json", + "crash-reports", + "catalog.json", + ] { + assert!( + !names_bulk_data(Path::new(swept)), + "{swept} should be swept" + ); + } + } +} diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 784f426f..497f91d4 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -1,3 +1,4 @@ +use std::fmt::Write as _; use std::path::Path; use std::process::Stdio; use std::sync::Arc; @@ -5,25 +6,25 @@ use std::time::Duration; use chrono::{DateTime, Utc}; use interfrost::api::minecraft::ArgumentType; -use tokio::io::AsyncReadExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::Command; use crate::ClusterStage; -use oneclient_auth::MinecraftAccount; +use crate::LauncherResult; use crate::clusters::Cluster; -use oneclient_discord::Presence; +use crate::game::GameError; use crate::game::session::SessionRecorder; use crate::game::tail::spawn_log_tail; -use crate::game::GameError; -use oneclient_mc::{ - self as arguments, download_minecraft, download_version_info, get_loader_version, - game_files_missing, resolve_minecraft_version, -}; -use oneclient_events::{GroupedProgressSession, LaunchStage}; use crate::settings::GameSettingsProfile; use crate::state::LauncherState; -use crate::LauncherResult; +use oneclient_auth::MinecraftAccount; use oneclient_common::paths; +use oneclient_discord::Presence; +use oneclient_events::{GroupedProgressSession, LaunchStage}; +use oneclient_mc::{ + self as arguments, download_minecraft, download_version_info, game_files_missing, + get_loader_version, resolve_minecraft_version, +}; pub fn is_running(state: &LauncherState, cluster_id: i64) -> bool { state.games.is_running(cluster_id) @@ -44,19 +45,19 @@ pub async fn launch_cluster( ) -> LauncherResult { tracing::info!(cluster_id, search_for_java, "launching cluster"); - let parallel = state.settings.read().allow_parallel_running_clusters; - if !parallel && state.games.is_active(cluster_id) { - tracing::warn!(cluster_id, "cluster already launching or running; refusing launch"); + // Parallel clusters or not a cluster runs only once a second game would + // share its directory and its slot in the process manager + if state.games.is_active(cluster_id) { + tracing::warn!( + cluster_id, + "cluster already launching or running; refusing launch" + ); return Err(GameError::AlreadyRunning(cluster_id).into()); } - // A parallel attempt shares its entry with the session already under way, so - // clearing it here would drop that game's kill sender and pid - let adopted = state.games.is_active(cluster_id); - let result = start(state, cluster_id, account, search_for_java).await; - if result.is_err() && !adopted { + if result.is_err() { state.games.remove(cluster_id); state .services @@ -79,7 +80,19 @@ async fn start( events.game_stage(cluster_id, s); }; + // The stage goes first so a click while this waits is refused as already + // active rather than queued behind it stage(LaunchStage::Checking); + let launch_slot = match state.games.try_launch_slot() { + Some(slot) => slot, + None => { + tracing::debug!(cluster_id, "another launch is preparing; waiting for it"); + stage(LaunchStage::Waiting); + let slot = state.games.launch_slot().await; + stage(LaunchStage::Checking); + slot + } + }; let existing = state.clusters.get(cluster_id).await?; @@ -88,25 +101,38 @@ async fn start( // Non-dedicated clusters share one directory so a second game there would // materialize over the running one's - // `dir_in_use_by` waves a cluster past - // its own session which parallel launches make reachable - if !dedicated && let Some(other) = state.games.dir_in_use(&game_dir) { + if let Err(other) = state.games.claim_dir(cluster_id, &game_dir, !dedicated) { + if dedicated { + return Err(GameError::DirectoryInUse(other).into()); + } tracing::warn!(cluster_id, other, "shared game dir busy; refusing launch"); let name = running_cluster_name(state, other).await; return Err(GameError::SharedDirectoryBusy(name).into()); } - if let Some(other) = state.games.dir_in_use_by(&game_dir, cluster_id) { - return Err(GameError::DirectoryInUse(other).into()); + match state.clusters.mark_played(cluster_id).await { + Ok(()) => events.signal(oneclient_events::Signal::ClustersChanged), + Err(err) => tracing::debug!(cluster_id, error = %err, "could not record the launch time"), } - state.games.set_dir(cluster_id, game_dir.clone()); - let progress = GroupedProgressSession::start( &state.services.events, format!("Launching {}", existing.name), ); + if existing.uses_bundles() + && let Err(err) = crate::clusters::apply_bundle_java_override( + state, + cluster_id, + search_for_java, + false, + Some(&progress), + ) + .await + { + tracing::warn!(cluster_id, error = %err, "failed to apply bundle java override"); + } + let cluster = if existing.stage == ClusterStage::Ready { existing } else { @@ -129,13 +155,14 @@ async fn start( } } }; - if let Err(err) = oneclient_content::bundles::install_cluster_bundles( - cluster_id, - state.bundles.as_ref(), - Some(&progress), - &state.services.content(), - ) - .await + if cluster.uses_bundles() + && let Err(err) = oneclient_content::bundles::install_cluster_bundles( + cluster_id, + state.bundles.as_ref(), + Some(&progress), + &state.services.content(), + ) + .await { tracing::warn!(cluster_id, error = %err, "failed to install bundle content"); } @@ -164,6 +191,7 @@ async fn start( &state.services.mc(), Some(&progress), &version, + cluster.mc_loader, loader_version.as_ref(), false, ) @@ -197,8 +225,14 @@ async fn start( "resolved launch metadata" ); - let java = if let Some(runtime) = - state.java.runtime_for_profile(profile.java_path.as_deref()).await? + if let Some(dir) = crate::game::natives::natives_dir(&version_name) { + state.games.set_natives(cluster_id, dir); + } + + let java = if let Some(runtime) = state + .java + .runtime_for_profile(profile.java_path.as_deref()) + .await? { runtime } else { @@ -208,14 +242,22 @@ async fn start( .map(|v| v.major_version) .ok_or(GameError::MissingJavaVersion)?; - state.java.prepare(major, search_for_java, false, None).await? + state + .java + .prepare(major, search_for_java, false, None) + .await? }; match game_files_missing(&version_info, &java.os_arch, updated) { Ok(true) => { tracing::info!(cluster_id, "missing game files; repairing"); - let _ = state.clusters.set_stage(cluster_id, ClusterStage::Repairing).await; + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::Repairing) + .await; stage(LaunchStage::Downloading); + let natives_mode = + crate::game::natives::natives_mode(state, &version_name, Some(cluster_id)); if let Err(err) = download_minecraft( &state.services.mc(), &progress, @@ -223,6 +265,7 @@ async fn start( &java.os_arch, updated, false, + natives_mode, ) .await { @@ -230,7 +273,10 @@ async fn start( stage(LaunchStage::Exited); return Err(err.into()); } - let _ = state.clusters.set_stage(cluster_id, ClusterStage::Ready).await; + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::Ready) + .await; } Ok(false) => {} Err(err @ oneclient_mc::McError::NoNativesForPlatform { .. }) => { @@ -255,11 +301,8 @@ async fn start( let custom_args = profile.launch_args.clone().unwrap_or_default(); let loader_version_id = loader_version.as_ref().map(|lv| lv.id.as_str()); - let mods_in_cluster = crate::game::uses_cluster_mods_folder( - cluster.mc_loader, - loader_version_id, - &custom_args, - ); + let mods_in_cluster = + crate::game::uses_cluster_mods_folder(cluster.mc_loader, loader_version_id, &custom_args); if let Err(err) = crate::game::materialize_content(&state.services, &cluster, &cwd, mods_in_cluster).await @@ -267,12 +310,16 @@ async fn start( tracing::warn!(cluster_id, error = %err, "failed to materialize cluster content"); } + if !dedicated { + crate::game::link_cluster_logs(&cluster, &cwd).await; + } + let mods_dir = paths::cluster_mods_dir(&cluster.folder_name)?; if account.is_offline() { crate::game::skin_server::disable_incompatible_offline_mods(&[mods_dir.clone(), cwd.join("mods")]).await; if cluster.mc_loader == oneclient_common::domain::GameLoader::Fabric - && mc_version == "1.21.11" + && version.id == "1.21.11" { if let Ok(mod_jar) = crate::game::skin_server::prepare_offline_skins_mod().await { let target_mod = mods_dir.join("offlineskins-fabric.jar"); @@ -283,10 +330,6 @@ async fn start( } } - if !dedicated { - crate::game::link_cluster_logs(&cluster, &cwd).await; - } - let client_jar = paths::versions_dir()? .join(&version_name) .join(format!("{version_name}.jar")); @@ -306,13 +349,14 @@ async fn start( )?; let mut jvm_args = arguments::java_arguments( - updated, arg_map.get(&ArgumentType::Jvm).map(Vec::as_slice), &natives, &libraries, &classpaths, &version_name, - profile.mem_max.unwrap_or_else(oneclient_common::default_mem_max), + profile + .mem_max + .unwrap_or_else(oneclient_common::default_mem_max), profile.launch_args.clone().unwrap_or_default(), &java.os_arch, java.major, @@ -360,7 +404,6 @@ async fn start( } let mut mc_args = arguments::minecraft_arguments( - updated, arg_map.get(&ArgumentType::Game).map(Vec::as_slice), version_info.minecraft_arguments.as_deref(), &account.access_token, @@ -387,7 +430,13 @@ async fn start( } } } - arguments::append_profile_game_arguments(&mut mc_args, profile.force_fullscreen, None); + arguments::append_profile_game_arguments( + &mut mc_args, + profile.force_fullscreen, + profile.game_args.as_deref(), + ); + + drop(launch_slot); if let Some(reason) = run_hook(profile.hook_pre.as_deref(), &cwd).await { events @@ -409,18 +458,26 @@ async fn start( let (mut command, wrapper) = base_command(&profile, &java.absolute_path); - if profile.use_discrete_gpu() { - crate::game::gpu::prefer_discrete(&mut command, &java.absolute_path).await; + if let Some(gpu) = profile.gpu() { + crate::game::gpu::select(&mut command, &java.absolute_path, gpu).await; } - apply_env(&mut command, &profile); + apply_env(&mut command, &profile); - command + command .args(jvm_args) .arg(&version_info.main_class) .args(mc_args) .current_dir(&cwd); + let header = format!( + "OneClient {} · {} · {version_name} · Java {}\n\n{}\n\n", + env!("CARGO_PKG_VERSION"), + cluster.name, + java.major, + command_line(command.as_std(), &version_info.main_class), + ); + let log_path = oneclient_cluster::logs::cluster_output_log(&cluster)?; if let Some(parent) = log_path.parent() { polyio::create_dir_all(parent).await.ok(); @@ -431,10 +488,15 @@ async fn start( // The cloned handle shares the file offset // so stdout and stderr interleave instead of overwriting let handles = match tokio::fs::File::create(&log_path).await { - Ok(out) => match out.try_clone().await { - Ok(err) => Ok((out.into_std().await, err.into_std().await)), - Err(err) => Err(err), - }, + Ok(mut out) => { + if let Err(err) = out.write_all(header.as_bytes()).await { + tracing::warn!(cluster_id, error = %err, "failed to write the launch header"); + } + match out.try_clone().await { + Ok(err) => Ok((out.into_std().await, err.into_std().await)), + Err(err) => Err(err), + } + } Err(err) => Err(err), }; @@ -466,7 +528,9 @@ async fn start( let recorder = SessionRecorder::start( state, cluster_id, - profile.mem_max.unwrap_or_else(oneclient_common::default_mem_max), + profile + .mem_max + .unwrap_or_else(oneclient_common::default_mem_max), &java, ) .await; @@ -499,11 +563,11 @@ async fn start( let post_hook = profile.hook_post.clone(); tokio::spawn(async move { let cluster = cluster; - let status = tokio::select! { - status = child.wait() => status, + let (status, stopped) = tokio::select! { + status = child.wait() => (status, false), _ = kill_rx => { let _ = child.start_kill(); - child.wait().await + (child.wait().await, true) } }; @@ -512,7 +576,7 @@ async fn start( let outcome = match status { Ok(status) => Exit::Observed { code: status.code().map(i64::from), - success: status.success(), + success: status.success() || stopped || killed(&status), display: status.to_string(), }, Err(err) => Exit::Failed(err.to_string()), @@ -567,6 +631,19 @@ fn detach(command: &mut Command) { } } +#[cfg(unix)] +fn killed(status: &std::process::ExitStatus) -> bool { + use std::os::unix::process::ExitStatusExt; + const SIGKILL: i32 = 9; + const JVM_SIGTERM_EXIT: i32 = 143; + status.signal() == Some(SIGKILL) || status.code() == Some(JVM_SIGTERM_EXIT) +} + +#[cfg(not(unix))] +fn killed(_status: &std::process::ExitStatus) -> bool { + false +} + pub(crate) enum Exit { Observed { code: Option, @@ -655,18 +732,21 @@ pub(crate) async fn finalize_session( let crashed = !matches!(end.outcome, Exit::Observed { success: true, .. }); match end.outcome { - Exit::Observed { success: true, .. } => state - .services - .events - .notify("Game closed").body(format!("{name} exited")).send(), + Exit::Observed { success: true, .. } => {} Exit::Observed { display, .. } => state .services .events - .notify("Game crashed").body(format!("{name} exited with {display}")).error().send(), + .notify("Game crashed") + .body(format!("{name} exited with {display}")) + .error() + .send(), Exit::Failed(err) => state .services .events - .notify("Game error").body(format!("{name}: {err}")).error().send(), + .notify("Game error") + .body(format!("{name}: {err}")) + .error() + .send(), // Nothing was watching so there is no crash to report Exit::Inferred => {} } @@ -760,6 +840,66 @@ fn base_command(profile: &GameSettingsProfile, java_path: &str) -> (Command, Opt (command, Some(program.to_string())) } +fn command_line(command: &std::process::Command, main_class: &str) -> String { + #[cfg(not(windows))] + const CONTINUATION: &str = " \\\n "; + #[cfg(windows)] + const CONTINUATION: &str = " `\n "; + + let mut out = String::new(); + if let Some(dir) = command.get_current_dir() { + let _ = writeln!(out, "cd {}", shell_quote(&dir.to_string_lossy())); + } + + for (key, value) in command.get_envs() { + let (key, Some(value)) = (key.to_string_lossy(), value) else { + continue; + }; + let value = shell_quote(&value.to_string_lossy()); + #[cfg(not(windows))] + let _ = write!(out, "{key}={value} "); + #[cfg(windows)] + let _ = writeln!(out, "$env:{key} = {value}"); + } + + #[cfg(windows)] + { + out += "& "; + } + out += &shell_quote(&command.get_program().to_string_lossy()); + + for arg in command.get_args() { + let arg = arg.to_string_lossy(); + let arg = oneclient_cluster::logs::censor(&arg); + out += if arg.starts_with('-') || arg == main_class { + CONTINUATION + } else { + " " + }; + out += &shell_quote(&arg); + } + + out +} + +#[cfg(not(windows))] +fn shell_quote(arg: &str) -> String { + let plain = !arg.is_empty() + && arg + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_-./=:,+@%".contains(&b)); + if plain { + arg.to_string() + } else { + format!("'{}'", arg.replace('\'', r"'\''")) + } +} + +#[cfg(windows)] +fn shell_quote(arg: &str) -> String { + format!("'{}'", arg.replace('\'', "''")) +} + fn apply_env(command: &mut Command, profile: &GameSettingsProfile) { command.env_remove("_JAVA_OPTIONS"); diff --git a/packages/oneclient_core/src/game/log_replay.rs b/packages/oneclient_core/src/game/log_replay.rs index 46a04d2f..2eb86497 100644 --- a/packages/oneclient_core/src/game/log_replay.rs +++ b/packages/oneclient_core/src/game/log_replay.rs @@ -13,273 +13,276 @@ const ROLLOVER_SLACK_SECS: i64 = 12 * 60 * 60; /// Absent any of these the span is closed by the next join or the session end /// so a missing marker costs accuracy never correctness const LEAVE_MARKERS: &[&str] = &[ - "Stopping!", - "Stopping worker threads", - "Connection lost", - "Lost connection to server", - "Client disconnected with reason", - "Disconnected from server", + "Stopping!", + "Stopping worker threads", + "Connection lost", + "Lost connection to server", + "Client disconnected with reason", + "Disconnected from server", ]; /// Clean-shutdown marker its timestamp is the truest exit time available const STOP_MARKER: &str = "Stopping!"; pub(crate) fn parse_log_time(line: &str) -> Option { - let stamp = line.strip_prefix('[')?.split(']').next()?.trim(); + let stamp = line.strip_prefix('[')?.split(']').next()?.trim(); - // `%.f` also matches an empty fraction covering both `[12:29:00]` and - // `[12:29:00.123]` - if let Ok(time) = NaiveTime::parse_from_str(stamp, "%H:%M:%S%.f") { - return Some(time); - } + // `%.f` also matches an empty fraction covering both `[12:29:00]` and + // `[12:29:00.123]` + if let Ok(time) = NaiveTime::parse_from_str(stamp, "%H:%M:%S%.f") { + return Some(time); + } - // Some pack-shipped log4j configs prepend the date - for fmt in ["%Y-%m-%d %H:%M:%S%.f", "%d.%m.%Y %H:%M:%S%.f"] { - if let Ok(dt) = NaiveDateTime::parse_from_str(stamp, fmt) { - return Some(dt.time()); - } - } + // Some pack-shipped log4j configs prepend the date + for fmt in ["%Y-%m-%d %H:%M:%S%.f", "%d.%m.%Y %H:%M:%S%.f"] { + if let Ok(dt) = NaiveDateTime::parse_from_str(stamp, fmt) { + return Some(dt.time()); + } + } - None + None } pub(crate) fn is_leave_marker(line: &str) -> bool { - LEAVE_MARKERS.iter().any(|marker| line.contains(marker)) + LEAVE_MARKERS.iter().any(|marker| line.contains(marker)) } fn local_to_utc(date: NaiveDate, time: NaiveTime) -> DateTime { - let naive = NaiveDateTime::new(date, time); - match Local.from_local_datetime(&naive) { - LocalResult::Single(dt) => dt.with_timezone(&Utc), - // DST end the time happens twice the first fits a forward-walking log - LocalResult::Ambiguous(first, _) => first.with_timezone(&Utc), - // DST start this time never existed so step over the gap rather than - // reading it as UTC which would be off by the zone's whole offset - LocalResult::None => Local - .from_local_datetime(&(naive + chrono::Duration::hours(1))) - .earliest() - .map(|dt| dt.with_timezone(&Utc)) - .unwrap_or_else(|| Utc.from_utc_datetime(&naive)), - } + let naive = NaiveDateTime::new(date, time); + match Local.from_local_datetime(&naive) { + LocalResult::Single(dt) => dt.with_timezone(&Utc), + // DST end the time happens twice the first fits a forward-walking log + LocalResult::Ambiguous(first, _) => first.with_timezone(&Utc), + // DST start this time never existed so step over the gap rather than + // reading it as UTC which would be off by the zone's whole offset + LocalResult::None => Local + .from_local_datetime(&(naive + chrono::Duration::hours(1))) + .earliest() + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|| Utc.from_utc_datetime(&naive)), + } } /// Turns bare times into absolute instants advancing the date on wrap pub(crate) struct LogClock { - date: NaiveDate, - /// High-water mark not the previous line threads interleave so a line - /// may legitimately sit behind the one before it - peak: NaiveTime, + date: NaiveDate, + /// High-water mark not the previous line threads interleave so a line + /// may legitimately sit behind the one before it + peak: NaiveTime, } impl LogClock { - pub(crate) fn new(anchor: DateTime) -> Self { - let local = anchor.with_timezone(&Local).naive_local(); - Self { - date: local.date(), - peak: local.time(), - } - } + pub(crate) fn new(anchor: DateTime) -> Self { + let local = anchor.with_timezone(&Local).naive_local(); + Self { + date: local.date(), + peak: local.time(), + } + } - pub(crate) fn absolute(&mut self, time: NaiveTime) -> DateTime { - let behind = self.peak.signed_duration_since(time).num_seconds(); - if behind > ROLLOVER_SLACK_SECS { - self.date = self.date.succ_opt().unwrap_or(self.date); - self.peak = time; - } else if time > self.peak { - self.peak = time; - } - local_to_utc(self.date, time) - } + pub(crate) fn absolute(&mut self, time: NaiveTime) -> DateTime { + let behind = self.peak.signed_duration_since(time).num_seconds(); + if behind > ROLLOVER_SLACK_SECS { + self.date = self.date.succ_opt().unwrap_or(self.date); + self.peak = time; + } else if time > self.peak { + self.peak = time; + } + local_to_utc(self.date, time) + } } #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ServerSpan { - pub host: String, - pub port: Option, - pub joined_at: DateTime, - pub disconnected_at: Option>, + pub host: String, + pub port: Option, + pub joined_at: DateTime, + pub disconnected_at: Option>, } #[derive(Debug, Default, Clone)] pub(crate) struct LogReplay { - /// Timestamp of the last line carrying one a lower bound on the exit - pub last_activity: Option>, - pub stopped_at: Option>, - pub servers: Vec, + /// Timestamp of the last line carrying one a lower bound on the exit + pub last_activity: Option>, + pub stopped_at: Option>, + pub servers: Vec, } pub(crate) fn replay(content: &str, started_at: DateTime) -> LogReplay { - let mut clock = LogClock::new(started_at); - let mut out = LogReplay::default(); - // Timestamps appear only on the first line of a multi-line entry so carry - // the last one forward - let mut now = started_at; + let mut clock = LogClock::new(started_at); + let mut out = LogReplay::default(); + // Timestamps appear only on the first line of a multi-line entry so carry + // the last one forward + let mut now = started_at; - for line in content.lines() { - if let Some(time) = parse_log_time(line) { - now = clock.absolute(time); - out.last_activity = Some(now); - } + for line in content.lines() { + if let Some(time) = parse_log_time(line) { + now = clock.absolute(time); + out.last_activity = Some(now); + } - if let Some(ServerJoin { host, port }) = parse_server_join(line) { - close_open_span(&mut out, now); - out.servers.push(ServerSpan { - host, - port, - joined_at: now, - disconnected_at: None, - }); - continue; - } + if let Some(ServerJoin { host, port }) = parse_server_join(line) { + close_open_span(&mut out, now); + out.servers.push(ServerSpan { + host, + port, + joined_at: now, + disconnected_at: None, + }); + continue; + } - if is_leave_marker(line) { - close_open_span(&mut out, now); - } + if is_leave_marker(line) { + close_open_span(&mut out, now); + } - if line.contains(STOP_MARKER) { - out.stopped_at = Some(now); - } - } + if line.contains(STOP_MARKER) { + out.stopped_at = Some(now); + } + } - out + out } fn close_open_span(replay: &mut LogReplay, at: DateTime) { - if let Some(open) = replay - .servers - .iter_mut() - .rev() - .find(|span| span.disconnected_at.is_none()) - { - open.disconnected_at = Some(at.max(open.joined_at)); - } + if let Some(open) = replay + .servers + .iter_mut() + .rev() + .find(|span| span.disconnected_at.is_none()) + { + open.disconnected_at = Some(at.max(open.joined_at)); + } } #[cfg(test)] mod tests { - use super::*; - use chrono::Duration; + use super::*; + use chrono::Duration; - fn anchor() -> DateTime { - Local - .with_ymd_and_hms(2026, 7, 16, 12, 0, 0) - .unwrap() - .with_timezone(&Utc) - } + fn anchor() -> DateTime { + Local + .with_ymd_and_hms(2026, 7, 16, 12, 0, 0) + .unwrap() + .with_timezone(&Utc) + } - fn local_at(h: u32, m: u32, s: u32) -> DateTime { - Local - .with_ymd_and_hms(2026, 7, 16, h, m, s) - .unwrap() - .with_timezone(&Utc) - } + fn local_at(h: u32, m: u32, s: u32) -> DateTime { + Local + .with_ymd_and_hms(2026, 7, 16, h, m, s) + .unwrap() + .with_timezone(&Utc) + } - #[test] - fn parses_plain_and_fractional_times() { - assert_eq!( - parse_log_time("[12:29:00] [Client thread/INFO]: Setting user: Lynith"), - NaiveTime::from_hms_opt(12, 29, 0) - ); - assert_eq!( - parse_log_time("[12:29:00.123] [Render thread/INFO]: hi"), - NaiveTime::from_hms_milli_opt(12, 29, 0, 123) - ); - } + #[test] + fn parses_plain_and_fractional_times() { + assert_eq!( + parse_log_time("[12:29:00] [Client thread/INFO]: Setting user: Lynith"), + NaiveTime::from_hms_opt(12, 29, 0) + ); + assert_eq!( + parse_log_time("[12:29:00.123] [Render thread/INFO]: hi"), + NaiveTime::from_hms_milli_opt(12, 29, 0, 123) + ); + } - #[test] - fn parses_dated_stamp() { - assert_eq!( - parse_log_time("[2026-07-16 12:29:00] [Render thread/INFO]: hi"), - NaiveTime::from_hms_opt(12, 29, 0) - ); - } + #[test] + fn parses_dated_stamp() { + assert_eq!( + parse_log_time("[2026-07-16 12:29:00] [Render thread/INFO]: hi"), + NaiveTime::from_hms_opt(12, 29, 0) + ); + } - #[test] - fn ignores_untimestamped_lines() { - assert!(parse_log_time("\tat net.minecraft.Foo.bar(Foo.java:12)").is_none()); - assert!(parse_log_time("[Render thread/INFO]: no time here").is_none()); - } + #[test] + fn ignores_untimestamped_lines() { + assert!(parse_log_time("\tat net.minecraft.Foo.bar(Foo.java:12)").is_none()); + assert!(parse_log_time("[Render thread/INFO]: no time here").is_none()); + } - #[test] - fn clock_keeps_same_day_while_moving_forward() { - let mut clock = LogClock::new(anchor()); - let at = clock.absolute(NaiveTime::from_hms_opt(12, 30, 0).unwrap()); - assert_eq!(at, local_at(12, 30, 0)); - } + #[test] + fn clock_keeps_same_day_while_moving_forward() { + let mut clock = LogClock::new(anchor()); + let at = clock.absolute(NaiveTime::from_hms_opt(12, 30, 0).unwrap()); + assert_eq!(at, local_at(12, 30, 0)); + } - #[test] - fn clock_rolls_over_midnight() { - let mut clock = LogClock::new(anchor()); - clock.absolute(NaiveTime::from_hms_opt(23, 59, 0).unwrap()); - let at = clock.absolute(NaiveTime::from_hms_opt(0, 1, 0).unwrap()); - assert_eq!(at, local_at(12, 0, 0) + Duration::hours(12) + Duration::minutes(1)); - } + #[test] + fn clock_rolls_over_midnight() { + let mut clock = LogClock::new(anchor()); + clock.absolute(NaiveTime::from_hms_opt(23, 59, 0).unwrap()); + let at = clock.absolute(NaiveTime::from_hms_opt(0, 1, 0).unwrap()); + assert_eq!( + at, + local_at(12, 0, 0) + Duration::hours(12) + Duration::minutes(1) + ); + } - #[test] - fn clock_tolerates_out_of_order_threads() { - let mut clock = LogClock::new(anchor()); - clock.absolute(NaiveTime::from_hms_opt(12, 29, 8).unwrap()); - // A second thread's line lands a second late this must not add a day - let at = clock.absolute(NaiveTime::from_hms_opt(12, 29, 7).unwrap()); - assert_eq!(at, local_at(12, 29, 7)); - } + #[test] + fn clock_tolerates_out_of_order_threads() { + let mut clock = LogClock::new(anchor()); + clock.absolute(NaiveTime::from_hms_opt(12, 29, 8).unwrap()); + // A second thread's line lands a second late this must not add a day + let at = clock.absolute(NaiveTime::from_hms_opt(12, 29, 7).unwrap()); + assert_eq!(at, local_at(12, 29, 7)); + } - #[test] - fn replays_server_span_closed_by_quit() { - let log = "\ + #[test] + fn replays_server_span_closed_by_quit() { + let log = "\ [12:29:00] [Client thread/INFO]: Setting user: Lynith [12:30:00] [Render thread/INFO]: Connecting to mc.hypixel.net, 25565 [12:45:00] [Client thread/INFO]: Stopping! "; - let out = replay(log, anchor()); - assert_eq!(out.servers.len(), 1); - let span = &out.servers[0]; - assert_eq!(span.host, "mc.hypixel.net"); - assert_eq!(span.port, Some(25565)); - assert_eq!(span.joined_at, local_at(12, 30, 0)); - assert_eq!(span.disconnected_at, Some(local_at(12, 45, 0))); - assert_eq!(out.stopped_at, Some(local_at(12, 45, 0))); - assert_eq!(out.last_activity, Some(local_at(12, 45, 0))); - } + let out = replay(log, anchor()); + assert_eq!(out.servers.len(), 1); + let span = &out.servers[0]; + assert_eq!(span.host, "mc.hypixel.net"); + assert_eq!(span.port, Some(25565)); + assert_eq!(span.joined_at, local_at(12, 30, 0)); + assert_eq!(span.disconnected_at, Some(local_at(12, 45, 0))); + assert_eq!(out.stopped_at, Some(local_at(12, 45, 0))); + assert_eq!(out.last_activity, Some(local_at(12, 45, 0))); + } - #[test] - fn consecutive_joins_close_previous_span() { - let log = "\ + #[test] + fn consecutive_joins_close_previous_span() { + let log = "\ [12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565 [12:40:00] [Render thread/INFO]: Connecting to b.example.com, 25566 "; - let out = replay(log, anchor()); - assert_eq!(out.servers.len(), 2); - assert_eq!(out.servers[0].disconnected_at, Some(local_at(12, 40, 0))); - assert_eq!(out.servers[1].disconnected_at, None); - } + let out = replay(log, anchor()); + assert_eq!(out.servers.len(), 2); + assert_eq!(out.servers[0].disconnected_at, Some(local_at(12, 40, 0))); + assert_eq!(out.servers[1].disconnected_at, None); + } - #[test] - fn span_left_open_when_log_just_stops() { - let log = "[12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565\n"; - let out = replay(log, anchor()); - assert_eq!(out.servers[0].disconnected_at, None); - assert_eq!(out.stopped_at, None); - assert_eq!(out.last_activity, Some(local_at(12, 30, 0))); - } + #[test] + fn span_left_open_when_log_just_stops() { + let log = "[12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565\n"; + let out = replay(log, anchor()); + assert_eq!(out.servers[0].disconnected_at, None); + assert_eq!(out.stopped_at, None); + assert_eq!(out.last_activity, Some(local_at(12, 30, 0))); + } - #[test] - fn crash_without_stop_marker_reports_last_activity() { - let log = "\ + #[test] + fn crash_without_stop_marker_reports_last_activity() { + let log = "\ [12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565 [12:31:00] [Render thread/ERROR]: Encountered an unexpected exception \tat net.minecraft.Foo.bar(Foo.java:12) "; - let out = replay(log, anchor()); - assert_eq!(out.stopped_at, None); - assert_eq!(out.last_activity, Some(local_at(12, 31, 0))); - } + let out = replay(log, anchor()); + assert_eq!(out.stopped_at, None); + assert_eq!(out.last_activity, Some(local_at(12, 31, 0))); + } - #[test] - fn empty_log_yields_nothing() { - let out = replay("", anchor()); - assert!(out.servers.is_empty()); - assert!(out.last_activity.is_none()); - assert!(out.stopped_at.is_none()); - } + #[test] + fn empty_log_yields_nothing() { + let out = replay("", anchor()); + assert!(out.servers.is_empty()); + assert!(out.last_activity.is_none()); + assert!(out.stopped_at.is_none()); + } } diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs index b951ad77..63424f61 100644 --- a/packages/oneclient_core/src/game/mod.rs +++ b/packages/oneclient_core/src/game/mod.rs @@ -1,16 +1,17 @@ mod analytics; mod error; pub mod fabric; -mod gpu; +pub mod gpu; mod heal; mod launch; mod log_replay; +pub(crate) mod natives; +pub mod skin_server; mod process; mod reattach; mod session; mod shared_dir; mod tail; -pub mod skin_server; pub use analytics::{ Analytics, DayPlaytime, Persona, PlaytimeStats, ServerStat, WEEKDAY_LABELS, aggregate_servers, @@ -25,22 +26,23 @@ pub mod diagnosis; pub use diagnosis::{CrashDiagnosis, diagnose}; pub use error::GameError; +pub use fabric::{mods_folder_argument, uses_cluster_mods_folder}; pub use heal::clear_zeroed_files; pub use launch::{LaunchedGame, is_running, launch_cluster, offer_repair}; +pub use oneclient_mc::{ + DownloadPlan, GameVersionInfo, GameVersionKind, NativesMode, NativesReport, PlannedLibrary, + check_natives, + confirm_incomplete_install, download_minecraft, download_version_info, game_files_missing, + get_game_versions, get_loader_version, get_loader_versions, get_loaders_for_version, + get_version_ids, get_versions_for_loader, is_version_updated, libraries_missing, + natives_missing, plan_downloads, resolve_minecraft_version, validate_rules, verify_game_files, +}; pub use process::{ GameProcess, GameProcessManager, is_process_alive, kill_process, process_start_time, }; pub use reattach::recover_sessions; -pub use oneclient_mc::{ - DownloadPlan, NativesReport, PlannedLibrary, check_natives, confirm_incomplete_install, - download_minecraft, download_version_info, get_game_versions, - get_loader_version, get_loader_versions, get_loaders_for_version, is_version_updated, - game_files_missing, libraries_missing, natives_missing, plan_downloads, resolve_minecraft_version, - validate_rules, - verify_game_files, -}; -pub use fabric::{mods_folder_argument, uses_cluster_mods_folder}; pub use shared_dir::{ - dematerialize_content, import_manual_content, link_cluster_logs, materialize_content, - unlink_cluster_logs, write_allowed_symlinks, + DuplicateMods, dematerialize_content, import_manual_content, link_cluster_logs, + materialize_content, shadowed_bundle_mods, sync_cluster_mods, unlink_cluster_logs, + write_allowed_symlinks, }; diff --git a/packages/oneclient_core/src/game/natives.rs b/packages/oneclient_core/src/game/natives.rs new file mode 100644 index 00000000..aac88454 --- /dev/null +++ b/packages/oneclient_core/src/game/natives.rs @@ -0,0 +1,58 @@ +use std::path::PathBuf; + +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; +use oneclient_mc::MetadataStore; + +use crate::clusters::Cluster; +use crate::game::NativesMode; +use crate::state::LauncherState; + +pub(crate) fn natives_dir(version_name: &str) -> Option { + paths::natives_dir().ok().map(|dir| dir.join(version_name)) +} + +pub(crate) fn cached_version_name(metadata: &MetadataStore, cluster: &Cluster) -> Option { + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); + + if cluster.mc_loader == GameLoader::Vanilla { + return Some(mc_version); + } + + let loader = oneclient_mc::get_loader_version_cached( + metadata, + &mc_version, + cluster.mc_loader, + cluster.mc_loader_version.as_deref(), + ) + .ok()??; + + Some(format!("{mc_version}-{}", loader.id)) +} + +pub(crate) fn natives_holder( + state: &LauncherState, + version_name: &str, + exclude: Option, +) -> Option { + let dir = natives_dir(version_name)?; + state.games.natives_in_use_by(&dir, exclude) +} + +pub(crate) fn natives_mode( + state: &LauncherState, + version_name: &str, + exclude: Option, +) -> NativesMode { + match natives_holder(state, version_name, exclude) { + Some(holder) => { + tracing::info!( + version_name, + holder, + "natives are loaded by a running game; leaving them in place" + ); + NativesMode::LeaveInPlace + } + None => NativesMode::Extract, + } +} diff --git a/packages/oneclient_core/src/game/process.rs b/packages/oneclient_core/src/game/process.rs index 0d4938bc..1083ac48 100644 --- a/packages/oneclient_core/src/game/process.rs +++ b/packages/oneclient_core/src/game/process.rs @@ -1,4 +1,3 @@ - use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -10,46 +9,46 @@ use tokio::sync::oneshot; use oneclient_events::LaunchStage; fn probe(pid: u32) -> Option<(System, Pid)> { - let pid = Pid::from_u32(pid); - let mut sys = System::new(); - sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); - sys.process(pid)?; - Some((sys, pid)) + let pid = Pid::from_u32(pid); + let mut sys = System::new(); + sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + sys.process(pid)?; + Some((sys, pid)) } /// Unix seconds Pids get recycled so this is what pins process identity pub fn process_start_time(pid: u32) -> Option { - let (sys, pid) = probe(pid)?; - Some(sys.process(pid)?.start_time()) + let (sys, pid) = probe(pid)?; + Some(sys.process(pid)?.start_time()) } /// A matching pid whose start time differs is a recycled pid belonging to /// someone else never ours pub fn is_process_alive(pid: u32, started_at: Option) -> bool { - let Some(actual) = process_start_time(pid) else { - return false; - }; - match started_at { - Some(expected) => actual == expected, - // Pre-migration sessions have no recorded start time assuming running - // is the safer error as the exit time is recovered on a later start - None => true, - } + let Some(actual) = process_start_time(pid) else { + return false; + }; + match started_at { + Some(expected) => actual == expected, + // Pre-migration sessions have no recorded start time assuming running + // is the safer error as the exit time is recovered on a later start + None => true, + } } /// For games re-adopted after a launcher restart where no `Child` handle /// survives to kill through pub fn kill_process(pid: u32) -> bool { - let Some((sys, pid)) = probe(pid) else { - return false; - }; - let Some(process) = sys.process(pid) else { - return false; - }; - // Prefer a graceful terminate so the game can save before shutting down - process - .kill_with(Signal::Term) - .unwrap_or_else(|| process.kill()) + let Some((sys, pid)) = probe(pid) else { + return false; + }; + let Some(process) = sys.process(pid) else { + return false; + }; + // Prefer a graceful terminate so the game can save before shutting down + process + .kill_with(Signal::Term) + .unwrap_or_else(|| process.kill()) } #[derive(Debug, Clone)] @@ -64,6 +63,8 @@ pub struct GameProcessManager { inner: Mutex>, kills: Mutex>>, dirs: Mutex>, + natives: Mutex>, + launching: tokio::sync::Mutex<()>, } impl GameProcessManager { @@ -71,6 +72,18 @@ impl GameProcessManager { Self::default() } + /// One launch prepares at a time two launches at once would install the + /// same Java or download the same game files into the same paths + /// Held from the checks until the process spawns not for the whole session + pub async fn launch_slot(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.launching.lock().await + } + + /// `None` while another launch holds the slot + pub fn try_launch_slot(&self) -> Option> { + self.launching.try_lock().ok() + } + pub fn register_kill(&self, cluster_id: i64, tx: oneshot::Sender<()>) { self.kills.lock().unwrap().insert(cluster_id, tx); } @@ -111,21 +124,44 @@ impl GameProcessManager { self.inner.lock().unwrap().remove(&cluster_id); self.kills.lock().unwrap().remove(&cluster_id); self.dirs.lock().unwrap().remove(&cluster_id); + self.natives.lock().unwrap().remove(&cluster_id); + } + + pub fn set_natives(&self, cluster_id: i64, dir: PathBuf) { + self.natives.lock().unwrap().insert(cluster_id, dir); + } + + pub fn natives_in_use_by(&self, dir: &Path, exclude: Option) -> Option { + self.natives + .lock() + .unwrap() + .iter() + .find(|(id, d)| Some(**id) != exclude && d.as_path() == dir) + .map(|(id, _)| *id) } pub fn set_dir(&self, cluster_id: i64, dir: PathBuf) { self.dirs.lock().unwrap().insert(cluster_id, dir); } - /// Like [`Self::dir_in_use_by`] but counts a cluster's own session too the - /// shared game directory holds one game at a time whoever it belongs to - pub fn dir_in_use(&self, dir: &Path) -> Option { - self.dirs - .lock() - .unwrap() + /// Check and record under one lock so two launches racing on separate + /// threads cannot both find the directory free + /// `exclusive` is for the shared game directory which holds one game at a + /// time whoever it belongs to counting the cluster's own session too + /// Returns the cluster already holding `dir` + pub fn claim_dir(&self, cluster_id: i64, dir: &Path, exclusive: bool) -> Result<(), i64> { + let mut dirs = self.dirs.lock().unwrap(); + + let holder = dirs .iter() - .find(|(_, d)| d.as_path() == dir) - .map(|(id, _)| *id) + .find(|(id, d)| (exclusive || **id != cluster_id) && d.as_path() == dir) + .map(|(id, _)| *id); + if let Some(holder) = holder { + return Err(holder); + } + + dirs.insert(cluster_id, dir.to_path_buf()); + Ok(()) } pub fn dir_in_use_by(&self, dir: &Path, exclude: i64) -> Option { @@ -138,7 +174,11 @@ impl GameProcessManager { } pub fn pid(&self, cluster_id: i64) -> Option { - self.inner.lock().unwrap().get(&cluster_id).and_then(|p| p.pid) + self.inner + .lock() + .unwrap() + .get(&cluster_id) + .and_then(|p| p.pid) } pub fn is_running(&self, cluster_id: i64) -> bool { @@ -174,3 +214,43 @@ impl GameProcessManager { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_shared_dir_takes_one_game() { + let games = GameProcessManager::new(); + let shared = Path::new("shared"); + + assert_eq!(games.claim_dir(1, shared, true), Ok(())); + assert_eq!(games.claim_dir(2, shared, true), Err(1)); + + games.remove(1); + assert_eq!(games.claim_dir(2, shared, true), Ok(())); + } + + #[test] + fn dedicated_dirs_do_not_get_in_each_others_way() { + let games = GameProcessManager::new(); + + assert_eq!(games.claim_dir(1, Path::new("one"), false), Ok(())); + assert_eq!(games.claim_dir(2, Path::new("two"), false), Ok(())); + assert_eq!(games.claim_dir(3, Path::new("one"), false), Err(1)); + } + + #[test] + fn natives_are_held_until_the_game_is_removed() { + let games = GameProcessManager::new(); + let natives = Path::new("natives/1.8.9"); + + games.set_natives(1, natives.to_path_buf()); + assert_eq!(games.natives_in_use_by(natives, None), Some(1)); + assert_eq!(games.natives_in_use_by(natives, Some(1)), None); + assert_eq!(games.natives_in_use_by(Path::new("natives/1.21"), None), None); + + games.remove(1); + assert_eq!(games.natives_in_use_by(natives, None), None); + } +} diff --git a/packages/oneclient_core/src/game/reattach.rs b/packages/oneclient_core/src/game/reattach.rs index 54843134..9f4d3b7c 100644 --- a/packages/oneclient_core/src/game/reattach.rs +++ b/packages/oneclient_core/src/game/reattach.rs @@ -10,14 +10,14 @@ use oneclient_db::dao::game_session as session_dao; use oneclient_db::models::UnfinishedSession; use crate::clusters::Cluster; -use oneclient_discord::Presence; use crate::game::launch::{Exit, SessionEnd, finalize_session}; use crate::game::log_replay::{self, ServerSpan}; use crate::game::process::{is_process_alive, kill_process}; use crate::game::session::SessionRecorder; use crate::game::tail::spawn_log_tail; -use oneclient_events::LaunchStage; use crate::state::LauncherState; +use oneclient_discord::Presence; +use oneclient_events::LaunchStage; /// Only a fallback for a process we don't own so a slow poll is plenty const LIVENESS_POLL: Duration = Duration::from_secs(3); @@ -25,380 +25,411 @@ const LIVENESS_POLL: Duration = Duration::from_secs(3); /// Call once at startup before anything can launch a game #[tracing::instrument(skip(state), level = "debug")] pub async fn recover_sessions(state: &Arc) { - let sessions = match session_dao::unfinished_sessions(&state.services.db).await { - Ok(sessions) => sessions, - Err(err) => { - tracing::warn!(error = %err, "failed to list unfinished game sessions"); - return; - } - }; + let sessions = match session_dao::unfinished_sessions(&state.services.db).await { + Ok(sessions) => sessions, + Err(err) => { + tracing::warn!(error = %err, "failed to list unfinished game sessions"); + return; + } + }; - if sessions.is_empty() { - return; - } + if sessions.is_empty() { + return; + } - tracing::info!(count = sessions.len(), "recovering unfinished game sessions"); + tracing::info!( + count = sessions.len(), + "recovering unfinished game sessions" + ); - let mut live = Vec::new(); - let mut dead = Vec::new(); - for session in sessions { - match classify(state, session).await { - Some((cluster, session, started_at, Some(pid))) => { - live.push((cluster, session, started_at, pid)); - } - Some((cluster, session, started_at, None)) => { - dead.push((cluster, session, started_at)); - } - None => {} - } - } + let mut live = Vec::new(); + let mut dead = Vec::new(); + for session in sessions { + match classify(state, session).await { + Some((cluster, session, started_at, Some(pid))) => { + live.push((cluster, session, started_at, pid)); + } + Some((cluster, session, started_at, None)) => { + dead.push((cluster, session, started_at)); + } + None => {} + } + } - // Live games first reconciling an exited session tears down a shared game - // dir and that check reads the live registrations - for (cluster, session, started_at, pid) in live { - readopt(state, cluster, session, started_at, pid).await; - } + // Live games first reconciling an exited session tears down a shared game + // dir and that check reads the live registrations + for (cluster, session, started_at, pid) in live { + readopt(state, cluster, session, started_at, pid).await; + } - for (cluster, session, started_at) in dead { - reconcile(state, cluster, session, started_at).await; - } + for (cluster, session, started_at) in dead { + reconcile(state, cluster, session, started_at).await; + } } /// `None` means the session was closed out here and needs no further work async fn classify( - state: &Arc, - session: UnfinishedSession, + state: &Arc, + session: UnfinishedSession, ) -> Option<(Cluster, UnfinishedSession, DateTime, Option)> { - let cluster_id = session.cluster_id; + let cluster_id = session.cluster_id; - if state.games.is_active(cluster_id) { - return None; - } + if state.games.is_active(cluster_id) { + return None; + } - let started_at = match DateTime::parse_from_rfc3339(&session.started_at) { - Ok(at) => at.with_timezone(&Utc), - Err(err) => { - tracing::warn!(cluster_id, session = %session.started_at, error = %err, "unparseable session start; closing it"); - close_untraceable(state, &session).await; - return None; - } - }; + let started_at = match DateTime::parse_from_rfc3339(&session.started_at) { + Ok(at) => at.with_timezone(&Utc), + Err(err) => { + tracing::warn!(cluster_id, session = %session.started_at, error = %err, "unparseable session start; closing it"); + close_untraceable(state, &session).await; + return None; + } + }; - // No cluster means no log and no playtime worth attributing close the row - // so it stops being reconsidered forever - let Ok(cluster) = state.clusters.get(cluster_id).await else { - tracing::warn!(cluster_id, "cluster missing for unfinished session; closing it"); - close_untraceable(state, &session).await; - return None; - }; + // No cluster means no log and no playtime worth attributing close the row + // so it stops being reconsidered forever + let Ok(cluster) = state.clusters.get(cluster_id).await else { + tracing::warn!( + cluster_id, + "cluster missing for unfinished session; closing it" + ); + close_untraceable(state, &session).await; + return None; + }; - let alive = match (session.pid, session.pid_started_at) { - (Some(pid), Some(pid_started_at)) => u32::try_from(pid) - .ok() - .filter(|pid| is_process_alive(*pid, Some(pid_started_at as u64))), - (Some(pid), None) => { - tracing::debug!(cluster_id, pid, "session has no process start time; not re-adopting"); - None - } - (None, _) => None, - }; + let alive = match (session.pid, session.pid_started_at) { + (Some(pid), Some(pid_started_at)) => u32::try_from(pid) + .ok() + .filter(|pid| is_process_alive(*pid, Some(pid_started_at as u64))), + (Some(pid), None) => { + tracing::debug!( + cluster_id, + pid, + "session has no process start time; not re-adopting" + ); + None + } + (None, _) => None, + }; - Some((cluster, session, started_at, alive)) + Some((cluster, session, started_at, alive)) } /// Ends at the start time which books no playtime rather than inventing some async fn close_untraceable(state: &Arc, session: &UnfinishedSession) { - if let Err(err) = session_dao::finish_session_at( - &state.services.db, - &session.started_at, - &session.started_at, - None, - ) - .await - { - tracing::warn!(error = %err, "failed to close untraceable session"); - } + if let Err(err) = session_dao::finish_session_at( + &state.services.db, + &session.started_at, + &session.started_at, + None, + ) + .await + { + tracing::warn!(error = %err, "failed to close untraceable session"); + } } #[tracing::instrument(skip(state, cluster, session), fields(cluster_id = cluster.id, pid), level = "debug")] async fn readopt( - state: &Arc, - cluster: Cluster, - session: UnfinishedSession, - started_at: DateTime, - pid: u32, + state: &Arc, + cluster: Cluster, + session: UnfinishedSession, + started_at: DateTime, + pid: u32, ) { - let cluster_id = cluster.id; - let Ok(cwd) = cluster.game_dir() else { - return; - }; + let cluster_id = cluster.id; + let Ok(cwd) = cluster.game_dir() else { + return; + }; - tracing::info!(cluster_id, pid, "re-adopting running game"); + tracing::info!(cluster_id, pid, "re-adopting running game"); - let open_server = open_server_of(state, &session.started_at).await; - let recorder = SessionRecorder::resume( - state.services.db.clone(), - session.started_at.clone(), - open_server, - ); + let open_server = open_server_of(state, &session.started_at).await; + let recorder = SessionRecorder::resume( + state.services.db.clone(), + session.started_at.clone(), + open_server, + ); - state.games.set_stage(cluster_id, LaunchStage::Running); - state.games.set_pid(cluster_id, Some(pid)); - state.games.set_dir(cluster_id, cwd.clone()); - state.services.events.game_stage(cluster_id, LaunchStage::Running); - state.discord.set_presence(Presence::Playing { - cluster: cluster.name.clone(), - mc_version: cluster.mc_version.clone(), - }); + state.games.set_stage(cluster_id, LaunchStage::Running); + state.games.set_pid(cluster_id, Some(pid)); + state.games.set_dir(cluster_id, cwd.clone()); + let version_name = state + .metadata + .try_lock() + .ok() + .and_then(|metadata| crate::game::natives::cached_version_name(&metadata, &cluster)); + match version_name.as_deref().and_then(crate::game::natives::natives_dir) { + Some(dir) => state.games.set_natives(cluster_id, dir), + None => tracing::debug!(cluster_id, "could not resolve the re-adopted game's natives"), + } + state + .services + .events + .game_stage(cluster_id, LaunchStage::Running); + state.discord.set_presence(Presence::Playing { + cluster: cluster.name.clone(), + mc_version: cluster.mc_version.clone(), + }); - let Ok(log_path) = oneclient_cluster::logs::cluster_output_log(&cluster) else { - return; - }; - // A re-adopted game still writes its log so crashes stay readable even - // though the exit code went with the launcher that spawned it - let crash_watch = crate::game::diagnosis::CrashWatch::new(); - let tail = spawn_log_tail( - cluster_id, - log_path, - state.services.events.clone(), - Some(recorder.clone()), - crash_watch.clone(), - ); + let Ok(log_path) = oneclient_cluster::logs::cluster_output_log(&cluster) else { + return; + }; + // A re-adopted game still writes its log so crashes stay readable even + // though the exit code went with the launcher that spawned it + let crash_watch = crate::game::diagnosis::CrashWatch::new(); + let tail = spawn_log_tail( + cluster_id, + log_path, + state.services.events.clone(), + Some(recorder.clone()), + crash_watch.clone(), + ); - let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); - state.games.register_kill(cluster_id, kill_tx); + let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); + state.games.register_kill(cluster_id, kill_tx); - // Resolved up front so the watcher task doesn't reach back into the state - let global = state.settings.read().global_game_settings.clone(); - let post_hook = state.clusters.resolve_settings(&global, &cluster) - .await - .ok() - .and_then(|profile| profile.hook_post); - let dedicated = cluster.uses_dedicated_dir(); - let pid_started_at = session.pid_started_at.map(|t| t as u64); - let state = Arc::clone(state); + // Resolved up front so the watcher task doesn't reach back into the state + let global = state.settings.read().global_game_settings.clone(); + let post_hook = state + .clusters + .resolve_settings(&global, &cluster) + .await + .ok() + .and_then(|profile| profile.hook_post); + let dedicated = cluster.uses_dedicated_dir(); + let pid_started_at = session.pid_started_at.map(|t| t as u64); + let state = Arc::clone(state); - tokio::spawn(async move { - tokio::select! { - () = wait_for_exit(pid, pid_started_at) => {} - _ = kill_rx => { - kill_process(pid); - wait_for_exit(pid, pid_started_at).await; - } - } + tokio::spawn(async move { + tokio::select! { + () = wait_for_exit(pid, pid_started_at) => {} + _ = kill_rx => { + kill_process(pid); + wait_for_exit(pid, pid_started_at).await; + } + } - tail.stop().await; + tail.stop().await; - finalize_session( - &state, - &cluster, - &cwd, - dedicated, - post_hook.as_deref(), - Some(recorder), - SessionEnd { - started_at, - ended_at: Utc::now(), - outcome: Exit::Inferred, - // With parallel clusters a newer game may already own this - // cluster's slot only tear down if it is still ours - owns_slot: state.games.pid(cluster_id) == Some(pid), - diagnosis: crash_watch.take(), - }, - ) - .await; - }); + finalize_session( + &state, + &cluster, + &cwd, + dedicated, + post_hook.as_deref(), + Some(recorder), + SessionEnd { + started_at, + ended_at: Utc::now(), + outcome: Exit::Inferred, + // With parallel clusters a newer game may already own this + // cluster's slot only tear down if it is still ours + owns_slot: state.games.pid(cluster_id) == Some(pid), + diagnosis: crash_watch.take(), + }, + ) + .await; + }); } async fn wait_for_exit(pid: u32, pid_started_at: Option) { - while is_process_alive(pid, pid_started_at) { - tokio::time::sleep(LIVENESS_POLL).await; - } + while is_process_alive(pid, pid_started_at) { + tokio::time::sleep(LIVENESS_POLL).await; + } } #[tracing::instrument(skip(state, cluster, session), fields(cluster_id = cluster.id), level = "debug")] async fn reconcile( - state: &Arc, - cluster: Cluster, - session: UnfinishedSession, - started_at: DateTime, + state: &Arc, + cluster: Cluster, + session: UnfinishedSession, + started_at: DateTime, ) { - let cluster_id = cluster.id; - let log = read_session_log(&cluster).await; + let cluster_id = cluster.id; + let log = read_session_log(&cluster).await; - let replay = log - .as_ref() - .map(|log| log_replay::replay(&log.content, started_at)) - .unwrap_or_default(); + let replay = log + .as_ref() + .map(|log| log_replay::replay(&log.content, started_at)) + .unwrap_or_default(); - let now = Utc::now().max(started_at); + let now = Utc::now().max(started_at); - let plausible = |at: DateTime| (started_at..=now).contains(&at).then_some(at); + let plausible = |at: DateTime| (started_at..=now).contains(&at).then_some(at); - // Prefer the log's own timestamps over mtime mtime only helps when nothing - // in the log carries a time - let ended_at = replay - .stopped_at - .and_then(plausible) - .or_else(|| replay.last_activity.and_then(plausible)) - .or_else(|| log.as_ref().and_then(|log| log.modified).and_then(plausible)) - .unwrap_or(started_at); + // Prefer the log's own timestamps over mtime mtime only helps when nothing + // in the log carries a time + let ended_at = replay + .stopped_at + .and_then(plausible) + .or_else(|| replay.last_activity.and_then(plausible)) + .or_else(|| { + log.as_ref() + .and_then(|log| log.modified) + .and_then(plausible) + }) + .unwrap_or(started_at); - tracing::info!( - cluster_id, - session = %session.started_at, - %ended_at, - clean_exit = replay.stopped_at.is_some(), - servers = replay.servers.len(), - "recovered session that outlived the launcher" - ); + tracing::info!( + cluster_id, + session = %session.started_at, + %ended_at, + clean_exit = replay.stopped_at.is_some(), + servers = replay.servers.len(), + "recovered session that outlived the launcher" + ); - let open_server = apply_spans(state, &session.started_at, &replay.servers).await; - let recorder = SessionRecorder::resume( - state.services.db.clone(), - session.started_at.clone(), - open_server, - ); + let open_server = apply_spans(state, &session.started_at, &replay.servers).await; + let recorder = SessionRecorder::resume( + state.services.db.clone(), + session.started_at.clone(), + open_server, + ); - let Ok(cwd) = cluster.game_dir() else { - recorder.finish_at(&ended_at.to_rfc3339(), None).await; - return; - }; + let Ok(cwd) = cluster.game_dir() else { + recorder.finish_at(&ended_at.to_rfc3339(), None).await; + return; + }; - // A cluster re-adopted moments ago may share this directory clearing it - // mid-session would pull that game apart underneath it - let shared_dir_busy = state.games.dir_in_use_by(&cwd, cluster_id).is_some(); - if shared_dir_busy { - tracing::debug!(cluster_id, "shared game dir still in use; skipping exit cleanup"); - } + // A cluster re-adopted moments ago may share this directory clearing it + // mid-session would pull that game apart underneath it + let shared_dir_busy = state.games.dir_in_use_by(&cwd, cluster_id).is_some(); + if shared_dir_busy { + tracing::debug!( + cluster_id, + "shared game dir still in use; skipping exit cleanup" + ); + } - finalize_session( - state, - &cluster, - &cwd, - cluster.uses_dedicated_dir() || shared_dir_busy, - // Post hook skipped on purpose the exit already happened possibly days - // ago so firing it during startup would surprise the user - None, - Some(recorder), - SessionEnd { - started_at, - ended_at, - outcome: Exit::Inferred, - // The cluster may have a newer session playing right now so only - // claim the slot if nothing else holds it - owns_slot: !state.games.is_active(cluster_id), - // Nothing watched this log while it ran so no crash was recognised - diagnosis: None, - }, - ) - .await; + finalize_session( + state, + &cluster, + &cwd, + cluster.uses_dedicated_dir() || shared_dir_busy, + // Post hook skipped on purpose the exit already happened possibly days + // ago so firing it during startup would surprise the user + None, + Some(recorder), + SessionEnd { + started_at, + ended_at, + outcome: Exit::Inferred, + // The cluster may have a newer session playing right now so only + // claim the slot if nothing else holds it + owns_slot: !state.games.is_active(cluster_id), + // Nothing watched this log while it ran so no crash was recognised + diagnosis: None, + }, + ) + .await; } /// Returns the `joined_at` of a span still left open for the caller to close async fn apply_spans( - state: &Arc, - session_id: &str, - spans: &[ServerSpan], + state: &Arc, + session_id: &str, + spans: &[ServerSpan], ) -> Option { - let db = &state.services.db; - let existing = session_dao::list_session_servers(db, session_id) - .await - .unwrap_or_default(); + let db = &state.services.db; + let existing = session_dao::list_session_servers(db, session_id) + .await + .unwrap_or_default(); - // Fewer replayed spans than recorded means the log was rotated or truncated - // so the live rows are the better record keep them - if spans.len() < existing.len() { - tracing::debug!( - session = %session_id, - replayed = spans.len(), - existing = existing.len(), - "log replay is thinner than recorded spans; keeping recorded ones" - ); - return existing - .into_iter() - .find(|row| row.disconnected_at.is_none()) - .map(|row| row.joined_at); - } + // Fewer replayed spans than recorded means the log was rotated or truncated + // so the live rows are the better record keep them + if spans.len() < existing.len() { + tracing::debug!( + session = %session_id, + replayed = spans.len(), + existing = existing.len(), + "log replay is thinner than recorded spans; keeping recorded ones" + ); + return existing + .into_iter() + .find(|row| row.disconnected_at.is_none()) + .map(|row| row.joined_at); + } - if let Err(err) = session_dao::delete_session_servers(db, session_id).await { - tracing::warn!(session = %session_id, error = %err, "failed to clear server spans for replay"); - return None; - } + if let Err(err) = session_dao::delete_session_servers(db, session_id).await { + tracing::warn!(session = %session_id, error = %err, "failed to clear server spans for replay"); + return None; + } - let mut open = None; - for span in spans { - let joined_at = span.joined_at.to_rfc3339(); - if let Err(err) = session_dao::insert_server_join_at( - db, - session_id, - &span.host, - span.port.map(i64::from), - &joined_at, - ) - .await - { - tracing::warn!(session = %session_id, error = %err, "failed to replay server join"); - continue; - } + let mut open = None; + for span in spans { + let joined_at = span.joined_at.to_rfc3339(); + if let Err(err) = session_dao::insert_server_join_at( + db, + session_id, + &span.host, + span.port.map(i64::from), + &joined_at, + ) + .await + { + tracing::warn!(session = %session_id, error = %err, "failed to replay server join"); + continue; + } - match span.disconnected_at { - Some(at) => { - if let Err(err) = - session_dao::finish_server_at(db, &joined_at, &at.to_rfc3339()).await - { - tracing::warn!(session = %session_id, error = %err, "failed to replay server leave"); - } - } - None => open = Some(joined_at), - } - } + match span.disconnected_at { + Some(at) => { + if let Err(err) = + session_dao::finish_server_at(db, &joined_at, &at.to_rfc3339()).await + { + tracing::warn!(session = %session_id, error = %err, "failed to replay server leave"); + } + } + None => open = Some(joined_at), + } + } - open + open } async fn open_server_of(state: &Arc, session_id: &str) -> Option { - session_dao::list_session_servers(&state.services.db, session_id) - .await - .unwrap_or_default() - .into_iter() - .find(|row| row.disconnected_at.is_none()) - .map(|row| row.joined_at) + session_dao::list_session_servers(&state.services.db, session_id) + .await + .unwrap_or_default() + .into_iter() + .find(|row| row.disconnected_at.is_none()) + .map(|row| row.joined_at) } struct SessionLog { - content: String, - modified: Option>, + content: String, + modified: Option>, } /// `cluster-output.log` is preferred it is truncated at every launch so it /// belongs to one session whereas `latest.log` may already have rotated async fn read_session_log(cluster: &Cluster) -> Option { - let mut candidates: Vec = Vec::new(); - if let Ok(path) = oneclient_cluster::logs::cluster_output_log(cluster) { - candidates.push(path); - } - if let Ok(dir) = cluster.dir() { - candidates.push(dir.join("logs").join("latest.log")); - } + let mut candidates: Vec = Vec::new(); + if let Ok(path) = oneclient_cluster::logs::cluster_output_log(cluster) { + candidates.push(path); + } + if let Ok(dir) = cluster.dir() { + candidates.push(dir.join("logs").join("latest.log")); + } - for path in candidates { - match read_log(&path).await { - Some(log) if !log.content.trim().is_empty() => return Some(log), - _ => continue, - } - } + for path in candidates { + match read_log(&path).await { + Some(log) if !log.content.trim().is_empty() => return Some(log), + _ => continue, + } + } - None + None } async fn read_log(path: &Path) -> Option { - let content = polyio::read_to_string(path).await.ok()?; - let modified = polyio::stat(path) - .await - .ok() - .and_then(|meta| meta.modified().ok()) - .map(DateTime::::from); + let content = polyio::read_to_string(path).await.ok()?; + let modified = polyio::stat(path) + .await + .ok() + .and_then(|meta| meta.modified().ok()) + .map(DateTime::::from); - Some(SessionLog { content, modified }) + Some(SessionLog { content, modified }) } diff --git a/packages/oneclient_core/src/game/session.rs b/packages/oneclient_core/src/game/session.rs index 57c0d27b..795552ac 100644 --- a/packages/oneclient_core/src/game/session.rs +++ b/packages/oneclient_core/src/game/session.rs @@ -5,235 +5,240 @@ use oneclient_db::dao::game_session as session_dao; use oneclient_db::models::NewGameSession; use tokio::sync::Mutex; -use oneclient_java::JavaRuntime; -use oneclient_content::packages::{ContentType, PackageStore}; use crate::state::LauncherState; +use oneclient_content::packages::{ContentType, PackageStore}; +use oneclient_java::JavaRuntime; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ServerJoin { - pub host: String, - pub port: Option, + pub host: String, + pub port: Option, } /// The session row's `started_at` which is its primary key pub(crate) type SessionId = String; pub(crate) fn parse_server_join(line: &str) -> Option { - const MARKER: &str = "Connecting to "; - let idx = line.find(MARKER)?; - let rest = line[idx + MARKER.len()..].trim(); - if rest.is_empty() { - return None; - } + const MARKER: &str = "Connecting to "; + let idx = line.find(MARKER)?; + let rest = line[idx + MARKER.len()..].trim(); + if rest.is_empty() { + return None; + } - let (host, port) = match rest.split_once(", ") { - Some((host, port)) => { - let port = port.split_whitespace().next().and_then(|p| p.parse::().ok()); - (host.trim(), port) - } - None => (rest, None), - }; + let (host, port) = match rest.split_once(", ") { + Some((host, port)) => { + let port = port + .split_whitespace() + .next() + .and_then(|p| p.parse::().ok()); + (host.trim(), port) + } + None => (rest, None), + }; - let host = host.trim(); - if host.is_empty() || host.chars().any(char::is_whitespace) { - return None; - } + let host = host.trim(); + if host.is_empty() || host.chars().any(char::is_whitespace) { + return None; + } - Some(ServerJoin { - host: host.to_string(), - port, - }) + Some(ServerJoin { + host: host.to_string(), + port, + }) } #[derive(Clone)] pub(crate) struct SessionRecorder { - session_started_at: String, - db: DbPool, - open_server: Arc>>, + session_started_at: String, + db: DbPool, + open_server: Arc>>, } impl SessionRecorder { - #[tracing::instrument(skip(state, java), fields(cluster_id, ram_allocated_mb), level = "debug")] - pub(crate) async fn start( - state: &Arc, - cluster_id: i64, - ram_allocated_mb: u32, - java: &JavaRuntime, - ) -> Option { - let mods_enabled = count_enabled_mods(state, cluster_id).await; + #[tracing::instrument( + skip(state, java), + fields(cluster_id, ram_allocated_mb), + level = "debug" + )] + pub(crate) async fn start( + state: &Arc, + cluster_id: i64, + ram_allocated_mb: u32, + java: &JavaRuntime, + ) -> Option { + let mods_enabled = count_enabled_mods(state, cluster_id).await; - let java_vendor = java.vendor.to_string(); - let started_at = chrono::Utc::now().to_rfc3339(); - let new = NewGameSession { - cluster_id, - started_at: &started_at, - ram_allocated_mb: i64::from(ram_allocated_mb), - mods_enabled: mods_enabled as i64, - java_vendor: Some(java_vendor.as_str()), - java_version: Some(java.version.as_str()), - }; + let java_vendor = java.vendor.to_string(); + let started_at = chrono::Utc::now().to_rfc3339(); + let new = NewGameSession { + cluster_id, + started_at: &started_at, + ram_allocated_mb: i64::from(ram_allocated_mb), + mods_enabled: mods_enabled as i64, + java_vendor: Some(java_vendor.as_str()), + java_version: Some(java.version.as_str()), + }; - let session = match session_dao::insert_session(&state.services.db, &new).await { - Ok(row) => row, - Err(err) => { - tracing::warn!(cluster_id, error = %err, "failed to record game session"); - return None; - } - }; + let session = match session_dao::insert_session(&state.services.db, &new).await { + Ok(row) => row, + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to record game session"); + return None; + } + }; - Some(Self { - session_started_at: session.started_at, - db: state.services.db.clone(), - open_server: Arc::new(Mutex::new(None)), - }) - } + Some(Self { + session_started_at: session.started_at, + db: state.services.db.clone(), + open_server: Arc::new(Mutex::new(None)), + }) + } - /// Re-attaches to a row left open by a previous launcher run so a game that - /// outlived the launcher keeps recording into the same session - pub(crate) fn resume(db: DbPool, session_started_at: SessionId, open_server: Option) -> Self { - Self { - session_started_at, - db, - open_server: Arc::new(Mutex::new(open_server)), - } - } + /// Re-attaches to a row left open by a previous launcher run so a game that + /// outlived the launcher keeps recording into the same session + pub(crate) fn resume( + db: DbPool, + session_started_at: SessionId, + open_server: Option, + ) -> Self { + Self { + session_started_at, + db, + open_server: Arc::new(Mutex::new(open_server)), + } + } - /// Playtime is measured against this so it agrees with the session span - /// analytics reads back out of the row - pub(crate) fn started_at(&self) -> Option> { - chrono::DateTime::parse_from_rfc3339(&self.session_started_at) - .ok() - .map(|at| at.with_timezone(&chrono::Utc)) - } + /// Playtime is measured against this so it agrees with the session span + /// analytics reads back out of the row + pub(crate) fn started_at(&self) -> Option> { + chrono::DateTime::parse_from_rfc3339(&self.session_started_at) + .ok() + .map(|at| at.with_timezone(&chrono::Utc)) + } - /// Without this a launcher restart cannot tell a live game from one that - /// exited unobserved - #[tracing::instrument(skip(self), level = "debug")] - pub(crate) async fn record_process(&self, pid: u32, pid_started_at: Option) { - if let Err(err) = session_dao::set_session_process( - &self.db, - &self.session_started_at, - Some(i64::from(pid)), - pid_started_at.map(|t| t as i64), - ) - .await - { - tracing::warn!(pid, error = %err, "failed to record session process"); - } - } + /// Without this a launcher restart cannot tell a live game from one that + /// exited unobserved + #[tracing::instrument(skip(self), level = "debug")] + pub(crate) async fn record_process(&self, pid: u32, pid_started_at: Option) { + if let Err(err) = session_dao::set_session_process( + &self.db, + &self.session_started_at, + Some(i64::from(pid)), + pid_started_at.map(|t| t as i64), + ) + .await + { + tracing::warn!(pid, error = %err, "failed to record session process"); + } + } - pub(crate) async fn observe(&self, line: &str) { - if let Some(join) = parse_server_join(line) { - self.open(join).await; - } else if super::log_replay::is_leave_marker(line) { - self.close_open().await; - } - } + pub(crate) async fn observe(&self, line: &str) { + if let Some(join) = parse_server_join(line) { + self.open(join).await; + } else if super::log_replay::is_leave_marker(line) { + self.close_open().await; + } + } - async fn open(&self, join: ServerJoin) { - let mut open = self.open_server.lock().await; - if let Some(prev) = open.take() - && let Err(err) = session_dao::finish_server(&self.db, &prev).await - { - tracing::warn!(error = %err, "failed to close previous server row"); - } + async fn open(&self, join: ServerJoin) { + let mut open = self.open_server.lock().await; + if let Some(prev) = open.take() + && let Err(err) = session_dao::finish_server(&self.db, &prev).await + { + tracing::warn!(error = %err, "failed to close previous server row"); + } - let port = join.port.map(i64::from); - match session_dao::insert_server_join( - &self.db, - &self.session_started_at, - &join.host, - port, - ) - .await - { - Ok(joined_at) => *open = Some(joined_at), - Err(err) => tracing::warn!(error = %err, "failed to record server join"), - } - } + let port = join.port.map(i64::from); + match session_dao::insert_server_join(&self.db, &self.session_started_at, &join.host, port) + .await + { + Ok(joined_at) => *open = Some(joined_at), + Err(err) => tracing::warn!(error = %err, "failed to record server join"), + } + } - async fn close_open(&self) { - if let Some(prev) = self.open_server.lock().await.take() - && let Err(err) = session_dao::finish_server(&self.db, &prev).await - { - tracing::warn!(error = %err, "failed to close server row"); - } - } + async fn close_open(&self) { + if let Some(prev) = self.open_server.lock().await.take() + && let Err(err) = session_dao::finish_server(&self.db, &prev).await + { + tracing::warn!(error = %err, "failed to close server row"); + } + } - /// The time is explicit because an exit is not always observed as it happens - /// one recovered from a log ended in the past - #[tracing::instrument(skip(self), fields(exit_code), level = "debug")] - pub(crate) async fn finish_at(self, ended_at: &str, exit_code: Option) { - if let Some(open) = self.open_server.lock().await.take() - && let Err(err) = session_dao::finish_server_at(&self.db, &open, ended_at).await - { - tracing::warn!(error = %err, "failed to close open server on exit"); - } + /// The time is explicit because an exit is not always observed as it happens + /// one recovered from a log ended in the past + #[tracing::instrument(skip(self), fields(exit_code), level = "debug")] + pub(crate) async fn finish_at(self, ended_at: &str, exit_code: Option) { + if let Some(open) = self.open_server.lock().await.take() + && let Err(err) = session_dao::finish_server_at(&self.db, &open, ended_at).await + { + tracing::warn!(error = %err, "failed to close open server on exit"); + } - if let Err(err) = - session_dao::finish_session_at(&self.db, &self.session_started_at, ended_at, exit_code) - .await - { - tracing::warn!(session = %self.session_started_at, error = %err, "failed to finish game session"); - } - } + if let Err(err) = + session_dao::finish_session_at(&self.db, &self.session_started_at, ended_at, exit_code) + .await + { + tracing::warn!(session = %self.session_started_at, error = %err, "failed to finish game session"); + } + } } #[tracing::instrument(skip(state), fields(cluster_id), level = "debug")] async fn count_enabled_mods(state: &Arc, cluster_id: i64) -> usize { - match PackageStore::list_linked_artifacts(cluster_id, &state.services.content()).await { - Ok(linked) => linked - .into_iter() - .filter(|a| a.enabled && a.content_type == ContentType::Mod) - .count(), - Err(err) => { - tracing::warn!(cluster_id, error = %err, "failed to count mods for session"); - 0 - } - } + match PackageStore::list_linked_artifacts(cluster_id, &state.services.content()).await { + Ok(linked) => linked + .into_iter() + .filter(|a| a.enabled && a.content_type == ContentType::Mod) + .count(), + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to count mods for session"); + 0 + } + } } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn parses_host_and_port() { - let join = - parse_server_join("[12:00:00] [Render thread/INFO]: Connecting to mc.hypixel.net, 25565") - .expect("should parse"); - assert_eq!(join.host, "mc.hypixel.net"); - assert_eq!(join.port, Some(25565)); - } + #[test] + fn parses_host_and_port() { + let join = parse_server_join( + "[12:00:00] [Render thread/INFO]: Connecting to mc.hypixel.net, 25565", + ) + .expect("should parse"); + assert_eq!(join.host, "mc.hypixel.net"); + assert_eq!(join.port, Some(25565)); + } - #[test] - fn parses_ip_and_port() { - let join = parse_server_join("[Render thread/INFO]: Connecting to 192.168.1.10, 25577") - .expect("should parse"); - assert_eq!(join.host, "192.168.1.10"); - assert_eq!(join.port, Some(25577)); - } + #[test] + fn parses_ip_and_port() { + let join = parse_server_join("[Render thread/INFO]: Connecting to 192.168.1.10, 25577") + .expect("should parse"); + assert_eq!(join.host, "192.168.1.10"); + assert_eq!(join.port, Some(25577)); + } - #[test] - fn parses_host_without_port() { - let join = parse_server_join("Connecting to play.example.com").expect("should parse"); - assert_eq!(join.host, "play.example.com"); - assert_eq!(join.port, None); - } + #[test] + fn parses_host_without_port() { + let join = parse_server_join("Connecting to play.example.com").expect("should parse"); + assert_eq!(join.host, "play.example.com"); + assert_eq!(join.port, None); + } - #[test] - fn ignores_unrelated_lines() { - assert!(parse_server_join("[Render thread/INFO]: Loading world").is_none()); - assert!(parse_server_join("Connecting to ").is_none()); - } + #[test] + fn ignores_unrelated_lines() { + assert!(parse_server_join("[Render thread/INFO]: Loading world").is_none()); + assert!(parse_server_join("Connecting to ").is_none()); + } - #[test] - fn rejects_addresses_with_spaces() { - assert!( - parse_server_join( - "[INFO]: Connecting to voice chat server: 'vc.example.com:24454'" - ) - .is_none() - ); - } + #[test] + fn rejects_addresses_with_spaces() { + assert!( + parse_server_join("[INFO]: Connecting to voice chat server: 'vc.example.com:24454'") + .is_none() + ); + } } diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs index 66178f09..c372adac 100644 --- a/packages/oneclient_core/src/game/shared_dir.rs +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -5,20 +5,21 @@ use std::path::{Path, PathBuf}; use oneclient_db::dao::applied_migration as migration_dao; use oneclient_db::dao::artifact as artifact_dao; use oneclient_db::dao::cluster as cluster_dao; +use oneclient_db::dao::cluster_bundle as bundle_dao; use crate::LauncherResult; use crate::clusters::Cluster; +use crate::state::{LauncherServices, LauncherState}; use oneclient_cluster::remove_mods_link; -use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use oneclient_common::paths; -use oneclient_content::packages::store::manifest::{ - self, ManifestEntry, MaterializedManifest, -}; +use oneclient_content::packages::PackageStore; +use oneclient_content::packages::fabric_version; +use oneclient_content::packages::local_manifest::JarModId; +use oneclient_content::packages::store::manifest::{self, ManifestEntry, MaterializedManifest}; use oneclient_content::packages::store::{ artifact_absolute_path, link_or_copy, remove_entry, sweep_staging_files, }; -use oneclient_content::packages::PackageStore; -use crate::state::LauncherServices; const REDIRECTED_DIRS: [&str; 2] = ["logs", "crash-reports"]; @@ -46,6 +47,18 @@ impl Desired { } } +struct Unrestored { + content_type: ContentType, + file_name: String, + hash: String, +} + +impl Unrestored { + fn relative_path(&self) -> String { + manifest::entry_path(self.content_type.folder_name(), &self.file_name) + } +} + #[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id, game_dir = %game_dir.display()), level = "debug")] pub async fn materialize_content( services: &LauncherServices, @@ -53,9 +66,16 @@ pub async fn materialize_content( game_dir: &Path, mods_in_cluster: bool, ) -> LauncherResult<()> { + let mods_sync = lock_mods_sync(cluster.id).await; + let dedicated = cluster.uses_dedicated_dir(); let cluster_dir = cluster.dir()?; - let global_root = paths::shared_minecraft_dir()?; + let isolated = cluster.is_isolated(); + let global_root = if isolated { + cluster_dir.clone() + } else { + paths::shared_minecraft_dir()? + }; polyio::create_dir_all(game_dir).await.ok(); polyio::create_dir_all(&global_root).await.ok(); @@ -64,26 +84,55 @@ pub async fn materialize_content( polyio::create_dir_all(paths::cluster_mods_dir(&cluster.folder_name)?) .await .ok(); - ensure_mods_link(cluster).await; + if isolated { + remove_mods_link(&cluster.folder_name).await; + } else { + ensure_mods_link(cluster).await; + } prune_mods_links(services).await; } else { unwind_cluster_mods(cluster, &cluster_dir).await; } - adopt_into_global(&cluster_dir, &global_root).await; - adopt_into_global(game_dir, &global_root).await; - ensure_global_links(game_dir, &global_root).await; + if !isolated { + adopt_into_global(&cluster_dir, &global_root).await; + adopt_into_global(game_dir, &global_root).await; + ensure_global_links(game_dir, &global_root).await; + } let mods_swapped = !dedicated && !mods_in_cluster; drop_stale_notes(&[game_dir, &cluster_dir, &global_root], mods_swapped).await; // In the shared directory this often belongs to another cluster so every // use of it checks the id - let previous = manifest::load(game_dir, manifest::MANIFEST_NAME) - .await - .map(without_global_entries); + let loaded_previous = manifest::load(game_dir, manifest::MANIFEST_NAME).await; + let previous = if isolated { + loaded_previous + } else { + loaded_previous.map(without_global_entries) + }; + let previous_global = if isolated { + None + } else { + manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await + }; + + let repair_unlinked = mods_in_cluster && !cluster.user_created && !isolated; + if repair_unlinked && manifest::mods_live_in_cluster(&cluster_dir).await { + clear_unlinked_mods_once( + services, + cluster, + &cluster_dir.join(ContentType::Mod.folder_name()), + ) + .await; + } + + if mods_in_cluster { + sync_mods_folder(services, cluster).await; + } + drop(mods_sync); let previous_mods = manifest::load(&cluster_dir, manifest::MODS_MANIFEST_NAME).await; - let previous_global = manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await; + crate::game::heal::clear_zeroed_files(game_dir).await; if mods_in_cluster { crate::game::heal::clear_zeroed_mods(&cluster_dir).await; @@ -98,67 +147,48 @@ pub async fn materialize_content( let into = cluster_dir.join(ContentType::Mod.folder_name()); let ours = ours_in_folder(ContentType::Mod, &linked, previous.as_ref()); - tracing::info!(cluster_id = cluster.id, "moving mods out of the shared game directory"); + tracing::info!( + cluster_id = cluster.id, + "moving mods out of the shared game directory" + ); stash_content_files(&from, &into, ContentType::Mod, &ours).await; } for content_type in GLOBAL_TYPES { let dir = global_root.join(content_type.folder_name()); - let disabled = disable_hand_removed( - services, - cluster, - &dir, - content_type, - previous_global.as_ref(), - ) - .await; + let seen = if isolated { + previous.as_ref() + } else { + previous_global.as_ref() + }; + let disabled = disable_hand_removed(services, cluster, &dir, content_type, seen).await; if !disabled.is_empty() { - let (title, body) = removal_notice(&disabled); + let (title, body) = removal_notice(&disabled, !isolated); services.events.notify(title).body(body).send(); } } - if mods_in_cluster { - clear_unlinked_mods_once(services, cluster, &cluster_dir.join(ContentType::Mod.folder_name())) - .await; + if repair_unlinked { + clear_unlinked_mods_once( + services, + cluster, + &cluster_dir.join(ContentType::Mod.folder_name()), + ) + .await; } - import_manual_content_with(services, cluster, game_dir, mods_in_cluster, true).await; + let failed_imports = + import_manual_content_with(services, cluster, game_dir, mods_in_cluster, true).await; - if let Err(err) = oneclient_content::packages::reconcile_duplicate_activity( - cluster.id, - &services.content(), - ) - .await + if let Err(err) = + oneclient_content::packages::reconcile_duplicate_activity(cluster.id, &services.content()) + .await { // Not worth blocking a launch the duplicates were already there tracing::warn!(cluster_id = cluster.id, %err, "failed to resolve duplicate package versions"); } - match oneclient_content::packages::disable_foreign_game_versions( - cluster.id, - &cluster.mc_version, - cluster.mc_loader as i64, - &services.content(), - ) - .await - { - Ok(disabled) if !disabled.is_empty() => { - let names = removal_summary(&disabled); - let body = if disabled.len() == 1 { - format!("{names} is built for a different Minecraft version, so it has been switched off in {}.", cluster.name) - } else { - format!("{names} are built for a different Minecraft version, so they have been switched off in {}.", cluster.name) - }; - services.events.notify("Incompatible mods switched off").body(body).send(); - } - Ok(_) => {} - Err(err) => { - tracing::warn!(cluster_id = cluster.id, %err, "failed to switch off mods built for another game version"); - } - } - let active_mods_dir = if mods_in_cluster { cluster_dir.as_path() } else { @@ -168,17 +198,34 @@ pub async fn materialize_content( clear_disabled_mod_files(services, cluster, &active_mods_dir).await; let mut unrestored = Vec::new(); + let wanted: &[ContentType] = if isolated { + &[ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ] + } else { + &[ContentType::Mod] + }; let (mods, rest): (Vec, Vec) = - desired_mods(services, cluster, &mut unrestored) + desired_linked(services, cluster, &mut unrestored, wanted) .await? .into_iter() - .partition(|_| mods_in_cluster); + .partition(|desired| mods_in_cluster && desired.content_type == ContentType::Mod); // read across every cluster rather than this one so a pack installed anywhere is present here too - let packs = desired_global(services, &mut unrestored).await?; + let packs = if isolated { + Vec::new() + } else { + desired_global(services, &mut unrestored).await? + }; if !unrestored.is_empty() { - let (title, body) = unrestored_notice(&unrestored); + let names: Vec = unrestored + .iter() + .map(|item| item.file_name.clone()) + .collect(); + let (title, body) = unrestored_notice(&names); services.events.notify(title).body(body).send(); } @@ -187,7 +234,11 @@ pub async fn materialize_content( // it themselves so it cannot be taken any earlier let _manifest = manifest::lock().await; - let mods_root = if mods_in_cluster { &cluster_dir } else { game_dir }; + let mods_root = if mods_in_cluster { + &cluster_dir + } else { + game_dir + }; for content_type in SWAP_TYPES { sweep_staging_files(&mods_root.join(content_type.folder_name())).await; } @@ -195,10 +246,24 @@ pub async fn materialize_content( sweep_staging_files(&global_root.join(content_type.folder_name())).await; } + let (held_mods, held_rest) = if isolated { + held_in_place(&unrestored, &cluster_dir, game_dir, mods_in_cluster).await + } else { + (Vec::new(), Vec::new()) + }; + // While the game is still closed this is what lands a package removed // mid-session and clears another cluster's content from the shared dir - let mod_paths: HashSet = mods.iter().map(Desired::relative_path).collect(); - let rest_paths: HashSet = rest.iter().map(Desired::relative_path).collect(); + let mod_paths: HashSet = mods + .iter() + .map(Desired::relative_path) + .chain(held_mods.iter().map(|entry| entry.path.clone())) + .collect(); + let rest_paths: HashSet = rest + .iter() + .map(Desired::relative_path) + .chain(held_rest.iter().map(|entry| entry.path.clone())) + .collect(); let pack_paths: HashSet = packs.iter().map(Desired::relative_path).collect(); prune_previous(&cluster_dir, previous_mods.as_ref(), &mod_paths).await; @@ -219,11 +284,18 @@ pub async fn materialize_content( } if mods_in_cluster { - let mod_entries = link_desired(&cluster_dir, &mods).await; + let mut mod_entries = link_desired(&cluster_dir, &mods).await; + mod_entries.extend(held_mods); + let spared = if isolated { + failed_imports + } else { + HashSet::new() + }; drop_unmaterialized( &cluster_dir.join(ContentType::Mod.folder_name()), ContentType::Mod, &mod_entries, + &spared, ) .await; manifest::save( @@ -234,15 +306,18 @@ pub async fn materialize_content( .await; } - let pack_entries = link_desired(&global_root, &packs).await; - manifest::save( - &global_root, - manifest::GLOBAL_MANIFEST_NAME, - &MaterializedManifest::new(cluster.id, pack_entries), - ) - .await; + if !isolated { + let pack_entries = link_desired(&global_root, &packs).await; + manifest::save( + &global_root, + manifest::GLOBAL_MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, pack_entries), + ) + .await; + } - let entries = link_desired(game_dir, &rest).await; + let mut entries = link_desired(game_dir, &rest).await; + entries.extend(held_rest); manifest::save( game_dir, manifest::MANIFEST_NAME, @@ -258,7 +333,7 @@ pub async fn materialize_content( // every enabled pack across every cluster async fn desired_global( services: &LauncherServices, - unrestored: &mut Vec, + unrestored: &mut Vec, ) -> LauncherResult> { let mut desired = Vec::new(); @@ -268,13 +343,20 @@ async fn desired_global( continue; } - let Some(artifact) = artifact_dao::get_artifact_by_hash(&services.db, &row.hash).await? + let Some(artifact) = + artifact_dao::get_artifact_by_hash(&services.db, &row.hash).await? else { continue; }; - let Some(src) = cached_file(services, &row.hash, &artifact.path).await else { - unrestored.push(row.file_name); + let Some(src) = + cached_file(services, &row.hash, &artifact.path, artifact.size_bytes).await + else { + unrestored.push(Unrestored { + content_type, + file_name: row.file_name, + hash: row.hash, + }); continue; }; @@ -449,15 +531,20 @@ async fn disable_hand_removed( previous: Option<&MaterializedManifest>, ) -> Vec { let removed = hand_removed_content(dir, content_type, previous).await; - if removed.is_empty() { - return Vec::new(); - } + disable_removed(services, cluster, content_type, removed).await +} +async fn disable_removed( + services: &LauncherServices, + cluster: &Cluster, + content_type: ContentType, + removed: Vec<(String, String)>, +) -> Vec { let ctx = services.content(); let mut disabled = Vec::new(); for (name, hash) in removed { - let outcome = if content_type.is_global() { + let outcome = if cluster.shares_content(content_type) { disable_globally(cluster, &hash, &ctx).await } else { oneclient_content::bundles::set_artifact_enabled_to(cluster.id, &hash, false, &ctx) @@ -488,6 +575,458 @@ async fn disable_hand_removed( disabled } +async fn lock_mods_sync(cluster_id: i64) -> tokio::sync::OwnedMutexGuard<()> { + static LOCKS: std::sync::Mutex< + std::collections::BTreeMap>>, + > = std::sync::Mutex::new(std::collections::BTreeMap::new()); + + let lock = { + let mut locks = LOCKS.lock().unwrap(); + locks.retain(|_, lock| std::sync::Arc::strong_count(lock) > 1); + locks.entry(cluster_id).or_default().clone() + }; + lock.lock_owned().await +} + +pub async fn sync_cluster_mods(state: &LauncherState, cluster_id: i64) -> bool { + let _sync = lock_mods_sync(cluster_id).await; + if state.games.is_active(cluster_id) { + return false; + } + let Ok(cluster) = state.clusters.get(cluster_id).await else { + return false; + }; + let Ok(cluster_dir) = cluster.dir() else { + return false; + }; + + let in_cluster = launch_uses_cluster_mods(state, &cluster).await; + if !in_cluster && !cluster.uses_dedicated_dir() { + return false; + } + if in_cluster + && !cluster.user_created + && !cluster.is_isolated() + && !unlinked_mods_cleared(&state.services, cluster_id).await + { + return false; + } + + if manifest::mods_live_in_cluster(&cluster_dir).await { + return sync_mods_folder(&state.services, &cluster).await; + } + + let dir = cluster_dir.join(ContentType::Mod.folder_name()); + let Some(linked) = linked_mods(&cluster, &state.services.content()).await else { + return false; + }; + let Some(on_disk) = jars_on_disk(&state.services, &dir, &linked).await else { + return false; + }; + let added: Vec<&str> = on_disk + .iter() + .filter(|(name, hash)| { + !linked + .iter() + .any(|link| link.hash == *hash || link.cluster_file_name == *name) + }) + .map(|(name, _)| name.as_str()) + .collect(); + + import_added(&state.services, &cluster, &dir, &added, true).await +} + +async fn launch_uses_cluster_mods(state: &LauncherState, cluster: &Cluster) -> bool { + let global = state.settings.read().global_game_settings.clone(); + let custom_args = state + .clusters + .resolve_settings(&global, cluster) + .await + .ok() + .and_then(|profile| profile.launch_args) + .unwrap_or_default(); + let loader_version = cluster.mc_loader_version.as_deref().unwrap_or("999.0.0"); + + super::fabric::uses_cluster_mods_folder(cluster.mc_loader, Some(loader_version), &custom_args) +} + +async fn import_added( + services: &LauncherServices, + cluster: &Cluster, + dir: &Path, + added: &[&str], + discard_stale: bool, +) -> bool { + let mut changed = false; + for name in added { + let Some(Ok(row)) = adopt_file( + services, + cluster, + &dir.join(name), + ContentType::Mod, + discard_stale, + ) + .await + else { + continue; + }; + tracing::info!(file = %name, "registered a mod added by hand"); + if row.file_name != *name + && let Err(err) = + artifact_dao::link_cluster_artifact(&services.db, cluster.id, &row.hash, name).await + { + tracing::warn!(file = %name, error = %err, "failed to keep the added mod's name"); + } + changed = true; + } + + changed +} + +fn merge_live_writes( + mut current: MaterializedManifest, + previous: &MaterializedManifest, + fresh: &MaterializedManifest, +) -> MaterializedManifest { + current + .entries + .retain(|entry| !previous.entries.contains(entry) || fresh.entries.contains(entry)); + + for entry in &fresh.entries { + if !previous.entries.contains(entry) && !current.contains(&entry.path) { + current.entries.push(entry.clone()); + } + } + + current +} + +async fn sync_mods_folder(services: &LauncherServices, cluster: &Cluster) -> bool { + let Ok(cluster_dir) = cluster.dir() else { + return false; + }; + let Some(previous) = manifest::load(&cluster_dir, manifest::MODS_MANIFEST_NAME) + .await + .filter(|loaded| loaded.cluster_id == cluster.id) + else { + return false; + }; + let dir = cluster_dir.join(ContentType::Mod.folder_name()); + let ctx = services.content(); + + let Some(linked) = linked_mods(cluster, &ctx).await else { + return false; + }; + let Some(on_disk) = jars_on_disk(services, &dir, &linked).await else { + return false; + }; + let names_on_disk: HashSet<&str> = on_disk.iter().map(|(name, _)| name.as_str()).collect(); + let mut changed = false; + let mut added = Vec::new(); + let mut replaced: Vec<(String, String, String)> = Vec::new(); + + for (name, hash) in &on_disk { + let relative = manifest::entry_path(ContentType::Mod.folder_name(), name); + let materialized = previous + .entries + .iter() + .any(|entry| entry.path == relative && entry.hash == *hash); + + if materialized { + continue; + } + + replaced.extend( + linked + .iter() + .filter(|link| { + link.enabled && link.cluster_file_name == *name && link.hash != *hash + }) + .map(|link| (name.clone(), link.hash.clone(), hash.clone())), + ); + + if let Some(link) = linked.iter().find(|link| &link.hash == hash) { + if link.cluster_file_name != *name + && !names_on_disk.contains(link.cluster_file_name.as_str()) + { + tracing::info!(from = %link.cluster_file_name, to = %name, "mod renamed by hand"); + match artifact_dao::link_cluster_artifact(&services.db, cluster.id, hash, name) + .await + { + Ok(_) => changed = true, + Err(err) => { + tracing::warn!(file = %name, error = %err, "failed to follow a renamed mod") + } + } + } + + if !link.enabled { + tracing::info!(file = %name, "mod put back by hand; switching it on"); + match oneclient_content::bundles::set_artifact_enabled_to( + cluster.id, hash, true, &ctx, + ) + .await + { + Ok(_) => changed = true, + Err(err) => { + tracing::warn!(file = %name, error = %err, "failed to switch a restored mod on") + } + } + } + continue; + } + + added.push(name.as_str()); + } + + changed |= import_added(services, cluster, &dir, &added, false).await; + + let Some(linked) = linked_mods(cluster, &ctx).await else { + return changed; + }; + + for (name, old_hash, new_hash) in &replaced { + if !linked.iter().any(|link| link.hash == *new_hash) { + continue; + } + tracing::info!(file = %name, "mod replaced by hand; switching the old copy off"); + detach_overwritten_link(services, &dir.join(name), old_hash).await; + let relative = manifest::entry_path(ContentType::Mod.folder_name(), name); + forget_manifest_entry(&cluster_dir, &relative).await; + match PackageStore::set_artifact_enabled_to(cluster.id, old_hash, false, &ctx).await { + Ok(_) => changed = true, + Err(err) => { + tracing::warn!(file = %name, error = %err, "failed to switch the replaced mod off") + } + } + } + + let removed = { + let _guard = manifest::lock().await; + match manifest::load(&cluster_dir, manifest::MODS_MANIFEST_NAME).await { + Some(fresh) => { + let mut live = live_mod_entries(&previous, &linked); + live.entries.retain(|entry| fresh.entries.contains(entry)); + hand_removed_content(&dir, ContentType::Mod, Some(&live)).await + } + None => Vec::new(), + } + }; + let disabled = disable_removed(services, cluster, ContentType::Mod, removed).await; + if !disabled.is_empty() { + changed = true; + let (title, body) = removal_notice(&disabled, cluster.shares_content(ContentType::Mod)); + services.events.notify(title).body(body).send(); + } + + let Some(linked) = linked_mods(cluster, &ctx).await else { + return changed; + }; + let mut current = next_mods_manifest(&previous, on_disk, &linked); + for entry in &previous.entries { + let path = cluster_dir.join(&entry.path); + if !current.contains(&entry.path) + && polyio::symlink_metadata(&path).await.is_ok() + && !polyio::try_exists(&path).await.unwrap_or(true) + { + current.entries.push(entry.clone()); + } + } + { + let _guard = manifest::lock().await; + let current = match manifest::load(&cluster_dir, manifest::MODS_MANIFEST_NAME).await { + Some(fresh) => merge_live_writes(current, &previous, &fresh), + None => current, + }; + manifest::save(&cluster_dir, manifest::MODS_MANIFEST_NAME, ¤t).await; + } + + changed +} + +fn next_mods_manifest( + previous: &MaterializedManifest, + on_disk: Vec<(String, String)>, + linked: &[oneclient_content::packages::LinkedArtifactInfo], +) -> MaterializedManifest { + let on_disk = MaterializedManifest::new( + previous.cluster_id, + on_disk + .into_iter() + .map(|(name, hash)| ManifestEntry { + path: manifest::entry_path(ContentType::Mod.folder_name(), &name), + hash, + }) + .collect(), + ); + let live = live_mod_entries(&on_disk, linked); + + let entries = on_disk + .entries + .into_iter() + .filter(|entry| live.entries.contains(entry) || previous.entries.contains(entry)) + .collect(); + + MaterializedManifest::new(previous.cluster_id, entries) +} + +async fn linked_mods( + cluster: &Cluster, + ctx: &oneclient_content::ContentCtx, +) -> Option> { + match PackageStore::list_linked_artifacts(cluster.id, ctx).await { + Ok(linked) => Some( + linked + .into_iter() + .filter(|link| link.content_type == ContentType::Mod) + .collect(), + ), + Err(err) => { + tracing::warn!(error = %err, "cannot list links; leaving the mods folder alone"); + None + } + } +} + +async fn jars_on_disk( + services: &LauncherServices, + dir: &Path, + linked: &[oneclient_content::packages::LinkedArtifactInfo], +) -> Option> { + let mut entries = match polyio::read_dir(dir).await { + Ok(entries) => entries, + Err(err) => { + tracing::warn!(dir = %dir.display(), error = %err, "cannot read the mods folder"); + return None; + } + }; + + let mut out = Vec::new(); + loop { + let entry = match entries.next_entry().await { + Ok(Some(entry)) => entry, + Ok(None) => break, + Err(err) => { + tracing::warn!(dir = %dir.display(), error = %err, "cannot list the mods folder"); + return None; + } + }; + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { + continue; + }; + if name.starts_with('.') || !has_content_extension(ContentType::Mod, &name) { + continue; + } + + if let Some(hash) = cache_link_hash(services, &path, &name, linked).await { + out.push((name, hash)); + continue; + } + + let hash = match polyio::stat(&path).await { + Ok(meta) if !meta.is_file() || meta.len() == 0 => continue, + Ok(_) => polyio::sha1_file(&path).await, + Err(err) => Err(err), + }; + let hash = match hash { + Ok(hash) => polyio::normalize_hash(&hash), + Err(_) if !polyio::try_exists(&path).await.unwrap_or(true) => continue, + Err(err) => { + tracing::warn!(file = %name, error = %err, "cannot read a jar in the mods folder"); + return None; + } + }; + + if !linked.iter().any(|link| link.hash == hash) + && let Err(err) = polyio::read_zip_file_entries(&path, |_| false).await + { + tracing::debug!(file = %name, error = %err, "not a whole jar yet; leaving it for a later pass"); + continue; + } + out.push((name, hash)); + } + + Some(out) +} + +async fn cache_link_hash( + services: &LauncherServices, + path: &Path, + name: &str, + linked: &[oneclient_content::packages::LinkedArtifactInfo], +) -> Option { + let target = polyio::read_link(path).await.ok()?; + + for link in linked.iter().filter(|link| link.cluster_file_name == name) { + let Some(artifact) = artifact_dao::get_artifact_by_hash(&services.db, &link.hash) + .await + .ok() + .flatten() + else { + continue; + }; + if artifact_absolute_path(&artifact.path).is_ok_and(|cached| cached == target) { + return cache_intact(&target, &link.hash, artifact.size_bytes) + .await + .then(|| link.hash.clone()); + } + } + + None +} + +async fn detach_overwritten_link(services: &LauncherServices, path: &Path, old_hash: &str) { + let Ok(target) = polyio::read_link(path).await else { + return; + }; + let cached = artifact_dao::get_artifact_by_hash(&services.db, old_hash) + .await + .ok() + .flatten() + .and_then(|artifact| artifact_absolute_path(&artifact.path).ok()); + if cached.as_deref() != Some(target.as_path()) { + return; + } + + if let Err(err) = move_entry(&target, path).await { + tracing::warn!(file = %path.display(), error = %err, "failed to move a replaced mod out of the cache"); + } +} + +async fn forget_manifest_entry(cluster_dir: &Path, relative: &str) { + let _guard = manifest::lock().await; + if let Some(mut loaded) = manifest::load(cluster_dir, manifest::MODS_MANIFEST_NAME).await { + loaded.entries.retain(|entry| entry.path != relative); + manifest::save(cluster_dir, manifest::MODS_MANIFEST_NAME, &loaded).await; + } +} + +fn live_mod_entries( + previous: &MaterializedManifest, + linked: &[oneclient_content::packages::LinkedArtifactInfo], +) -> MaterializedManifest { + let live: HashSet<(String, &str)> = linked + .iter() + .filter(|link| link.content_type == ContentType::Mod && link.enabled) + .map(|link| { + ( + manifest::entry_path(ContentType::Mod.folder_name(), &link.cluster_file_name), + link.hash.as_str(), + ) + }) + .collect(); + + let entries = previous + .entries + .iter() + .filter(|entry| live.contains(&(entry.path.clone(), entry.hash.as_str()))) + .cloned() + .collect(); + + MaterializedManifest::new(previous.cluster_id, entries) +} + // switches a globally installed artifact off for every cluster that has it async fn disable_globally( cluster: &Cluster, @@ -516,15 +1055,20 @@ fn removal_summary(disabled: &[String]) -> String { } } -fn removal_notice(disabled: &[String]) -> (&'static str, String) { +fn removal_notice(disabled: &[String], shared: bool) -> (&'static str, String) { let names = removal_summary(disabled); + let where_from = if shared { + "your shared folder" + } else { + "this instance" + }; + let scope = if shared { " on every cluster" } else { "" }; if disabled.len() == 1 { return ( "Content disabled", format!( - "{names} is gone from your shared folder, so it has been switched off on every \ - cluster. Turn it back on in OneClient to restore it." + "{names} is gone from {where_from}, so it has been switched off{scope}. Turn it back on in OneClient to restore it." ), ); } @@ -532,8 +1076,7 @@ fn removal_notice(disabled: &[String]) -> (&'static str, String) { ( "Content disabled", format!( - "{names} are gone from your shared folder, so they have been switched off on every \ - cluster. Turn them back on in OneClient to restore them." + "{names} are gone from {where_from}, so they have been switched off{scope}. Turn them back on in OneClient to restore them." ), ) } @@ -612,16 +1155,17 @@ pub async fn dematerialize_content( Ok(()) } -async fn desired_mods( +async fn desired_linked( services: &LauncherServices, cluster: &Cluster, - unrestored: &mut Vec, + unrestored: &mut Vec, + wanted: &[ContentType], ) -> LauncherResult> { let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()).await?; let mut desired = Vec::with_capacity(linked.len()); for link in linked { - if !link.enabled || link.content_type != ContentType::Mod { + if !link.enabled || !wanted.contains(&link.content_type) { continue; } @@ -630,8 +1174,14 @@ async fn desired_mods( continue; }; - let Some(src) = cached_file(services, &link.hash, &artifact.path).await else { - unrestored.push(link.cluster_file_name); + let Some(src) = + cached_file(services, &link.hash, &artifact.path, artifact.size_bytes).await + else { + unrestored.push(Unrestored { + content_type: link.content_type, + file_name: link.cluster_file_name, + hash: link.hash, + }); continue; }; @@ -643,16 +1193,217 @@ async fn desired_mods( }); } - Ok(desired) + Ok(skip_shadowed_bundle_mods(services, cluster, desired).await) +} + +async fn skip_shadowed_bundle_mods( + services: &LauncherServices, + cluster: &Cluster, + mut desired: Vec, +) -> Vec { + let mods: Vec<(String, PathBuf)> = desired + .iter() + .filter(|item| item.content_type == ContentType::Mod) + .map(|item| (item.hash.clone(), item.src.clone())) + .collect(); + let shadowed = shadowed_bundle_hashes( + services, + cluster.id, + cluster.mc_loader == GameLoader::Fabric, + &mods, + ) + .await + .shadowed; + if shadowed.is_empty() { + return desired; + } + + static SAID: std::sync::Mutex> = + std::sync::Mutex::new(std::collections::BTreeSet::new()); + + let mut newly_skipped = Vec::new(); + if let Ok(cluster_dir) = cluster.dir() { + let in_cluster = manifest::mods_live_in_cluster(&cluster_dir).await; + for item in desired.iter().filter(|item| shadowed.contains(&item.hash)) { + let newly = if in_cluster { + polyio::symlink_metadata(cluster_dir.join(item.relative_path())) + .await + .is_ok() + } else { + SAID.lock().unwrap().insert((cluster.id, item.hash.clone())) + }; + if newly { + newly_skipped.push(item.file_name.clone()); + } + } + } + if !newly_skipped.is_empty() { + services + .events + .notify("Bundle mod skipped") + .body(format!( + "Your own copy of {} is used instead of the bundle's. Remove yours to get the bundle's back.", + removal_summary(&newly_skipped) + )) + .send(); + } + + desired.retain(|item| { + let skip = shadowed.contains(&item.hash); + if skip { + tracing::info!( + cluster_id = cluster.id, + file = %item.file_name, + "skipping a bundle mod the user added their own copy of" + ); + } + !skip + }); + + desired +} + +pub async fn shadowed_bundle_mods(state: &LauncherState, cluster_id: i64) -> DuplicateMods { + let services = &state.services; + let Ok(linked) = PackageStore::list_linked_artifacts(cluster_id, &services.content()).await + else { + return DuplicateMods::default(); + }; + + let mut mods = Vec::new(); + for link in linked + .iter() + .filter(|link| link.content_type == ContentType::Mod && link.enabled) + { + if let Ok(Some(artifact)) = + artifact_dao::get_artifact_by_hash(&services.db, &link.hash).await + && let Ok(path) = artifact_absolute_path(&artifact.path) + { + mods.push((link.hash.clone(), path)); + } + } + + let loader_picks_newer = cluster_dao::get_by_id(&services.db, cluster_id) + .await + .ok() + .flatten() + .is_some_and(|row| row.mc_loader == GameLoader::Fabric as i64); + + shadowed_bundle_hashes(services, cluster_id, loader_picks_newer, &mods).await +} + +async fn shadowed_bundle_hashes( + services: &LauncherServices, + cluster_id: i64, + loader_picks_newer: bool, + mods: &[(String, PathBuf)], +) -> DuplicateMods { + let bundled: HashSet = match bundle_dao::list_bundle_tracked(&services.db, cluster_id) + .await + { + Ok(rows) => rows.into_iter().map(|row| row.hash).collect(), + Err(err) => { + tracing::warn!(error = %err, "cannot list bundle mods; not checking for duplicates"); + return DuplicateMods::default(); + } + }; + if bundled.is_empty() { + return DuplicateMods::default(); + } + + let mut copies = Vec::new(); + for bundle_pass in [false, true] { + let pass = mods + .iter() + .filter(|(hash, _)| bundled.contains(hash) == bundle_pass); + copies.extend( + futures_util::future::join_all(pass.map(async |(hash, jar)| ModCopy { + hash: hash.clone(), + bundled: bundle_pass, + mod_id: cached_mod_id(hash, jar).await, + })) + .await, + ); + + if !bundle_pass && copies.iter().all(|copy| copy.mod_id.is_none()) { + return DuplicateMods::default(); + } + } + + shadowed_bundle_copies(&copies, loader_picks_newer) +} + +async fn cached_mod_id(hash: &str, jar: &Path) -> Option { + static IDS: std::sync::Mutex>> = + std::sync::Mutex::new(std::collections::BTreeMap::new()); + + if let Some(id) = IDS.lock().unwrap().get(hash) { + return id.clone(); + } + + let id = oneclient_content::packages::local_manifest::read_jar_mod_id(jar).await; + if id.is_some() || polyio::try_exists(jar).await.unwrap_or(false) { + IDS.lock().unwrap().insert(hash.to_owned(), id.clone()); + } + id +} + +struct ModCopy { + hash: String, + bundled: bool, + mod_id: Option, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct DuplicateMods { + pub shadowed: HashSet, + pub outranked: HashSet, +} + +fn shadowed_bundle_copies(mods: &[ModCopy], loader_picks_newer: bool) -> DuplicateMods { + let mut duplicates = DuplicateMods::default(); + + for copy in mods.iter().filter(|copy| copy.bundled) { + let Some(bundle) = ©.mod_id else { + continue; + }; + let users: Vec<(&str, &JarModId)> = mods + .iter() + .filter(|copy| !copy.bundled) + .filter_map(|copy| Some((copy.hash.as_str(), copy.mod_id.as_ref()?))) + .filter(|(_, user)| user.id == bundle.id) + .collect(); + if users.is_empty() { + continue; + } + + if loader_picks_newer && users.iter().all(|(_, user)| is_older(user, bundle)) { + duplicates + .outranked + .extend(users.iter().map(|(hash, _)| (*hash).to_owned())); + } else { + duplicates.shadowed.insert(copy.hash.clone()); + } + } + + duplicates +} + +fn is_older(user: &JarModId, bundle: &JarModId) -> bool { + match (&user.version, &bundle.version) { + (Some(user), Some(bundle)) => fabric_version::compare(user, bundle).is_lt(), + _ => false, + } } async fn cached_file( services: &LauncherServices, hash: &str, stored_path: &str, + size_bytes: Option, ) -> Option { let src = artifact_absolute_path(stored_path).ok()?; - if polyio::try_exists(&src).await.unwrap_or(false) { + if cache_intact(&src, hash, size_bytes).await { return Some(src); } @@ -676,7 +1427,76 @@ async fn cached_file( .filter(|artifact| artifact.hash == hash)?; let path = artifact_absolute_path(&restored.path).ok()?; - polyio::try_exists(&path).await.unwrap_or(false).then_some(path) + polyio::try_exists(&path) + .await + .unwrap_or(false) + .then_some(path) +} + +async fn cache_intact(src: &Path, hash: &str, size_bytes: Option) -> bool { + let Ok(meta) = polyio::stat(src).await else { + return false; + }; + + let len = meta.len(); + let suspect = len == 0 || size_bytes.is_some_and(|expected| expected as u64 != len); + if !suspect { + return true; + } + + let intact = polyio::sha1_file(src) + .await + .is_ok_and(|disk| polyio::normalize_hash(&disk) == hash); + if !intact { + tracing::warn!( + hash, + file = %src.display(), + size = len, + expected = ?size_bytes, + "cached package file is damaged; treating it as missing" + ); + } + + intact +} + +async fn held_in_place( + unrestored: &[Unrestored], + cluster_dir: &Path, + game_dir: &Path, + mods_in_cluster: bool, +) -> (Vec, Vec) { + let mut mods = Vec::new(); + let mut rest = Vec::new(); + + for item in unrestored { + let in_cluster = mods_in_cluster && item.content_type == ContentType::Mod; + let root = if in_cluster { cluster_dir } else { game_dir }; + let path = item.relative_path(); + + if !polyio::symlink_metadata(root.join(&path)) + .await + .is_ok_and(|meta| meta.file_type().is_file()) + { + continue; + } + + tracing::info!( + file = %item.file_name, + "keeping the instance's own copy of content the cache could not restore" + ); + let entry = ManifestEntry { + path, + hash: item.hash.clone(), + }; + if in_cluster { + mods.push(entry); + } else { + rest.push(entry); + } + } + + (mods, rest) } async fn link_desired(root: &Path, desired: &[Desired]) -> Vec { @@ -703,11 +1523,9 @@ async fn link_desired(root: &Path, desired: &[Desired]) -> Vec { entries } -const UNLINKED_MODS_REPAIR: &str = "clear-unlinked-mods"; - #[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")] async fn clear_unlinked_mods_once(services: &LauncherServices, cluster: &Cluster, mods_dir: &Path) { - let id = format!("{UNLINKED_MODS_REPAIR}:{}", cluster.id); + let id = oneclient_cluster::unlinked_mods_repair_id(cluster.id); match migration_dao::is_applied(&services.db, &id).await { Ok(true) => return, Ok(false) => {} @@ -753,13 +1571,25 @@ async fn clear_unlinked_mods_once(services: &LauncherServices, cluster: &Cluster mark_repair_applied(services, &id).await; } +async fn unlinked_mods_cleared(services: &LauncherServices, cluster_id: i64) -> bool { + let id = oneclient_cluster::unlinked_mods_repair_id(cluster_id); + migration_dao::is_applied(&services.db, &id) + .await + .unwrap_or(false) +} + async fn mark_repair_applied(services: &LauncherServices, id: &str) { if let Err(err) = migration_dao::mark_applied(&services.db, id).await { tracing::warn!(repair = id, error = %err, "failed to record a one-time repair"); } } -async fn drop_unmaterialized(dir: &Path, content_type: ContentType, entries: &[ManifestEntry]) { +async fn drop_unmaterialized( + dir: &Path, + content_type: ContentType, + entries: &[ManifestEntry], + spared: &HashSet, +) { let kept: HashSet<&str> = entries .iter() .filter_map(|entry| entry.path.rsplit('/').next()) @@ -777,7 +1607,7 @@ async fn drop_unmaterialized(dir: &Path, content_type: ContentType, entries: &[M if name.starts_with('.') || is_note(name) || !has_content_extension(content_type, name) { continue; } - if kept.contains(name) { + if kept.contains(name) || spared.contains(name) { continue; } @@ -900,6 +1730,7 @@ pub async fn import_manual_content( cluster: &Cluster, game_dir: &Path, ) { + let _sync = lock_mods_sync(cluster.id).await; let mods_in_cluster = match cluster.dir() { Ok(dir) => manifest::mods_live_in_cluster(&dir).await, Err(_) => false, @@ -914,12 +1745,12 @@ async fn import_manual_content_with( game_dir: &Path, mods_in_cluster: bool, discard_originals: bool, -) { +) -> HashSet { let linked = match PackageStore::list_linked_artifacts(cluster.id, &services.content()).await { Ok(linked) => linked, Err(err) => { tracing::warn!(error = %err, "failed to list links; skipping manual-content import"); - return; + return HashSet::new(); } }; @@ -948,7 +1779,7 @@ async fn import_manual_content_with( ), }; - import_from_dir( + let failed_mods = import_from_dir( services, cluster, &mods_dir, @@ -959,20 +1790,35 @@ async fn import_manual_content_with( ) .await; - let Ok(global_root) = paths::shared_minecraft_dir() else { - return; + let isolated = cluster.is_isolated(); + let global_root = if isolated { + let Ok(dir) = cluster.dir() else { + return failed_mods; + }; + dir + } else { + let Ok(dir) = paths::shared_minecraft_dir() else { + return failed_mods; + }; + dir + }; + let global_manifest = if isolated { + manifest.clone() + } else { + manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await }; - let global_manifest = manifest::load(&global_root, manifest::GLOBAL_MANIFEST_NAME).await; for content_type in GLOBAL_TYPES { let dir = global_root.join(content_type.folder_name()); - let known = match artifact_dao::list_global_artifacts(&services.db, content_type as i64) - .await - { - Ok(rows) => rows.into_iter().map(|row| row.file_name).collect(), - Err(err) => { - tracing::warn!(error = %err, "cannot list global content; skipping its import"); - continue; + let known = if isolated { + names_linked_here(&linked, content_type) + } else { + match artifact_dao::list_global_artifacts(&services.db, content_type as i64).await { + Ok(rows) => rows.into_iter().map(|row| row.file_name).collect(), + Err(err) => { + tracing::warn!(error = %err, "cannot list global content; skipping its import"); + continue; + } } }; @@ -987,6 +1833,8 @@ async fn import_manual_content_with( ) .await; } + + failed_mods } fn names_linked_here( @@ -1008,9 +1856,10 @@ async fn import_from_dir( known: &HashSet, manifest: Option<&MaterializedManifest>, discard_originals: bool, -) { +) -> HashSet { + let mut failed = HashSet::new(); let Ok(mut entries) = polyio::read_dir(dir).await else { - return; + return failed; }; while let Ok(Some(entry)) = entries.next_entry().await { @@ -1037,34 +1886,51 @@ async fn import_from_dir( continue; } - if is_cached_artifact(services, &path).await { - tracing::debug!( - file = name, - dir = %dir.display(), - "discarding stale launcher content; the cache still holds it" - ); - if let Err(err) = polyio::remove_file(&path).await { - tracing::warn!(file = name, error = %err, "failed to discard stale content"); - } - continue; - } - - match PackageStore::import_local_file(&path, content_type, cluster.id, &services.content()) - .await - { - Ok(row) => { + match adopt_file(services, cluster, &path, content_type, true).await { + Some(Ok(row)) => { tracing::debug!(file = name, "registered manually-added content"); if discard_originals { discard_adopted_original(&row, &path, name).await; } } - Err(err) => tracing::warn!( - file = name, - error = %err, - "failed to register manually-added content" - ), + Some(Err(_)) => { + failed.insert(name.to_owned()); + } + None => {} } } + + failed +} + +async fn adopt_file( + services: &LauncherServices, + cluster: &Cluster, + path: &Path, + content_type: ContentType, + discard_stale: bool, +) -> Option> { + if discard_stale && !cluster.is_isolated() && is_stale_launcher_content(services, path).await { + tracing::debug!( + file = %path.display(), + "discarding stale launcher content; the cache still holds it" + ); + if let Err(err) = polyio::remove_file(path).await { + tracing::warn!(file = %path.display(), error = %err, "failed to discard stale content"); + } + return None; + } + + let imported = + PackageStore::import_local_file(path, content_type, cluster.id, &services.content()).await; + if let Err(err) = &imported { + tracing::warn!( + file = %path.display(), + error = %err, + "failed to register manually-added content" + ); + } + Some(imported) } async fn discard_adopted_original( @@ -1075,7 +1941,10 @@ async fn discard_adopted_original( match artifact_absolute_path(&row.path) { Ok(cached) if polyio::try_exists(&cached).await.unwrap_or(false) => {} _ => { - tracing::warn!(file = name, "adopted content is not in the cache; leaving the original"); + tracing::warn!( + file = name, + "adopted content is not in the cache; leaving the original" + ); return; } } @@ -1085,16 +1954,15 @@ async fn discard_adopted_original( } } -async fn is_cached_artifact(services: &LauncherServices, path: &Path) -> bool { +async fn is_stale_launcher_content(services: &LauncherServices, path: &Path) -> bool { let Ok(hash) = polyio::sha1_file(path).await else { return false; }; + let hash = polyio::normalize_hash(&hash); - artifact_dao::get_artifact_by_hash(&services.db, &polyio::normalize_hash(&hash)) + artifact_dao::is_launcher_owned(&services.db, &hash) .await - .ok() - .flatten() - .is_some() + .unwrap_or(false) } fn has_content_extension(content_type: ContentType, name: &str) -> bool { @@ -1610,7 +2478,11 @@ mod tests { assert_eq!(lines.len(), 2); assert!(lines[0].starts_with("[prefix]/home/alex/")); assert!(lines[1].starts_with("[prefix]/var/home/alex/")); - assert!(lines.iter().all(|line| line.ends_with(std::path::MAIN_SEPARATOR))); + assert!( + lines + .iter() + .all(|line| line.ends_with(std::path::MAIN_SEPARATOR)) + ); } #[test] @@ -1709,13 +2581,17 @@ mod tests { assert!(!shared.join("bsl.zip").exists(), "managed pack left behind"); assert!(!shared.join("bsl.zip.txt").exists(), "sidecar left behind"); assert_eq!( - polyio::read_to_string(stash.join("bsl.zip.txt")).await.unwrap(), + polyio::read_to_string(stash.join("bsl.zip.txt")) + .await + .unwrap(), "BLOOM=off" ); restore_stashed(&stash, &shared, ContentType::Shader, &ours).await; assert_eq!( - polyio::read_to_string(shared.join("bsl.zip.txt")).await.unwrap(), + polyio::read_to_string(shared.join("bsl.zip.txt")) + .await + .unwrap(), "BLOOM=off" ); @@ -1723,7 +2599,9 @@ mod tests { .await .unwrap(); assert_eq!( - polyio::read_to_string(shared.join("bsl.zip.txt")).await.unwrap(), + polyio::read_to_string(shared.join("bsl.zip.txt")) + .await + .unwrap(), "BLOOM=on" ); @@ -1735,10 +2613,15 @@ mod tests { let root = polyio::testing::ScratchDir::new("unpacked"); let shared = root.join("shared").join("shaderpacks"); let stash = root.join("cluster").join("shaderpacks"); - polyio::create_dir_all(shared.join("Loose/shaders")).await.unwrap(); - polyio::write(shared.join("Loose/shaders/final.fsh"), b"void main".as_slice()) + polyio::create_dir_all(shared.join("Loose/shaders")) .await .unwrap(); + polyio::write( + shared.join("Loose/shaders/final.fsh"), + b"void main".as_slice(), + ) + .await + .unwrap(); stash_content_files(&shared, &stash, ContentType::Shader, &HashSet::new()).await; assert!(!shared.join("Loose").exists()); @@ -1809,7 +2692,12 @@ mod tests { let jar = game_dir.join("mods").join("theirs.jar"); polyio::write(&jar, b"jar".as_slice()).await.unwrap(); - prune_previous(game_dir, Some(&manifest_of(1, &["mods/theirs.jar"])), &HashSet::new()).await; + prune_previous( + game_dir, + Some(&manifest_of(1, &["mods/theirs.jar"])), + &HashSet::new(), + ) + .await; assert!(polyio::symlink_metadata(&jar).await.is_err()); @@ -1827,9 +2715,17 @@ mod tests { let mine = game_dir.join("mods").join("handmade.jar"); polyio::write(&mine, b"jar".as_slice()).await.unwrap(); - prune_previous(game_dir, Some(&manifest_of(1, &["mods/ours.jar"])), &HashSet::new()).await; + prune_previous( + game_dir, + Some(&manifest_of(1, &["mods/ours.jar"])), + &HashSet::new(), + ) + .await; - assert!(mine.exists(), "a file we never materialized is not ours to delete"); + assert!( + mine.exists(), + "a file we never materialized is not ours to delete" + ); std::fs::remove_dir_all(root.path()).ok(); } @@ -1863,7 +2759,10 @@ mod tests { stash.join("removed.jar").exists(), "and it must not be silently deleted either" ); - assert!(shared.join("options.txt").exists(), "sidecars still restore"); + assert!( + shared.join("options.txt").exists(), + "sidecars still restore" + ); std::fs::remove_dir_all(root.path()).ok(); } @@ -1884,7 +2783,9 @@ mod tests { polyio::write(stash.join("leftover.jar"), b"jar".as_slice()) .await .unwrap(); - polyio::create_dir_all(stash.join("unpacked")).await.unwrap(); + polyio::create_dir_all(stash.join("unpacked")) + .await + .unwrap(); polyio::write(stash.join("unpacked").join("inner.txt"), b"x".as_slice()) .await .unwrap(); @@ -1978,7 +2879,7 @@ mod tests { #[test] fn the_global_notice_owns_up_to_its_reach() { - let (_, body) = removal_notice(&["bsl.zip".into()]); + let (_, body) = removal_notice(&["bsl.zip".into()], true); assert!(body.contains("every cluster"), "{body}"); assert!(!body.contains("'s folder"), "{body}"); @@ -1989,7 +2890,9 @@ mod tests { polyio::create_dir_all(root.path()).await.unwrap(); for file in present { - polyio::write(root.join(file), b"jar".as_slice()).await.unwrap(); + polyio::write(root.join(file), b"jar".as_slice()) + .await + .unwrap(); } root @@ -2008,6 +2911,141 @@ mod tests { ) } + #[test] + fn a_bundle_mod_the_user_added_their_own_copy_of_is_skipped() { + let versioned = |hash: &str, bundled: bool, id: &str, version: Option<&str>| ModCopy { + hash: hash.into(), + bundled, + mod_id: Some(JarModId { + id: id.into(), + version: version.map(Into::into), + }), + }; + let copy = |hash: &str, bundled: bool, mod_id: Option<&str>| match mod_id { + Some(id) => versioned(hash, bundled, id, None), + None => ModCopy { + hash: hash.into(), + bundled, + mod_id: None, + }, + }; + + let duplicates = shadowed_bundle_copies( + &[ + copy("bundle-argentum", true, Some("argentum")), + copy("user-argentum", false, Some("argentum")), + copy("bundle-sodium", true, Some("sodium")), + copy("bundle-unreadable", true, None), + copy("user-unreadable", false, None), + ], + true, + ); + + assert_eq!( + duplicates.shadowed, + HashSet::from(["bundle-argentum".to_owned()]) + ); + assert!(duplicates.outranked.is_empty()); + + let versioned_copies = [ + versioned("bundle-api", true, "fabric-api", Some("0.141.6+1.21.11")), + versioned("user-api", false, "fabric-api", Some("0.141.4+1.21.11")), + versioned("bundle-sodium", true, "sodium", Some("0.8.3")), + versioned("user-sodium", false, "sodium", Some("0.8.5")), + versioned("bundle-iris", true, "iris", Some("1.9.0")), + versioned("user-iris", false, "iris", Some("1.9.0")), + ]; + let duplicates = shadowed_bundle_copies(&versioned_copies, true); + + assert_eq!( + duplicates.shadowed, + HashSet::from(["bundle-sodium".to_owned(), "bundle-iris".to_owned()]) + ); + assert_eq!(duplicates.outranked, HashSet::from(["user-api".to_owned()])); + + let duplicates = shadowed_bundle_copies(&versioned_copies, false); + + assert_eq!( + duplicates.shadowed, + HashSet::from([ + "bundle-api".to_owned(), + "bundle-sodium".to_owned(), + "bundle-iris".to_owned() + ]) + ); + assert!(duplicates.outranked.is_empty()); + } + + #[test] + fn the_next_manifest_keeps_live_jars_and_the_launchers_leftovers() { + let previous = mods_manifest(&["leftover.jar"]); + let on_disk = vec![ + ("leftover.jar".to_owned(), "hash-leftover.jar".to_owned()), + ("live.jar".to_owned(), "live.jar-hash".to_owned()), + ("off.jar".to_owned(), "off.jar-hash".to_owned()), + ( + "unregistered.jar".to_owned(), + "unregistered-hash".to_owned(), + ), + ]; + + let next = next_mods_manifest( + &previous, + on_disk, + &[link("live.jar", true), link("off.jar", false)], + ); + + let paths: Vec<&str> = next.entries.iter().map(|e| e.path.as_str()).collect(); + assert_eq!( + paths, + vec![ + manifest::entry_path("mods", "leftover.jar").as_str(), + manifest::entry_path("mods", "live.jar").as_str(), + ], + "a switched-off jar on disk must stay out so putting it back reads as the user's doing" + ); + } + + #[test] + fn a_live_install_or_removal_during_a_pass_survives_its_save() { + let previous = mods_manifest(&["kept.jar", "removed.jar"]); + let fresh = mods_manifest(&["kept.jar", "installed.jar"]); + let current = mods_manifest(&["kept.jar", "removed.jar", "dropped-in.jar"]); + + let merged = merge_live_writes(current, &previous, &fresh); + + let paths: Vec<&str> = merged.entries.iter().map(|e| e.path.as_str()).collect(); + assert_eq!( + paths, + vec![ + manifest::entry_path("mods", "kept.jar").as_str(), + manifest::entry_path("mods", "dropped-in.jar").as_str(), + manifest::entry_path("mods", "installed.jar").as_str(), + ] + ); + } + + #[test] + fn only_enabled_links_under_the_same_name_and_hash_can_be_hand_removed() { + let manifest = mods_manifest(&["live.jar", "off.jar", "replaced.jar", "renamed.jar"]); + let mut live = link("live.jar", true); + live.hash = "hash-live.jar".into(); + let mut off = link("off.jar", false); + off.hash = "hash-off.jar".into(); + let mut updated = link("replaced.jar", true); + updated.hash = "hash-new".into(); + let mut renamed = link("renamed-2.jar", true); + renamed.hash = "hash-renamed.jar".into(); + + let kept = live_mod_entries(&manifest, &[live, off, updated, renamed]); + + let paths: Vec<&str> = kept.entries.iter().map(|e| e.path.as_str()).collect(); + assert_eq!( + paths, + vec![manifest::entry_path("mods", "live.jar").as_str()] + ); + } + #[tokio::test] async fn a_jar_the_user_deleted_is_reported_with_its_hash() { let dir = mods_scratch("hand_removed", &["kept.jar", "also_kept.jar"]).await; @@ -2038,7 +3076,11 @@ mod tests { let manifest = mods_manifest(&["a.jar", "b.jar"]); let missing = Path::new("definitely-not-a-directory-ÑŽ"); - assert!(hand_removed_content(missing, ContentType::Mod, Some(&manifest)).await.is_empty()); + assert!( + hand_removed_content(missing, ContentType::Mod, Some(&manifest)) + .await + .is_empty() + ); } #[tokio::test] @@ -2047,7 +3089,9 @@ mod tests { let manifest = mods_manifest(&["a.jar", "b.jar", "c.jar", "d.jar"]); assert!( - hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await.is_empty(), + hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)) + .await + .is_empty(), "an empty folder where everything was is not four deliberate deletions" ); @@ -2059,7 +3103,12 @@ mod tests { let dir = mods_scratch("small_clear", &[]).await; let manifest = mods_manifest(&["a.jar", "b.jar"]); - assert_eq!(hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)).await.len(), 2); + assert_eq!( + hand_removed_content(dir.path(), ContentType::Mod, Some(&manifest)) + .await + .len(), + 2 + ); std::fs::remove_dir_all(dir.path()).ok(); } @@ -2068,7 +3117,11 @@ mod tests { async fn a_first_launch_concludes_nothing() { let dir = mods_scratch("no_manifest", &[]).await; - assert!(hand_removed_content(dir.path(), ContentType::Mod, None).await.is_empty()); + assert!( + hand_removed_content(dir.path(), ContentType::Mod, None) + .await + .is_empty() + ); std::fs::remove_dir_all(dir.path()).ok(); } diff --git a/packages/oneclient_core/src/game/skin_server.rs b/packages/oneclient_core/src/game/skin_server.rs index b0cff223..afd07ce6 100644 --- a/packages/oneclient_core/src/game/skin_server.rs +++ b/packages/oneclient_core/src/game/skin_server.rs @@ -31,15 +31,15 @@ static SKIN_SERVER_PRIVATE_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvAI static SIGNING_KEY: OnceLock = OnceLock::new(); pub fn sign_sha1_with_rsa(data: &[u8]) -> Option { - use sha1::Digest; + use sha1_0_10::Digest; let key = SIGNING_KEY.get_or_init(|| { RsaPrivateKey::from_pkcs8_pem(SKIN_SERVER_PRIVATE_KEY_PEM) .expect("valid embedded private key") }); - let mut hasher = sha1::Sha1::new(); + let mut hasher = sha1_0_10::Sha1::new(); hasher.update(data); let digest = hasher.finalize(); - let signing_scheme = Pkcs1v15Sign::new::(); + let signing_scheme = Pkcs1v15Sign::new::(); let sig_bytes = key.sign(signing_scheme, &digest).ok()?; Some(base64::engine::general_purpose::STANDARD.encode(sig_bytes)) } diff --git a/packages/oneclient_core/src/game/tail.rs b/packages/oneclient_core/src/game/tail.rs index 5e5394f5..6fd1cb43 100644 --- a/packages/oneclient_core/src/game/tail.rs +++ b/packages/oneclient_core/src/game/tail.rs @@ -12,270 +12,292 @@ use oneclient_events::EventBus; const POLL_INTERVAL: Duration = Duration::from_millis(200); pub(crate) struct LogTail { - stop: Arc, - handle: tokio::task::JoinHandle<()>, + stop: Arc, + handle: tokio::task::JoinHandle<()>, } impl LogTail { - /// Stops only after one last pass the lines explaining why the game - /// exited are written moments before it does - pub(crate) async fn stop(self) { - self.stop.store(true, Ordering::Relaxed); - let _ = self.handle.await; - } + /// Stops only after one last pass the lines explaining why the game + /// exited are written moments before it does + pub(crate) async fn stop(self) { + self.stop.store(true, Ordering::Relaxed); + let _ = self.handle.await; + } } pub(crate) fn spawn_log_tail( - cluster_id: i64, - path: PathBuf, - events: EventBus, - recorder: Option, - crash_watch: CrashWatch, + cluster_id: i64, + path: PathBuf, + events: EventBus, + recorder: Option, + crash_watch: CrashWatch, ) -> LogTail { - let stop = Arc::new(AtomicBool::new(false)); - let flag = Arc::clone(&stop); + let stop = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&stop); - let handle = tokio::spawn(async move { - if let Err(err) = tail(cluster_id, &path, &events, recorder.as_ref(), &crash_watch, &flag).await { - tracing::warn!(cluster_id, path = %path.display(), error = %err, "game log tail stopped"); - } - }); + let handle = tokio::spawn(async move { + if let Err(err) = tail( + cluster_id, + &path, + &events, + recorder.as_ref(), + &crash_watch, + &flag, + ) + .await + { + tracing::warn!(cluster_id, path = %path.display(), error = %err, "game log tail stopped"); + } + }); - LogTail { stop, handle } + LogTail { stop, handle } } async fn tail( - cluster_id: i64, - path: &PathBuf, - events: &EventBus, - recorder: Option<&SessionRecorder>, - crash_watch: &CrashWatch, - stop: &AtomicBool, + cluster_id: i64, + path: &PathBuf, + events: &EventBus, + recorder: Option<&SessionRecorder>, + crash_watch: &CrashWatch, + stop: &AtomicBool, ) -> std::io::Result<()> { - // The game may not have created the file yet on a re-adopt so wait for it - // rather than giving up - let file = loop { - match tokio::fs::File::open(path).await { - Ok(file) => break file, - Err(_) if !stop.load(Ordering::Relaxed) => { - tokio::time::sleep(POLL_INTERVAL).await; - } - Err(err) => return Err(err), - } - }; + // The game may not have created the file yet on a re-adopt so wait for it + // rather than giving up + let file = loop { + match tokio::fs::File::open(path).await { + Ok(file) => break file, + Err(_) if !stop.load(Ordering::Relaxed) => { + tokio::time::sleep(POLL_INTERVAL).await; + } + Err(err) => return Err(err), + } + }; - let mut reader = BufReader::new(file); - let mut line = String::new(); - let mut offset = 0u64; + let mut reader = BufReader::new(file); + let mut line = String::new(); + let mut offset = 0u64; - loop { - line.clear(); - let read = reader.read_line(&mut line).await?; + loop { + line.clear(); + let read = reader.read_line(&mut line).await?; - if read == 0 { - // The read above returned EOF on a drained file so the final pass - // has already run by the time the flag is seen here - if stop.load(Ordering::Relaxed) { - return Ok(()); - } - // A relaunch truncates the log if the file shrank our offset is - // past the end and we would never read again - let len = polyio::stat(path).await.map(|m| m.len()).unwrap_or(offset); - if len < offset { - reader.seek(std::io::SeekFrom::Start(0)).await?; - offset = 0; - } - tokio::time::sleep(POLL_INTERVAL).await; - continue; - } + if read == 0 { + // The read above returned EOF on a drained file so the final pass + // has already run by the time the flag is seen here + if stop.load(Ordering::Relaxed) { + return Ok(()); + } + // A relaunch truncates the log if the file shrank our offset is + // past the end and we would never read again + let len = polyio::stat(path).await.map(|m| m.len()).unwrap_or(offset); + if len < offset { + reader.seek(std::io::SeekFrom::Start(0)).await?; + offset = 0; + } + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } - offset += read as u64; + offset += read as u64; - // A partial line means the game is mid-write wait for the newline - // rather than emitting half a line and re-emitting the rest later - if !line.ends_with('\n') { - // Unless the game is gone a process killed mid-write leaves a - // newline that never arrives hanging the caller of `stop()` - if stop.load(Ordering::Relaxed) { - emit(cluster_id, &line, events, recorder, crash_watch).await; - return Ok(()); - } - reader.seek(std::io::SeekFrom::Start(offset - read as u64)).await?; - offset -= read as u64; - tokio::time::sleep(POLL_INTERVAL).await; - continue; - } + // A partial line means the game is mid-write wait for the newline + // rather than emitting half a line and re-emitting the rest later + if !line.ends_with('\n') { + // Unless the game is gone a process killed mid-write leaves a + // newline that never arrives hanging the caller of `stop()` + if stop.load(Ordering::Relaxed) { + emit(cluster_id, &line, events, recorder, crash_watch).await; + return Ok(()); + } + reader + .seek(std::io::SeekFrom::Start(offset - read as u64)) + .await?; + offset -= read as u64; + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } - emit(cluster_id, &line, events, recorder, crash_watch).await; - } + emit(cluster_id, &line, events, recorder, crash_watch).await; + } } async fn emit( - cluster_id: i64, - line: &str, - events: &EventBus, - recorder: Option<&SessionRecorder>, - crash_watch: &CrashWatch, + cluster_id: i64, + line: &str, + events: &EventBus, + recorder: Option<&SessionRecorder>, + crash_watch: &CrashWatch, ) { - // Blank lines are kept so the console reads the way the log does - let text = line.trim_end_matches(['\n', '\r']).to_string(); - if let Some(recorder) = recorder { - recorder.observe(&text).await; - } - // Read on the way past rather than re-opening the log afterwards a crash - // can take the log with it - crash_watch.observe(&text); - events.game_log(cluster_id, text); + // Blank lines are kept so the console reads the way the log does + let text = oneclient_cluster::logs::censor(line.trim_end_matches(['\n', '\r'])).into_owned(); + if let Some(recorder) = recorder { + recorder.observe(&text).await; + } + // Read on the way past rather than re-opening the log afterwards a crash + // can take the log with it + crash_watch.observe(&text); + events.game_log(cluster_id, text); } #[cfg(test)] mod tests { - use super::*; - use tokio::io::AsyncWriteExt; - use tokio::sync::mpsc; + use super::*; + use tokio::io::AsyncWriteExt; + use tokio::sync::mpsc; - struct Harness { - dir: async_tempfile::TempDir, - rx: oneclient_events::EventReceiver, - events: EventBus, - } + struct Harness { + dir: async_tempfile::TempDir, + rx: oneclient_events::EventReceiver, + events: EventBus, + } - impl Harness { - async fn new() -> Self { - let (tx, rx) = mpsc::unbounded_channel(); - let dir = polyio::tempdir().await.expect("temp dir"); - Self { - dir, - rx, - events: EventBus::new(tx), - } - } + impl Harness { + async fn new() -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let dir = polyio::tempdir().await.expect("temp dir"); + Self { + dir, + rx, + events: EventBus::new(tx), + } + } - fn path(&self) -> PathBuf { - self.dir.dir_path().join("cluster-output.log") - } + fn path(&self) -> PathBuf { + self.dir.dir_path().join("cluster-output.log") + } - fn drain(&mut self) -> Vec { - let mut out = Vec::new(); - while let Ok(oneclient_events::Event::Game(oneclient_events::GameEvent::Log { line, .. })) = - self.rx.try_recv() - { - out.push(line); - } - out - } - } + fn drain(&mut self) -> Vec { + let mut out = Vec::new(); + while let Ok(oneclient_events::Event::Game(oneclient_events::GameEvent::Log { + line, + .. + })) = self.rx.try_recv() + { + out.push(line); + } + out + } + } - async fn settle() { - tokio::time::sleep(POLL_INTERVAL * 4).await; - } + async fn settle() { + tokio::time::sleep(POLL_INTERVAL * 4).await; + } - #[tokio::test] - async fn follows_lines_appended_after_start() { - let mut h = Harness::new().await; - let path = h.path(); - polyio::write(&path, "[12:00:00] first\n").await.unwrap(); + #[tokio::test] + async fn follows_lines_appended_after_start() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] first\n").await.unwrap(); - let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); - settle().await; + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; - let mut file = tokio::fs::OpenOptions::new() - .append(true) - .open(&path) - .await - .unwrap(); - file.write_all(b"[12:00:01] second\n").await.unwrap(); - file.flush().await.unwrap(); - settle().await; - tail.stop().await; + let mut file = tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await + .unwrap(); + file.write_all(b"[12:00:01] second\n").await.unwrap(); + file.flush().await.unwrap(); + settle().await; + tail.stop().await; - assert_eq!(h.drain(), vec!["[12:00:00] first", "[12:00:01] second"]); - } + assert_eq!(h.drain(), vec!["[12:00:00] first", "[12:00:01] second"]); + } - #[tokio::test] - async fn waits_for_a_file_that_does_not_exist_yet() { - let mut h = Harness::new().await; - let path = h.path(); + #[tokio::test] + async fn waits_for_a_file_that_does_not_exist_yet() { + let mut h = Harness::new().await; + let path = h.path(); - let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); - settle().await; - polyio::write(&path, "[12:00:00] late\n").await.unwrap(); - settle().await; - tail.stop().await; + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + polyio::write(&path, "[12:00:00] late\n").await.unwrap(); + settle().await; + tail.stop().await; - assert_eq!(h.drain(), vec!["[12:00:00] late"]); - } + assert_eq!(h.drain(), vec!["[12:00:00] late"]); + } - #[tokio::test] - async fn holds_back_a_partial_line_until_its_newline_lands() { - let mut h = Harness::new().await; - let path = h.path(); - polyio::write(&path, "[12:00:00] half").await.unwrap(); + #[tokio::test] + async fn holds_back_a_partial_line_until_its_newline_lands() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] half").await.unwrap(); - let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); - settle().await; - // A line mid-write must not be emitted or it would arrive twice - assert!(h.drain().is_empty()); + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + // A line mid-write must not be emitted or it would arrive twice + assert!(h.drain().is_empty()); - let mut file = tokio::fs::OpenOptions::new() - .append(true) - .open(&path) - .await - .unwrap(); - file.write_all(b" and half\n").await.unwrap(); - file.flush().await.unwrap(); - settle().await; - tail.stop().await; + let mut file = tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await + .unwrap(); + file.write_all(b" and half\n").await.unwrap(); + file.flush().await.unwrap(); + settle().await; + tail.stop().await; - assert_eq!(h.drain(), vec!["[12:00:00] half and half"]); - } + assert_eq!(h.drain(), vec!["[12:00:00] half and half"]); + } - #[tokio::test] - async fn recovers_when_the_log_is_truncated() { - let mut h = Harness::new().await; - let path = h.path(); - polyio::write(&path, "[12:00:00] old session line\n").await.unwrap(); + #[tokio::test] + async fn recovers_when_the_log_is_truncated() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] old session line\n") + .await + .unwrap(); - let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); - settle().await; - assert_eq!(h.drain(), vec!["[12:00:00] old session line"]); + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + assert_eq!(h.drain(), vec!["[12:00:00] old session line"]); - // A relaunch truncates the log the tail would otherwise sit forever at - // an offset past the new end - polyio::write(&path, "[13:00:00] new\n").await.unwrap(); - settle().await; - tail.stop().await; + // A relaunch truncates the log the tail would otherwise sit forever at + // an offset past the new end + polyio::write(&path, "[13:00:00] new\n").await.unwrap(); + settle().await; + tail.stop().await; - assert_eq!(h.drain(), vec!["[13:00:00] new"]); - } + assert_eq!(h.drain(), vec!["[13:00:00] new"]); + } - #[tokio::test] - async fn stops_even_when_the_last_line_has_no_newline() { - let mut h = Harness::new().await; - let path = h.path(); - // A process killed mid-write leaves a newline that never arrives - polyio::write(&path, "[12:00:00] cut off mid-writ").await.unwrap(); + #[tokio::test] + async fn stops_even_when_the_last_line_has_no_newline() { + let mut h = Harness::new().await; + let path = h.path(); + // A process killed mid-write leaves a newline that never arrives + polyio::write(&path, "[12:00:00] cut off mid-writ") + .await + .unwrap(); - let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); - settle().await; + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; - // Waiting for that newline would hang the exit path forever - let stopped = tokio::time::timeout(Duration::from_secs(5), tail.stop()).await; - assert!(stopped.is_ok(), "tail.stop() hung on an unterminated final line"); - assert_eq!(h.drain(), vec!["[12:00:00] cut off mid-writ"]); - } + // Waiting for that newline would hang the exit path forever + let stopped = tokio::time::timeout(Duration::from_secs(5), tail.stop()).await; + assert!( + stopped.is_ok(), + "tail.stop() hung on an unterminated final line" + ); + assert_eq!(h.drain(), vec!["[12:00:00] cut off mid-writ"]); + } - #[tokio::test] - async fn emits_the_final_lines_written_just_before_exit() { - let mut h = Harness::new().await; - let path = h.path(); - polyio::write(&path, "[12:00:00] Stopping!\n").await.unwrap(); + #[tokio::test] + async fn emits_the_final_lines_written_just_before_exit() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] Stopping!\n") + .await + .unwrap(); - // Stopping immediately as the exit path does must still flush the - // reason the game exited - let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); - tail.stop().await; + // Stopping immediately as the exit path does must still flush the + // reason the game exited + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + tail.stop().await; - assert_eq!(h.drain(), vec!["[12:00:00] Stopping!"]); - } + assert_eq!(h.drain(), vec!["[12:00:00] Stopping!"]); + } } diff --git a/packages/oneclient_core/src/images/mod.rs b/packages/oneclient_core/src/images/mod.rs index 71d5370b..affc3de6 100644 --- a/packages/oneclient_core/src/images/mod.rs +++ b/packages/oneclient_core/src/images/mod.rs @@ -5,14 +5,18 @@ use bytes::Bytes; use reqwest::Method; use tokio::sync::Mutex; -use polyio::sha1_bytes; -use oneclient_net::RequestError; +use crate::{LauncherError, LauncherResult}; use oneclient_common::paths; use oneclient_net::RequestClient; -use crate::{LauncherError, LauncherResult}; +use oneclient_net::RequestError; +use oneclient_net::{EtagPolicy, fetch_cached}; +use polyio::sha1_bytes; pub const DEFAULT_IMAGE_EDGE: u32 = 1200; +pub const PREVIEW_IMAGE_EDGE: u32 = 512; +pub const BACKGROUND_IMAGE_EDGE: u32 = 2560; + /// Image urls come from untrusted remote descriptions, so cap what is fetched and decoded. const MAX_IMAGE_BYTES: usize = 8 * 1024 * 1024; @@ -37,6 +41,15 @@ impl MemoryCache { Some(hit) } + fn remove(&mut self, key: &str) { + if let Some(dropped) = self.entries.remove(key) { + self.bytes -= dropped.len(); + if let Some(at) = self.order.iter().position(|k| k == key) { + self.order.remove(at); + } + } + } + fn insert(&mut self, key: String, value: Bytes) { // A single oversized image must not evict the entire cache to store itself. if value.len() > MEMORY_CACHE_BUDGET { @@ -115,10 +128,127 @@ impl ImageCacheStore { Ok(bytes) } + #[tracing::instrument(level = "debug", skip(self, net))] + pub async fn prefetch( + &self, + net: &RequestClient, + url: &str, + edges: &[u32], + ) -> LauncherResult<()> { + let mut missing = Vec::new(); + for edge in edges { + let path = Self::disk_path(url, *edge)?; + if !tokio::fs::try_exists(&path).await.unwrap_or(false) { + missing.push((*edge, path)); + } + } + + if missing.is_empty() { + return Ok(()); + } + + let raw = match paths::local_image_path(url) { + Some(source) => Bytes::from(polyio::read(&source).await?), + None => download(net, url).await?, + }; + + let edges: Vec = missing.iter().map(|(edge, _)| *edge).collect(); + let sized = downscale_each_if_oversized(raw, edges).await; + + for ((_, path), bytes) in missing.into_iter().zip(sized) { + if let Some(parent) = path.parent() { + polyio::create_dir_all(parent).await?; + } + if let Err(err) = polyio::write(&path, &bytes).await { + tracing::warn!("failed to persist prefetched image for {url}: {err}"); + } + } + + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self, net))] + pub async fn refresh( + &self, + net: &RequestClient, + url: &str, + edges: &[u32], + ) -> LauncherResult<()> { + ensure_fetchable(net, url)?; + + let source = Self::source_path(url)?; + if let Some(parent) = source.parent() { + polyio::create_dir_all(parent).await?; + } + + let Some(fetched) = fetch_cached(net, url, &source, EtagPolicy::CommitNow).await? else { + return self.prefetch(net, url, edges).await; + }; + + if fetched.bytes.len() > MAX_IMAGE_BYTES { + return Err(refused(url, "body is too large")); + } + + let mut stale = Vec::new(); + for edge in edges { + let path = Self::disk_path(url, *edge)?; + if fetched.changed || !tokio::fs::try_exists(&path).await.unwrap_or(false) { + stale.push((*edge, path)); + } + } + + if stale.is_empty() { + return Ok(()); + } + + let raw = Bytes::from(fetched.bytes); + let edges: Vec = stale.iter().map(|(edge, _)| *edge).collect(); + let sized = downscale_each_if_oversized(raw, edges).await; + + for ((edge, path), bytes) in stale.into_iter().zip(sized) { + if let Some(parent) = path.parent() { + polyio::create_dir_all(parent).await?; + } + if let Err(err) = polyio::write(&path, &bytes).await { + tracing::warn!("failed to persist refreshed image for {url}: {err}"); + continue; + } + self.memory.lock().await.remove(&Self::mem_key(url, edge)); + } + + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self, net))] + pub async fn ensure_on_disk( + &self, + net: &RequestClient, + url: &str, + max_edge: u32, + ) -> LauncherResult { + let path = Self::disk_path(url, max_edge)?; + if tokio::fs::try_exists(&path).await.unwrap_or(false) { + return Ok(path); + } + + self.prefetch(net, url, &[max_edge]).await?; + + if tokio::fs::try_exists(&path).await.unwrap_or(false) { + Ok(path) + } else { + Err(refused(url, "the image could not be cached on disk")) + } + } + fn mem_key(url: &str, max_edge: u32) -> String { format!("{max_edge}|{url}") } + fn source_path(url: &str) -> LauncherResult { + let name = format!("{}.source", sha1_bytes(url.as_bytes())); + Ok(paths::images_cache_dir()?.join(name)) + } + fn disk_path(url: &str, max_edge: u32) -> LauncherResult { let mut name = sha1_bytes(url.as_bytes()); name.push('_'); @@ -135,6 +265,10 @@ static DECODE_LIMIT: std::sync::LazyLock = std::sync::LazyLock::new(|| tokio::sync::Semaphore::new(2)); async fn downscale_if_oversized(bytes: Bytes, max_edge: u32) -> Bytes { + if max_edge == 0 { + return bytes; + } + let _permit = DECODE_LIMIT.acquire().await; let candidate = bytes.clone(); match tokio::task::spawn_blocking(move || downscale(&candidate, max_edge)).await { @@ -148,7 +282,7 @@ async fn downscale_if_oversized(bytes: Bytes, max_edge: u32) -> Bytes { const MAX_DECODE_EDGE: u32 = 8192; const MAX_DECODE_ALLOC: u64 = 128 * 1024 * 1024; -fn downscale(bytes: &[u8], max_edge: u32) -> Option { +fn decode_limited(bytes: &[u8]) -> Option { let mut limits = image::Limits::default(); limits.max_image_width = Some(MAX_DECODE_EDGE); limits.max_image_height = Some(MAX_DECODE_EDGE); @@ -157,12 +291,15 @@ fn downscale(bytes: &[u8], max_edge: u32) -> Option { let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)); reader.limits(limits); - let img = reader + reader .with_guessed_format() .ok()? .decode() .inspect_err(|err| tracing::warn!("refused to decode an image: {err}")) - .ok()?; + .ok() +} + +fn encode_downscaled(img: &image::DynamicImage, max_edge: u32) -> Option { if img.width().max(img.height()) <= max_edge { return None; } @@ -172,7 +309,9 @@ fn downscale(bytes: &[u8], max_edge: u32) -> Option { let mut out = Vec::new(); let mut cursor = std::io::Cursor::new(&mut out); if resized.color().has_alpha() { - resized.write_to(&mut cursor, image::ImageFormat::Png).ok()?; + resized + .write_to(&mut cursor, image::ImageFormat::Png) + .ok()?; } else { image::codecs::jpeg::JpegEncoder::new_with_quality(&mut cursor, 85) .encode_image(&resized) @@ -182,17 +321,56 @@ fn downscale(bytes: &[u8], max_edge: u32) -> Option { Some(Bytes::from(out)) } +fn downscale(bytes: &[u8], max_edge: u32) -> Option { + encode_downscaled(&decode_limited(bytes)?, max_edge) +} + +fn downscale_each(bytes: &[u8], edges: &[u32]) -> Vec> { + let Some(img) = decode_limited(bytes) else { + return vec![None; edges.len()]; + }; + + edges + .iter() + .map(|edge| encode_downscaled(&img, *edge)) + .collect() +} + +async fn downscale_each_if_oversized(bytes: Bytes, edges: Vec) -> Vec { + let _permit = DECODE_LIMIT.acquire().await; + let candidate = bytes.clone(); + let count = edges.len(); + + let resized = tokio::task::spawn_blocking(move || downscale_each(&candidate, &edges)) + .await + .unwrap_or_else(|_| vec![None; count]); + + resized + .into_iter() + .map(|smaller| smaller.unwrap_or_else(|| bytes.clone())) + .collect() +} + #[tracing::instrument(level = "debug", skip(net))] -async fn download(net: &RequestClient, url: &str) -> LauncherResult { +fn ensure_fetchable(net: &RequestClient, url: &str) -> LauncherResult { let parsed: reqwest::Url = url.parse().map_err(RequestError::from)?; if !matches!(parsed.scheme(), "http" | "https") { return Err(refused(url, "unsupported scheme")); } - if !is_public_host(&parsed) { + // SSRF guard: refuse non-public hosts unless the user deliberately + // configured that host as the custom API endpoint / meta URL base, + // which is what makes `localhost` dev backends reachable + if !is_public_host(&parsed) && !net.config().allows_host(&parsed) { return Err(refused(url, "host is not public")); } + Ok(parsed) +} + +async fn download(net: &RequestClient, url: &str) -> LauncherResult { + let parsed = ensure_fetchable(net, url)?; + let request = reqwest::Request::new(Method::GET, parsed); let mut res = net.send(request).await?; diff --git a/packages/oneclient_core/src/java_store.rs b/packages/oneclient_core/src/java_store.rs index eebcb6a9..31f2f0c5 100644 --- a/packages/oneclient_core/src/java_store.rs +++ b/packages/oneclient_core/src/java_store.rs @@ -7,73 +7,73 @@ use oneclient_db::{DbPool, dao, models::JavaVersionRow}; use oneclient_java::{JavaRuntime, JavaStore, JavaVendor, StoreError, StoreResult}; pub struct SqlJavaStore { - pool: DbPool, + pool: DbPool, } impl SqlJavaStore { - #[must_use] - pub fn new(pool: DbPool) -> Self { - Self { pool } - } + #[must_use] + pub fn new(pool: DbPool) -> Self { + Self { pool } + } } fn row_to_runtime(row: JavaVersionRow) -> JavaRuntime { - JavaRuntime { - absolute_path: row.absolute_path, - major: row.major as u32, - version: row.version, - vendor: JavaVendor::from_str(&row.vendor).unwrap_or(JavaVendor::Other(row.vendor)), - os_arch: row.os_arch, - is_jdk: row.is_jdk, - probe_version: row.probe_version.try_into().unwrap_or(0), - } + JavaRuntime { + absolute_path: row.absolute_path, + major: row.major as u32, + version: row.version, + vendor: JavaVendor::from_str(&row.vendor).unwrap_or(JavaVendor::Other(row.vendor)), + os_arch: row.os_arch, + is_jdk: row.is_jdk, + probe_version: row.probe_version.try_into().unwrap_or(0), + } } #[async_trait::async_trait] impl JavaStore for SqlJavaStore { - async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult { - let row = dao::java::insert( - &self.pool, - &runtime.absolute_path, - runtime.major, - &runtime.version, - &runtime.vendor.to_string(), - &runtime.os_arch, - runtime.is_jdk, - runtime.probe_version, - ) - .await - .map_err(StoreError::new)?; + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult { + let row = dao::java::insert( + &self.pool, + &runtime.absolute_path, + runtime.major, + &runtime.version, + &runtime.vendor.to_string(), + &runtime.os_arch, + runtime.is_jdk, + runtime.probe_version, + ) + .await + .map_err(StoreError::new)?; - Ok(row_to_runtime(row)) - } + Ok(row_to_runtime(row)) + } - async fn get_by_path(&self, absolute_path: &str) -> StoreResult> { - Ok(dao::java::get_by_path(&self.pool, absolute_path) - .await - .map_err(StoreError::new)? - .map(row_to_runtime)) - } + async fn get_by_path(&self, absolute_path: &str) -> StoreResult> { + Ok(dao::java::get_by_path(&self.pool, absolute_path) + .await + .map_err(StoreError::new)? + .map(row_to_runtime)) + } - async fn latest_by_major(&self, major: u32) -> StoreResult> { - Ok(dao::java::get_latest_by_major(&self.pool, major) - .await - .map_err(StoreError::new)? - .map(row_to_runtime)) - } + async fn latest_by_major(&self, major: u32) -> StoreResult> { + Ok(dao::java::get_latest_by_major(&self.pool, major) + .await + .map_err(StoreError::new)? + .map(row_to_runtime)) + } - async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()> { - dao::java::delete_by_path(&self.pool, absolute_path) - .await - .map_err(StoreError::new) - } + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()> { + dao::java::delete_by_path(&self.pool, absolute_path) + .await + .map_err(StoreError::new) + } - async fn list(&self) -> StoreResult> { - Ok(dao::java::list_all(&self.pool) - .await - .map_err(StoreError::new)? - .into_iter() - .map(row_to_runtime) - .collect()) - } + async fn list(&self) -> StoreResult> { + Ok(dao::java::list_all(&self.pool) + .await + .map_err(StoreError::new)? + .into_iter() + .map(row_to_runtime) + .collect()) + } } diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs index a364cbc7..2f597408 100644 --- a/packages/oneclient_core/src/lib.rs +++ b/packages/oneclient_core/src/lib.rs @@ -5,6 +5,7 @@ pub mod dev; pub mod changelog; pub mod clusters; +pub mod disable_warnings; mod error; pub mod game; pub mod images; @@ -15,61 +16,71 @@ pub mod recovery; pub mod relocate; pub mod reporting; pub mod settings; +pub mod simulate; +mod state; pub mod storage; pub mod tos; -mod state; -pub mod simulate; pub mod verify; pub mod versions; -pub use oneclient_content::bundles::{ - apply_bundle_updates, apply_bundle_updates_with, check_bundle_updates, - cluster_has_bundle_content, effective_enabled, - install_bundle, - install_cluster_bundles, - install_package_from_bundle, list_cluster_bundle_overrides, - pending_optional_mods, resolve_optional_mods, skip_optional_mods, PendingOptionalMod, - set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override, - set_bundle_package_overrides, - ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile, - BundleFileKind, BundleManifest, BundleOptionalPackage, BundlesManager, BundleUpdateCheckResult, - BundleWithUpdateStatus, FileUpdateStatus, get_bundles_with_update_status, - remove_artifact_from_cluster, set_artifact_enabled_to, -}; -pub use changelog::{fetch_changelog, parse_changelog, ChangelogGroup}; -pub use tos::{fetch_terms, TermsDocument}; -pub use oneclient_discord::{DiscordRpc, Presence}; +pub use changelog::{ChangelogEntry, fetch_changelog}; pub use clusters::{ Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions, - ensure_from_bundles, ensure_from_versions, estimate_cluster_download, required_java_major, + apply_bundle_java_override, ensure_from_bundles, ensure_from_versions, + estimate_cluster_download, required_java_major, }; +pub use disable_warnings::{DisableWarnings, fetch_disable_warnings}; pub use error::{LauncherError, LauncherResult, SentryExclusion}; -pub use game::{GameError, LaunchedGame, get_loader_versions, launch_cluster}; +pub use game::{ + GameError, GameVersionInfo, GameVersionKind, LaunchedGame, get_loader_versions, + get_loaders_for_version, get_version_ids, get_versions_for_loader, launch_cluster, +}; pub use images::ImageCacheStore; +pub use migration::{ + ImportTarget, MigrationDetection, MigrationSource, SourceInstance, detect as detect_migration, + import_game_dir as import_migration_game_dir, +}; pub use oneclient_cluster::logs::{ LogFileInfo, LogKind, LogLevel, LogLine, LogsError, MclogsUploadResponse, ReadOptions, delete_log_at, list_cluster_logs, read_log_at, upload_log_at, }; -pub use oneclient_mc::{McError as MetadataError, MetadataStore}; -pub use migration::{ - detect as detect_migration, import_game_dir as import_migration_game_dir, ImportTarget, - MigrationDetection, MigrationSource, SourceInstance, -}; pub use oneclient_cluster::screenshots::{ - ScreenshotInfo, ScreenshotsError, delete_screenshot, list_cluster_screenshots, load_screenshot, + ScreenshotInfo, ScreenshotsError, delete_screenshot, list_cluster_screenshots, + load_picked_image, load_screenshot, +}; +pub use oneclient_cluster::worlds::{ + DataPackInfo, LEVEL_DAT, PackIcon, WORLD_ICON, WorldInfo, WorldsError, add_world_datapacks, + delete_world, delete_world_datapack, list_cluster_worlds, list_world_datapacks, world_size, +}; +pub use oneclient_content::bundles::{ + ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile, BundleFileKind, + BundleFileType, BundleManifest, BundleOptionalPackage, BundleUpdateCheckResult, + BundleWithUpdateStatus, BundlesManager, FileUpdateStatus, PendingOptionalMod, + apply_bundle_updates, apply_bundle_updates_with, check_bundle_updates, + cluster_has_bundle_content, clusters_sharing_artifact, delete_artifact, effective_enabled, + get_bundles_with_update_status, install_bundle, install_cluster_bundles, + install_package_from_bundle, list_cluster_bundle_overrides, pending_optional_mods, + remove_artifact_from_cluster, resolve_optional_mods, set_artifact_enabled_to, + set_bundle_package_enabled, set_bundle_package_opt_in, set_bundle_package_override, + set_bundle_package_overrides, skip_optional_mods, }; pub use oneclient_content::packages::LinkedArtifactInfo; -pub use oneclient_db::models::SeenStatus; pub use oneclient_content::packages::updates::{ BrowserPackageUpdate, BrowserUpdateCheck, apply_browser_package_update, - cached_browser_package_updates, check_browser_package_updates, - refresh_browser_package_updates, skip_browser_package_update, + cached_browser_package_updates, check_browser_package_updates, refresh_browser_package_updates, + skip_browser_package_update, }; +pub use oneclient_db::models::SeenStatus; +pub use oneclient_discord::{DiscordRpc, Presence}; +pub use oneclient_mc::{McError as MetadataError, MetadataStore}; pub use settings::ProfileUpdate; -pub use verify::{ClusterVerifyReport, verify_cluster_files}; pub use state::LauncherServices; pub use state::LauncherState; pub use state::run_startup_tasks; +pub use state::shutdown; +pub use tos::{TermsDocument, fetch_terms}; +pub use verify::{ClusterVerifyReport, verify_cluster_files}; pub use versions::{ - RemoteMigration, VersionMetadata, VersionsManager, VersionsManifest, resolve_migration_chain, + ReleaseTarget, RemoteMigration, VersionArts, VersionMetadata, VersionsManager, + VersionsManifest, resolve_migration_chain, }; diff --git a/packages/oneclient_core/src/logger.rs b/packages/oneclient_core/src/logger.rs index 59900805..c858b177 100644 --- a/packages/oneclient_core/src/logger.rs +++ b/packages/oneclient_core/src/logger.rs @@ -69,6 +69,7 @@ const UI_FLOOD_TARGETS: &[&str] = &[ "freya_core::runner", "freya_core::tree", "freya_winit::renderer", + "freya::metrics", "ragnarok::executor", "ragnarok::nodes_state", "torin::torin", @@ -89,10 +90,7 @@ fn sentry_event_filter(metadata: &tracing::Metadata<'_>) -> sentry_tracing::Even } /// `None` when the target is one of ours, meaning the default filter decides -fn dependency_filter( - level: tracing::Level, - target: &str, -) -> Option { +fn dependency_filter(level: tracing::Level, target: &str) -> Option { if is_app_target(target) { return None; } @@ -217,7 +215,10 @@ pub fn init_filtered(filter: impl FnOnce() -> String) -> LauncherResult<()> { let logs_dir = oneclient_common::paths::logs_dir()?; std::fs::create_dir_all(&logs_dir)?; - let log_path = logs_dir.join(format!("{}.log", chrono::Local::now().to_rfc3339().replace(':', "-"))); + let log_path = logs_dir.join(format!( + "{}.log", + chrono::Local::now().to_rfc3339().replace(':', "-") + )); let file = std::fs::OpenOptions::new() .create(true) diff --git a/packages/oneclient_core/src/migration/mod.rs b/packages/oneclient_core/src/migration/mod.rs index f8d1f770..9014898c 100644 --- a/packages/oneclient_core/src/migration/mod.rs +++ b/packages/oneclient_core/src/migration/mod.rs @@ -44,9 +44,10 @@ pub struct SourceInstance { pub instance_id: i64, pub folder_name: String, pub mc_version: String, - /// Set for "migrated" clusters (e.g. 26.1 fabric -> 26.1.2 fabric) + /// Set for "migrated" clusters (e.g. 26.1 fabric -> 26.1.2 fabric) pub target_mc_version: Option, pub mc_loader: GameLoader, + pub target_mc_loader: Option, /// Empty when the source has no category concept pub categories: Vec, pub has_game_dir: bool, @@ -62,7 +63,14 @@ pub struct MigrationDetection { impl SourceInstance { #[must_use] pub fn import_version(&self) -> &str { - self.target_mc_version.as_deref().unwrap_or(&self.mc_version) + self.target_mc_version + .as_deref() + .unwrap_or(&self.mc_version) + } + + #[must_use] + pub fn import_loader(&self) -> GameLoader { + self.target_mc_loader.unwrap_or(self.mc_loader) } } @@ -124,6 +132,7 @@ mod tests { mc_version: "26.1".to_string(), target_mc_version: None, mc_loader: GameLoader::Fabric, + target_mc_loader: None, categories: Vec::new(), has_game_dir: true, }; @@ -133,6 +142,24 @@ mod tests { assert_eq!(instance.import_version(), "26.1.2"); } + #[test] + fn import_loader_prefers_resolved_target() { + let mut instance = SourceInstance { + instance_id: 1, + folder_name: "1.20.1 Forge".to_string(), + mc_version: "1.20.1".to_string(), + target_mc_version: None, + mc_loader: GameLoader::Forge, + target_mc_loader: None, + categories: Vec::new(), + has_game_dir: true, + }; + assert_eq!(instance.import_loader(), GameLoader::Forge); + + instance.target_mc_loader = Some(GameLoader::NeoForge); + assert_eq!(instance.import_loader(), GameLoader::NeoForge); + } + #[test] fn ids_round_trip() { for source in MigrationSource::ALL.iter().copied() { diff --git a/packages/oneclient_core/src/migration/oneclient_v1.rs b/packages/oneclient_core/src/migration/oneclient_v1.rs index 4da642d6..2071b2ee 100644 --- a/packages/oneclient_core/src/migration/oneclient_v1.rs +++ b/packages/oneclient_core/src/migration/oneclient_v1.rs @@ -99,6 +99,7 @@ async fn detect_inner(root: &Path, db_path: &Path) -> LauncherResult LauncherResult> { mc_version: String::new(), target_mc_version: None, mc_loader: GameLoader::Vanilla, + target_mc_loader: None, categories: Vec::new(), has_game_dir: true, }], @@ -129,54 +130,6 @@ pub async fn import_game_dir( mod tests { use super::*; - fn excluded(name: &str) -> bool { - IMPORT_EXCLUDE_TOP - .iter() - .any(|e| e.eq_ignore_ascii_case(name)) - } - - #[test] - fn excludes_package_and_launcher_managed_dirs() { - for name in [ - "mods", - "resourcepacks", - "shaderpacks", - "datapacks", - "versions", - "libraries", - "assets", - "logs", - ] { - assert!(excluded(name), "{name} should be excluded"); - } - } - - #[test] - fn excludes_mojang_credentials() { - assert!(excluded("launcher_accounts.json")); - assert!(excluded("launcher_msa_credentials.bin")); - } - - #[test] - fn excludes_third_party_launcher_data() { - for name in ["cheatbreaker_accounts.json", "feather-mods", "jre"] { - assert!(excluded(name), "{name} should be excluded"); - } - } - - #[test] - fn keeps_user_data() { - for name in [ - "saves", - "config", - "options.txt", - "screenshots", - "servers.dat", - ] { - assert!(!excluded(name), "{name} must not be excluded"); - } - } - #[tokio::test] async fn import_copies_user_data_and_skips_the_rest() { let src = polyio::tempdir().await.expect("src dir"); @@ -188,6 +141,7 @@ mod tests { "servers.dat", "launcher_accounts.json", "launcher_msa_credentials.bin", + "cheatbreaker_accounts.json", ] { polyio::write(src.join(file), b"x".to_vec()).await.unwrap(); } @@ -198,6 +152,9 @@ mod tests { ("mods", "sodium.jar"), ("resourcepacks", "faithful.zip"), ("shaderpacks", "bsl.zip"), + ("datapacks", "pack.zip"), + ("feather-mods", "mod.jar"), + ("jre", "bin/java"), ("versions", "1.21.1/1.21.1.jar"), ("libraries", "com/foo/foo.jar"), ("assets", "objects/ab/abcdef"), @@ -210,7 +167,9 @@ mod tests { polyio::write(path, b"x".to_vec()).await.unwrap(); } - polyio::copy_dir(src, dst, IMPORT_EXCLUDE_TOP).await.unwrap(); + polyio::copy_dir(src, dst, IMPORT_EXCLUDE_TOP) + .await + .unwrap(); for kept in [ "options.txt", @@ -225,12 +184,16 @@ mod tests { "mods", "resourcepacks", "shaderpacks", + "datapacks", "versions", "libraries", "assets", "logs", "launcher_accounts.json", "launcher_msa_credentials.bin", + "cheatbreaker_accounts.json", + "feather-mods", + "jre", ] { assert!( !dst.join(skipped).exists(), diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs index 9c712a26..34334df4 100644 --- a/packages/oneclient_core/src/recovery.rs +++ b/packages/oneclient_core/src/recovery.rs @@ -1,424 +1,564 @@ use std::path::{Path, PathBuf}; -use oneclient_db::dao::{ - artifact as artifact_dao, cluster as cluster_dao, cluster_bundle as cluster_bundle_dao, -}; -use oneclient_db::models::NewCluster; use oneclient_db::DbPool; +use oneclient_db::dao::{ + artifact as artifact_dao, cluster as cluster_dao, cluster_bundle as cluster_bundle_dao, +}; +use oneclient_db::models::{ClusterKind, NewCluster}; use strum::IntoEnumIterator; +use crate::LauncherResult; use crate::clusters::ClusterStage; -use oneclient_content::packages::PackageStore; -use polyio::sha1_file; +use crate::state::LauncherState; +use oneclient_cluster::encode_tags; +use oneclient_cluster::profiles::create_profile_from_global; use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; use oneclient_common::paths; -use oneclient_cluster::profiles::create_profile_from_global; -use crate::state::LauncherState; use oneclient_common::version::parse_mc_version; -use crate::LauncherResult; +use oneclient_content::packages::PackageStore; +use polyio::sha1_file; const FILE_CONTENT_TYPES: [ContentType; 4] = [ - ContentType::Mod, - ContentType::ResourcePack, - ContentType::Shader, - ContentType::DataPack, + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, ]; #[derive(Debug, Default, Clone, Copy)] pub struct RecoveryReport { - pub adopted_clusters: usize, - pub indexed_artifacts: usize, - pub relinked_files: usize, + pub adopted_clusters: usize, + pub indexed_artifacts: usize, + pub relinked_files: usize, } impl RecoveryReport { - pub fn did_recover(&self) -> bool { - self.adopted_clusters > 0 - } + pub fn did_recover(&self) -> bool { + self.adopted_clusters > 0 + } } #[tracing::instrument(skip(state))] pub async fn reconstruct_from_disk(state: &LauncherState) -> LauncherResult { - let mut report = RecoveryReport::default(); + let mut report = RecoveryReport::default(); - // Held across the whole scan+adopt so a concurrent cluster create (folder - // written before its DB row commits) isn't misclassified as an orphan - let _guard = state.clusters.provisioning_guard().await; + // Held across the whole scan+adopt so a concurrent cluster create (folder + // written before its DB row commits) isn't misclassified as an orphan + let _guard = state.clusters.provisioning_guard().await; - let clusters_dir = paths::clusters_dir()?; - let orphans = orphan_cluster_folders(&state.services.db, &clusters_dir).await?; - if orphans.is_empty() { - return Ok(report); - } + let clusters_dir = paths::clusters_dir()?; + let orphans = orphan_cluster_folders(&state.services.db, &clusters_dir).await?; + if orphans.is_empty() { + return Ok(report); + } - tracing::info!( - orphan_folders = orphans.len(), - "user_data.db appears reset; reconstructing from disk" - ); + tracing::info!( + orphan_folders = orphans.len(), + "user_data.db appears reset; reconstructing from disk" + ); - if let Err(err) = state.java.rescan().await { - tracing::warn!("java rescan during recovery failed: {err:#}"); - } + if let Err(err) = state.java.rescan().await { + tracing::warn!("java rescan during recovery failed: {err:#}"); + } - report.indexed_artifacts = rebuild_artifact_cache(&state.services.db).await?; + report.indexed_artifacts = rebuild_artifact_cache(&state.services.db).await?; - for folder in orphans { - match adopt_cluster(state, &folder, &mut report).await { - Ok(()) => {} - Err(err) => { - tracing::warn!(folder = %folder, "failed to adopt cluster folder: {err:#}"); - } - } - } + for folder in orphans { + match adopt_cluster(state, &folder, &mut report).await { + Ok(()) => {} + Err(err) => { + tracing::warn!(folder = %folder, "failed to adopt cluster folder: {err:#}"); + } + } + } - tracing::info!( - adopted = report.adopted_clusters, - artifacts = report.indexed_artifacts, - files = report.relinked_files, - "database reconstruction complete" - ); + tracing::info!( + adopted = report.adopted_clusters, + artifacts = report.indexed_artifacts, + files = report.relinked_files, + "database reconstruction complete" + ); - Ok(report) + Ok(report) } #[tracing::instrument(level = "debug", skip(pool))] -async fn orphan_cluster_folders( - pool: &DbPool, - clusters_dir: &Path, -) -> LauncherResult> { - let mut orphans = Vec::new(); - for name in list_dir_names(clusters_dir).await? { - if cluster_dao::get_by_folder_name(pool, &name).await?.is_none() { - orphans.push(name); - } - } - Ok(orphans) +async fn orphan_cluster_folders(pool: &DbPool, clusters_dir: &Path) -> LauncherResult> { + let mut orphans = Vec::new(); + for name in list_dir_names(clusters_dir).await? { + if cluster_dao::get_by_folder_name(pool, &name) + .await? + .is_none() + { + orphans.push(name); + } + } + Ok(orphans) } #[tracing::instrument(level = "debug", skip(pool))] async fn rebuild_artifact_cache(pool: &DbPool) -> LauncherResult { - let cache_root = paths::packages_cache_dir()?; - if !cache_root.exists() { - return Ok(0); - } + let cache_root = paths::packages_cache_dir()?; + if !cache_root.exists() { + return Ok(0); + } - let launcher_dir = paths::data_dir()?; - let mut indexed = 0; + let launcher_dir = paths::data_dir()?; + let mut indexed = 0; - for content_name in list_dir_names(&cache_root).await? { - let Some(content_type) = ContentType::from_folder_name(&content_name) else { - continue; - }; - let content_dir = cache_root.join(&content_name); + for content_name in list_dir_names(&cache_root).await? { + let Some(content_type) = ContentType::from_folder_name(&content_name) else { + continue; + }; + let content_dir = cache_root.join(&content_name); - for provider_name in list_dir_names(&content_dir).await? { - let Some(provider) = provider_from_dir_name(&provider_name) else { - continue; - }; - let provider_dir = content_dir.join(&provider_name); + for provider_name in list_dir_names(&content_dir).await? { + let Some(provider) = provider_from_dir_name(&provider_name) else { + continue; + }; + let provider_dir = content_dir.join(&provider_name); - for project_id in list_dir_names(&provider_dir).await? { - let project_dir = provider_dir.join(&project_id); + for project_id in list_dir_names(&provider_dir).await? { + let project_dir = provider_dir.join(&project_id); - for version_id in list_dir_names(&project_dir).await? { - let version_dir = project_dir.join(&version_id); + for version_id in list_dir_names(&project_dir).await? { + let version_dir = project_dir.join(&version_id); - for file in list_files(&version_dir).await? { - if let Err(err) = index_cache_file( - pool, - launcher_dir, - content_type, - provider, - &project_id, - &version_id, - &file, - ) - .await - { - tracing::warn!(path = %file.display(), "failed to index cached file: {err:#}"); - continue; - } - indexed += 1; - } - } - } - } - } + for file in list_files(&version_dir).await? { + if let Err(err) = index_cache_file( + pool, + launcher_dir, + content_type, + provider, + &project_id, + &version_id, + &file, + ) + .await + { + tracing::warn!(path = %file.display(), "failed to index cached file: {err:#}"); + continue; + } + indexed += 1; + } + } + } + } + } - Ok(indexed) + Ok(indexed) } #[allow(clippy::too_many_arguments)] #[tracing::instrument(level = "debug", skip(pool, launcher_dir, file))] async fn index_cache_file( - pool: &DbPool, - launcher_dir: &Path, - content_type: ContentType, - provider: ProviderId, - project_id: &str, - version_id: &str, - file: &Path, + pool: &DbPool, + launcher_dir: &Path, + content_type: ContentType, + provider: ProviderId, + project_id: &str, + version_id: &str, + file: &Path, ) -> LauncherResult<()> { - let hash = sha1_file(file).await?; - let stored_path = relative_launcher_path(launcher_dir, file); - let file_name = file - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - let size = polyio::stat(file).await.ok().map(|m| m.len() as i64); + let hash = sha1_file(file).await?; + let stored_path = relative_launcher_path(launcher_dir, file); + let file_name = file + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let size = polyio::stat(file).await.ok().map(|m| m.len() as i64); - artifact_dao::insert_artifact( - pool, - &hash, - content_type as i64, - &stored_path, - &file_name, - size, - ) - .await?; + artifact_dao::insert_artifact( + pool, + &hash, + content_type as i64, + &stored_path, + &file_name, + size, + ) + .await?; - if matches!(provider, ProviderId::Modrinth | ProviderId::CurseForge) { - artifact_dao::upsert_provider_release( - pool, - provider as i64, - project_id, - version_id, - &hash, - &file_name, - version_id, - None, - "[]", - "[]", - ) - .await?; - } + if matches!(provider, ProviderId::Modrinth | ProviderId::CurseForge) { + artifact_dao::upsert_provider_release( + pool, + provider as i64, + project_id, + version_id, + &hash, + &file_name, + version_id, + None, + "[]", + "[]", + ) + .await?; + } - Ok(()) + Ok(()) } #[tracing::instrument(level = "debug", skip(state, report))] async fn adopt_cluster( - state: &LauncherState, - folder_name: &str, - report: &mut RecoveryReport, + state: &LauncherState, + folder_name: &str, + report: &mut RecoveryReport, ) -> LauncherResult<()> { - let (name, mc_version, loader) = parse_folder_identity(folder_name); + let recorded = match oneclient_common::paths::cluster_dir(folder_name) { + Ok(dir) => oneclient_cluster::identity::read(&dir).await, + Err(_) => None, + }; - let global = state.settings.read().global_game_settings.clone(); - let profile = create_profile_from_global(&state.services.db, &global, &name, None, None).await?; + let (name, mc_version, parsed_loader) = match &recorded { + Some(identity) => ( + identity.name.clone(), + identity.mc_version.clone(), + identity.mc_loader, + ), + None => parse_folder_identity(folder_name), + }; - let row = cluster_dao::insert( - &state.services.db, - &NewCluster { - name: &name, - folder_name, - mc_version: &mc_version, - mc_loader: loader as i64, - mc_loader_version: None, - setting_profile_name: Some(&profile.name), - stage: ClusterStage::NotReady as i64, - }, - ) - .await?; + let guessed = match &recorded { + Some(_) => None, + None => orphan_instance_kind(folder_name).await, + }; + let guessed_kind = guessed.map(|(kind, _)| kind); + let loader = guessed.map_or(parsed_loader, |(_, loader)| loader); + if let Some(kind) = guessed_kind { + tracing::info!( + folder = folder_name, + ?kind, + ?loader, + "orphan folder has no instance file but is marked as its own instance; adopting it as one" + ); + } - report.adopted_clusters += 1; - tracing::info!( - cluster_id = row.id, - folder = folder_name, - mc_version = %mc_version, - loader = %loader, - "adopted cluster folder from disk" - ); + let global = state.settings.read().global_game_settings.clone(); + let profile = + create_profile_from_global(&state.services.db, &global, folder_name, None, None).await?; - relink_cluster_files(state, row.id, folder_name, report).await?; - Ok(()) + let tags = recorded + .as_ref() + .map_or_else(|| "[]".to_string(), |identity| encode_tags(&identity.tags)); + + let row = cluster_dao::insert( + &state.services.db, + &NewCluster { + name: &name, + folder_name, + mc_version: &mc_version, + mc_loader: loader as i64, + mc_loader_version: recorded + .as_ref() + .and_then(|identity| identity.mc_loader_version.as_deref()), + setting_profile_name: Some(&profile.name), + stage: ClusterStage::NotReady as i64, + kind: recorded.as_ref().map_or_else( + || guessed_kind.map_or(0, ClusterKind::as_i64), + |identity| identity.kind.as_i64(), + ), + user_created: recorded.as_ref().map_or_else( + || i64::from(guessed_kind.is_some()), + |identity| i64::from(identity.user_created), + ), + description: recorded + .as_ref() + .and_then(|identity| identity.description.as_deref()), + tags: &tags, + cover_path: recorded + .as_ref() + .and_then(|identity| identity.cover_path.as_deref()), + }, + ) + .await?; + + report.adopted_clusters += 1; + tracing::info!( + cluster_id = row.id, + folder = folder_name, + mc_version = %mc_version, + loader = %loader, + "adopted cluster folder from disk" + ); + + if let Ok(cluster) = oneclient_cluster::Cluster::try_from_row(row.clone()) { + state.clusters.ensure_dedicated_marker(&cluster).await.ok(); + } + + relink_cluster_files(state, row.id, folder_name, report).await?; + Ok(()) } /// Files are taken into the artifact cache not indexed in place the cache is /// the only place content is materialized from so in-place rows break on tidy-up #[tracing::instrument(level = "debug", skip(state, report))] async fn relink_cluster_files( - state: &LauncherState, - cluster_id: i64, - folder_name: &str, - report: &mut RecoveryReport, + state: &LauncherState, + cluster_id: i64, + folder_name: &str, + report: &mut RecoveryReport, ) -> LauncherResult<()> { - let pool = &state.services.db; - let content = state.services.content(); - let cluster_root = paths::cluster_dir(folder_name)?; + let pool = &state.services.db; + let content = state.services.content(); + let cluster_root = paths::cluster_dir(folder_name)?; - for content_type in FILE_CONTENT_TYPES { - let dir = cluster_root.join(content_type.folder_name()); + for content_type in FILE_CONTENT_TYPES { + let dir = cluster_root.join(content_type.folder_name()); - if polyio::symlink_metadata(&dir) - .await - .is_ok_and(|meta| meta.file_type().is_symlink()) - { - continue; - } + if polyio::symlink_metadata(&dir) + .await + .is_ok_and(|meta| meta.file_type().is_symlink()) + { + continue; + } - let files = match list_files(&dir).await { - Ok(files) => files, - Err(_) => continue, - }; + let files = match list_files(&dir).await { + Ok(files) => files, + Err(_) => continue, + }; - for file in files { - let file_name = file - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - let enabled = !file_name.ends_with(".disabled"); - let base_name = file_name.trim_end_matches(".disabled").to_string(); + for file in files { + let file_name = file + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let enabled = !file_name.ends_with(".disabled"); + let base_name = file_name.trim_end_matches(".disabled").to_string(); - let row = match PackageStore::import_local_file( - &file, - content_type, - cluster_id, - &content, - ) - .await - { - Ok(row) => row, - Err(err) => { - tracing::warn!(path = %file.display(), "failed to adopt cluster file: {err:#}"); - continue; - } - }; + let row = match PackageStore::import_local_file( + &file, + content_type, + cluster_id, + &content, + ) + .await + { + Ok(row) => row, + Err(err) => { + tracing::warn!(path = %file.display(), "failed to adopt cluster file: {err:#}"); + continue; + } + }; - // `import_local_file` links under the artifact's own name which is - // the `.disabled` spelling for a file the user had switched off - if !enabled { - artifact_dao::update_cluster_artifact(pool, cluster_id, &row.hash, &base_name, 0) - .await?; - } + // `import_local_file` links under the artifact's own name which is + // the `.disabled` spelling for a file the user had switched off + if !enabled { + artifact_dao::update_cluster_artifact(pool, cluster_id, &row.hash, &base_name, 0) + .await?; + } - report.relinked_files += 1; - } - } + report.relinked_files += 1; + } + } - Ok(()) + Ok(()) } #[tracing::instrument(skip(state))] pub async fn restore_bundle_tracking(state: &LauncherState) -> LauncherResult<()> { - let clusters = state.clusters.list().await?; + let clusters = state.clusters.list().await?; - for cluster in clusters { - let archives = match state - .bundles - .archives_for(&state.services.content(), &cluster.mc_version, cluster.mc_loader) - .await - { - Ok(archives) => archives, - Err(err) => { - tracing::warn!( - cluster_id = cluster.id, - "failed to load bundle archives during recovery: {err:#}" - ); - continue; - } - }; + for cluster in clusters { + if !cluster.uses_bundles() { + continue; + } - for archive in archives { - let bundle_name = &archive.manifest.name; - for file in &archive.manifest.files { - use oneclient_content::bundles::BundleFileKind; - let BundleFileKind::Managed { - provider, - project_id, - version_id, - .. - } = &file.kind - else { - continue; - }; + let archives = match state + .bundles + .archives_for( + &state.services.content(), + &cluster.mc_version, + cluster.mc_loader, + ) + .await + { + Ok(archives) => archives, + Err(err) => { + tracing::warn!( + cluster_id = cluster.id, + "failed to load bundle archives during recovery: {err:#}" + ); + continue; + } + }; - let Some(release) = artifact_dao::get_provider_release( - &state.services.db, - *provider as i64, - project_id, - version_id, - ) - .await? - else { - continue; - }; + for archive in archives { + let bundle_name = &archive.manifest.name; + for file in &archive.manifest.files { + use oneclient_content::bundles::BundleFileKind; + let BundleFileKind::Managed { + provider, + project_id, + version_id, + .. + } = &file.kind + else { + continue; + }; - if artifact_dao::is_cluster_linked(&state.services.db, cluster.id, &release.hash) - .await? - { - cluster_bundle_dao::track_bundle_artifact( - &state.services.db, - cluster.id, - &release.hash, - bundle_name, - version_id, - project_id, - ) - .await?; - } - } - } - } + let Some(release) = artifact_dao::get_provider_release( + &state.services.db, + *provider as i64, + project_id, + version_id, + ) + .await? + else { + continue; + }; - Ok(()) + if artifact_dao::is_cluster_linked(&state.services.db, cluster.id, &release.hash) + .await? + { + cluster_bundle_dao::track_bundle_artifact( + &state.services.db, + cluster.id, + &release.hash, + bundle_name, + version_id, + project_id, + ) + .await?; + } + } + } + } + + Ok(()) +} + +async fn orphan_instance_kind(folder_name: &str) -> Option<(ClusterKind, GameLoader)> { + if !paths::cluster_uses_dedicated_dir(folder_name) || looks_provisioned(folder_name) { + return None; + } + + let mods = paths::cluster_dir(folder_name) + .ok()? + .join(ContentType::Mod.folder_name()); + let jars = list_files(&mods) + .await + .unwrap_or_default() + .into_iter() + .filter(|file| { + file.extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("jar")) + }); + + let mut found = Vec::new(); + for jar in jars { + if let Some(loader) = oneclient_content::packages::read_jar_loader(&jar).await { + found.push(loader); + } + } + + Some(match loader_from_votes(&found) { + Some(loader) => (ClusterKind::Modded, loader), + None => (ClusterKind::Vanilla, GameLoader::Vanilla), + }) +} + +fn loader_from_votes(found: &[GameLoader]) -> Option { + let count = |loaders: &[GameLoader]| found.iter().filter(|l| loaders.contains(l)).count(); + let fabric_family = count(&[GameLoader::Fabric, GameLoader::Quilt]); + let forge_family = count(&[GameLoader::Forge, GameLoader::NeoForge]); + + if fabric_family == 0 && forge_family == 0 { + None + } else if fabric_family >= forge_family { + Some(if found.contains(&GameLoader::Quilt) { + GameLoader::Quilt + } else { + GameLoader::Fabric + }) + } else { + Some(if found.contains(&GameLoader::NeoForge) { + GameLoader::NeoForge + } else { + GameLoader::Forge + }) + } +} + +fn looks_provisioned(folder_name: &str) -> bool { + let without_copy_suffix = folder_name + .rsplit_once(" (") + .filter(|(_, tail)| { + tail.strip_suffix(')') + .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + }) + .map(|(stem, _)| stem); + + parse_provisioned_name(folder_name).is_some() + || without_copy_suffix.is_some_and(|stem| parse_provisioned_name(stem).is_some()) +} + +fn parse_provisioned_name(folder_name: &str) -> Option<(String, GameLoader)> { + let tokens: Vec<&str> = folder_name.split_whitespace().collect(); + + for take in [2usize, 1] { + if tokens.len() > take { + let split = tokens.len() - take; + let loader_str = tokens[split..].join(" "); + if let Ok(loader) = loader_str.parse::() { + let version = tokens[..split].join(" "); + if parse_mc_version(&version).is_some() { + return Some((version, loader)); + } + } + } + } + + None } fn parse_folder_identity(folder_name: &str) -> (String, String, GameLoader) { - let tokens: Vec<&str> = folder_name.split_whitespace().collect(); + if let Some((version, loader)) = parse_provisioned_name(folder_name) { + return (folder_name.to_string(), version, loader); + } - for take in [2usize, 1] { - if tokens.len() > take { - let split = tokens.len() - take; - let loader_str = tokens[split..].join(" "); - if let Ok(loader) = loader_str.parse::() { - let version = tokens[..split].join(" "); - if parse_mc_version(&version).is_some() { - return (folder_name.to_string(), version, loader); - } - } - } - } - - let mc_version = parse_mc_version(folder_name) - .map(|_| folder_name.to_string()) - .unwrap_or_else(|| "unknown".to_string()); - (folder_name.to_string(), mc_version, GameLoader::Vanilla) + let mc_version = parse_mc_version(folder_name) + .map(|_| folder_name.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + (folder_name.to_string(), mc_version, GameLoader::Vanilla) } fn provider_from_dir_name(name: &str) -> Option { - ProviderId::iter().find(|p| p.dir_name() == name) + ProviderId::iter().find(|p| p.dir_name() == name) } fn relative_launcher_path(launcher_dir: &Path, abs: &Path) -> String { - abs.strip_prefix(launcher_dir) - .unwrap_or(abs) - .to_string_lossy() - .replace('\\', "/") + abs.strip_prefix(launcher_dir) + .unwrap_or(abs) + .to_string_lossy() + .replace('\\', "/") } async fn list_dir_names(dir: &Path) -> LauncherResult> { - let mut names = Vec::new(); - let Ok(mut entries) = polyio::read_dir(dir).await else { - return Ok(names); - }; - while let Some(entry) = entries.next_entry().await? { - if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) { - names.push(entry.file_name().to_string_lossy().into_owned()); - } - } - Ok(names) + let mut names = Vec::new(); + let Ok(mut entries) = polyio::read_dir(dir).await else { + return Ok(names); + }; + while let Some(entry) = entries.next_entry().await? { + if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) { + names.push(entry.file_name().to_string_lossy().into_owned()); + } + } + Ok(names) } async fn list_files(dir: &Path) -> LauncherResult> { - let mut files = Vec::new(); - let Ok(mut entries) = polyio::read_dir(dir).await else { - return Ok(files); - }; - while let Some(entry) = entries.next_entry().await? { - if entry.file_type().await.map(|t| t.is_file()).unwrap_or(false) { - files.push(entry.path()); - } - } - Ok(files) + let mut files = Vec::new(); + let Ok(mut entries) = polyio::read_dir(dir).await else { + return Ok(files); + }; + while let Some(entry) = entries.next_entry().await? { + if entry + .file_type() + .await + .map(|t| t.is_file()) + .unwrap_or(false) + { + files.push(entry.path()); + } + } + Ok(files) } diff --git a/packages/oneclient_core/src/relocate.rs b/packages/oneclient_core/src/relocate.rs index ccf8bfdf..ff1b095c 100644 --- a/packages/oneclient_core/src/relocate.rs +++ b/packages/oneclient_core/src/relocate.rs @@ -19,22 +19,22 @@ static IN_PROGRESS: AtomicBool = AtomicBool::new(false); #[must_use] pub fn in_progress() -> bool { - IN_PROGRESS.load(Ordering::SeqCst) + IN_PROGRESS.load(Ordering::SeqCst) } struct InProgress; impl InProgress { - fn begin() -> Self { - IN_PROGRESS.store(true, Ordering::SeqCst); - Self - } + fn begin() -> Self { + IN_PROGRESS.store(true, Ordering::SeqCst); + Self + } } impl Drop for InProgress { - fn drop(&mut self) { - IN_PROGRESS.store(false, Ordering::SeqCst); - } + fn drop(&mut self) { + IN_PROGRESS.store(false, Ordering::SeqCst); + } } const HEADROOM: f64 = 1.05; @@ -43,779 +43,891 @@ const REPORT_EVERY: u64 = 32 * 1024 * 1024; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RelocationPlan { - pub from: PathBuf, - pub to: PathBuf, - pub bytes: u64, - pub available: Option, - pub warning: Option, + pub from: PathBuf, + pub to: PathBuf, + pub bytes: u64, + pub available: Option, + pub warning: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RelocationOutcome { - pub to: PathBuf, - pub bytes: u64, - pub skipped_links: usize, + pub to: PathBuf, + pub bytes: u64, + pub skipped_links: usize, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Leftovers { - pub path: PathBuf, - pub bytes: u64, + pub path: PathBuf, + pub bytes: u64, } pub async fn plan(state: &LauncherState, picked: &Path) -> Result { - let from = ready_to_move(state)?; - let checked = data_dir::check(picked).await?; + let from = ready_to_move(state)?; + let checked = data_dir::check(picked).await?; - weigh(state, from, checked, &[]).await + weigh(state, from, checked, &[]).await } pub async fn plan_default(state: &LauncherState) -> Result { - let from = ready_to_move(state)?; - let home = data_dir::default_path()?; + let from = ready_to_move(state)?; + let home = data_dir::default_path()?; - if home == from { - return Err("Your game data is already in the default folder.".to_string()); - } + if home == from { + return Err("Your game data is already in the default folder.".to_string()); + } - let keep = config_owned_names(&home); - let checked = data_dir::check_exact(&home).await?; + let keep = config_owned_names(&home); + let checked = data_dir::check_exact(&home).await?; - weigh(state, from, checked, &keep).await + weigh(state, from, checked, &keep).await } fn ready_to_move(state: &LauncherState) -> Result { - let from = current_dir()?; + let from = current_dir()?; - if let Some(reason) = busy_reason(state) { - return Err(reason); - } + if let Some(reason) = busy_reason(state) { + return Err(reason); + } - if let Some(reason) = restart_pending(state, &from) { - return Err(reason); - } + if let Some(reason) = restart_pending(state, &from) { + return Err(reason); + } - Ok(from) + Ok(from) } async fn weigh( - state: &LauncherState, - from: PathBuf, - checked: data_dir::DataDirCheck, - settled: &[OsString], + state: &LauncherState, + from: PathBuf, + checked: data_dir::DataDirCheck, + settled: &[OsString], ) -> Result { - let to = checked.path; + let to = checked.path; - if to == from { - return Err("Your game data is already there.".to_string()); - } + if to == from { + return Err("Your game data is already there.".to_string()); + } - if to.starts_with(&from) || from.starts_with(&to) { - return Err(format!( - "Pick a folder outside {} (one cannot hold the other).", - from.display() - )); - } + if to.starts_with(&from) || from.starts_with(&to) { + return Err(format!( + "Pick a folder outside {} (one cannot hold the other).", + from.display() + )); + } - if incomplete(&to) { - return Err(format!( - "{} holds a move that was interrupted. Delete that folder and try again.", - to.display() - )); - } + if incomplete(&to) { + return Err(format!( + "{} holds a move that was interrupted. Delete that folder and try again.", + to.display() + )); + } - if let Some(old) = leftovers(state).await { - return Err(format!( - "Clear the {} still sitting in {} first. OneClient keeps track of one old folder at a time.", - format_bytes(old.bytes), - old.path.display() - )); - } + if let Some(old) = leftovers(state).await { + return Err(format!( + "Clear the {} still sitting in {} first. OneClient keeps track of one old folder at a time.", + format_bytes(old.bytes), + old.path.display() + )); + } - if !data_dir::looks_empty(&to, settled).await { - return Err(format!("{} is not empty. Pick an empty folder.", to.display())); - } + if !data_dir::looks_empty(&to, settled).await { + return Err(format!( + "{} is not empty. Pick an empty folder.", + to.display() + )); + } - let bytes = collect(&from, &skipped_names(&from)).await?.total; - let available = data_dir::available_space(&to); + let bytes = collect(&from, &skipped_names(&from)).await?.total; + let available = data_dir::available_space(&to); - if let Some(available) = available - && (available as f64) < bytes as f64 * HEADROOM - { - return Err(format!( - "{} needs {} and only {} is free there.", - to.display(), - format_bytes(bytes), - format_bytes(available) - )); - } + if let Some(available) = available + && (available as f64) < bytes as f64 * HEADROOM + { + return Err(format!( + "{} needs {} and only {} is free there.", + to.display(), + format_bytes(bytes), + format_bytes(available) + )); + } - Ok(RelocationPlan { - from, - to, - bytes, - available, - warning: checked.warning, - }) + Ok(RelocationPlan { + from, + to, + bytes, + available, + warning: checked.warning, + }) } /// `on_copied` is handed the bytes written and the total to write, throttled to /// one call per [`REPORT_EVERY`] bytes plus a final one when the tree lands #[tracing::instrument(skip(state, plan, on_copied), fields(from = %plan.from.display(), to = %plan.to.display()))] pub async fn relocate( - state: &LauncherState, - plan: &RelocationPlan, - mut on_copied: impl FnMut(u64, u64), + state: &LauncherState, + plan: &RelocationPlan, + mut on_copied: impl FnMut(u64, u64), ) -> Result { - if let Some(reason) = busy_reason(state) { - return Err(reason); - } + if let Some(reason) = busy_reason(state) { + return Err(reason); + } - if plan.from != current_dir()? { - return Err("The data folder changed since this move was planned.".to_string()); - } + if plan.from != current_dir()? { + return Err("The data folder changed since this move was planned.".to_string()); + } - if let Some(reason) = restart_pending(state, &plan.from) { - return Err(reason); - } + if let Some(reason) = restart_pending(state, &plan.from) { + return Err(reason); + } - let _in_progress = InProgress::begin(); + let _in_progress = InProgress::begin(); - drop_materialized_content(state).await; + drop_materialized_content(state).await; - let skip = skipped_names(&plan.from); - let found = collect(&plan.from, &skip).await?; + let skip = skipped_names(&plan.from); + let found = collect(&plan.from, &skip).await?; - polyio::create_dir_all(&plan.to) - .await - .map_err(|err| format!("Couldn't create {}: {err}", plan.to.display()))?; + polyio::create_dir_all(&plan.to) + .await + .map_err(|err| format!("Couldn't create {}: {err}", plan.to.display()))?; - let marker = plan.to.join(IN_PROGRESS_MARKER); - polyio::write(&marker, b"".as_slice()) - .await - .map_err(|err| format!("Couldn't write to {}: {err}", plan.to.display()))?; + let marker = plan.to.join(IN_PROGRESS_MARKER); + polyio::write(&marker, b"".as_slice()) + .await + .map_err(|err| format!("Couldn't write to {}: {err}", plan.to.display()))?; - let copied = match write_everything(state, plan, &found, &mut on_copied).await { - Ok(copied) => copied, - Err(err) => { - abandon(&plan.to).await; - return Err(err); - } - }; + let copied = match write_everything(state, plan, &found, &mut on_copied).await { + Ok(copied) => copied, + Err(err) => { + abandon(&plan.to).await; + return Err(err); + } + }; - polyio::remove_file(&marker) - .await - .map_err(|err| format!("Couldn't finish the move at {}: {err}", plan.to.display()))?; + polyio::remove_file(&marker) + .await + .map_err(|err| format!("Couldn't finish the move at {}: {err}", plan.to.display()))?; - if let Err(err) = commit(state, &plan.from, &plan.to).await { - discard_unclaimed(&plan.to).await; - return Err(err); - } + if let Err(err) = commit(state, &plan.from, &plan.to).await { + discard_unclaimed(&plan.to).await; + return Err(err); + } - tracing::info!( - bytes = copied, - path = %plan.to.display(), - "the game data moved; a restart picks up the new folder" - ); + tracing::info!( + bytes = copied, + path = %plan.to.display(), + "the game data moved; a restart picks up the new folder" + ); - Ok(RelocationOutcome { - to: plan.to.clone(), - bytes: copied, - skipped_links: found.links.len(), - }) + Ok(RelocationOutcome { + to: plan.to.clone(), + bytes: copied, + skipped_links: found.links.len(), + }) } pub async fn leftovers(state: &LauncherState) -> Option { - let path = state.settings.read().previous_data_dir.clone()?; + let path = state.settings.read().previous_data_dir.clone()?; - if current_dir().is_ok_and(|current| current == path) { - return None; - } + if current_dir().is_ok_and(|current| current == path) { + return None; + } - if !polyio::try_exists(&path).await.unwrap_or(false) { - return None; - } + if !polyio::try_exists(&path).await.unwrap_or(false) { + return None; + } - Some(Leftovers { - bytes: dir_size(&path).await, - path, - }) + Some(Leftovers { + bytes: dir_size(&path).await, + path, + }) } #[tracing::instrument(skip(state))] pub async fn discard_leftovers(state: &LauncherState) -> Result { - let Some(old) = state.settings.read().previous_data_dir.clone() else { - return Ok(0); - }; + let Some(old) = state.settings.read().previous_data_dir.clone() else { + return Ok(0); + }; - let current = current_dir()?; - if old == current { - return Err("That is the folder you are using now.".to_string()); - } + let current = current_dir()?; + if old == current { + return Err("That is the folder you are using now.".to_string()); + } - let freed = dir_size(&old).await; - let keep = config_owned_names(&old); + let freed = dir_size(&old).await; + let keep = config_owned_names(&old); - if keep.is_empty() { - polyio::remove_dir_all(&old) - .await - .map_err(|err| format!("Couldn't remove {}: {err}", old.display()))?; - } else { - remove_children_except(&old, &keep).await?; - } + if keep.is_empty() { + polyio::remove_dir_all(&old) + .await + .map_err(|err| format!("Couldn't remove {}: {err}", old.display()))?; + } else { + remove_children_except(&old, &keep).await?; + } - { - let mut settings = state.settings.write(); - settings.previous_data_dir = None; - } + { + let mut settings = state.settings.write(); + settings.previous_data_dir = None; + } - let snapshot = state.settings.read().clone(); - save_settings(&snapshot) - .await - .map_err(|err| format!("Couldn't save your settings: {err}"))?; + let snapshot = state.settings.read().clone(); + save_settings(&snapshot) + .await + .map_err(|err| format!("Couldn't save your settings: {err}"))?; - Ok(freed) + Ok(freed) } #[must_use] pub fn incomplete(dir: &Path) -> bool { - dir.join(IN_PROGRESS_MARKER).exists() + dir.join(IN_PROGRESS_MARKER).exists() +} + +pub async fn adopt_legacy_dir() -> Result, String> { + let (Some(from), Ok(to)) = (paths::legacy_dir(), paths::standard_dir()) else { + return Ok(None); + }; + + let Ok(raw) = polyio::read(from.join(paths::SETTINGS_FILE)).await else { + return Ok(None); + }; + + let chosen_folder = serde_json::from_slice::(&raw) + .map_or(true, |settings| !settings["data_dir"].is_null()); + if chosen_folder { + return Ok(None); + } + + #[cfg(unix)] + if std::os::unix::net::UnixStream::connect(from.join("ipc.sock")).is_ok() { + return Ok(None); + } + + let old = polyio::canonicalize(&from).unwrap_or_else(|_| from.clone()); + + if let Some(parent) = to.parent() { + polyio::create_dir_all(parent) + .await + .map_err(|err| format!("Couldn't create {}: {err}", parent.display()))?; + } + polyio::remove_dir(&to).await.ok(); + + polyio::rename(&from, &to).await.map_err(|err| { + format!( + "Couldn't move {} to {}, staying on the old folder: {err}", + from.display(), + to.display() + ) + })?; + + let database = to.join(paths::DATABASE_FILE); + if database.is_file() + && let Err(err) = rewrite_java_paths(&database, &from, &to).await + { + return match polyio::rename(&to, &from).await { + Ok(()) => Err(format!("{err} Staying on {}.", from.display())), + Err(back) => Err(format!( + "{err} Moving {} back failed too: {back}", + to.display() + )), + }; + } + + for dir in from.ancestors().skip(1).take(2) { + if polyio::remove_dir(dir).await.is_err() { + break; + } + } + + let new = polyio::canonicalize(&to).unwrap_or_else(|_| to.clone()); + repoint_links(&new, &old, &from).await.map_err(|err| { + format!( + "Moved to {} but a link could not be repointed: {err}", + to.display() + ) + })?; + + Ok(Some(from)) +} + +async fn repoint_links(root: &Path, old: &Path, raw_old: &Path) -> Result<(), polyio::IOError> { + let mut stack = vec![root.to_path_buf()]; + + while let Some(dir) = stack.pop() { + let mut entries = polyio::read_dir(&dir).await?; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Ok(kind) = entry.file_type().await else { + continue; + }; + + if kind.is_dir() { + stack.push(path); + continue; + } + + if !kind.is_symlink() { + continue; + } + + let Ok(target) = polyio::read_link(&path).await else { + continue; + }; + let Ok(rest) = target + .strip_prefix(old) + .or_else(|_| target.strip_prefix(raw_old)) + else { + continue; + }; + + let target = root.join(rest); + if target.is_dir() { + polyio::remove_symlink_dir(&path).await?; + polyio::symlink_dir(&target, &path).await?; + } else { + polyio::remove_file(&path).await?; + polyio::symlink_file(&target, &path).await?; + } + } + } + + Ok(()) } fn current_dir() -> Result { - paths::data_dir() - .map(Path::to_path_buf) - .map_err(|err| format!("Couldn't work out where your game data lives: {err}")) + paths::data_dir() + .map(Path::to_path_buf) + .map_err(|err| format!("Couldn't work out where your game data lives: {err}")) } pub fn restart_pending(state: &LauncherState, current: &Path) -> Option { - let settled = state.settings.read().data_dir.clone()?; + let settled = state.settings.read().data_dir.clone()?; - (settled != current).then(|| { - format!( - "A move to {} is already waiting. Restart OneClient to finish it.", - settled.display() - ) - }) + (settled != current).then(|| { + format!( + "A move to {} is already waiting. Restart OneClient to finish it.", + settled.display() + ) + }) } fn busy_reason(state: &LauncherState) -> Option { - let active = state.games.active_ids().len(); - if active == 0 { - return None; - } + let active = state.games.active_ids().len(); + if active == 0 { + return None; + } - Some(format!( - "Close Minecraft first. {active} instance{} still open.", - if active == 1 { " is" } else { "s are" } - )) + Some(format!( + "Close Minecraft first. {active} instance{} still open.", + if active == 1 { " is" } else { "s are" } + )) } fn skipped_names(from: &Path) -> Vec { - let mut names = database_names(); - names.extend(config_owned_names(from)); - names + let mut names = database_names(); + names.extend(config_owned_names(from)); + names } fn database_names() -> Vec { - let Ok(db) = paths::database_file() else { - return Vec::new(); - }; - let Some(name) = db.file_name().and_then(|name| name.to_str()) else { - return Vec::new(); - }; + let Ok(db) = paths::database_file() else { + return Vec::new(); + }; + let Some(name) = db.file_name().and_then(|name| name.to_str()) else { + return Vec::new(); + }; - ["", "-wal", "-shm", "-journal"] - .iter() - .map(|suffix| OsString::from(format!("{name}{suffix}"))) - .collect() + ["", "-wal", "-shm", "-journal"] + .iter() + .map(|suffix| OsString::from(format!("{name}{suffix}"))) + .collect() } fn config_owned_names(dir: &Path) -> Vec { - if paths::config_dir().is_ok_and(|config| config == dir) { - [ - paths::settings_file(), - paths::damaged_settings_file(), - paths::auth_file(), - paths::logs_dir(), - ] - .into_iter() - .flatten() - .filter_map(|path| path.file_name().map(std::ffi::OsStr::to_os_string)) - .collect() - } else { - Vec::new() - } + if paths::config_dir().is_ok_and(|config| config == dir) { + [ + paths::settings_file(), + paths::damaged_settings_file(), + paths::auth_file(), + paths::logs_dir(), + ] + .into_iter() + .flatten() + .filter_map(|path| path.file_name().map(std::ffi::OsStr::to_os_string)) + .collect() + } else { + Vec::new() + } } async fn drop_materialized_content(state: &LauncherState) { - let mut dirs = Vec::new(); + let mut dirs = Vec::new(); - if let Ok(shared) = paths::shared_minecraft_dir() { - dirs.push((shared.clone(), manifest::MANIFEST_NAME)); - dirs.push((shared, manifest::GLOBAL_MANIFEST_NAME)); - } + if let Ok(shared) = paths::shared_minecraft_dir() { + dirs.push((shared.clone(), manifest::MANIFEST_NAME)); + dirs.push((shared, manifest::GLOBAL_MANIFEST_NAME)); + } - match state.clusters.list().await { - Ok(clusters) => { - for cluster in &clusters { - if let Ok(dir) = cluster.dir() { - dirs.push((dir, manifest::MODS_MANIFEST_NAME)); - } + match state.clusters.list().await { + Ok(clusters) => { + for cluster in &clusters { + if let Ok(dir) = cluster.dir() { + dirs.push((dir, manifest::MODS_MANIFEST_NAME)); + } - if cluster.uses_dedicated_dir() - && let Ok(dir) = cluster.game_dir() - { - dirs.push((dir, manifest::MANIFEST_NAME)); - } - } - } - Err(err) => tracing::warn!(%err, "could not list clusters; only clearing the shared folder"), - } + if cluster.uses_dedicated_dir() + && let Ok(dir) = cluster.game_dir() + { + dirs.push((dir, manifest::MANIFEST_NAME)); + } + } + } + Err(err) => { + tracing::warn!(%err, "could not list clusters; only clearing the shared folder") + } + } - for (dir, manifest_name) in dirs { - let Some(loaded) = manifest::load(&dir, manifest_name).await else { - continue; - }; + for (dir, manifest_name) in dirs { + let Some(loaded) = manifest::load(&dir, manifest_name).await else { + continue; + }; - for entry in &loaded.entries { - let mut path = dir.clone(); - path.extend(entry.path.split('/')); + for entry in &loaded.entries { + let mut path = dir.clone(); + path.extend(entry.path.split('/')); - if let Err(err) = remove_entry(&path).await { - tracing::warn!(path = %path.display(), %err, "could not clear linked package"); - } - } + if let Err(err) = remove_entry(&path).await { + tracing::warn!(path = %path.display(), %err, "could not clear linked package"); + } + } - manifest::clear(&dir, manifest_name).await; - } + manifest::clear(&dir, manifest_name).await; + } - if let Ok(shared) = paths::shared_minecraft_dir() { - crate::game::unlink_cluster_logs(&shared).await; - } + if let Ok(shared) = paths::shared_minecraft_dir() { + crate::game::unlink_cluster_logs(&shared).await; + } } #[derive(Debug, Default)] struct Found { - dirs: Vec, - files: Vec<(PathBuf, u64)>, - links: Vec, - total: u64, + dirs: Vec, + files: Vec<(PathBuf, u64)>, + links: Vec, + total: u64, } async fn collect(root: &Path, skip_top: &[OsString]) -> Result { - let mut found = Found::default(); - let mut stack = vec![(root.to_path_buf(), true)]; + let mut found = Found::default(); + let mut stack = vec![(root.to_path_buf(), true)]; - while let Some((dir, is_top)) = stack.pop() { - let mut entries = match polyio::read_dir(&dir).await { - Ok(entries) => entries, - Err(err) => { - return Err(format!("Couldn't read {}: {err}", dir.display())); - } - }; + while let Some((dir, is_top)) = stack.pop() { + let mut entries = match polyio::read_dir(&dir).await { + Ok(entries) => entries, + Err(err) => { + return Err(format!("Couldn't read {}: {err}", dir.display())); + } + }; - while let Ok(Some(entry)) = entries.next_entry().await { - let path = entry.path(); - let name = entry.file_name(); + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let name = entry.file_name(); - if is_top && skip_top.contains(&name) { - continue; - } + if is_top && skip_top.contains(&name) { + continue; + } - let Ok(file_type) = entry.file_type().await else { - continue; - }; + let Ok(file_type) = entry.file_type().await else { + continue; + }; - let Ok(relative) = path.strip_prefix(root).map(Path::to_path_buf) else { - continue; - }; + let Ok(relative) = path.strip_prefix(root).map(Path::to_path_buf) else { + continue; + }; - if file_type.is_symlink() { - found.links.push(relative); - continue; - } + if file_type.is_symlink() { + found.links.push(relative); + continue; + } - if file_type.is_dir() { - found.dirs.push(relative); - stack.push((path, false)); - } else if file_type.is_file() { - if let Ok(meta) = entry.metadata().await { - found.total += meta.len(); - found.files.push((relative, meta.len())); - } - } else { - tracing::debug!( - path = %relative.display(), - "leaving a socket or pipe behind" - ); - } - } - } + if file_type.is_dir() { + found.dirs.push(relative); + stack.push((path, false)); + } else if file_type.is_file() { + if let Ok(meta) = entry.metadata().await { + found.total += meta.len(); + found.files.push((relative, meta.len())); + } + } else { + tracing::debug!( + path = %relative.display(), + "leaving a socket or pipe behind" + ); + } + } + } - Ok(found) + Ok(found) } async fn copy_tree( - from: &Path, - to: &Path, - found: &Found, - on_copied: &mut impl FnMut(u64, u64), + from: &Path, + to: &Path, + found: &Found, + on_copied: &mut impl FnMut(u64, u64), ) -> Result { - for relative in &found.dirs { - let dir = to.join(relative); - polyio::create_dir_all(&dir) - .await - .map_err(|err| format!("Couldn't create {}: {err}", dir.display()))?; - } + for relative in &found.dirs { + let dir = to.join(relative); + polyio::create_dir_all(&dir) + .await + .map_err(|err| format!("Couldn't create {}: {err}", dir.display()))?; + } - for link in &found.links { - tracing::warn!(path = %link.display(), "leaving a link behind; it points outside our tree"); - } + for link in &found.links { + tracing::warn!(path = %link.display(), "leaving a link behind; it points outside our tree"); + } - let mut copied = 0u64; - let mut reported = 0u64; + let mut copied = 0u64; + let mut reported = 0u64; - for (relative, size) in &found.files { - let src = from.join(relative); - let dest = to.join(relative); + for (relative, size) in &found.files { + let src = from.join(relative); + let dest = to.join(relative); - if let Some(parent) = dest.parent() { - polyio::create_dir_all(parent).await.ok(); - } + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await.ok(); + } - let written = polyio::copy(&src, &dest) - .await - .map_err(|err| format!("Couldn't copy {}: {err}", relative.display()))?; + let written = polyio::copy(&src, &dest) + .await + .map_err(|err| format!("Couldn't copy {}: {err}", relative.display()))?; - if written != *size { - return Err(format!( - "{} arrived short. Nothing was removed from the old folder.", - relative.display() - )); - } + if written != *size { + return Err(format!( + "{} arrived short. Nothing was removed from the old folder.", + relative.display() + )); + } - copied += written; + copied += written; - if copied - reported >= REPORT_EVERY { - reported = copied; - on_copied(copied, found.total); - } - } + if copied - reported >= REPORT_EVERY { + reported = copied; + on_copied(copied, found.total); + } + } - on_copied(copied, found.total.max(copied)); - Ok(copied) + on_copied(copied, found.total.max(copied)); + Ok(copied) } async fn write_everything( - state: &LauncherState, - plan: &RelocationPlan, - found: &Found, - on_copied: &mut impl FnMut(u64, u64), + state: &LauncherState, + plan: &RelocationPlan, + found: &Found, + on_copied: &mut impl FnMut(u64, u64), ) -> Result { - let copied = copy_tree(&plan.from, &plan.to, found, on_copied).await?; + let copied = copy_tree(&plan.from, &plan.to, found, on_copied).await?; - if copied != found.total { - return Err(format!( - "Only {} of {} arrived at {}.", - format_bytes(copied), - format_bytes(found.total), - plan.to.display() - )); - } + if copied != found.total { + return Err(format!( + "Only {} of {} arrived at {}.", + format_bytes(copied), + format_bytes(found.total), + plan.to.display() + )); + } - let database = snapshot_database(&state.services.db, &plan.to).await?; + let database = snapshot_database(&state.services.db, &plan.to).await?; - let rewritten = rewrite_java_paths(&database, &plan.from, &plan.to).await?; - if rewritten > 0 { - tracing::info!(rows = rewritten, "repointed Java runtimes at the new folder"); - } + let rewritten = rewrite_java_paths(&database, &plan.from, &plan.to).await?; + if rewritten > 0 { + tracing::info!( + rows = rewritten, + "repointed Java runtimes at the new folder" + ); + } - Ok(copied) + Ok(copied) } async fn snapshot_database(pool: &DbPool, to: &Path) -> Result { - let Ok(name) = paths::database_file() else { - return Err("Couldn't work out where the database lives.".to_string()); - }; - let Some(name) = name.file_name() else { - return Err("Couldn't work out what the database is called.".to_string()); - }; + let Ok(name) = paths::database_file() else { + return Err("Couldn't work out where the database lives.".to_string()); + }; + let Some(name) = name.file_name() else { + return Err("Couldn't work out what the database is called.".to_string()); + }; - let target = to.join(name); + let target = to.join(name); - sqlx::query("VACUUM INTO ?") - .bind(target.to_string_lossy().as_ref()) - .execute(pool) - .await - .map_err(|err| format!("Couldn't copy the database to {}: {err}", target.display()))?; + sqlx::query("VACUUM INTO ?") + .bind(target.to_string_lossy().as_ref()) + .execute(pool) + .await + .map_err(|err| format!("Couldn't copy the database to {}: {err}", target.display()))?; - Ok(target) + Ok(target) } async fn rewrite_java_paths(database: &Path, from: &Path, to: &Path) -> Result { - let old = java_root(from); - let new = java_root(to); + let old = java_root(from); + let new = java_root(to); - let options = sqlx::sqlite::SqliteConnectOptions::new() - .filename(database) - .create_if_missing(false) - .foreign_keys(false); + let options = sqlx::sqlite::SqliteConnectOptions::new() + .filename(database) + .create_if_missing(false) + .foreign_keys(false); - let mut conn = ::connect_with(&options) - .await - .map_err(|err| format!("Couldn't open the copied database: {err}"))?; + let mut conn = ::connect_with(&options) + .await + .map_err(|err| format!("Couldn't open the copied database: {err}"))?; - let result = swap_java_prefix(&mut conn, &old, &new).await; + let result = swap_java_prefix(&mut conn, &old, &new).await; - if let Err(err) = sqlx::Connection::close(conn).await { - tracing::warn!(%err, "failed to close the copied database cleanly"); - } + if let Err(err) = sqlx::Connection::close(conn).await { + tracing::warn!(%err, "failed to close the copied database cleanly"); + } - result + result } fn java_root(dir: &Path) -> String { - dir.join("metadata") - .join("java") - .to_string_lossy() - .into_owned() + dir.join("metadata") + .join("java") + .to_string_lossy() + .into_owned() } async fn swap_java_prefix( - conn: &mut sqlx::SqliteConnection, - old: &str, - new: &str, + conn: &mut sqlx::SqliteConnection, + old: &str, + new: &str, ) -> Result { - let len = i64::try_from(old.chars().count()).unwrap_or(i64::MAX); + let len = i64::try_from(old.chars().count()).unwrap_or(i64::MAX); - let mut tx = conn - .begin() - .await - .map_err(|err| format!("Couldn't start a database transaction: {err}"))?; + let mut tx = conn + .begin() + .await + .map_err(|err| format!("Couldn't start a database transaction: {err}"))?; - let profiles = sqlx::query( - "UPDATE setting_profiles + let profiles = sqlx::query( + "UPDATE setting_profiles SET java_path = ? || substr(java_path, ?) WHERE java_path IS NOT NULL AND substr(java_path, 1, ?) = ?", - ) - .bind(new) - .bind(len + 1) - .bind(len) - .bind(old) - .execute(&mut *tx) - .await - .map_err(|err| format!("Couldn't update your Java settings: {err}"))?; + ) + .bind(new) + .bind(len + 1) + .bind(len) + .bind(old) + .execute(&mut *tx) + .await + .map_err(|err| format!("Couldn't update your Java settings: {err}"))?; - let runtimes = sqlx::query( - "UPDATE java_versions + let runtimes = sqlx::query( + "UPDATE java_versions SET absolute_path = ? || substr(absolute_path, ?) WHERE substr(absolute_path, 1, ?) = ?", - ) - .bind(new) - .bind(len + 1) - .bind(len) - .bind(old) - .execute(&mut *tx) - .await - .map_err(|err| format!("Couldn't update your Java runtimes: {err}"))?; + ) + .bind(new) + .bind(len + 1) + .bind(len) + .bind(old) + .execute(&mut *tx) + .await + .map_err(|err| format!("Couldn't update your Java runtimes: {err}"))?; - let orphans: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM setting_profiles p + let orphans: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM setting_profiles p WHERE p.java_path IS NOT NULL AND NOT EXISTS (SELECT 1 FROM java_versions j WHERE j.absolute_path = p.java_path)", - ) - .fetch_one(&mut *tx) - .await - .map_err(|err| format!("Couldn't check your Java settings: {err}"))?; + ) + .fetch_one(&mut *tx) + .await + .map_err(|err| format!("Couldn't check your Java settings: {err}"))?; - if orphans > 0 { - tx.rollback().await.ok(); - return Err(format!( - "{orphans} of your profiles would lose their Java runtime. Nothing was changed." - )); - } + if orphans > 0 { + tx.rollback().await.ok(); + return Err(format!( + "{orphans} of your profiles would lose their Java runtime. Nothing was changed." + )); + } - tx.commit() - .await - .map_err(|err| format!("Couldn't save the database changes: {err}"))?; + tx.commit() + .await + .map_err(|err| format!("Couldn't save the database changes: {err}"))?; - Ok(profiles.rows_affected() + runtimes.rows_affected()) + Ok(profiles.rows_affected() + runtimes.rows_affected()) } async fn commit(state: &LauncherState, from: &Path, to: &Path) -> Result<(), String> { - { - let mut settings = state.settings.write(); - settings.data_dir = Some(to.to_path_buf()); - settings.previous_data_dir = Some(from.to_path_buf()); - } + { + let mut settings = state.settings.write(); + settings.data_dir = Some(to.to_path_buf()); + settings.previous_data_dir = Some(from.to_path_buf()); + } - let snapshot = state.settings.read().clone(); + let snapshot = state.settings.read().clone(); - save_settings(&snapshot).await.map_err(|err| { - format!("Everything was copied but your settings could not be saved: {err}") - }) + save_settings(&snapshot) + .await + .map_err(|err| format!("Everything was copied but your settings could not be saved: {err}")) } async fn abandon(to: &Path) { - if !incomplete(to) { - tracing::error!( - path = %to.display(), - "leaving the destination alone; it is not carrying our marker" - ); - return; - } + if !incomplete(to) { + tracing::error!( + path = %to.display(), + "leaving the destination alone; it is not carrying our marker" + ); + return; + } - discard_unclaimed(to).await; + discard_unclaimed(to).await; } async fn discard_unclaimed(to: &Path) { - match polyio::remove_dir_all(to).await { - Ok(()) => tracing::info!(path = %to.display(), "cleared the abandoned folder"), - Err(err) => tracing::error!( - path = %to.display(), - %err, - "could not clear the abandoned folder; delete it before trying again" - ), - } + match polyio::remove_dir_all(to).await { + Ok(()) => tracing::info!(path = %to.display(), "cleared the abandoned folder"), + Err(err) => tracing::error!( + path = %to.display(), + %err, + "could not clear the abandoned folder; delete it before trying again" + ), + } } async fn remove_children_except(dir: &Path, keep: &[OsString]) -> Result<(), String> { - let mut entries = polyio::read_dir(dir) - .await - .map_err(|err| format!("Couldn't read {}: {err}", dir.display()))?; + let mut entries = polyio::read_dir(dir) + .await + .map_err(|err| format!("Couldn't read {}: {err}", dir.display()))?; - while let Ok(Some(entry)) = entries.next_entry().await { - let name = entry.file_name(); - if keep.contains(&name) { - continue; - } + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); + if keep.contains(&name) { + continue; + } - let path = entry.path(); - let Ok(file_type) = entry.file_type().await else { - continue; - }; + let path = entry.path(); + let Ok(file_type) = entry.file_type().await else { + continue; + }; - let removed = if file_type.is_dir() { - polyio::remove_dir_all(&path).await - } else { - polyio::remove_file(&path).await - }; + let removed = if file_type.is_dir() { + polyio::remove_dir_all(&path).await + } else { + polyio::remove_file(&path).await + }; - if let Err(err) = removed { - tracing::warn!(path = %path.display(), %err, "could not remove leftover"); - } - } + if let Err(err) = removed { + tracing::warn!(path = %path.display(), %err, "could not remove leftover"); + } + } - Ok(()) + Ok(()) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn a_managed_runtime_sits_under_the_prefix_the_swap_looks_for() { - let root = PathBuf::from("D:").join("OneClient"); - let prefix = java_root(&root); + #[test] + fn the_write_ahead_sidecars_are_never_copied_by_hand() { + let names = database_names(); - let runtime = paths::java_dir() - .map(|dir| dir.join("21").join("bin").join("java")) - .expect("java dir resolves"); + assert!( + names.iter().any(|name| name == "user_data.db-wal"), + "a copy that took the database but not its log would arrive stale" + ); + assert!(names.iter().any(|name| name == "user_data.db-shm")); + } - let managed = root.join("metadata").join("java").join("21"); - assert!(managed.to_string_lossy().starts_with(&prefix)); - assert!(runtime.to_string_lossy().contains("java")); - } + #[test] + fn a_folder_that_is_not_the_config_folder_gives_nothing_up() { + let names = config_owned_names(Path::new("D:").join("OneClient").as_path()); - #[test] - fn the_write_ahead_sidecars_are_never_copied_by_hand() { - let names = database_names(); + assert!( + names.is_empty(), + "settings only stay behind when the source folder is the one holding them" + ); + } - assert!( - names.iter().any(|name| name == "user_data.db-wal"), - "a copy that took the database but not its log would arrive stale" - ); - assert!(names.iter().any(|name| name == "user_data.db-shm")); - } + #[tokio::test] + async fn the_walk_skips_what_it_is_told_to_and_counts_the_rest() { + let root = polyio::testing::ScratchDir::new("relocate_collect"); + let dir = root.path(); + polyio::create_dir_all(dir.join("metadata")).await.unwrap(); - #[test] - fn a_folder_that_is_not_the_config_folder_gives_nothing_up() { - let names = config_owned_names(Path::new("D:").join("OneClient").as_path()); + polyio::write(dir.join("user_data.db"), b"skipped".as_slice()) + .await + .unwrap(); + polyio::write(dir.join("metadata").join("kept.bin"), b"1234".as_slice()) + .await + .unwrap(); - assert!( - names.is_empty(), - "settings only stay behind when the source folder is the one holding them" - ); - } + let found = collect(dir, &[OsString::from("user_data.db")]) + .await + .unwrap(); - #[tokio::test] - async fn the_walk_skips_what_it_is_told_to_and_counts_the_rest() { - let root = polyio::testing::ScratchDir::new("relocate_collect"); - let dir = root.path(); - polyio::create_dir_all(dir.join("metadata")).await.unwrap(); + assert_eq!(found.total, 4, "only the file we did not skip counts"); + assert_eq!(found.files.len(), 1); + assert_eq!( + found.dirs.len(), + 1, + "the folder itself still has to be made" + ); - polyio::write(dir.join("user_data.db"), b"skipped".as_slice()) - .await - .unwrap(); - polyio::write(dir.join("metadata").join("kept.bin"), b"1234".as_slice()) - .await - .unwrap(); + std::fs::remove_dir_all(dir).ok(); + } - let found = collect(dir, &[OsString::from("user_data.db")]).await.unwrap(); + #[cfg(unix)] + #[tokio::test] + async fn a_socket_is_walked_past_rather_than_queued_for_copying() { + let root = polyio::testing::ScratchDir::new("relocate_socket"); + let dir = root.path(); - assert_eq!(found.total, 4, "only the file we did not skip counts"); - assert_eq!(found.files.len(), 1); - assert_eq!(found.dirs.len(), 1, "the folder itself still has to be made"); + let socket = std::os::unix::net::UnixListener::bind(dir.join("ipc.sock")).unwrap(); + polyio::write(dir.join("kept.bin"), vec![0u8; 100]) + .await + .unwrap(); - std::fs::remove_dir_all(dir).ok(); - } + let found = collect(dir, &[]).await.unwrap(); - #[cfg(unix)] - #[tokio::test] - async fn a_socket_is_walked_past_rather_than_queued_for_copying() { - let root = polyio::testing::ScratchDir::new("relocate_socket"); - let dir = root.path(); + assert_eq!( + found.files.len(), + 1, + "a socket cannot be copied, so it must never reach the copy list" + ); + assert_eq!(found.total, 100, "nor count towards what has to fit"); - let socket = std::os::unix::net::UnixListener::bind(dir.join("ipc.sock")).unwrap(); - polyio::write(dir.join("kept.bin"), vec![0u8; 100]) - .await - .unwrap(); + drop(socket); + std::fs::remove_dir_all(dir).ok(); + } - let found = collect(dir, &[]).await.unwrap(); + #[tokio::test] + async fn a_folder_kept_out_of_the_copy_is_kept_out_of_the_total_too() { + let root = polyio::testing::ScratchDir::new("relocate_skip_dir"); + let dir = root.path(); + polyio::create_dir_all(dir.join("logs")).await.unwrap(); - assert_eq!( - found.files.len(), - 1, - "a socket cannot be copied, so it must never reach the copy list" - ); - assert_eq!(found.total, 100, "nor count towards what has to fit"); + polyio::write(dir.join("logs").join("latest.log"), vec![0u8; 900]) + .await + .unwrap(); + polyio::write(dir.join("kept.bin"), vec![0u8; 100]) + .await + .unwrap(); - drop(socket); - std::fs::remove_dir_all(dir).ok(); - } + let found = collect(dir, &[OsString::from("logs")]).await.unwrap(); - #[tokio::test] - async fn a_folder_kept_out_of_the_copy_is_kept_out_of_the_total_too() { - let root = polyio::testing::ScratchDir::new("relocate_skip_dir"); - let dir = root.path(); - polyio::create_dir_all(dir.join("logs")).await.unwrap(); + assert_eq!(found.total, 100, "the skipped folder is not counted"); + assert!( + found.dirs.is_empty(), + "nor is it recreated at the destination" + ); - polyio::write(dir.join("logs").join("latest.log"), vec![0u8; 900]) - .await - .unwrap(); - polyio::write(dir.join("kept.bin"), vec![0u8; 100]) - .await - .unwrap(); - - let found = collect(dir, &[OsString::from("logs")]).await.unwrap(); - - assert_eq!(found.total, 100, "the skipped folder is not counted"); - assert!( - found.dirs.is_empty(), - "nor is it recreated at the destination" - ); - - std::fs::remove_dir_all(dir).ok(); - } + std::fs::remove_dir_all(dir).ok(); + } } diff --git a/packages/oneclient_core/src/reporting.rs b/packages/oneclient_core/src/reporting.rs index 01a15bf6..5e167a32 100644 --- a/packages/oneclient_core/src/reporting.rs +++ b/packages/oneclient_core/src/reporting.rs @@ -1,5 +1,4 @@ use std::panic; -use std::sync::Arc; use std::time::Duration; use sentry::protocol::{Breadcrumb, Context, Event}; @@ -59,15 +58,13 @@ pub fn init(enabled: bool) -> Option { let guard = sentry::init(( SENTRY_DSN, - ClientOptions { - release: Some(format!("oneclient@{}", env!("CARGO_PKG_VERSION")).into()), - environment: Some(ENVIRONMENT.into()), - attach_stacktrace: true, - send_default_pii: false, - before_send: Some(Arc::new(drop_opted_out_events)), - before_breadcrumb: Some(Arc::new(drop_opted_out_breadcrumbs)), - ..Default::default() - }, + ClientOptions::new() + .release(format!("oneclient@{}", env!("CARGO_PKG_VERSION"))) + .environment(ENVIRONMENT) + .attach_stacktrace(true) + .send_default_pii(false) + .before_send(drop_opted_out_events) + .before_breadcrumb(drop_opted_out_breadcrumbs), )); if !guard.is_enabled() { @@ -91,4 +88,4 @@ fn install_panic_flush() { client.flush(Some(PANIC_FLUSH_TIMEOUT)); } })); -} \ No newline at end of file +} diff --git a/packages/oneclient_core/src/settings/data_dir.rs b/packages/oneclient_core/src/settings/data_dir.rs index 6e4842ed..97a8e5a3 100644 --- a/packages/oneclient_core/src/settings/data_dir.rs +++ b/packages/oneclient_core/src/settings/data_dir.rs @@ -12,258 +12,260 @@ const LOW_SPACE_BYTES: u64 = 5 * 1000 * 1000 * 1000; const PROBE_NAME: &str = ".oneclient_write_test"; const OS_CLUTTER: &[&str] = &[ - ".DS_Store", - ".localized", - ".Spotlight-V100", - ".Trashes", - ".fseventsd", - "desktop.ini", - "Thumbs.db", - "$RECYCLE.BIN", - "System Volume Information", + ".DS_Store", + ".localized", + ".Spotlight-V100", + ".Trashes", + ".fseventsd", + "desktop.ini", + "Thumbs.db", + "$RECYCLE.BIN", + "System Volume Information", ]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct DataDirCheck { - pub path: PathBuf, - pub warning: Option, + pub path: PathBuf, + pub warning: Option, } pub async fn looks_empty(dir: &Path, ignored: &[OsString]) -> bool { - let Ok(mut entries) = polyio::read_dir(dir).await else { - return true; - }; + let Ok(mut entries) = polyio::read_dir(dir).await else { + return true; + }; - while let Ok(Some(entry)) = entries.next_entry().await { - let name = entry.file_name(); + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name(); - if ignored.contains(&name) { - continue; - } + if ignored.contains(&name) { + continue; + } - let Some(name) = name.to_str() else { - return false; - }; + let Some(name) = name.to_str() else { + return false; + }; - if !OS_CLUTTER.iter().any(|junk| junk.eq_ignore_ascii_case(name)) { - return false; - } - } + if !OS_CLUTTER + .iter() + .any(|junk| junk.eq_ignore_ascii_case(name)) + { + return false; + } + } - true + true } pub async fn resolve(picked: &Path) -> PathBuf { - if picked.file_name().is_some_and(|name| name == FOLDER_NAME) - || looks_empty(picked, &[]).await - { - picked.to_path_buf() - } else { - picked.join(FOLDER_NAME) - } + if picked.file_name().is_some_and(|name| name == FOLDER_NAME) || looks_empty(picked, &[]).await + { + picked.to_path_buf() + } else { + picked.join(FOLDER_NAME) + } } pub fn default_path() -> Result { - paths::config_dir() - .map(Path::to_path_buf) - .map_err(|err| format!("Couldn't work out where OneClient keeps its settings: {err}")) + paths::config_dir() + .map(Path::to_path_buf) + .map_err(|err| format!("Couldn't work out where OneClient keeps its settings: {err}")) } #[must_use] pub fn is_default(dir: &Path) -> bool { - default_path().is_ok_and(|home| home == dir) + default_path().is_ok_and(|home| home == dir) } pub async fn check(picked: &Path) -> Result { - check_exact(&resolve(picked).await).await + check_exact(&resolve(picked).await).await } pub async fn check_exact(path: &Path) -> Result { - let path = path.to_path_buf(); + let path = path.to_path_buf(); - let existed = polyio::try_exists(&path).await.unwrap_or(false); - polyio::create_dir_all(&path) - .await - .map_err(|err| format!("Couldn't create {}: {err}", path.display()))?; + let existed = polyio::try_exists(&path).await.unwrap_or(false); + polyio::create_dir_all(&path) + .await + .map_err(|err| format!("Couldn't create {}: {err}", path.display()))?; - let probe = path.join(PROBE_NAME); - let writable = polyio::write(&probe, b"".as_slice()).await; - polyio::remove_file(&probe).await.ok(); + let probe = path.join(PROBE_NAME); + let writable = polyio::write(&probe, b"".as_slice()).await; + polyio::remove_file(&probe).await.ok(); - if let Err(err) = writable { - if !existed { - polyio::remove_dir(&path).await.ok(); - } - return Err(format!("Can't write to {}: {err}", path.display())); - } + if let Err(err) = writable { + if !existed { + polyio::remove_dir(&path).await.ok(); + } + return Err(format!("Can't write to {}: {err}", path.display())); + } - if !existed { - polyio::remove_dir(&path).await.ok(); - } + if !existed { + polyio::remove_dir(&path).await.ok(); + } - Ok(DataDirCheck { - warning: low_space_warning(&path), - path, - }) + Ok(DataDirCheck { + warning: low_space_warning(&path), + path, + }) } pub async fn apply(picked: Option) -> Result<(), String> { - let mut settings = LauncherSettings::default(); - let mut chosen = None; + let mut settings = LauncherSettings::default(); + let mut chosen = None; - if let Some(picked) = picked { - let checked = check(&picked).await?; + if let Some(picked) = picked { + let checked = check(&picked).await?; - polyio::create_dir_all(&checked.path) - .await - .map_err(|err| format!("Couldn't create {}: {err}", checked.path.display()))?; + polyio::create_dir_all(&checked.path) + .await + .map_err(|err| format!("Couldn't create {}: {err}", checked.path.display()))?; - settings.data_dir = Some(checked.path.clone()); - chosen = Some(checked.path); - } + settings.data_dir = Some(checked.path.clone()); + chosen = Some(checked.path); + } - save_settings(&settings) - .await - .map_err(|err| format!("Couldn't save your settings: {err}"))?; + save_settings(&settings) + .await + .map_err(|err| format!("Couldn't save your settings: {err}"))?; - if let Some(chosen) = chosen { - paths::set_data_dir(chosen); - } + if let Some(chosen) = chosen { + paths::set_data_dir(chosen); + } - Ok(()) + Ok(()) } fn low_space_warning(path: &Path) -> Option { - let available = available_space(path)?; - if available >= LOW_SPACE_BYTES { - return None; - } + let available = available_space(path)?; + if available >= LOW_SPACE_BYTES { + return None; + } - Some(format!( - "Only {} free here. Minecraft, its libraries and a Java runtime need a few GB before you \ + Some(format!( + "Only {} free here. Minecraft, its libraries and a Java runtime need a few GB before you \ install anything.", - crate::storage::format_bytes(available) - )) + crate::storage::format_bytes(available) + )) } pub(crate) fn available_space(path: &Path) -> Option { - let target = polyio::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + let target = polyio::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); - sysinfo::Disks::new_with_refreshed_list() - .iter() - .filter(|disk| target.starts_with(disk.mount_point())) - .max_by_key(|disk| disk.mount_point().as_os_str().len()) - .map(sysinfo::Disk::available_space) + sysinfo::Disks::new_with_refreshed_list() + .iter() + .filter(|disk| target.starts_with(disk.mount_point())) + .max_by_key(|disk| disk.mount_point().as_os_str().len()) + .map(sysinfo::Disk::available_space) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[tokio::test] - async fn a_folder_with_things_in_it_gets_a_folder_of_its_own() { - let root = polyio::testing::ScratchDir::new("data_dir_busy"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); - polyio::write(dir.join("holiday.jpg"), b"mine".as_slice()) - .await - .unwrap(); + #[tokio::test] + async fn a_folder_with_things_in_it_gets_a_folder_of_its_own() { + let root = polyio::testing::ScratchDir::new("data_dir_busy"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + polyio::write(dir.join("holiday.jpg"), b"mine".as_slice()) + .await + .unwrap(); - assert_eq!(resolve(dir).await, dir.join(FOLDER_NAME)); + assert_eq!(resolve(dir).await, dir.join(FOLDER_NAME)); - std::fs::remove_dir_all(dir).ok(); - } + std::fs::remove_dir_all(dir).ok(); + } - #[tokio::test] - async fn an_empty_folder_is_used_as_it_stands() { - let root = polyio::testing::ScratchDir::new("data_dir_empty"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); + #[tokio::test] + async fn an_empty_folder_is_used_as_it_stands() { + let root = polyio::testing::ScratchDir::new("data_dir_empty"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); - assert_eq!( - resolve(dir).await, - dir.to_path_buf(), - "a folder made for this should not get another one inside it" - ); + assert_eq!( + resolve(dir).await, + dir.to_path_buf(), + "a folder made for this should not get another one inside it" + ); - std::fs::remove_dir_all(dir).ok(); - } + std::fs::remove_dir_all(dir).ok(); + } - #[tokio::test] - async fn a_folder_the_finder_has_been_in_still_counts_as_empty() { - let root = polyio::testing::ScratchDir::new("data_dir_clutter"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); - polyio::write(dir.join(".DS_Store"), b"junk".as_slice()) - .await - .unwrap(); + #[tokio::test] + async fn a_folder_the_finder_has_been_in_still_counts_as_empty() { + let root = polyio::testing::ScratchDir::new("data_dir_clutter"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + polyio::write(dir.join(".DS_Store"), b"junk".as_slice()) + .await + .unwrap(); - assert_eq!( - resolve(dir).await, - dir.to_path_buf(), - "the folder looks empty to the person who picked it" - ); + assert_eq!( + resolve(dir).await, + dir.to_path_buf(), + "the folder looks empty to the person who picked it" + ); - std::fs::remove_dir_all(dir).ok(); - } + std::fs::remove_dir_all(dir).ok(); + } - #[tokio::test] - async fn resolving_twice_does_not_nest() { - let root = polyio::testing::ScratchDir::new("data_dir_nest"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); - polyio::write(dir.join("holiday.jpg"), b"mine".as_slice()) - .await - .unwrap(); + #[tokio::test] + async fn resolving_twice_does_not_nest() { + let root = polyio::testing::ScratchDir::new("data_dir_nest"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + polyio::write(dir.join("holiday.jpg"), b"mine".as_slice()) + .await + .unwrap(); - let once = resolve(dir).await; - assert_eq!(resolve(&once).await, once); + let once = resolve(dir).await; + assert_eq!(resolve(&once).await, once); - std::fs::remove_dir_all(dir).ok(); - } + std::fs::remove_dir_all(dir).ok(); + } - #[tokio::test] - async fn the_settings_that_stayed_behind_do_not_make_the_default_folder_look_busy() { - let root = polyio::testing::ScratchDir::new("data_dir_settled"); - let dir = root.path(); - polyio::create_dir_all(dir).await.unwrap(); - polyio::write(dir.join("settings.json"), b"{}".as_slice()) - .await - .unwrap(); + #[tokio::test] + async fn the_settings_that_stayed_behind_do_not_make_the_default_folder_look_busy() { + let root = polyio::testing::ScratchDir::new("data_dir_settled"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + polyio::write(dir.join("settings.json"), b"{}".as_slice()) + .await + .unwrap(); - let settled = [OsString::from("settings.json")]; + let settled = [OsString::from("settings.json")]; - assert!( - looks_empty(dir, &settled).await, - "moving back in has to be allowed over the settings that never left" - ); + assert!( + looks_empty(dir, &settled).await, + "moving back in has to be allowed over the settings that never left" + ); - polyio::create_dir_all(dir.join("clusters")).await.unwrap(); + polyio::create_dir_all(dir.join("clusters")).await.unwrap(); - assert!( - !looks_empty(dir, &settled).await, - "game data already sitting there is another matter" - ); + assert!( + !looks_empty(dir, &settled).await, + "game data already sitting there is another matter" + ); - std::fs::remove_dir_all(dir).ok(); - } + std::fs::remove_dir_all(dir).ok(); + } - #[tokio::test] - async fn a_writable_folder_passes_and_is_not_left_behind() { - let root = polyio::testing::ScratchDir::new("data_dir_check"); - polyio::create_dir_all(root.path()).await.unwrap(); + #[tokio::test] + async fn a_writable_folder_passes_and_is_not_left_behind() { + let root = polyio::testing::ScratchDir::new("data_dir_check"); + polyio::create_dir_all(root.path()).await.unwrap(); - polyio::write(root.join("holiday.jpg"), b"mine".as_slice()) - .await - .unwrap(); + polyio::write(root.join("holiday.jpg"), b"mine".as_slice()) + .await + .unwrap(); - let checked = check(root.path()).await.unwrap(); - assert_eq!(checked.path, root.join(FOLDER_NAME)); - assert!( - !checked.path.exists(), - "a folder the user has not confirmed yet is litter" - ); + let checked = check(root.path()).await.unwrap(); + assert_eq!(checked.path, root.join(FOLDER_NAME)); + assert!( + !checked.path.exists(), + "a folder the user has not confirmed yet is litter" + ); - std::fs::remove_dir_all(root.path()).ok(); - } + std::fs::remove_dir_all(root.path()).ok(); + } } diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs index 81db7eac..91db0191 100644 --- a/packages/oneclient_core/src/settings/launcher.rs +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -8,91 +8,139 @@ use oneclient_cluster::GameSettingsProfile; #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum ViewLayout { - #[default] - Grid, - List, + #[default] + Grid, + List, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum LaunchBehaviour { + #[default] + KeepVisible, + HideWhilePlaying, + CloseLauncher, +} + +impl LaunchBehaviour { + pub const ALL: [Self; 3] = [ + Self::KeepVisible, + Self::HideWhilePlaying, + Self::CloseLauncher, + ]; + + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::KeepVisible => "Keep visible", + Self::HideWhilePlaying => "Hide while playing", + Self::CloseLauncher => "Close completely", + } + } } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)] #[serde(default)] pub struct ViewState { - pub layout: ViewLayout, - #[serde(skip_serializing_if = "Option::is_none")] - pub sort: Option, + pub layout: ViewLayout, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option, } +pub const CURRENT_SETTINGS_VERSION: u32 = 2; + #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(default)] pub struct LauncherSettings { - pub settings_version: u32, - pub log_debug: bool, - pub auto_update: bool, - pub crash_reporting: bool, - pub enable_gamemode: bool, - pub discord_enabled: bool, - pub max_concurrent_requests: usize, - pub global_game_settings: GameSettingsProfile, - pub allow_parallel_running_clusters: bool, - pub dynamic_background_enabled: bool, - pub start_maximized: bool, - pub animations_enabled: bool, - pub view_states: BTreeMap, - pub seen_onboarding: bool, - pub skip_microsoft_java: bool, - pub microsoft_java_migrated: bool, - pub accepted_tos_version: u32, - pub accepted_privacy_version: u32, - pub declined_tos: bool, - pub seen_versions: Vec, - pub seen_changelog_version: Option, - pub modrinth_api_key: Option, - pub curseforge_api_key: Option, - pub custom_api_endpoint: Option, - pub custom_meta_url_base: Option, - pub data_dir: Option, - pub previous_data_dir: Option, + pub settings_version: u32, + pub log_debug: bool, + pub auto_update: bool, + pub crash_reporting: bool, + pub enable_gamemode: bool, + pub discord_enabled: bool, + pub max_concurrent_requests: usize, + pub global_game_settings: GameSettingsProfile, + pub allow_parallel_running_clusters: bool, + pub launch_behaviour: LaunchBehaviour, + pub run_in_background: bool, + pub show_tray_icon: bool, + pub dynamic_background_enabled: bool, + pub start_maximized: bool, + pub animations_enabled: bool, + pub view_states: BTreeMap, + pub seen_onboarding: bool, + pub skip_microsoft_java: bool, + pub microsoft_java_migrated: bool, + pub accepted_tos_version: u32, + pub accepted_privacy_version: u32, + pub declined_tos: bool, + pub seen_versions: Vec, + pub seen_changelog_version: Option, + pub pending_release_migrations: Vec, + pub modrinth_api_key: Option, + pub curseforge_api_key: Option, + pub custom_api_endpoint: Option, + pub custom_meta_url_base: Option, + pub data_dir: Option, + pub previous_data_dir: Option, } impl LauncherSettings { - pub fn view_state(&self, key: &str) -> ViewState { - self.view_states.get(key).cloned().unwrap_or_default() - } + pub fn view_state(&self, key: &str) -> ViewState { + self.view_states.get(key).cloned().unwrap_or_default() + } - pub fn set_view_state(&mut self, key: impl Into, state: ViewState) { - self.view_states.insert(key.into(), state); - } + pub fn set_view_state(&mut self, key: impl Into, state: ViewState) { + self.view_states.insert(key.into(), state); + } +} + +impl LauncherSettings { + /// Brings a file written by an older launcher up to the current defaults + pub fn migrate(&mut self) { + if self.settings_version < 2 { + // Had no toggle so a saved `false` was never the user's choice + // dedicated clusters plus one shared cluster may now run together + self.allow_parallel_running_clusters = true; + } + self.settings_version = CURRENT_SETTINGS_VERSION; + } } impl Default for LauncherSettings { - fn default() -> Self { - Self { - settings_version: 1, - log_debug: false, - auto_update: true, - crash_reporting: true, - discord_enabled: true, - enable_gamemode: false, - max_concurrent_requests: 25, - global_game_settings: GameSettingsProfile::default_global_profile(), - allow_parallel_running_clusters: false, - dynamic_background_enabled: true, - start_maximized: false, - animations_enabled: true, - view_states: BTreeMap::new(), - seen_onboarding: false, - skip_microsoft_java: false, - microsoft_java_migrated: false, - accepted_tos_version: 0, - accepted_privacy_version: 0, - declined_tos: false, - seen_versions: Vec::new(), - seen_changelog_version: None, - modrinth_api_key: None, - curseforge_api_key: None, - custom_api_endpoint: None, - custom_meta_url_base: None, - data_dir: None, - previous_data_dir: None, - } - } + fn default() -> Self { + Self { + settings_version: CURRENT_SETTINGS_VERSION, + log_debug: false, + auto_update: true, + crash_reporting: true, + discord_enabled: true, + enable_gamemode: false, + max_concurrent_requests: 25, + global_game_settings: GameSettingsProfile::default_global_profile(), + allow_parallel_running_clusters: true, + launch_behaviour: LaunchBehaviour::default(), + run_in_background: false, + show_tray_icon: true, + dynamic_background_enabled: true, + start_maximized: false, + animations_enabled: true, + view_states: BTreeMap::new(), + seen_onboarding: false, + skip_microsoft_java: false, + microsoft_java_migrated: false, + accepted_tos_version: 0, + accepted_privacy_version: 0, + declined_tos: false, + seen_versions: Vec::new(), + seen_changelog_version: None, + pending_release_migrations: Vec::new(), + modrinth_api_key: None, + curseforge_api_key: None, + custom_api_endpoint: None, + custom_meta_url_base: None, + data_dir: None, + previous_data_dir: None, + } + } } diff --git a/packages/oneclient_core/src/settings/mod.rs b/packages/oneclient_core/src/settings/mod.rs index 2c54d3af..5b33b041 100644 --- a/packages/oneclient_core/src/settings/mod.rs +++ b/packages/oneclient_core/src/settings/mod.rs @@ -2,9 +2,9 @@ mod launcher; pub mod data_dir; -pub use launcher::{LauncherSettings, ViewLayout, ViewState}; +pub use launcher::{LaunchBehaviour, LauncherSettings, ViewLayout, ViewState}; pub use oneclient_cluster::{ - GameSettingsProfile, PackageUpdateMode, ProfileUpdate, SettingsOsExtra, + GameSettingsProfile, PackageUpdateMode, ProfileUpdate, SettingsOsExtra, }; pub use oneclient_common::Resolution; @@ -14,11 +14,10 @@ pub mod store; /// client never reaches back into global settings (and thus into its own owner) #[must_use] pub fn net_config(settings: &LauncherSettings) -> oneclient_net::NetConfig { - oneclient_net::NetConfig::default().with_overrides( - settings.curseforge_api_key.as_deref(), - settings.modrinth_api_key.as_deref(), - settings.custom_api_endpoint.as_deref(), - settings.custom_meta_url_base.as_deref(), - ) + oneclient_net::NetConfig::default().with_overrides( + settings.curseforge_api_key.as_deref(), + settings.modrinth_api_key.as_deref(), + settings.custom_api_endpoint.as_deref(), + settings.custom_meta_url_base.as_deref(), + ) } - diff --git a/packages/oneclient_core/src/settings/store.rs b/packages/oneclient_core/src/settings/store.rs index e7e28a88..7ca05990 100644 --- a/packages/oneclient_core/src/settings/store.rs +++ b/packages/oneclient_core/src/settings/store.rs @@ -1,8 +1,8 @@ use parking_lot::RwLock; -use oneclient_events::EventBus; -use oneclient_common::paths; use crate::{LauncherError, LauncherResult}; +use oneclient_common::paths; +use oneclient_events::EventBus; use super::launcher::LauncherSettings; use oneclient_cluster::GameSettingsProfile; @@ -17,7 +17,9 @@ pub async fn load_settings(notify: Option<&EventBus>) -> LauncherSettings { LauncherSettings::default() } else { let data = polyio::read(&path).await?; - serde_json::from_slice(&data)? + let mut settings: LauncherSettings = serde_json::from_slice(&data)?; + settings.migrate(); + settings }) } .await @@ -27,7 +29,11 @@ pub async fn load_settings(notify: Option<&EventBus>) -> LauncherSettings { tracing::warn!("failed to read settings file: {err}"); if let Some(notify) = notify { - notify.notify("Settings").body("Failed to load settings").error().send(); + notify + .notify("Settings") + .body("Failed to load settings") + .error() + .send(); } recover_after_failed_load().await diff --git a/packages/oneclient_core/src/simulate.rs b/packages/oneclient_core/src/simulate.rs index 63c3f6d2..1c241ace 100644 --- a/packages/oneclient_core/src/simulate.rs +++ b/packages/oneclient_core/src/simulate.rs @@ -1,12 +1,12 @@ use std::path::{Path, PathBuf}; +use oneclient_common::paths; use oneclient_content::packages::PackageStore; use oneclient_content::packages::store::artifact_absolute_path; -use oneclient_common::paths; use oneclient_db::dao::artifact as artifact_dao; -use crate::state::LauncherState; use crate::LauncherResult; +use crate::state::LauncherState; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct SimulationReport { @@ -281,10 +281,8 @@ mod tests { use super::*; fn scratch(tag: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "oneclient-simulate-{tag}-{}", - uuid::Uuid::new_v4() - )); + let dir = + std::env::temp_dir().join(format!("oneclient-simulate-{tag}-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); dir } @@ -351,6 +349,9 @@ mod tests { assert_eq!(report.affected, 4); assert_eq!(report.samples.len(), 3); - assert_eq!(report.summary("Corrupted"), "Corrupted 4 file(s): aaaa and 3 more"); + assert_eq!( + report.summary("Corrupted"), + "Corrupted 4 file(s): aaaa and 3 more" + ); } } diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs index e0a9c594..fc58bb7e 100644 --- a/packages/oneclient_core/src/state.rs +++ b/packages/oneclient_core/src/state.rs @@ -3,179 +3,200 @@ use std::sync::Arc; use oneclient_db::DbPool; use tokio::sync::Mutex; -use oneclient_auth::AuthService; -use crate::clusters::ClusterManager; -use oneclient_content::bundles::BundlesManager; -use oneclient_discord::DiscordRpc; -use oneclient_net::RequestClient; -use crate::images::ImageCacheStore; -use oneclient_java::JavaService; -use oneclient_events::EventBus; -use oneclient_mc::MetadataStore; -use oneclient_content::packages::provider::PackageProviderRegistry; -use oneclient_common::paths; -use crate::settings::{store, LauncherSettings}; -use crate::versions::VersionsManager; use crate::LauncherResult; +use crate::clusters::ClusterManager; +use crate::images::ImageCacheStore; +use crate::settings::{LauncherSettings, store}; +use crate::versions::VersionsManager; +use oneclient_auth::AuthService; +use oneclient_common::paths; +use oneclient_content::bundles::BundlesManager; +use oneclient_content::packages::provider::PackageProviderRegistry; +use oneclient_discord::DiscordRpc; +use oneclient_events::EventBus; +use oneclient_java::JavaService; +use oneclient_mc::MetadataStore; +use oneclient_net::RequestClient; #[derive(Clone)] pub struct LauncherServices { - pub events: EventBus, - pub requester: RequestClient, - pub db: DbPool, - pub packages: PackageProviderRegistry, + pub events: EventBus, + pub requester: RequestClient, + pub db: DbPool, + pub packages: PackageProviderRegistry, } pub struct LauncherState { - pub services: LauncherServices, - pub settings: parking_lot::RwLock, - pub auth: Arc, - pub java: JavaService, - pub clusters: ClusterManager, - pub metadata: Mutex, - pub bundles: Arc, - pub versions: Arc, - pub images: ImageCacheStore, - pub games: crate::game::GameProcessManager, - pub discord: DiscordRpc, + pub services: LauncherServices, + pub settings: parking_lot::RwLock, + pub auth: Arc, + pub java: JavaService, + pub clusters: ClusterManager, + pub metadata: Mutex, + pub bundles: Arc, + pub versions: Arc, + pub images: ImageCacheStore, + pub games: crate::game::GameProcessManager, + pub discord: DiscordRpc, } impl LauncherServices { - #[must_use] - pub fn content(&self) -> oneclient_content::ContentCtx { - oneclient_content::ContentCtx::new( - self.db.clone(), - self.requester.clone(), - self.events.clone(), - self.packages.clone(), - ) - } + #[must_use] + pub fn content(&self) -> oneclient_content::ContentCtx { + oneclient_content::ContentCtx::new( + self.db.clone(), + self.requester.clone(), + self.events.clone(), + self.packages.clone(), + ) + } - /// Both fields are refcounted handles so building this per call is cheap - #[must_use] - pub fn mc(&self) -> oneclient_mc::McCtx { - oneclient_mc::McCtx::new(self.requester.clone(), self.events.clone()) - } + /// Both fields are refcounted handles so building this per call is cheap + #[must_use] + pub fn mc(&self) -> oneclient_mc::McCtx { + oneclient_mc::McCtx::new(self.requester.clone(), self.events.clone()) + } } impl LauncherState { - /// No side effects beyond opening the database and reading settings the - /// background startup work is [`run_startup_tasks`] which the caller runs + /// No side effects beyond opening the database and reading settings the + /// background startup work is [`run_startup_tasks`] which the caller runs #[tracing::instrument(skip(events))] - pub async fn new(events: EventBus) -> LauncherResult> { + pub async fn new(events: EventBus) -> LauncherResult> { let services = LauncherServices { - events, - db: oneclient_db::connect(paths::database_file()?).await?, - // Defaults because loading settings needs the event bus already inside - // `services` Real config is pushed in below before any request - requester: RequestClient::new(oneclient_net::NetConfig::default())?, - packages: PackageProviderRegistry::new(), - }; + events, + db: oneclient_db::connect(paths::database_file()?).await?, + // Defaults because loading settings needs the event bus already inside + // `services` Real config is pushed in below before any request + requester: RequestClient::new(oneclient_net::NetConfig::default())?, + packages: PackageProviderRegistry::new(), + }; let settings = store::load_settings(Some(&services.events)).await; - oneclient_common::consent::init(settings.declined_tos); - services - .requester - .set_config(crate::settings::net_config(&settings)); - let auth = Arc::new( - AuthService::load(services.requester.clone(), services.events.clone()).await?, - ); + oneclient_common::consent::init(settings.declined_tos); + services + .requester + .set_config(crate::settings::net_config(&settings)); + let auth = + Arc::new(AuthService::load(services.requester.clone(), services.events.clone()).await?); let java = JavaService::new( - std::sync::Arc::new(crate::java_store::SqlJavaStore::new(services.db.clone())), - services.requester.clone(), - services.events.clone(), - ); + std::sync::Arc::new(crate::java_store::SqlJavaStore::new(services.db.clone())), + services.requester.clone(), + services.events.clone(), + ); let discord = DiscordRpc::spawn(settings.discord_enabled); - let clusters = ClusterManager::new(services.db.clone()); + let clusters = ClusterManager::new(services.db.clone()); - let state = Arc::new(Self { - services, - settings: parking_lot::RwLock::new(settings), - auth, - java, - clusters, - metadata: Mutex::new(MetadataStore::new()), - bundles: Arc::new(BundlesManager::new()), - versions: Arc::new(VersionsManager::from_cache().await), - images: ImageCacheStore::new(), - games: crate::game::GameProcessManager::new(), - discord, - }); + let state = Arc::new(Self { + services, + settings: parking_lot::RwLock::new(settings), + auth, + java, + clusters, + metadata: Mutex::new(MetadataStore::new()), + bundles: Arc::new(BundlesManager::new()), + versions: Arc::new(VersionsManager::from_cache().await), + images: ImageCacheStore::new(), + games: crate::game::GameProcessManager::new(), + discord, + }); - Ok(state) - } + Ok(state) + } } /// Java archives are the only downloads big enough for a leaked scratch file to /// matter and they land flat in the java dir so this needs no recursion async fn sweep_java_scratch_files() { - let dir = match paths::java_dir() { - Ok(dir) => dir, - Err(err) => { - tracing::warn!("could not resolve the java dir to sweep: {err:#}"); - return; - } - }; + let dir = match paths::java_dir() { + Ok(dir) => dir, + Err(err) => { + tracing::warn!("could not resolve the java dir to sweep: {err:#}"); + return; + } + }; - if let Err(err) = polyio::sweep_temp_files(&dir).await { - tracing::warn!("java scratch file sweep failed: {err:#}"); - } + if let Err(err) = polyio::sweep_temp_files(&dir).await { + tracing::warn!("java scratch file sweep failed: {err:#}"); + } } pub fn run_startup_tasks(state: &Arc) { - let background = Arc::clone(state); - tokio::spawn(async move { - sweep_java_scratch_files().await; + let background = Arc::clone(state); + tokio::spawn(async move { + sweep_java_scratch_files().await; + background.clusters.sweep_trash().await; - let recovery = match crate::recovery::reconstruct_from_disk(&background).await { - Ok(report) => report, - Err(err) => { - tracing::error!("disk recovery failed: {err:#}"); - crate::recovery::RecoveryReport::default() - } - }; + let recovery = match crate::recovery::reconstruct_from_disk(&background).await { + Ok(report) => report, + Err(err) => { + tracing::error!("disk recovery failed: {err:#}"); + crate::recovery::RecoveryReport::default() + } + }; - crate::game::recover_sessions(&background).await; + crate::game::recover_sessions(&background).await; - let content = background.services.content(); - let (versions_res, bundles_res) = tokio::join!( - background.versions.sync(&background.services), - background.bundles.sync(&content), - ); - if let Err(err) = versions_res { - tracing::error!("versions manifest sync failed: {err:#}"); - } - if let Err(err) = bundles_res { - tracing::error!("bundle catalog sync failed: {err:#}"); - } + let content = background.services.content(); + let (versions_res, bundles_res, _) = tokio::join!( + background.versions.sync(&background.services), + background.bundles.sync(&content), + oneclient_content::packages::load_bad_mods(&content), + ); + if let Err(err) = versions_res { + tracing::error!("versions manifest sync failed: {err:#}"); + } + if let Err(err) = crate::clusters::record_new_versions(&background).await { + tracing::warn!("could not record new versions for migration: {err:#}"); + } + if let Err(err) = bundles_res { + tracing::error!("bundle catalog sync failed: {err:#}"); + } - if recovery.did_recover() - && let Err(err) = crate::recovery::restore_bundle_tracking(&background).await - { - tracing::warn!("bundle tracking restore failed: {err:#}"); - } + let art = Arc::clone(&background); + tokio::spawn(async move { + crate::versions::prefetch_version_art(&art).await; + }); - if let Err(err) = crate::clusters::apply_remote_migrations(&background).await { - tracing::error!("cluster migrations failed: {err:#}"); - } + if recovery.did_recover() + && let Err(err) = crate::recovery::restore_bundle_tracking(&background).await + { + tracing::warn!("bundle tracking restore failed: {err:#}"); + } - if let Err(err) = crate::clusters::ensure_from_versions(&background).await { - tracing::error!("versions cluster provisioning failed: {err:#}"); - } else { - background.services.events.signal(oneclient_events::Signal::ClustersChanged); - } + if let Err(err) = crate::clusters::apply_remote_migrations(&background).await { + tracing::error!("cluster migrations failed: {err:#}"); + } - // Must run last judging an artifact unused before recovery bundle - // tracking and provisioning have restored their rows would evict - // content still in use Only the row-driven half runs unattended - if let Err(err) = - oneclient_content::packages::store::collect_unused_artifacts(&content).await - { - tracing::warn!("package cache cleanup failed: {err:#}"); - } + if let Err(err) = crate::clusters::ensure_from_versions(&background).await { + tracing::error!("versions cluster provisioning failed: {err:#}"); + } else { + background + .services + .events + .signal(oneclient_events::Signal::ClustersChanged); + } - background.services.events.signal(oneclient_events::Signal::SyncComplete); - }); + // Must run last judging an artifact unused before recovery bundle + // tracking and provisioning have restored their rows would evict + // content still in use Only the row-driven half runs unattended + if let Err(err) = + oneclient_content::packages::store::collect_unused_artifacts(&content).await + { + tracing::warn!("package cache cleanup failed: {err:#}"); + } + + background + .services + .events + .signal(oneclient_events::Signal::SyncComplete); + }); +} + +pub async fn shutdown(state: &LauncherState) { + tracing::info!("shutting the launcher down"); + state.discord.shutdown(); + state.services.db.close().await; } diff --git a/packages/oneclient_core/src/storage.rs b/packages/oneclient_core/src/storage.rs index e149dbde..2185e3b1 100644 --- a/packages/oneclient_core/src/storage.rs +++ b/packages/oneclient_core/src/storage.rs @@ -10,16 +10,13 @@ use crate::state::LauncherState; use oneclient_common::domain::ContentType; use oneclient_common::paths; use oneclient_content::packages::store::manifest; -use oneclient_content::packages::store::{ - find_unreferenced_files, remove_unreferenced_files, -}; +use oneclient_content::packages::store::{find_unreferenced_files, remove_unreferenced_files}; use oneclient_events::EventBus; const LEGACY_TYPES: [ContentType; 2] = [ContentType::Mod, ContentType::DataPack]; // stable id for the storage scan's progress -pub const STORAGE_SCAN_PROGRESS: Uuid = - Uuid::from_u128(0x5354_4F52_4147_4500_0000_0000_0000_0001); +pub const STORAGE_SCAN_PROGRESS: Uuid = Uuid::from_u128(0x5354_4F52_4147_4500_0000_0000_0000_0001); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageEntry { @@ -159,25 +156,11 @@ async fn natives_in_use(state: &LauncherState) -> Option> { let mut in_use = HashSet::new(); for cluster in clusters { - let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); - - if cluster.mc_loader == oneclient_common::domain::GameLoader::Vanilla { - in_use.insert(mc_version); - continue; - } - - let resolved = oneclient_mc::get_loader_version_cached( - &metadata, - &mc_version, - cluster.mc_loader, - cluster.mc_loader_version.as_deref(), - ); - - match resolved { - Ok(Some(loader)) => { - in_use.insert(format!("{mc_version}-{}", loader.id)); + match crate::game::natives::cached_version_name(&metadata, &cluster) { + Some(version_name) => { + in_use.insert(version_name); } - Ok(None) | Err(_) => { + None => { tracing::info!( cluster = %cluster.name, loader = %cluster.mc_loader, @@ -575,7 +558,9 @@ mod tests { let dir = root.path(); polyio::create_dir_all(dir.join("nested")).await.unwrap(); - polyio::write(dir.join("a.jar"), vec![0u8; 1000]).await.unwrap(); + polyio::write(dir.join("a.jar"), vec![0u8; 1000]) + .await + .unwrap(); polyio::write(dir.join("nested").join("b.jar"), vec![0u8; 500]) .await .unwrap(); @@ -603,7 +588,9 @@ mod tests { polyio::create_dir_all(&store).await.unwrap(); polyio::create_dir_all(&cluster).await.unwrap(); - polyio::write(store.join("mod.jar"), vec![0u8; 1000]).await.unwrap(); + polyio::write(store.join("mod.jar"), vec![0u8; 1000]) + .await + .unwrap(); polyio::symlink_file(store.join("mod.jar"), cluster.join("mod.jar")) .await .unwrap(); diff --git a/packages/oneclient_core/src/tos.rs b/packages/oneclient_core/src/tos.rs index 23676900..f58e6ba7 100644 --- a/packages/oneclient_core/src/tos.rs +++ b/packages/oneclient_core/src/tos.rs @@ -1,11 +1,10 @@ - use serde::{Deserialize, Serialize}; +use crate::{LauncherError, LauncherResult}; use oneclient_common::constants; use oneclient_common::paths; -use oneclient_net::{EtagPolicy, fetch_cached}; use oneclient_net::RequestClient; -use crate::{LauncherError, LauncherResult}; +use oneclient_net::{EtagPolicy, fetch_cached}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TermsDocument { @@ -51,10 +50,7 @@ impl TermsDocument { #[tracing::instrument(level = "debug", skip(net))] pub async fn fetch_terms(net: &RequestClient) -> LauncherResult { - let url = format!( - "{}/oneclient/tos.json", - net.config().meta_url_base - ); + let url = format!("{}/oneclient/tos.json", net.config().meta_url_base); let cache_path = paths::caches_dir()?.join("TERMS.json"); let fetched = fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow) @@ -66,7 +62,6 @@ pub async fn fetch_terms(net: &RequestClient) -> LauncherResult { Ok(fetched.json()?) } - #[cfg(test)] mod tests { use super::*; diff --git a/packages/oneclient_core/src/verify.rs b/packages/oneclient_core/src/verify.rs index 97774788..c7f3dc08 100644 --- a/packages/oneclient_core/src/verify.rs +++ b/packages/oneclient_core/src/verify.rs @@ -1,18 +1,18 @@ use std::sync::Arc; +use oneclient_common::paths; use oneclient_content::packages::PackageStore; use oneclient_content::packages::store::artifact_absolute_path; use oneclient_db::dao::artifact as artifact_dao; use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase}; -use oneclient_common::paths; use polyio::{normalize_hash, sha1_file}; +use crate::LauncherResult; use crate::clusters::prepare::prepare_cluster_locked; use crate::game::{ download_version_info, get_loader_version, resolve_minecraft_version, verify_game_files, }; use crate::state::LauncherState; -use crate::LauncherResult; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct ClusterVerifyReport { @@ -25,6 +25,7 @@ pub struct ClusterVerifyReport { pub unrepairable: Vec, /// Game files re-downloaded uncounted because the assets index was gone too pub reinstalled_game_files: bool, + pub natives_in_use_by: Option, } impl ClusterVerifyReport { @@ -38,6 +39,16 @@ impl ClusterVerifyReport { #[must_use] pub fn summary(&self) -> String { + let summary = self.counts_summary(); + match &self.natives_in_use_by { + Some(name) => format!( + "{summary} Native libraries were skipped because {name} is running, verify again after closing it." + ), + None => summary, + } + } + + fn counts_summary(&self) -> String { if self.is_clean() { return format!("All {} files verified.", self.checked); } @@ -123,6 +134,7 @@ async fn run_verify( &state.services.mc(), Some(progress), &version, + cluster.mc_loader, loader_version.as_ref(), false, ) @@ -154,6 +166,7 @@ async fn run_verify( // Counted apart from content which repairs itself as it goes one bucket // would count the content ones twice let mut game_broken = 0; + let mut natives_broken = 0; // The index lives in the assets directory so its absence usually means that // whole directory is gone hand the work to prepare @@ -173,6 +186,7 @@ async fn run_verify( report.corrupt += game.corrupt; report.missing += game.missing; game_broken = game.corrupt + game.missing; + natives_broken = game.natives_unextracted; } else { tracing::warn!("assets index is missing; re-downloading the game files wholesale"); } @@ -181,9 +195,26 @@ async fn run_verify( // Runs `prepare` directly not the launch path which skips clusters already // marked `Ready` and so never heals one with deleted files - if game_broken > 0 || reinstalled_game_files { + let natives_holder = crate::game::natives::natives_holder(state, &version_info.id, None) + .filter(|_| natives_broken > 0); + let repairable = if natives_holder.is_some() { + game_broken.saturating_sub(natives_broken) + } else { + game_broken + }; + + if repairable > 0 || reinstalled_game_files { prepare_cluster_locked(state, cluster_id, false, true, true, Some(progress)).await?; - report.repaired += game_broken; + report.repaired += repairable; + } + + if let Some(holder) = natives_holder { + let name = state + .clusters + .get(holder) + .await + .map_or_else(|_| "another cluster".to_string(), |cluster| cluster.name); + report.natives_in_use_by = Some(name); } report.reinstalled_game_files = reinstalled_game_files; @@ -215,8 +246,8 @@ async fn verify_cluster_content( for (index, link) in linked.iter().enumerate() { child.set_progress(index as u64, Some(linked.len() as u64)); - let Some(artifact) = artifact_dao::get_artifact_by_hash(&state.services.db, &link.hash) - .await? + let Some(artifact) = + artifact_dao::get_artifact_by_hash(&state.services.db, &link.hash).await? else { continue; }; @@ -284,7 +315,9 @@ async fn repair_content( let outcome = async { let provider = content.providers.get(provider_id)?; - let version = provider.get_version(project_id, version_id, &content).await?; + let version = provider + .get_version(project_id, version_id, &content) + .await?; // The cluster is pinned to one exact file by hash another file from the // same version would silently change what is installed @@ -355,6 +388,7 @@ mod tests { repaired: 4, unrepairable: Vec::new(), reinstalled_game_files: false, + natives_in_use_by: None, }; assert!(!report.is_clean()); @@ -379,6 +413,20 @@ mod tests { ); } + #[test] + fn skipped_natives_are_named_rather_than_counted_as_repaired() { + let report = ClusterVerifyReport { + checked: 40, + natives_in_use_by: Some("Hypixel".to_string()), + ..Default::default() + }; + + assert_eq!( + report.summary(), + "All 40 files verified. Native libraries were skipped because Hypixel is running, verify again after closing it." + ); + } + #[test] fn content_that_cannot_be_replaced_is_still_reported() { let report = ClusterVerifyReport { diff --git a/packages/oneclient_core/src/versions/arts.rs b/packages/oneclient_core/src/versions/arts.rs new file mode 100644 index 00000000..c03d1b35 --- /dev/null +++ b/packages/oneclient_core/src/versions/arts.rs @@ -0,0 +1,248 @@ +use std::collections::{HashMap, HashSet}; + +use oneclient_common::version::{VersionKey, parse_mc_version}; + +const DEFAULT_ART_KEY: &str = "default"; + +pub type ArtsManifest = HashMap; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct VersionArts { + by_patch: HashMap<(u32, u32, u32), String>, + by_minor: HashMap<(u32, u32), String>, + by_major: HashMap, + default: Option, +} + +impl VersionArts { + #[must_use] + pub fn new(manifest: &ArtsManifest, meta_url_base: &str) -> Self { + let mut arts = Self::default(); + + for (version, path) in manifest { + let url = format!("{meta_url_base}{path}"); + + if version == DEFAULT_ART_KEY { + arts.default = Some(url); + continue; + } + + let Some(parsed) = parse_mc_version(version) else { + tracing::warn!("ignoring version art under an unparseable key {version}"); + continue; + }; + + match (parsed.minor, parsed.patch) { + (Some(minor), Some(patch)) => { + arts.by_patch.insert((parsed.major, minor, patch), url); + } + (Some(minor), None) => { + arts.by_minor.insert((parsed.major, minor), url); + } + (None, _) => { + arts.by_major.insert(parsed.major, url); + } + } + } + + arts + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.by_patch.is_empty() + && self.by_minor.is_empty() + && self.by_major.is_empty() + && self.default.is_none() + } + + #[must_use] + pub fn gallery(&self) -> Vec { + let mut ordered: Vec<(u32, Option, Option, &str)> = self + .by_major + .iter() + .map(|(major, url)| (*major, None, None, url.as_str())) + .chain( + self.by_minor + .iter() + .map(|((major, minor), url)| (*major, Some(*minor), None, url.as_str())), + ) + .chain(self.by_patch.iter().map(|((major, minor, patch), url)| { + (*major, Some(*minor), Some(*patch), url.as_str()) + })) + .collect(); + + ordered.sort_unstable_by(|a, b| { + let rank = |v: &(u32, Option, Option, &str)| { + (v.0, v.1.unwrap_or(u32::MAX), v.2.unwrap_or(u32::MAX)) + }; + rank(b).cmp(&rank(a)) + }); + + let mut seen = HashSet::new(); + let mut urls: Vec = ordered + .into_iter() + .filter(|(_, _, _, url)| seen.insert(*url)) + .map(|(_, _, _, url)| url.to_string()) + .collect(); + + if let Some(default) = &self.default + && seen.insert(default.as_str()) + { + urls.push(default.clone()); + } + + urls + } + + #[must_use] + pub fn specific_art_url(&self, major: Option, key: Option) -> Option { + let major = major?; + let (minor, patch) = key?; + + patch + .and_then(|patch| self.by_patch.get(&(major, minor, patch))) + .or_else(|| self.by_minor.get(&(major, minor))) + .cloned() + } + + #[must_use] + pub fn fallback_art_url(&self, major: Option) -> Option { + major + .and_then(|major| self.by_major.get(&major)) + .or(self.default.as_ref()) + .cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BASE: &str = "https://example.invalid"; + + fn manifest() -> ArtsManifest { + [ + ("1.9", "/art/combat.webp"), + ("1.16", "/art/nether.webp"), + ("1.21", "/art/trials.webp"), + ("1.21.4", "/art/garden.webp"), + ("1.21.11", "/art/mounts.webp"), + ("26.1", "/art/tiny.webp"), + ("26.1.3", "/art/chaos.webp"), + (DEFAULT_ART_KEY, "/art/dirt.webp"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + fn resolve(version: &str) -> Option { + let arts = VersionArts::new(&manifest(), BASE); + let parsed = parse_mc_version(version)?; + arts.specific_art_url(Some(parsed.major), parsed.key()) + .or_else(|| arts.fallback_art_url(Some(parsed.major))) + } + + #[test] + fn a_patch_key_does_not_collide_with_its_minor_line() { + assert_eq!( + resolve("26.1.3").as_deref(), + Some("https://example.invalid/art/chaos.webp") + ); + assert_eq!( + resolve("26.1").as_deref(), + Some("https://example.invalid/art/tiny.webp") + ); + assert_eq!( + resolve("26.1.9").as_deref(), + Some("https://example.invalid/art/tiny.webp") + ); + } + + #[test] + fn minor_specific_art_wins_over_its_line() { + assert_eq!( + resolve("1.21.4").as_deref(), + Some("https://example.invalid/art/garden.webp") + ); + assert_eq!( + resolve("1.21.11").as_deref(), + Some("https://example.invalid/art/mounts.webp") + ); + } + + #[test] + fn minor_without_its_own_art_falls_back_to_the_line() { + assert_eq!( + resolve("1.21.2").as_deref(), + Some("https://example.invalid/art/trials.webp") + ); + assert_eq!( + resolve("1.16.5").as_deref(), + Some("https://example.invalid/art/nether.webp") + ); + } + + #[test] + fn bare_line_resolves_to_itself() { + assert_eq!( + resolve("1.9").as_deref(), + Some("https://example.invalid/art/combat.webp") + ); + } + + #[test] + fn unknown_versions_and_missing_majors_use_the_default() { + assert_eq!( + resolve("1.20.1").as_deref(), + Some("https://example.invalid/art/dirt.webp") + ); + + let arts = VersionArts::new(&manifest(), BASE); + assert_eq!( + arts.fallback_art_url(None).as_deref(), + Some("https://example.invalid/art/dirt.webp") + ); + } + + #[test] + fn a_manifest_without_a_default_resolves_to_nothing() { + let manifest: ArtsManifest = [("1.9".to_string(), "/art/combat.webp".to_string())] + .into_iter() + .collect(); + let arts = VersionArts::new(&manifest, BASE); + assert_eq!(arts.specific_art_url(Some(20), Some((1, None))), None); + assert_eq!(arts.fallback_art_url(Some(20)), None); + } + + #[test] + fn the_gallery_is_deduped_newest_first_and_ends_with_the_default() { + let mut manifest = manifest(); + manifest.insert("1.21.5".to_string(), "/art/garden.webp".to_string()); + + let arts = VersionArts::new(&manifest, BASE); + + assert_eq!( + arts.gallery(), + vec![ + "https://example.invalid/art/tiny.webp", + "https://example.invalid/art/chaos.webp", + "https://example.invalid/art/trials.webp", + "https://example.invalid/art/mounts.webp", + "https://example.invalid/art/garden.webp", + "https://example.invalid/art/nether.webp", + "https://example.invalid/art/combat.webp", + "https://example.invalid/art/dirt.webp", + ] + ); + } + + #[test] + fn unparseable_keys_are_skipped() { + let manifest: ArtsManifest = [("snapshot".to_string(), "/art/snap.webp".to_string())] + .into_iter() + .collect(); + assert!(VersionArts::new(&manifest, BASE).is_empty()); + } +} diff --git a/packages/oneclient_core/src/versions/manager.rs b/packages/oneclient_core/src/versions/manager.rs index 48754a74..6298b56f 100644 --- a/packages/oneclient_core/src/versions/manager.rs +++ b/packages/oneclient_core/src/versions/manager.rs @@ -1,14 +1,20 @@ +use serde::de::DeserializeOwned; +use tokio::sync::{Mutex, RwLock}; -use tokio::sync::RwLock; - +use crate::LauncherResult; +use crate::state::LauncherServices; +use crate::versions::arts::{ArtsManifest, VersionArts}; +use crate::versions::manifest::{ + ReleaseTarget, RemoteMigration, VersionMetadata, VersionsManifest, added_release_targets, +}; use oneclient_common::paths; use oneclient_net::{EtagPolicy, fetch_cached}; -use crate::state::LauncherServices; -use crate::versions::manifest::{RemoteMigration, VersionMetadata, VersionsManifest}; -use crate::LauncherResult; pub struct VersionsManager { manifest: RwLock, + arts: RwLock, + syncing: Mutex<()>, + added: std::sync::Mutex>, } impl VersionsManager { @@ -16,6 +22,9 @@ impl VersionsManager { pub fn new() -> Self { Self { manifest: RwLock::new(VersionsManifest::default()), + arts: RwLock::new(ArtsManifest::default()), + syncing: Mutex::new(()), + added: std::sync::Mutex::new(Vec::new()), } } @@ -23,22 +32,31 @@ impl VersionsManager { /// immediately instead of blocking on a request that usually 304s #[must_use] pub async fn from_cache() -> Self { - let manifest = Self::cached_manifest() - .await - .unwrap_or_else(VersionsManifest::default); + let (manifest, arts) = tokio::join!(Self::cached_manifest(), Self::cached_arts()); Self { - manifest: RwLock::new(manifest), + manifest: RwLock::new(manifest.unwrap_or_default()), + arts: RwLock::new(arts.unwrap_or_default()), + syncing: Mutex::new(()), + added: std::sync::Mutex::new(Vec::new()), } } + async fn cached_arts() -> Option { + Self::cached_json("version-arts.json", "version arts are").await + } + async fn cached_manifest() -> Option { - let path = paths::caches_dir().ok()?.join("versions.json"); + Self::cached_json("versions.json", "versions manifest is").await + } + + async fn cached_json(file_name: &str, what: &str) -> Option { + let path = paths::caches_dir().ok()?.join(file_name); let bytes = polyio::read(&path).await.ok()?; match serde_json::from_slice(&bytes) { - Ok(manifest) => Some(manifest), + Ok(value) => Some(value), Err(err) => { - tracing::warn!("cached versions manifest is unreadable: {err}"); + tracing::warn!("cached {what} unreadable: {err}"); None } } @@ -46,34 +64,95 @@ impl VersionsManager { #[tracing::instrument(level = "debug", skip(self, services))] pub async fn sync(&self, services: &LauncherServices) -> LauncherResult { - let Some((manifest, changed)) = Self::fetch_manifest(services).await? else { + let _guard = self.syncing.lock().await; + + let (manifest, arts) = + tokio::join!(Self::fetch_manifest(services), Self::fetch_arts(services)); + + let mut changed = false; + + match arts { + Ok(Some((arts, arts_changed))) => { + *self.arts.write().await = arts; + changed |= arts_changed; + } + Ok(None) => { + tracing::debug!("skipping version arts sync; no remote or cached arts available"); + } + Err(err) => tracing::warn!( + "version arts sync failed, art falls back to the bundled background: {err}" + ), + } + + let Some((manifest, manifest_changed)) = manifest? else { tracing::debug!("skipping versions sync; no remote or cached manifest available"); - return Ok(false); + return Ok(changed); }; - *self.manifest.write().await = manifest; - Ok(changed) + let mut current = self.manifest.write().await; + let added = added_release_targets(¤t, &manifest); + *current = manifest; + drop(current); + + if !added.is_empty() { + tracing::info!(added = ?added, "versions manifest gained new versions"); + let mut pending = self.added.lock().unwrap(); + for target in added { + if !pending.contains(&target) { + pending.push(target); + } + } + } + Ok(changed || manifest_changed) } pub async fn metadata(&self, meta_url_base: &str) -> Vec { self.manifest.read().await.metadata(meta_url_base) } + pub async fn arts(&self, meta_url_base: &str) -> VersionArts { + let arts = self.arts.read().await; + VersionArts::new(&arts, meta_url_base) + } + pub async fn migrations(&self) -> Vec { self.manifest.read().await.migrations.clone() } + pub async fn shows_initial_migration(&self, target: &ReleaseTarget) -> bool { + self.manifest.read().await.shows_initial_migration(target) + } + + pub fn take_added_versions(&self) -> Vec { + std::mem::take(&mut *self.added.lock().unwrap()) + } + #[tracing::instrument(level = "debug", skip(services))] async fn fetch_manifest( services: &LauncherServices, ) -> LauncherResult> { - let manifest_path = paths::caches_dir()?.join("versions.json"); + Self::fetch_json(services, "versions.json", "metadata.json").await + } + + #[tracing::instrument(level = "debug", skip(services))] + async fn fetch_arts( + services: &LauncherServices, + ) -> LauncherResult> { + Self::fetch_json(services, "version-arts.json", "arts.json").await + } + + async fn fetch_json( + services: &LauncherServices, + file_name: &str, + remote_name: &str, + ) -> LauncherResult> { + let path = paths::caches_dir()?.join(file_name); let url = format!( - "{}/oneclient/versions/metadata.json", + "{}/oneclient/versions/{remote_name}", services.requester.config().meta_url_base ); let Some(fetched) = - fetch_cached(&services.requester, &url, &manifest_path, EtagPolicy::CommitNow).await? + fetch_cached(&services.requester, &url, &path, EtagPolicy::CommitNow).await? else { return Ok(None); }; @@ -87,5 +166,3 @@ impl Default for VersionsManager { Self::new() } } - - diff --git a/packages/oneclient_core/src/versions/manifest.rs b/packages/oneclient_core/src/versions/manifest.rs index 913f91eb..a109f736 100644 --- a/packages/oneclient_core/src/versions/manifest.rs +++ b/packages/oneclient_core/src/versions/manifest.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::str::FromStr; use serde::{Deserialize, Serialize}; @@ -24,9 +25,6 @@ pub struct RemoteCluster { pub long_description: Option, #[serde(default)] pub tags: Vec, - /// Default for every entry in this cluster an entry's own key wins - #[serde(default)] - pub predownload: Option, #[serde(default)] pub entries: Vec, } @@ -47,7 +45,13 @@ pub struct RemoteEntry { #[serde(default)] pub tags: Option>, #[serde(default)] - pub predownload: Option, + pub flags: EntryFlags, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntryFlags { + #[serde(default)] + pub show_initial_migration: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -55,6 +59,8 @@ pub struct RemoteMigration { pub id: String, pub from: MigrationSource, pub to: MigrationTarget, + #[serde(default)] + pub allow_without_bundles: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -66,35 +72,199 @@ pub struct MigrationSource { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct MigrationTarget { pub mc_version: String, + #[serde(default)] + pub loader: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MigrationNode { + pub mc_version: String, + pub loader: GameLoader, +} + +impl RemoteMigration { + #[must_use] + pub fn endpoints(&self) -> Option<(MigrationNode, MigrationNode)> { + let from_loader = GameLoader::from_str(&self.from.loader).ok()?; + let to_loader = match &self.to.loader { + Some(loader) => GameLoader::from_str(loader).ok()?, + None => from_loader, + }; + + Some(( + MigrationNode { + mc_version: self.from.mc_version.clone(), + loader: from_loader, + }, + MigrationNode { + mc_version: self.to.mc_version.clone(), + loader: to_loader, + }, + )) + } +} + +#[must_use] +pub fn cyclic_migration_ids(rules: &[RemoteMigration]) -> HashSet { + let edges: Vec<(MigrationNode, MigrationNode)> = rules + .iter() + .filter_map(RemoteMigration::endpoints) + .filter(|(from, to)| from != to) + .collect(); + + rules + .iter() + .filter(|rule| { + let Some((from, to)) = rule.endpoints() else { + return false; + }; + from != to && reaches(&edges, &to, &from) + }) + .map(|rule| rule.id.clone()) + .collect() +} + +fn reaches( + edges: &[(MigrationNode, MigrationNode)], + start: &MigrationNode, + goal: &MigrationNode, +) -> bool { + let mut seen: HashSet<&MigrationNode> = HashSet::new(); + let mut stack = vec![start]; + + while let Some(node) = stack.pop() { + if node == goal { + return true; + } + if !seen.insert(node) { + continue; + } + stack.extend( + edges + .iter() + .filter(|(from, _)| from == node) + .map(|(_, to)| to), + ); + } + + false +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReleaseTarget { + pub mc_version: String, + pub loader: GameLoader, +} + +impl ReleaseTarget { + #[must_use] + pub fn key(&self) -> String { + format!("{}:{}", self.mc_version, self.loader.modrinth_name()) + } + + #[must_use] + pub fn from_key(key: &str) -> Option { + let (mc_version, loader) = key.rsplit_once(':')?; + if mc_version.is_empty() { + return None; + } + Some(Self { + mc_version: mc_version.to_string(), + loader: GameLoader::from_str(loader).ok()?, + }) + } +} + +fn release_targets(manifest: &VersionsManifest) -> Vec { + manifest + .clusters + .iter() + .flat_map(|cluster| { + cluster.entries.iter().filter_map(move |entry| { + Some(ReleaseTarget { + mc_version: format_mc_version( + cluster.major_version, + entry.minor_version, + entry.patch_version, + ), + loader: GameLoader::from_str(entry.loader.as_deref()?).ok()?, + }) + }) + }) + .collect() +} + +impl VersionsManifest { + #[must_use] + pub fn shows_initial_migration(&self, target: &ReleaseTarget) -> bool { + self.clusters + .iter() + .flat_map(|cluster| { + cluster + .entries + .iter() + .map(move |entry| (cluster.major_version, entry)) + }) + .find(|(major, entry)| { + format_mc_version(*major, entry.minor_version, entry.patch_version) + == target.mc_version + && entry + .loader + .as_deref() + .and_then(|loader| GameLoader::from_str(loader).ok()) + == Some(target.loader) + }) + .and_then(|(_, entry)| entry.flags.show_initial_migration) + .unwrap_or(true) + } +} + +#[must_use] +pub fn added_release_targets( + previous: &VersionsManifest, + next: &VersionsManifest, +) -> Vec { + if previous.clusters.is_empty() { + return Vec::new(); + } + let known = release_targets(previous); + let mut added: Vec = Vec::new(); + for target in release_targets(next) { + if !known.contains(&target) && !added.contains(&target) { + added.push(target); + } + } + added } -/// Returns the input unchanged when no rule matches -/// Multi-hop chains are -/// followed the loop is bounded by rule count so cyclic rules can't spin forever #[must_use] pub fn resolve_migration_chain( mc_version: &str, loader: GameLoader, rules: &[RemoteMigration], -) -> String { - let mut current = mc_version.to_string(); +) -> (String, GameLoader) { + let mut current = MigrationNode { + mc_version: mc_version.to_string(), + loader, + }; for _ in 0..=rules.len() { - let Some(rule) = rules.iter().find(|rule| { - rule.from.mc_version == current - && GameLoader::from_str(&rule.from.loader).is_ok_and(|l| l == loader) - }) else { + let Some((_, to)) = rules + .iter() + .filter_map(RemoteMigration::endpoints) + .find(|(from, _)| *from == current) + else { break; }; - if rule.to.mc_version == current { + if to == current { break; } - current = rule.to.mc_version.clone(); + current = to; } - current + (current.mc_version, current.loader) } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -107,8 +277,6 @@ pub struct VersionMetadata { pub art_url: Option, pub long_description: Option, pub tags: Vec, - /// Fetched up front during onboarding rather than on first launch - pub predownload: bool, } impl VersionMetadata { @@ -151,7 +319,6 @@ impl VersionsManifest { art_url: art_url(&cluster.art, meta_url_base), long_description: cluster.long_description.clone(), tags: cluster.tags.clone(), - predownload: cluster.predownload.unwrap_or(false), }); for entry in &cluster.entries { @@ -161,13 +328,13 @@ impl VersionsManifest { patch_version: entry.patch_version, loader: entry.loader.clone(), name: entry.name.clone().unwrap_or_else(|| cluster_name.clone()), - art_url: art_url(&entry.art, meta_url_base).or_else(|| art_url(&cluster.art, meta_url_base)), + art_url: art_url(&entry.art, meta_url_base) + .or_else(|| art_url(&cluster.art, meta_url_base)), long_description: entry .long_description .clone() .or_else(|| cluster.long_description.clone()), tags: entry.tags.clone().unwrap_or_else(|| cluster.tags.clone()), - predownload: entry.predownload.or(cluster.predownload).unwrap_or(false), }); } } @@ -187,6 +354,108 @@ mod tests { assert!(manifest.migrations.is_empty()); } + fn versions(entries: &str) -> VersionsManifest { + serde_json::from_str(&format!( + r#"{{"clusters":[{{"major_version":26,"entries":[{entries}]}}]}}"# + )) + .expect("should parse") + } + + #[test] + fn a_version_missing_from_the_previous_manifest_is_added() { + let previous = versions(r#"{"minor_version":2,"loader":"fabric"}"#); + let next = versions( + r#"{"minor_version":2,"loader":"fabric"},{"minor_version":3,"loader":"fabric"}"#, + ); + assert_eq!( + added_release_targets(&previous, &next), + vec![ReleaseTarget { + mc_version: "26.3".into(), + loader: GameLoader::Fabric + }] + ); + } + + #[test] + fn nothing_is_added_without_a_previous_manifest() { + let next = versions(r#"{"minor_version":3,"loader":"fabric"}"#); + assert!( + added_release_targets(&VersionsManifest::default(), &next).is_empty(), + "a fresh install has no cached manifest and must not see every version as new" + ); + } + + #[test] + fn a_new_loader_for_a_known_version_is_added() { + let previous = versions(r#"{"minor_version":3,"loader":"fabric"}"#); + let next = versions( + r#"{"minor_version":3,"loader":"fabric"},{"minor_version":3,"loader":"neoforge"}"#, + ); + assert_eq!( + added_release_targets(&previous, &next), + vec![ReleaseTarget { + mc_version: "26.3".into(), + loader: GameLoader::NeoForge + }] + ); + } + + #[test] + fn show_initial_migration_reads_the_entry_flag() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[{"major_version":8,"entries":[ + {"minor_version":9,"loader":"ornithe","flags":{"show_initial_migration":false}} + ]},{"major_version":26,"entries":[ + {"minor_version":3,"loader":"fabric"}, + {"minor_version":4,"loader":"fabric","flags":{}} + ]}]}"#, + ) + .expect("should parse"); + + let target = |mc_version: &str, loader| ReleaseTarget { + mc_version: mc_version.into(), + loader, + }; + assert!(!manifest.shows_initial_migration(&target("1.8.9", GameLoader::Ornithe))); + assert!( + manifest.shows_initial_migration(&target("26.3", GameLoader::Fabric)), + "no flags means show" + ); + assert!( + manifest.shows_initial_migration(&target("26.4", GameLoader::Fabric)), + "an empty flags object means show" + ); + assert!( + manifest.shows_initial_migration(&target("1.8.9", GameLoader::Fabric)), + "the flag belongs to its own loader" + ); + } + + #[test] + fn unknown_flags_are_ignored() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[{"major_version":26,"entries":[ + {"minor_version":3,"loader":"fabric","flags":{"something_new":true}} + ]}]}"#, + ) + .expect("an unknown flag must not break parsing"); + assert_eq!( + manifest.clusters[0].entries[0].flags.show_initial_migration, + None + ); + } + + #[test] + fn release_target_keys_round_trip() { + let target = ReleaseTarget { + mc_version: "1.8.9".into(), + loader: GameLoader::Fabric, + }; + assert_eq!(target.key(), "1.8.9:fabric"); + assert_eq!(ReleaseTarget::from_key("1.8.9:fabric"), Some(target)); + assert_eq!(ReleaseTarget::from_key("garbage"), None); + } + #[test] fn entry_without_patch_key_parses() { let manifest: VersionsManifest = serde_json::from_str( @@ -214,39 +483,6 @@ mod tests { assert_eq!(entry.key(), Some((1, Some(2)))); } - #[test] - fn predownload_inherits_from_cluster_and_entry_overrides() { - let manifest: VersionsManifest = serde_json::from_str( - r#"{"clusters":[ - {"major_version":26,"predownload":true,"entries":[ - {"minor_version":1}, - {"minor_version":2,"predownload":false} - ]}, - {"major_version":21,"entries":[ - {"minor_version":1}, - {"minor_version":11,"predownload":true} - ]} - ]}"#, - ) - .expect("should parse"); - - let metadata = manifest.metadata("https://example.test"); - let flag = |major: u32, minor: Option| { - metadata - .iter() - .find(|m| m.major_version == major && m.minor_version == minor) - .expect("row present") - .predownload - }; - - assert!(flag(26, None)); - assert!(flag(26, Some(1))); - assert!(!flag(26, Some(2))); - assert!(flag(21, Some(11))); - assert!(!flag(21, None)); - assert!(!flag(21, Some(1))); - } - #[test] fn mc_version_skips_the_cluster_row() { let manifest: VersionsManifest = serde_json::from_str( @@ -270,16 +506,28 @@ mod tests { }, to: MigrationTarget { mc_version: to.to_string(), + loader: None, }, + allow_without_bundles: false, } } + fn loader_rule(id: &str, version: &str, from: &str, to: &str) -> RemoteMigration { + let mut rule = rule(id, version, from, version); + rule.to.loader = Some(to.to_string()); + rule + } + + fn fabric(version: &str) -> (String, GameLoader) { + (version.to_string(), GameLoader::Fabric) + } + #[test] fn chain_resolves_single_hop() { let rules = [rule("a", "26.1", "fabric", "26.1.2")]; assert_eq!( resolve_migration_chain("26.1", GameLoader::Fabric, &rules), - "26.1.2" + fabric("26.1.2") ); } @@ -291,7 +539,7 @@ mod tests { ]; assert_eq!( resolve_migration_chain("26.1", GameLoader::Fabric, &rules), - "26.1.3" + fabric("26.1.3") ); } @@ -300,7 +548,7 @@ mod tests { let rules = [rule("a", "26.1", "forge", "26.1.2")]; assert_eq!( resolve_migration_chain("26.1", GameLoader::Fabric, &rules), - "26.1" + fabric("26.1") ); } @@ -309,7 +557,7 @@ mod tests { let rules = [rule("a", "21.1", "fabric", "21.1.2")]; assert_eq!( resolve_migration_chain("26.1", GameLoader::Fabric, &rules), - "26.1" + fabric("26.1") ); } @@ -318,7 +566,7 @@ mod tests { let self_rule = [rule("a", "26.1", "fabric", "26.1")]; assert_eq!( resolve_migration_chain("26.1", GameLoader::Fabric, &self_rule), - "26.1" + fabric("26.1") ); let cycle = [ @@ -326,7 +574,70 @@ mod tests { rule("b", "26.2", "fabric", "26.1"), ]; let out = resolve_migration_chain("26.1", GameLoader::Fabric, &cycle); - assert!(out == "26.1" || out == "26.2"); + assert!(out == fabric("26.1") || out == fabric("26.2")); + } + + #[test] + fn chain_switches_loader() { + let rules = [ + rule("a", "26.1", "fabric", "26.1.2"), + loader_rule("b", "26.1.2", "fabric", "neoforge"), + ]; + assert_eq!( + resolve_migration_chain("26.1", GameLoader::Fabric, &rules), + ("26.1.2".to_string(), GameLoader::NeoForge) + ); + } + + #[test] + fn missing_target_loader_keeps_the_source_loader() { + let (from, to) = rule("a", "26.1", "forge", "26.1.2").endpoints().unwrap(); + assert_eq!(from.loader, GameLoader::Forge); + assert_eq!(to.loader, GameLoader::Forge); + let (from, to) = loader_rule("a", "1.20.1", "forge", "neoforge") + .endpoints() + .unwrap(); + assert_eq!(from.loader, GameLoader::Forge); + assert_eq!(to.loader, GameLoader::NeoForge); + } + + #[test] + fn unknown_target_loader_invalidates_the_rule() { + let broken = loader_rule("a", "1.20.1", "forge", "rift"); + assert!(broken.endpoints().is_none()); + } + + #[test] + fn opposite_loader_rules_are_a_cycle() { + let rules = [ + loader_rule("there", "1.8.9", "fabric", "ornithe"), + loader_rule("back", "1.8.9", "ornithe", "fabric"), + loader_rule("other", "1.20.1", "forge", "neoforge"), + ]; + let cyclic = cyclic_migration_ids(&rules); + assert!(cyclic.contains("there")); + assert!(cyclic.contains("back")); + assert!(!cyclic.contains("other")); + } + + #[test] + fn a_three_loader_ring_is_a_cycle() { + let rules = [ + loader_rule("a", "1.20.1", "forge", "neoforge"), + loader_rule("b", "1.20.1", "neoforge", "fabric"), + loader_rule("c", "1.20.1", "fabric", "forge"), + ]; + assert_eq!(cyclic_migration_ids(&rules).len(), 3); + } + + #[test] + fn a_chain_is_not_a_cycle() { + let rules = [ + rule("a", "26.1", "fabric", "26.1.2"), + loader_rule("b", "26.1.2", "fabric", "quilt"), + rule("self", "26.3", "fabric", "26.3"), + ]; + assert!(cyclic_migration_ids(&rules).is_empty()); } #[test] @@ -342,5 +653,25 @@ mod tests { assert_eq!(manifest.migrations[0].id, "x"); assert_eq!(manifest.migrations[0].from.mc_version, "26.1"); assert_eq!(manifest.migrations[0].to.mc_version, "26.1.2"); + assert_eq!(manifest.migrations[0].to.loader, None); + assert!(!manifest.migrations[0].allow_without_bundles); + } + + #[test] + fn loader_migrations_parse() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[],"migrations":[ + {"id":"x","from":{"mc_version":"1.20.1","loader":"forge"}, + "to":{"mc_version":"1.20.1","loader":"neoforge"}, + "allow_without_bundles":true} + ]}"#, + ) + .expect("should parse"); + + assert_eq!( + manifest.migrations[0].to.loader.as_deref(), + Some("neoforge") + ); + assert!(manifest.migrations[0].allow_without_bundles); } } diff --git a/packages/oneclient_core/src/versions/mod.rs b/packages/oneclient_core/src/versions/mod.rs index e9ace6ba..bbb2d017 100644 --- a/packages/oneclient_core/src/versions/mod.rs +++ b/packages/oneclient_core/src/versions/mod.rs @@ -1,8 +1,13 @@ +mod arts; mod manager; mod manifest; +mod prefetch; +pub use arts::VersionArts; pub use manager::VersionsManager; pub use manifest::{ - MigrationSource, MigrationTarget, RemoteMigration, VersionMetadata, VersionsManifest, + MigrationNode, MigrationSource, MigrationTarget, ReleaseTarget, RemoteMigration, + VersionMetadata, VersionsManifest, added_release_targets, cyclic_migration_ids, resolve_migration_chain, }; +pub use prefetch::prefetch_version_art; diff --git a/packages/oneclient_core/src/versions/prefetch.rs b/packages/oneclient_core/src/versions/prefetch.rs new file mode 100644 index 00000000..6b4d0f49 --- /dev/null +++ b/packages/oneclient_core/src/versions/prefetch.rs @@ -0,0 +1,67 @@ +use std::collections::BTreeSet; + +use futures_util::StreamExt; + +use crate::images::{BACKGROUND_IMAGE_EDGE, DEFAULT_IMAGE_EDGE, PREVIEW_IMAGE_EDGE}; +use crate::state::LauncherState; + +const PREFETCH_CONCURRENCY: usize = 4; + +const PREFETCH_EDGES: [u32; 3] = [ + PREVIEW_IMAGE_EDGE, + DEFAULT_IMAGE_EDGE, + BACKGROUND_IMAGE_EDGE, +]; + +#[tracing::instrument(level = "debug", skip(state))] +pub async fn prefetch_version_art(state: &LauncherState) { + let meta_url_base = state.services.requester.config().meta_url_base.clone(); + + let mut urls: BTreeSet = state + .versions + .arts(&meta_url_base) + .await + .gallery() + .into_iter() + .collect(); + + urls.extend( + state + .versions + .metadata(&meta_url_base) + .await + .into_iter() + .filter_map(|entry| entry.art_url), + ); + + if urls.is_empty() { + tracing::debug!("no version art to prefetch"); + return; + } + + let total = urls.len(); + let mut failed = 0usize; + + let mut tasks = futures_util::stream::iter(urls) + .map(|url| async move { + let outcome = state + .images + .refresh(&state.services.requester, &url, &PREFETCH_EDGES) + .await; + (url, outcome) + }) + .buffer_unordered(PREFETCH_CONCURRENCY); + + while let Some((url, outcome)) = tasks.next().await { + if let Err(err) = outcome { + failed += 1; + tracing::debug!("version art prefetch failed for {url}: {err}"); + } + } + + if failed > 0 { + tracing::warn!("version art prefetch finished with {failed} of {total} unavailable"); + } else { + tracing::debug!("version art prefetch warmed {total} images"); + } +} diff --git a/packages/oneclient_core/tests/bundle_updates.rs b/packages/oneclient_core/tests/bundle_updates.rs index 312e130c..7c40d96f 100644 --- a/packages/oneclient_core/tests/bundle_updates.rs +++ b/packages/oneclient_core/tests/bundle_updates.rs @@ -1,7 +1,10 @@ -use oneclient_content::bundles::{BundleFile, BundleFileKind, BundleManifest, check_bundle_updates}; -use oneclient_core::clusters::CreateClusterOptions; use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_content::bundles::{ + BundleFile, BundleFileKind, BundleFileType, BundleManifest, check_bundle_updates, + get_bundles_with_update_status, install_cluster_bundles, set_bundle_package_enabled, +}; use oneclient_core::LauncherState; +use oneclient_core::clusters::CreateClusterOptions; use oneclient_db::dao::{artifact as artifact_dao, cluster_bundle as bundle_dao}; use oneclient_db::models::OverrideType; @@ -10,6 +13,9 @@ const MC_VERSION: &str = "1.21.1"; const PROJECT_ID: &str = "sodium"; const VERSION_ID: &str = "v1"; const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const SHARED_BUNDLE: &str = "Shared Bundle"; +const FABRIC_API_HASH: &str = "dddddddddddddddddddddddddddddddddddddddd"; +const HIDDEN_DEP_HASH: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; fn managed_file(enabled: bool) -> BundleFile { BundleFile { @@ -17,6 +23,7 @@ fn managed_file(enabled: bool) -> BundleFile { hidden: false, path: "mods/sodium.jar".to_string(), size: 1, + file_type: BundleFileType::Normal, kind: BundleFileKind::Managed { provider: ProviderId::Modrinth, project_id: PROJECT_ID.to_string(), @@ -33,6 +40,7 @@ fn newly_shipped_file() -> BundleFile { hidden: false, path: "mods/newcomer.jar".to_string(), size: 1, + file_type: BundleFileType::Normal, kind: BundleFileKind::Managed { provider: ProviderId::Modrinth, project_id: "newcomer".to_string(), @@ -51,18 +59,21 @@ fn manifest(files: Vec) -> BundleManifest { loader: GameLoader::Fabric, loader_version: "0.16.0".to_string(), enabled: true, + java_version_override: None, files, } } async fn cluster_with_tracked_mod(state: &LauncherState) -> i64 { let global = state.settings.read().global_game_settings.clone(); - let cluster = state.clusters.create( - &global, - CreateClusterOptions::new("Bundle Cluster", MC_VERSION, GameLoader::Fabric), - ) - .await - .unwrap(); + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Bundle Cluster", MC_VERSION, GameLoader::Fabric), + ) + .await + .unwrap(); artifact_dao::insert_artifact( &state.services.db, @@ -116,9 +127,13 @@ async fn mod_still_in_manifest_is_not_removed() { .unwrap(); let cluster_id = cluster_with_tracked_mod(&state).await; - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert!( check.removals_available.is_empty(), @@ -136,9 +151,13 @@ async fn mod_dropped_from_manifest_is_removed() { .unwrap(); let cluster_id = cluster_with_tracked_mod(&state).await; - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert_eq!( check.removals_available.len(), @@ -169,9 +188,13 @@ async fn disabled_mod_dropped_from_manifest_is_still_removed() { .await .unwrap(); - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert_eq!( check.removals_available.len(), @@ -201,9 +224,13 @@ async fn user_disabled_mod_is_not_treated_as_a_removal() { .await .unwrap(); - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert!( check.removals_available.is_empty(), @@ -212,6 +239,56 @@ async fn user_disabled_mod_is_not_treated_as_a_removal() { ); } +#[tokio::test] +async fn user_disabled_mod_still_takes_updates() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + let mut newer = managed_file(true); + if let BundleFileKind::Managed { + version_id, sha1, .. + } = &mut newer.kind + { + *version_id = "v2".to_string(); + *sha1 = "cccccccccccccccccccccccccccccccccccccccc".to_string(); + } + oneclient_core::dev::seed_bundle_archive(&state, manifest(vec![newer])) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert_eq!( + check + .updates_available + .iter() + .map(|u| u.new_version_id.as_str()) + .collect::>(), + ["v2"], + "a user-disabled mod must still be offered the newer version" + ); + assert!(check.removals_available.is_empty()); + assert!(check.additions_available.is_empty()); +} + #[tokio::test] async fn live_bundle_takes_on_new_catalog_files() { let state = oneclient_core::dev::ephemeral_state().await.unwrap(); @@ -223,9 +300,13 @@ async fn live_bundle_takes_on_new_catalog_files() { .unwrap(); let cluster_id = cluster_with_tracked_mod(&state).await; - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert_eq!( check.additions_available.len(), @@ -265,9 +346,13 @@ async fn emptied_bundle_does_not_take_on_new_catalog_files() { .await .unwrap(); - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert!( check.additions_available.is_empty(), @@ -300,9 +385,13 @@ async fn removed_bundle_content_does_not_take_on_new_catalog_files() { .await .unwrap(); - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert!( check.additions_available.is_empty(), @@ -348,9 +437,13 @@ async fn opting_a_single_file_in_keeps_the_bundle_live() { .await .unwrap(); - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert_eq!( check.additions_available.len(), @@ -387,9 +480,13 @@ async fn delisted_bundle_content_is_removed() { seed_delisted_bundle(&state).await; let cluster_id = cluster_with_tracked_mod(&state).await; - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert_eq!( check.removals_available.len(), @@ -410,9 +507,13 @@ async fn delisted_bundle_content_another_bundle_still_ships_is_kept() { .unwrap(); let cluster_id = cluster_with_tracked_mod(&state).await; - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert!( check.removals_available.is_empty(), @@ -426,9 +527,13 @@ async fn tracked_bundle_that_never_synced_is_not_removed() { let state = oneclient_core::dev::ephemeral_state().await.unwrap(); let cluster_id = cluster_with_tracked_mod(&state).await; - let check = check_bundle_updates(cluster_id, state.bundles.as_ref(), &state.services.content()) - .await - .unwrap(); + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); assert!( check.removals_available.is_empty(), @@ -436,3 +541,1055 @@ async fn tracked_bundle_that_never_synced_is_not_removed() { check.removals_available ); } + +fn resource_pack_file() -> BundleFile { + BundleFile { + enabled: true, + hidden: false, + file_type: BundleFileType::Normal, + path: "resourcepacks/looks.zip".to_string(), + size: 1, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: "looks".to_string(), + version_id: "v1".to_string(), + sha1: "cccccccccccccccccccccccccccccccccccccccc".to_string(), + }, + } +} + +#[tokio::test] +async fn disabling_every_mod_stops_new_mods_while_a_resource_pack_stays_on() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![ + managed_file(true), + newly_shipped_file(), + resource_pack_file(), + ]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + "looks", + OverrideType::Enabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + let added: Vec = check + .additions_available + .iter() + .map(|a| a.new_file.kind.package_id()) + .collect(); + + assert!( + !added.iter().any(|id| id == "newcomer"), + "mods were switched off for this bundle so a new mod must not arrive: {added:?}" + ); + assert!( + added.iter().any(|id| id == "looks"), + "the resource pack side of the bundle is still live: {added:?}" + ); + assert!( + !check + .optional_available + .iter() + .any(|o| o.package_id == "newcomer"), + "mods were switched off for this bundle so a new mod must not be offered either" + ); + + let status = get_bundles_with_update_status( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + let types = &status + .iter() + .find(|b| b.archive.manifest.name == BUNDLE) + .unwrap() + .opted_in_types; + assert!( + !types.contains(&ContentType::Mod) && types.contains(&ContentType::ResourcePack), + "the package list must agree with the update path on which side is opted out: {types:?}" + ); +} + +fn fabric_api_file() -> BundleFile { + BundleFile { + enabled: true, + hidden: false, + file_type: BundleFileType::Normal, + path: "mods/fabric-api.jar".to_string(), + size: 1, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: "fabric-api".to_string(), + version_id: "v1".to_string(), + sha1: FABRIC_API_HASH.to_string(), + }, + } +} + +fn named_manifest(name: &str, files: Vec) -> BundleManifest { + let mut m = manifest(files); + m.name = name.to_string(); + m +} + +async fn install_fabric_api(state: &LauncherState, cluster_id: i64) { + artifact_dao::insert_artifact( + &state.services.db, + FABRIC_API_HASH, + ContentType::Mod as i64, + "artifacts/fabric-api.jar", + "fabric-api.jar", + Some(1), + ) + .await + .unwrap(); + artifact_dao::link_cluster_artifact( + &state.services.db, + cluster_id, + FABRIC_API_HASH, + "fabric-api.jar", + ) + .await + .unwrap(); + artifact_dao::upsert_provider_release( + &state.services.db, + ProviderId::Modrinth as i64, + "fabric-api", + "v1", + FABRIC_API_HASH, + "Fabric API", + "1.0.0", + None, + MC_VERSION, + "fabric", + ) + .await + .unwrap(); + bundle_dao::track_bundle_artifact( + &state.services.db, + cluster_id, + FABRIC_API_HASH, + SHARED_BUNDLE, + "v1", + "fabric-api", + ) + .await + .unwrap(); +} + +#[tokio::test] +async fn a_mod_every_bundle_ships_does_not_keep_a_disabled_bundle_taking_new_mods() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + named_manifest( + BUNDLE, + vec![managed_file(true), newly_shipped_file(), fabric_api_file()], + ), + ) + .await + .unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + named_manifest(SHARED_BUNDLE, vec![fabric_api_file()]), + ) + .await + .unwrap(); + + let cluster_id = cluster_with_tracked_mod(&state).await; + install_fabric_api(&state, cluster_id).await; + + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + let added: Vec = check + .additions_available + .iter() + .map(|a| a.new_file.kind.package_id()) + .collect(); + + assert!( + !added.iter().any(|id| id == "newcomer"), + "a mod shared with another bundle must not hold this bundle open: {added:?}" + ); +} + +fn hidden_dependency_file() -> BundleFile { + BundleFile { + enabled: true, + hidden: true, + file_type: BundleFileType::Normal, + path: "mods/sodium-extra-lib.jar".to_string(), + size: 1, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: "sodium-extra-lib".to_string(), + version_id: "v1".to_string(), + sha1: HIDDEN_DEP_HASH.to_string(), + }, + } +} + +#[tokio::test] +async fn a_hidden_dependency_only_this_bundle_ships_does_not_hold_it_open() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![ + managed_file(true), + newly_shipped_file(), + hidden_dependency_file(), + ]), + ) + .await + .unwrap(); + + let cluster_id = cluster_with_tracked_mod(&state).await; + + artifact_dao::insert_artifact( + &state.services.db, + HIDDEN_DEP_HASH, + ContentType::Mod as i64, + "artifacts/sodium-extra-lib.jar", + "sodium-extra-lib.jar", + Some(1), + ) + .await + .unwrap(); + artifact_dao::link_cluster_artifact( + &state.services.db, + cluster_id, + HIDDEN_DEP_HASH, + "sodium-extra-lib.jar", + ) + .await + .unwrap(); + artifact_dao::upsert_provider_release( + &state.services.db, + ProviderId::Modrinth as i64, + "sodium-extra-lib", + "v1", + HIDDEN_DEP_HASH, + "Sodium Extra Lib", + "1.0.0", + None, + MC_VERSION, + "fabric", + ) + .await + .unwrap(); + bundle_dao::track_bundle_artifact( + &state.services.db, + cluster_id, + HIDDEN_DEP_HASH, + BUNDLE, + "v1", + "sodium-extra-lib", + ) + .await + .unwrap(); + + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + let added: Vec = check + .additions_available + .iter() + .map(|a| a.new_file.kind.package_id()) + .collect(); + + assert!( + !added.iter().any(|id| id == "newcomer"), + "a dependency the user was never shown must not read as wanting the bundle: {added:?}" + ); +} + +#[tokio::test] +async fn an_unrelated_catalog_bundle_sharing_a_mod_does_not_undo_the_opt_out() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + named_manifest( + BUNDLE, + vec![ + managed_file(true), + newly_shipped_file(), + resource_pack_file(), + ], + ), + ) + .await + .unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + named_manifest("Unrelated Bundle", vec![managed_file(true)]), + ) + .await + .unwrap(); + + let cluster_id = cluster_with_tracked_mod(&state).await; + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + "looks", + OverrideType::Enabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + let added: Vec = check + .additions_available + .iter() + .map(|a| a.new_file.kind.package_id()) + .collect(); + + assert!( + !added.iter().any(|id| id == "newcomer"), + "a bundle the user never installed must not speak for this one: {added:?}" + ); +} + +#[tokio::test] +async fn untracked_older_install_counts_as_opted_in() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![managed_file(true), newly_shipped_file()]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + bundle_dao::clear_bundle_tracking(&state.services.db, cluster_id, HASH) + .await + .unwrap(); + + let bundles = get_bundles_with_update_status( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert!( + bundles + .iter() + .all(|b| b.opted_in_types.contains(&ContentType::Mod)), + "a bundle the updater infers from its installed mods must not have its files hidden from the All tab" + ); +} + +#[tokio::test] +async fn package_list_and_updater_infer_the_same_bundle_through_overrides() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + named_manifest(BUNDLE, vec![managed_file(true), newly_shipped_file()]), + ) + .await + .unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + named_manifest(SHARED_BUNDLE, vec![managed_file(true)]), + ) + .await + .unwrap(); + + let cluster_id = cluster_with_tracked_mod(&state).await; + bundle_dao::clear_bundle_tracking(&state.services.db, cluster_id, HASH) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + SHARED_BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + assert!( + check + .additions_available + .iter() + .any(|a| a.new_file.kind.package_id() == "newcomer"), + "the updater infers this bundle from its uniquely installed mod" + ); + + let status = get_bundles_with_update_status( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + let types = &status + .iter() + .find(|b| b.archive.manifest.name == BUNDLE) + .unwrap() + .opted_in_types; + assert!( + types.contains(&ContentType::Mod), + "the package list must infer the same bundle the updater does: {types:?}" + ); +} + +const GITHUB_MOD_ID: &str = "github-mod"; +const GITHUB_OLD_HASH: &str = "1111111111111111111111111111111111111111"; + +/// The catalog has moved on to a newer release than the one installed +fn github_mod_file() -> BundleFile { + BundleFile { + enabled: true, + hidden: false, + file_type: BundleFileType::Normal, + path: "mods/github-mod.jar".to_string(), + size: 1, + kind: BundleFileKind::External { + file: oneclient_content::packages::types::ExternalFile { + name: "github-mod.jar".to_string(), + url: "https://github.com/example/github-mod/releases/download/v2/github-mod.jar" + .to_string(), + sha1: "2222222222222222222222222222222222222222".to_string(), + size: 1, + content_type: ContentType::Mod, + }, + id: Some(GITHUB_MOD_ID.to_string()), + meta: None, + }, + } +} + +#[tokio::test] +async fn an_outdated_github_mod_left_on_keeps_the_bundle_taking_mods() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![ + managed_file(true), + github_mod_file(), + newly_shipped_file(), + ]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + let db = &state.services.db; + artifact_dao::update_cluster_artifact(db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override(db, cluster_id, BUNDLE, PROJECT_ID, OverrideType::Disabled) + .await + .unwrap(); + artifact_dao::insert_artifact( + db, + GITHUB_OLD_HASH, + ContentType::Mod as i64, + "artifacts/github-mod.jar", + "github-mod.jar", + Some(1), + ) + .await + .unwrap(); + artifact_dao::link_cluster_artifact(db, cluster_id, GITHUB_OLD_HASH, "github-mod.jar") + .await + .unwrap(); + bundle_dao::track_bundle_artifact( + db, + cluster_id, + GITHUB_OLD_HASH, + BUNDLE, + GITHUB_OLD_HASH, + GITHUB_MOD_ID, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + assert!( + check + .additions_available + .iter() + .any(|a| a.new_file.kind.package_id() == "newcomer"), + "the GitHub mod is still on even though its installed release is older than the catalog's" + ); + + let status = get_bundles_with_update_status( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + let types = &status + .iter() + .find(|b| b.archive.manifest.name == BUNDLE) + .unwrap() + .opted_in_types; + assert!(types.contains(&ContentType::Mod), "{types:?}"); +} + +async fn cluster_with_mods_opted_out(state: &LauncherState, disabled_under: &str) -> i64 { + let cluster_id = cluster_with_tracked_mod(state).await; + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + disabled_under, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + "looks", + OverrideType::Enabled, + ) + .await + .unwrap(); + cluster_id +} + +async fn added_ids(state: &LauncherState, cluster_id: i64) -> Vec { + check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap() + .additions_available + .iter() + .map(|a| a.new_file.kind.package_id()) + .collect() +} + +#[tokio::test] +async fn a_disable_filed_under_another_bundle_still_opts_the_mods_out() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![ + managed_file(true), + newly_shipped_file(), + resource_pack_file(), + ]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_mods_opted_out(&state, "Old Bundle").await; + + let added = added_ids(&state, cluster_id).await; + assert!( + !added.iter().any(|id| id == "newcomer"), + "the user switched sodium off while it was filed under another bundle: {added:?}" + ); +} + +#[tokio::test] +async fn the_catalog_dropping_every_switched_off_mod_keeps_the_opt_out() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![managed_file(true), resource_pack_file()]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_mods_opted_out(&state, BUNDLE).await; + added_ids(&state, cluster_id).await; + + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![newly_shipped_file(), resource_pack_file()]), + ) + .await + .unwrap(); + + let added = added_ids(&state, cluster_id).await; + assert!( + !added.iter().any(|id| id == "newcomer"), + "mods stay opted out after the catalog replaces them: {added:?}" + ); + let status = get_bundles_with_update_status( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + let types = &status + .iter() + .find(|b| b.archive.manifest.name == BUNDLE) + .unwrap() + .opted_in_types; + assert!( + !types.contains(&ContentType::Mod), + "the package list must remember the opt-out too: {types:?}" + ); + + set_bundle_package_enabled( + cluster_id, + BUNDLE, + "looks", + true, + true, + &state.services.content(), + ) + .await + .unwrap(); + let added = added_ids(&state, cluster_id).await; + assert!( + !added.iter().any(|id| id == "newcomer"), + "switching a resource pack on must not opt the mods back in: {added:?}" + ); + + set_bundle_package_enabled( + cluster_id, + BUNDLE, + "newcomer", + true, + true, + &state.services.content(), + ) + .await + .unwrap(); + let added = added_ids(&state, cluster_id).await; + assert!( + added.iter().any(|id| id == "newcomer"), + "switching a mod back on opts the mods back in: {added:?}" + ); +} + +#[tokio::test] +async fn switching_a_new_mod_on_beside_switched_off_ones_installs_it() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![ + managed_file(true), + newly_shipped_file(), + resource_pack_file(), + ]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_mods_opted_out(&state, BUNDLE).await; + added_ids(&state, cluster_id).await; + + set_bundle_package_enabled( + cluster_id, + BUNDLE, + "newcomer", + true, + true, + &state.services.content(), + ) + .await + .unwrap(); + let added = added_ids(&state, cluster_id).await; + assert!( + added.iter().any(|id| id == "newcomer"), + "sodium staying off must not swallow the user switching newcomer on: {added:?}" + ); +} + +#[tokio::test] +async fn the_package_list_does_not_save_the_opt_out() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![managed_file(true), resource_pack_file()]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_mods_opted_out(&state, BUNDLE).await; + + let status = get_bundles_with_update_status( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + let types = &status + .iter() + .find(|b| b.archive.manifest.name == BUNDLE) + .unwrap() + .opted_in_types; + assert!(!types.contains(&ContentType::Mod), "{types:?}"); + assert!( + bundle_dao::list_type_opt_outs(&state.services.db, cluster_id) + .await + .unwrap() + .is_empty(), + "only the updater saves the opt-out" + ); + + added_ids(&state, cluster_id).await; + assert!( + !bundle_dao::list_type_opt_outs(&state.services.db, cluster_id) + .await + .unwrap() + .is_empty() + ); +} + +async fn seed_opt_in_bundle(state: &LauncherState, name: &str) { + let mut m = named_manifest(name, vec![managed_file(true)]); + m.enabled = false; + seed_bundle_with_pack(state, m).await; +} + +async fn seed_bundle_with_pack(state: &LauncherState, m: BundleManifest) { + let name = &m.name.clone(); + oneclient_core::dev::seed_bundle_archive(state, m) + .await + .unwrap(); + + let dir = oneclient_common::paths::data_dir().unwrap().join("bundles"); + std::fs::create_dir_all(&dir).unwrap(); + let mut empty_zip = b"PK\x05\x06".to_vec(); + empty_zip.extend([0u8; 18]); + std::fs::write(dir.join(format!("{name}.mrpack")), empty_zip).unwrap(); +} + +#[tokio::test] +async fn taking_a_bundle_at_the_prompt_records_the_choice() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + seed_opt_in_bundle(&state, "Opt In").await; + let cluster_id = cluster_with_tracked_mod(&state).await; + let ctx = state.services.content(); + let pending = || async { + oneclient_content::bundles::pending_bundle_choices(cluster_id, state.bundles.as_ref(), &ctx) + .await + .unwrap() + .into_iter() + .map(|archive| archive.manifest.name) + .collect::>() + }; + + assert_eq!(pending().await, ["Opt In".to_string()]); + + oneclient_content::bundles::set_bundle_choices( + cluster_id, + &[("Opt In".to_string(), false)], + &ctx, + ) + .await + .unwrap(); + assert!(pending().await.is_empty()); +} + +#[tokio::test] +async fn turning_a_bundle_down_removes_what_it_installed() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + seed_opt_in_bundle(&state, BUNDLE).await; + let cluster_id = cluster_with_tracked_mod(&state).await; + let ctx = state.services.content(); + + oneclient_content::bundles::set_bundle_choices(cluster_id, &[(BUNDLE.to_string(), true)], &ctx) + .await + .unwrap(); + let tracked = bundle_dao::list_bundle_tracked(&state.services.db, cluster_id) + .await + .unwrap(); + assert_eq!(tracked.len(), 1, "taking the bundle keeps its mods"); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + + oneclient_content::bundles::set_bundle_choices( + cluster_id, + &[(BUNDLE.to_string(), false)], + &ctx, + ) + .await + .unwrap(); + let tracked = bundle_dao::list_bundle_tracked(&state.services.db, cluster_id) + .await + .unwrap(); + assert!(tracked.is_empty()); + let overrides = bundle_dao::list_overrides(&state.services.db, cluster_id) + .await + .unwrap(); + assert_eq!(overrides.len(), 1, "a mod the user switched off stays off"); +} + +#[tokio::test] +async fn skyblock_nobody_asked_for_is_taken_out_of_an_old_26_3_cluster_once() { + const SKYBLOCK: &str = "Test [SkyBlock]"; + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + let db = &state.services.db; + let mut m = named_manifest(SKYBLOCK, vec![managed_file(true)]); + m.enabled = false; + m.category = "SkyBlock".to_string(); + m.mc_version = "26.3".to_string(); + seed_bundle_with_pack(&state, m).await; + let cluster_id = cluster_with_tracked_mod(&state).await; + let ctx = state.services.content(); + let track = || { + bundle_dao::track_bundle_artifact(db, cluster_id, HASH, SKYBLOCK, VERSION_ID, PROJECT_ID) + }; + let apply = || { + oneclient_content::bundles::apply_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &ctx, + None, + ) + }; + track().await.unwrap(); + bundle_dao::save_bundle_choices(db, cluster_id, &[(SKYBLOCK.to_string(), true)]) + .await + .unwrap(); + + sqlx::query("UPDATE clusters SET mc_version = '26.3' WHERE id = ?") + .bind(cluster_id) + .execute(db) + .await + .unwrap(); + apply().await.unwrap(); + assert_eq!( + bundle_dao::list_bundle_tracked(db, cluster_id) + .await + .unwrap() + .len(), + 1, + "a cluster made after bundles started asking is left alone" + ); + + sqlx::query("DELETE FROM applied_migrations") + .execute(db) + .await + .unwrap(); + sqlx::query("UPDATE clusters SET created_at = '2020-01-01T00:00:00+00:00' WHERE id = ?") + .bind(cluster_id) + .execute(db) + .await + .unwrap(); + apply().await.unwrap(); + assert!( + bundle_dao::list_bundle_tracked(db, cluster_id) + .await + .unwrap() + .is_empty() + ); + + artifact_dao::insert_artifact( + db, + HASH, + ContentType::Mod as i64, + "artifacts/sodium.jar", + "sodium.jar", + Some(1), + ) + .await + .unwrap(); + artifact_dao::link_cluster_artifact(db, cluster_id, HASH, "sodium.jar") + .await + .unwrap(); + track().await.unwrap(); + apply().await.unwrap(); + assert_eq!( + bundle_dao::list_bundle_tracked(db, cluster_id) + .await + .unwrap() + .len(), + 1, + "taking SkyBlock again later is not undone" + ); +} + +#[tokio::test] +async fn an_opt_in_bundle_switched_off_by_accident_is_repaired_at_install() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + seed_opt_in_bundle(&state, BUNDLE).await; + let cluster_id = cluster_with_tracked_mod(&state).await; + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + + install_cluster_bundles( + cluster_id, + state.bundles.as_ref(), + None, + &state.services.content(), + ) + .await + .unwrap(); + + let tracked = bundle_dao::list_bundle_tracked(&state.services.db, cluster_id) + .await + .unwrap(); + assert!( + tracked.iter().all(|row| row.enabled != 0), + "nothing recorded the disable so the install should undo it" + ); +} + +#[tokio::test] +async fn an_opt_in_bundle_counts_as_taken_only_on_a_recorded_choice() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + seed_opt_in_bundle(&state, "Opt In").await; + let cluster_id = cluster_with_tracked_mod(&state).await; + let ctx = state.services.content(); + let taken = || async { + let archives = state + .bundles + .archives_for(&ctx, MC_VERSION, GameLoader::Fabric) + .await + .unwrap(); + oneclient_content::bundles::taken_bundle_names(cluster_id, &archives, &ctx) + .await + .unwrap() + }; + + assert!(!taken().await.contains("Opt In")); + + oneclient_content::bundles::set_bundle_choices( + cluster_id, + &[("Opt In".to_string(), true)], + &ctx, + ) + .await + .unwrap(); + assert!(taken().await.contains("Opt In")); +} + +#[tokio::test] +async fn holding_one_mod_of_an_opt_in_bundle_does_not_bring_the_rest() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + let mut m = named_manifest("Opt In", vec![managed_file(true), fabric_api_file()]); + m.enabled = false; + seed_bundle_with_pack(&state, m).await; + let cluster_id = cluster_with_tracked_mod(&state).await; + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert!( + check.additions_available.is_empty(), + "an opt-in bundle nobody took should add nothing" + ); +} diff --git a/packages/oneclient_core/tests/clusters.rs b/packages/oneclient_core/tests/clusters.rs index 13f6946a..0dab74c5 100644 --- a/packages/oneclient_core/tests/clusters.rs +++ b/packages/oneclient_core/tests/clusters.rs @@ -1,123 +1,148 @@ use std::time::Duration; -use oneclient_core::clusters::{ClusterStage, CreateClusterOptions}; +use oneclient_cluster::profiles::create_settings_profile; use oneclient_common::domain::GameLoader; use oneclient_common::patch::Patch; -use oneclient_cluster::profiles::create_settings_profile; use oneclient_core::ProfileUpdate; +use oneclient_core::clusters::{ClusterStage, CreateClusterOptions}; #[tokio::test] async fn cluster_lifecycle_with_settings_profile() { - let state = oneclient_core::dev::ephemeral_state().await.unwrap(); - let settings = state.settings.read().clone(); + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + let settings = state.settings.read().clone(); - let global = state.settings.read().global_game_settings.clone(); + let global = state.settings.read().global_game_settings.clone(); - // Deliberately not a value `default_mem_max()` can return so the assertions - // distinguish "the cluster's own profile was used" from "fell back to global - // and matched" - let cluster = state.clusters.create( - &global, - CreateClusterOptions::new("Test Cluster", "1.21.1", GameLoader::Fabric).mem_max(5120), - ) - .await - .unwrap(); + // Deliberately not a value `default_mem_max()` can return so the assertions + // distinguish "the cluster's own profile was used" from "fell back to global + // and matched" + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Test Cluster", "1.21.1", GameLoader::Fabric).mem_max(5120), + ) + .await + .unwrap(); - assert_eq!(cluster.name, "Test Cluster"); - assert_eq!(cluster.mc_version, "1.21.1"); - assert_eq!(cluster.stage, ClusterStage::NotReady); - assert!(cluster.setting_profile_name.is_some()); - assert!(cluster.created_at.is_some()); + assert_eq!(cluster.name, "Test Cluster"); + assert_eq!(cluster.mc_version, "1.21.1"); + assert_eq!(cluster.stage, ClusterStage::NotReady); + assert!(cluster.setting_profile_name.is_some()); + assert!(cluster.created_at.is_some()); - let resolved = state.clusters.resolve_settings(&global, &cluster) - .await - .unwrap(); - assert_eq!(resolved.mem_max, Some(5120)); + let resolved = state + .clusters + .resolve_settings(&global, &cluster) + .await + .unwrap(); + assert_eq!(resolved.mem_max, Some(5120)); - state.clusters.update_profile(cluster.id, - ProfileUpdate { - mem_max: Patch::Set(6144), - ..Default::default() - }, - ) - .await - .unwrap(); + state + .clusters + .update_profile( + cluster.id, + ProfileUpdate { + mem_max: Patch::Set(6144), + ..Default::default() + }, + ) + .await + .unwrap(); - let updated = state.clusters.resolve_settings(&global, &cluster) - .await - .unwrap(); - assert_eq!(updated.mem_max, Some(6144)); + let updated = state + .clusters + .resolve_settings(&global, &cluster) + .await + .unwrap(); + assert_eq!(updated.mem_max, Some(6144)); - let shared = create_settings_profile(&state.services.db, &settings.global_game_settings, "Shared Profile") - .await - .unwrap(); + let shared = create_settings_profile( + &state.services.db, + &settings.global_game_settings, + "Shared Profile", + ) + .await + .unwrap(); - state.clusters.update(cluster.id, - oneclient_core::ClusterUpdate::default().setting_profile(shared.name.clone()), - ) - .await - .unwrap(); + state + .clusters + .update( + cluster.id, + oneclient_core::ClusterUpdate::default().setting_profile(shared.name.clone()), + ) + .await + .unwrap(); - let reassigned = state.clusters.get(cluster.id).await.unwrap(); - assert_eq!( - reassigned.setting_profile_name.as_deref(), - Some("Shared Profile") - ); + let reassigned = state.clusters.get(cluster.id).await.unwrap(); + assert_eq!( + reassigned.setting_profile_name.as_deref(), + Some("Shared Profile") + ); - let after_play = state.clusters.add_playtime(cluster.id, Duration::from_secs(120)) - .await - .unwrap(); - assert_eq!(after_play.overall_played, Duration::from_secs(120)); - assert!(after_play.last_played.is_some()); + let after_play = state + .clusters + .add_playtime(cluster.id, Duration::from_secs(120)) + .await + .unwrap(); + assert_eq!(after_play.overall_played, Duration::from_secs(120)); + assert!(after_play.last_played.is_some()); - state.clusters.delete(cluster.id, true).await.unwrap(); - assert!(state.clusters.get(cluster.id).await.is_err()); + state.clusters.delete(cluster.id, true).await.unwrap(); + assert!(state.clusters.get(cluster.id).await.is_err()); } #[tokio::test] async fn cleared_profile_fields_inherit_from_global() { - let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); - { - let mut settings = state.settings.write(); - settings.global_game_settings.mem_max = Some(8192); - } + { + let mut settings = state.settings.write(); + settings.global_game_settings.mem_max = Some(8192); + } - // Read the baseline *after* the write above so the new cluster inherits it - let global = state.settings.read().global_game_settings.clone(); - let cluster = state.clusters.create( - &global, - CreateClusterOptions::new("Inherit Test", "1.21.1", GameLoader::Vanilla).mem_max(2048), - ) - .await - .unwrap(); + // Read the baseline *after* the write above so the new cluster inherits it + let global = state.settings.read().global_game_settings.clone(); + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Inherit Test", "1.21.1", GameLoader::Vanilla).mem_max(2048), + ) + .await + .unwrap(); - let profile_name = cluster.setting_profile_name.clone().unwrap(); + let profile_name = cluster.setting_profile_name.clone().unwrap(); - state.clusters.update_profile(cluster.id, - ProfileUpdate { - mem_max: Patch::Clear, - force_fullscreen: Patch::Clear, - ..Default::default() - }, - ) - .await - .unwrap(); + state + .clusters + .update_profile( + cluster.id, + ProfileUpdate { + mem_max: Patch::Clear, + force_fullscreen: Patch::Clear, + ..Default::default() + }, + ) + .await + .unwrap(); let global = state.settings.read().global_game_settings.clone(); - let stored = oneclient_cluster::profiles::get_profile_or_default( - &state.services.db, - &global, - Some(&profile_name), - ) - .await - .unwrap(); - assert_eq!(stored.mem_max, None); - assert_eq!(stored.force_fullscreen, None); + let stored = oneclient_cluster::profiles::get_profile_or_default( + &state.services.db, + &global, + Some(&profile_name), + ) + .await + .unwrap(); + assert_eq!(stored.mem_max, None); + assert_eq!(stored.force_fullscreen, None); - let resolved = state.clusters.resolve_settings(&global, &cluster) - .await - .unwrap(); - assert_eq!(resolved.mem_max, Some(8192)); - assert_eq!(resolved.force_fullscreen, Some(false)); + let resolved = state + .clusters + .resolve_settings(&global, &cluster) + .await + .unwrap(); + assert_eq!(resolved.mem_max, Some(8192)); + assert_eq!(resolved.force_fullscreen, Some(false)); } diff --git a/packages/oneclient_core/tests/download_verify.rs b/packages/oneclient_core/tests/download_verify.rs index 6d762017..ba27115a 100644 --- a/packages/oneclient_core/tests/download_verify.rs +++ b/packages/oneclient_core/tests/download_verify.rs @@ -2,7 +2,7 @@ use oneclient_core::dev; use oneclient_core::game::download_to_path; use oneclient_events::{GroupedProgressSession, TaskCategory}; use polyio::testing::ScratchDir as Scratch; -use polyio::{Sha1Stream, sha1_bytes, sha1_file}; +use polyio::{sha1_bytes, sha1_file}; /// Minecraft asset objects are content-addressed and immutable so this /// URL/hash pair stays valid @@ -11,23 +11,6 @@ const ASSET_URL: &str = "https://resources.download.minecraft.net/af/af96f55a90eaf11b327f1b5f8834a051027dc506"; const ASSET_SIZE: u64 = 2063; -#[test] -fn sha1_stream_matches_one_shot_hash() { - let data: Vec = (0..70_000u32).map(|i| (i % 251) as u8).collect(); - - let mut hasher = Sha1Stream::new(); - for chunk in data.chunks(4096) { - hasher.update(chunk); - } - - assert_eq!(hasher.finish(), sha1_bytes(&data)); -} - -#[test] -fn sha1_stream_of_empty_input_matches() { - assert_eq!(Sha1Stream::new().finish(), sha1_bytes(&[])); -} - #[tokio::test] async fn sha1_file_matches_sha1_bytes() { let dir = Scratch::new("sha1-file"); diff --git a/packages/oneclient_core/tests/mods_folder_sync.rs b/packages/oneclient_core/tests/mods_folder_sync.rs new file mode 100644 index 00000000..2e0ef58d --- /dev/null +++ b/packages/oneclient_core/tests/mods_folder_sync.rs @@ -0,0 +1,124 @@ +use oneclient_common::domain::GameLoader; +use oneclient_content::packages::PackageStore; +use oneclient_content::packages::store::manifest::{self, MaterializedManifest}; +use oneclient_core::clusters::CreateClusterOptions; +use oneclient_core::game::sync_cluster_mods; + +fn jar(tag: &str) -> Vec { + let mut bytes = b"PK\x05\x06".to_vec(); + bytes.extend([0; 16]); + bytes.extend((tag.len() as u16).to_le_bytes()); + bytes.extend(tag.as_bytes()); + bytes +} + +#[tokio::test] +async fn hand_edits_to_the_mods_folder_reach_the_database() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + let global = state.settings.read().global_game_settings.clone(); + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Sync", "1.21.1", GameLoader::Fabric), + ) + .await + .unwrap(); + + let cluster_dir = cluster.dir().unwrap(); + let mods = cluster_dir.join("mods"); + std::fs::create_dir_all(&mods).unwrap(); + manifest::save( + &cluster_dir, + manifest::MODS_MANIFEST_NAME, + &MaterializedManifest::new(cluster.id, Vec::new()), + ) + .await; + + let ctx = state.services.content(); + let links = || async { + let mut links: Vec<(String, bool)> = PackageStore::list_linked_artifacts(cluster.id, &ctx) + .await + .unwrap() + .into_iter() + .map(|link| (link.cluster_file_name, link.enabled)) + .collect(); + links.sort(); + links + }; + + std::fs::write(mods.join("a.jar"), jar("one")).unwrap(); + assert!(sync_cluster_mods(&state, cluster.id).await); + assert_eq!(links().await, vec![("a.jar".to_owned(), true)]); + assert!( + !sync_cluster_mods(&state, cluster.id).await, + "a second pass over an untouched folder changes nothing" + ); + + std::fs::rename(mods.join("a.jar"), mods.join("b.jar")).unwrap(); + assert!(sync_cluster_mods(&state, cluster.id).await); + assert_eq!(links().await, vec![("b.jar".to_owned(), true)]); + + std::fs::write(mods.join("b.jar"), jar("two")).unwrap(); + assert!(sync_cluster_mods(&state, cluster.id).await); + assert_eq!( + links().await, + vec![("b.jar".to_owned(), false), ("b.jar".to_owned(), true)], + "the old build is switched off and the new one linked" + ); + assert!( + mods.join("b.jar").exists(), + "switching the old build off must not take the new jar with it" + ); + + std::fs::remove_file(mods.join("b.jar")).unwrap(); + assert!(sync_cluster_mods(&state, cluster.id).await); + assert_eq!( + links().await, + vec![("b.jar".to_owned(), false), ("b.jar".to_owned(), false)] + ); + + std::fs::write(mods.join("b.jar"), jar("two")).unwrap(); + assert!(sync_cluster_mods(&state, cluster.id).await); + assert_eq!( + links().await, + vec![("b.jar".to_owned(), false), ("b.jar".to_owned(), true)], + "a jar put back is switched on again" + ); +} + +#[tokio::test] +async fn a_mod_removed_in_the_launcher_stays_removed_before_the_first_launch() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + let global = state.settings.read().global_game_settings.clone(); + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Unlaunched", "1.21.1", GameLoader::Fabric), + ) + .await + .unwrap(); + + let mods = cluster.dir().unwrap().join("mods"); + std::fs::create_dir_all(&mods).unwrap(); + let ctx = state.services.content(); + + std::fs::write(mods.join("a.jar"), jar("one")).unwrap(); + assert!(sync_cluster_mods(&state, cluster.id).await); + let linked = PackageStore::list_linked_artifacts(cluster.id, &ctx) + .await + .unwrap(); + + oneclient_content::bundles::remove_artifact_from_cluster( + cluster.id, + &linked[0].hash, + false, + &ctx, + ) + .await + .unwrap(); + + assert!(!mods.join("a.jar").exists()); + assert!(!sync_cluster_mods(&state, cluster.id).await); +} diff --git a/packages/oneclient_core/tests/patch.rs b/packages/oneclient_core/tests/patch.rs index af44c1ef..e5ba07a6 100644 --- a/packages/oneclient_core/tests/patch.rs +++ b/packages/oneclient_core/tests/patch.rs @@ -2,32 +2,32 @@ use oneclient_common::patch::Patch; #[test] fn patch_apply_to_option() { - let mut slot = Some(42u32); + let mut slot = Some(42u32); - Patch::Unchanged.apply_to_option(&mut slot); - assert_eq!(slot, Some(42)); + Patch::Unchanged.apply_to_option(&mut slot); + assert_eq!(slot, Some(42)); - Patch::Set(99).apply_to_option(&mut slot); - assert_eq!(slot, Some(99)); + Patch::Set(99).apply_to_option(&mut slot); + assert_eq!(slot, Some(99)); - Patch::::Clear.apply_to_option(&mut slot); - assert_eq!(slot, None); + Patch::::Clear.apply_to_option(&mut slot); + assert_eq!(slot, None); } #[test] fn patch_command_empty_string_clears() { - let mut slot: Option = Some("wrapper".into()); + let mut slot: Option = Some("wrapper".into()); - Patch::Set(" ".to_owned()).apply_to_command_option(&mut slot); - assert_eq!(slot, Some("".to_owned())); + Patch::Set(" ".to_owned()).apply_to_command_option(&mut slot); + assert_eq!(slot, Some("".to_owned())); } #[test] fn patch_into_db_patch() { - assert_eq!(Patch::::Unchanged.into_db_patch(), None); - assert_eq!(Patch::::Clear.into_db_patch(), Some(None)); - assert_eq!( - Patch::Set("x".to_string()).into_db_patch(), - Some(Some("x".to_string())) - ); + assert_eq!(Patch::::Unchanged.into_db_patch(), None); + assert_eq!(Patch::::Clear.into_db_patch(), Some(None)); + assert_eq!( + Patch::Set("x".to_string()).into_db_patch(), + Some(Some("x".to_string())) + ); } diff --git a/packages/oneclient_core/tests/providers.rs b/packages/oneclient_core/tests/providers.rs index 73f3f2e8..d05845de 100644 --- a/packages/oneclient_core/tests/providers.rs +++ b/packages/oneclient_core/tests/providers.rs @@ -1,7 +1,6 @@ - -use oneclient_core::dev; use oneclient_common::domain::{ContentType, ProviderId}; use oneclient_content::packages::types::{PackageBody, SearchFilters}; +use oneclient_core::dev; #[test] fn registry_get_unknown_provider_errors() { @@ -15,15 +14,6 @@ fn registry_get_unknown_provider_errors() { assert!(err.to_string().contains("not registered")); } -#[tokio::test] -async fn registry_providers_match_ids() { - let registry = oneclient_content::packages::PackageProviderRegistry::new(); - - for id in registry.remote_ids() { - assert_eq!(registry.get(id).unwrap().id(), id); - } -} - #[tokio::test] #[ignore = "requires network"] async fn modrinth_search_returns_hits() { @@ -112,7 +102,7 @@ async fn modrinth_lookup_version_by_sha1() { let found = env.providers.lookup_version(&sha1, &env).await.unwrap(); assert!(found.is_some()); - + let (provider_id, _) = found.unwrap(); assert_eq!(provider_id, ProviderId::Modrinth); } diff --git a/packages/oneclient_db/migrations/20260918120000_release_migration_waitlist.sql b/packages/oneclient_db/migrations/20260918120000_release_migration_waitlist.sql new file mode 100644 index 00000000..e557a874 --- /dev/null +++ b/packages/oneclient_db/migrations/20260918120000_release_migration_waitlist.sql @@ -0,0 +1,13 @@ +CREATE TABLE release_migration_waitlist ( + target_cluster_id INTEGER NOT NULL, + provider INTEGER NOT NULL, + project_id TEXT NOT NULL, + content_type INTEGER NOT NULL, + source_hash TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + added_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + PRIMARY KEY (target_cluster_id, provider, project_id), + FOREIGN KEY (target_cluster_id) REFERENCES clusters (id) ON DELETE CASCADE +); +CREATE INDEX release_migration_waitlist_expires_at_idx ON release_migration_waitlist (expires_at); diff --git a/packages/oneclient_db/migrations/20260918130000_release_migration_waitlist_enabled.sql b/packages/oneclient_db/migrations/20260918130000_release_migration_waitlist_enabled.sql new file mode 100644 index 00000000..2e740bf9 --- /dev/null +++ b/packages/oneclient_db/migrations/20260918130000_release_migration_waitlist_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE release_migration_waitlist ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1; diff --git a/packages/oneclient_db/migrations/20260921120000_cluster_instances.sql b/packages/oneclient_db/migrations/20260921120000_cluster_instances.sql new file mode 100644 index 00000000..bdd46243 --- /dev/null +++ b/packages/oneclient_db/migrations/20260921120000_cluster_instances.sql @@ -0,0 +1,6 @@ +ALTER TABLE `clusters` ADD COLUMN `kind` INTEGER NOT NULL DEFAULT 0; +ALTER TABLE `clusters` ADD COLUMN `user_created` INTEGER NOT NULL DEFAULT 0; +ALTER TABLE `clusters` ADD COLUMN `description` TEXT; +ALTER TABLE `clusters` ADD COLUMN `tags` TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE `clusters` ADD COLUMN `cover_path` TEXT; +CREATE INDEX `clusters_user_created_idx` ON `clusters` (`user_created`); diff --git a/packages/oneclient_db/migrations/20260930120000_dismissed_provisions.sql b/packages/oneclient_db/migrations/20260930120000_dismissed_provisions.sql new file mode 100644 index 00000000..7ff89feb --- /dev/null +++ b/packages/oneclient_db/migrations/20260930120000_dismissed_provisions.sql @@ -0,0 +1,5 @@ +CREATE TABLE dismissed_provisions ( + mc_version TEXT NOT NULL, + mc_loader INTEGER NOT NULL, + PRIMARY KEY (mc_version, mc_loader) +); diff --git a/packages/oneclient_db/migrations/20261001120000_cluster_bundle_type_opt_outs.sql b/packages/oneclient_db/migrations/20261001120000_cluster_bundle_type_opt_outs.sql new file mode 100644 index 00000000..9abaa910 --- /dev/null +++ b/packages/oneclient_db/migrations/20261001120000_cluster_bundle_type_opt_outs.sql @@ -0,0 +1,7 @@ +CREATE TABLE cluster_bundle_type_opt_outs ( + cluster_id INTEGER NOT NULL, + bundle_name TEXT NOT NULL, + content_type INTEGER NOT NULL, + PRIMARY KEY (cluster_id, bundle_name, content_type), + FOREIGN KEY (cluster_id) REFERENCES clusters (id) ON DELETE CASCADE +); diff --git a/packages/oneclient_db/migrations/20261002120000_setting_profile_game_args.sql b/packages/oneclient_db/migrations/20261002120000_setting_profile_game_args.sql new file mode 100644 index 00000000..b2e5b660 --- /dev/null +++ b/packages/oneclient_db/migrations/20261002120000_setting_profile_game_args.sql @@ -0,0 +1,3 @@ +-- Extra arguments appended to the Minecraft command line, after the version's +-- own game arguments. NULL inherits the global profile like `launch_args`. +ALTER TABLE `setting_profiles` ADD COLUMN `game_args` TEXT; diff --git a/packages/oneclient_db/migrations/20261005120000_cluster_bundle_choices.sql b/packages/oneclient_db/migrations/20261005120000_cluster_bundle_choices.sql new file mode 100644 index 00000000..75e5a7c0 --- /dev/null +++ b/packages/oneclient_db/migrations/20261005120000_cluster_bundle_choices.sql @@ -0,0 +1,19 @@ +CREATE TABLE cluster_bundle_choices ( + cluster_id INTEGER NOT NULL, + bundle_name TEXT NOT NULL, + accepted INTEGER NOT NULL, + PRIMARY KEY (cluster_id, bundle_name), + FOREIGN KEY (cluster_id) REFERENCES clusters (id) ON DELETE CASCADE +); + +-- Opt-in bundles used to install without asking, so whatever a cluster already holds counts as taken +INSERT OR IGNORE INTO cluster_bundle_choices (cluster_id, bundle_name, accepted) +SELECT DISTINCT cluster_id, bundle_name, 1 +FROM cluster_artifacts +WHERE bundle_name IS NOT NULL; + +-- A bundle setup recorded removals for and that installed nothing was turned down at setup +INSERT OR IGNORE INTO cluster_bundle_choices (cluster_id, bundle_name, accepted) +SELECT DISTINCT cluster_id, bundle_name, 0 +FROM cluster_bundle_overrides +WHERE override_type = 'removed'; diff --git a/packages/oneclient_db/src/backup.rs b/packages/oneclient_db/src/backup.rs index 426deef6..15703703 100644 --- a/packages/oneclient_db/src/backup.rs +++ b/packages/oneclient_db/src/backup.rs @@ -57,9 +57,9 @@ pub fn list(database_path: &Path) -> Vec { .and_then(|name| name.to_str()) .is_some_and(|name| { name.ends_with(".db") - && name.strip_prefix(&prefix).is_some_and(|rest| { - rest.starts_with(|c: char| c.is_ascii_digit()) - }) + && name + .strip_prefix(&prefix) + .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())) }) }) .collect(); @@ -206,7 +206,10 @@ mod tests { .fetch_optional(&kept) .await .expect("probe"); - assert!(after.is_some(), "the replaced database must still be intact"); + assert!( + after.is_some(), + "the replaced database must still be intact" + ); } #[tokio::test] diff --git a/packages/oneclient_db/src/console.rs b/packages/oneclient_db/src/console.rs index 678b86d7..d9c6ecdf 100644 --- a/packages/oneclient_db/src/console.rs +++ b/packages/oneclient_db/src/console.rs @@ -163,6 +163,10 @@ mod tests { #[tokio::test] async fn invalid_sql_errors() { let pool = pool().await; - assert!(run_console_query(&pool, "SELECT * FROM nope").await.is_err()); + assert!( + run_console_query(&pool, "SELECT * FROM nope") + .await + .is_err() + ); } } diff --git a/packages/oneclient_db/src/dao/applied_migration.rs b/packages/oneclient_db/src/dao/applied_migration.rs index 27a205f1..5d55241e 100644 --- a/packages/oneclient_db/src/dao/applied_migration.rs +++ b/packages/oneclient_db/src/dao/applied_migration.rs @@ -2,23 +2,23 @@ use chrono::Utc; use sqlx::SqlitePool; pub async fn is_applied(pool: &SqlitePool, id: &str) -> Result { - let row = sqlx::query!("SELECT id FROM applied_migrations WHERE id = ?", id) - .fetch_optional(pool) - .await?; + let row = sqlx::query!("SELECT id FROM applied_migrations WHERE id = ?", id) + .fetch_optional(pool) + .await?; - Ok(row.is_some()) + Ok(row.is_some()) } pub async fn mark_applied(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> { - let applied_at = Utc::now().to_rfc3339(); + let applied_at = Utc::now().to_rfc3339(); - sqlx::query!( - "INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)", - id, - applied_at - ) - .execute(pool) - .await?; + sqlx::query!( + "INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)", + id, + applied_at + ) + .execute(pool) + .await?; - Ok(()) + Ok(()) } diff --git a/packages/oneclient_db/src/dao/artifact.rs b/packages/oneclient_db/src/dao/artifact.rs index 86b8c802..3ec0c7b0 100644 --- a/packages/oneclient_db/src/dao/artifact.rs +++ b/packages/oneclient_db/src/dao/artifact.rs @@ -1,37 +1,37 @@ use sqlx::SqlitePool; use crate::models::{ - ArtifactRow, ClusterArtifactRow, LinkedArtifactRow, ProviderReleaseRow, SeenStatus, + ArtifactRow, ClusterArtifactRow, ClusterKind, LinkedArtifactRow, ProviderReleaseRow, SeenStatus, }; pub async fn get_artifact_by_hash( - pool: &SqlitePool, - hash: &str, + pool: &SqlitePool, + hash: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ArtifactRow, - r#" + sqlx::query_as!( + ArtifactRow, + r#" SELECT hash, content_type, path, file_name, size_bytes FROM artifacts WHERE hash = ? "#, - hash - ) - .fetch_optional(pool) - .await + hash + ) + .fetch_optional(pool) + .await } pub async fn insert_artifact( - pool: &SqlitePool, - hash: &str, - content_type: i64, - path: &str, - file_name: &str, - size_bytes: Option, + pool: &SqlitePool, + hash: &str, + content_type: i64, + path: &str, + file_name: &str, + size_bytes: Option, ) -> Result { - sqlx::query_as!( - ArtifactRow, - r#" + sqlx::query_as!( + ArtifactRow, + r#" INSERT INTO artifacts (hash, content_type, path, file_name, size_bytes) VALUES (?, ?, ?, ?, ?) ON CONFLICT(hash) DO UPDATE SET @@ -41,40 +41,34 @@ pub async fn insert_artifact( size_bytes = COALESCE(excluded.size_bytes, artifacts.size_bytes) RETURNING hash, content_type, path, file_name, size_bytes "#, - hash, - content_type, - path, - file_name, - size_bytes - ) - .fetch_one(pool) - .await + hash, + content_type, + path, + file_name, + size_bytes + ) + .fetch_one(pool) + .await } /// `provider_releases` cascades the cached file is the caller's to delete /// this layer does not touch the disk pub async fn delete_artifact_if_unused(pool: &SqlitePool, hash: &str) -> Result { - let linked: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM cluster_artifacts WHERE hash = ?", - ) - .bind(hash) - .fetch_one(pool) - .await?; + let result = sqlx::query( + "DELETE FROM artifacts WHERE hash = ? AND NOT EXISTS (SELECT 1 FROM cluster_artifacts WHERE hash = ?) AND NOT EXISTS (SELECT 1 FROM clusters WHERE linked_modpack_hash = ?)", + ) + .bind(hash) + .bind(hash) + .bind(hash) + .execute(pool) + .await?; - if linked.0 > 0 { - return Ok(false); - } - - sqlx::query!("DELETE FROM artifacts WHERE hash = ?", hash) - .execute(pool) - .await?; - - Ok(true) + Ok(result.rows_affected() > 0) } use crate::models::GlobalArtifactRow; -/// One row per hash across every cluster for content that is installed globally +/// One row per hash across the OneClient clusters for content that is installed globally /// /// `enabled` is the OR over the clusters: one cluster still having a pack on is /// enough to keep it in the folder, because there is one folder and it can only @@ -86,81 +80,90 @@ use crate::models::GlobalArtifactRow; /// Written with the runtime-checked builder rather than `query!` so it needs no /// entry in the offline cache pub async fn list_global_artifacts( - pool: &SqlitePool, - content_type: i64, + pool: &SqlitePool, + content_type: i64, ) -> Result, sqlx::Error> { - sqlx::query_as::<_, GlobalArtifactRow>( - r#" + sqlx::query_as::<_, GlobalArtifactRow>( + r#" SELECT ca.hash AS hash, MIN(ca.cluster_file_name) AS file_name, MAX(ca.enabled) AS enabled FROM cluster_artifacts ca JOIN artifacts a ON a.hash = ca.hash - WHERE a.content_type = ? + JOIN clusters c ON c.id = ca.cluster_id + WHERE a.content_type = ? AND c.kind = ? GROUP BY ca.hash "#, - ) - .bind(content_type) - .fetch_all(pool) - .await + ) + .bind(content_type) + .bind(ClusterKind::OneClient.as_i64()) + .fetch_all(pool) + .await } -/// Sets the flag on every cluster that has this artifact +/// Sets the flag on every cluster sharing the global folder that has this artifact /// /// Globally installed content has one folder so writing only the row of /// whichever cluster the user happened to be looking at would leave the rest /// disagreeing with what is on disk pub async fn set_enabled_for_hash( - pool: &SqlitePool, - hash: &str, - enabled: i64, + pool: &SqlitePool, + hash: &str, + enabled: i64, ) -> Result { - let result = sqlx::query("UPDATE cluster_artifacts SET enabled = ? WHERE hash = ?") - .bind(enabled) - .bind(hash) - .execute(pool) - .await?; + let result = sqlx::query( + r#" + UPDATE cluster_artifacts SET enabled = ? + WHERE hash = ? AND cluster_id IN (SELECT id FROM clusters WHERE kind = ?) + "#, + ) + .bind(enabled) + .bind(hash) + .bind(ClusterKind::OneClient.as_i64()) + .execute(pool) + .await?; - Ok(result.rows_affected()) + Ok(result.rows_affected()) } pub async fn list_unused_artifacts(pool: &SqlitePool) -> Result, sqlx::Error> { - sqlx::query_as::<_, ArtifactRow>( - r#" + sqlx::query_as::<_, ArtifactRow>( + r#" SELECT hash, content_type, path, file_name, size_bytes FROM artifacts WHERE hash NOT IN (SELECT hash FROM cluster_artifacts) + AND hash NOT IN (SELECT linked_modpack_hash FROM clusters WHERE linked_modpack_hash IS NOT NULL) "#, - ) - .fetch_all(pool) - .await + ) + .fetch_all(pool) + .await } pub async fn list_artifact_paths(pool: &SqlitePool) -> Result, sqlx::Error> { - let rows: Vec<(String,)> = sqlx::query_as("SELECT path FROM artifacts") - .fetch_all(pool) - .await?; + let rows: Vec<(String,)> = sqlx::query_as("SELECT path FROM artifacts") + .fetch_all(pool) + .await?; - Ok(rows.into_iter().map(|(path,)| path).collect()) + Ok(rows.into_iter().map(|(path,)| path).collect()) } #[allow(clippy::too_many_arguments)] pub async fn upsert_provider_release( - pool: &SqlitePool, - provider_id: i64, - project_id: &str, - version_id: &str, - hash: &str, - display_name: &str, - display_version: &str, - published_at: Option<&str>, - mc_versions: &str, - mc_loaders: &str, + pool: &SqlitePool, + provider_id: i64, + project_id: &str, + version_id: &str, + hash: &str, + display_name: &str, + display_version: &str, + published_at: Option<&str>, + mc_versions: &str, + mc_loaders: &str, ) -> Result { - sqlx::query_as!( - ProviderReleaseRow, - r#" + sqlx::query_as!( + ProviderReleaseRow, + r#" INSERT INTO provider_releases ( provider, project_id, version_id, hash, display_name, display_version, published_at, mc_versions, mc_loaders @@ -177,50 +180,50 @@ pub async fn upsert_provider_release( provider as "provider!: i64", project_id, version_id, hash, display_name, display_version, published_at, mc_versions, mc_loaders "#, - provider_id, - project_id, - version_id, - hash, - display_name, - display_version, - published_at, - mc_versions, - mc_loaders - ) - .fetch_one(pool) - .await + provider_id, + project_id, + version_id, + hash, + display_name, + display_version, + published_at, + mc_versions, + mc_loaders + ) + .fetch_one(pool) + .await } pub async fn get_provider_release( - pool: &SqlitePool, - provider_id: i64, - project_id: &str, - version_id: &str, + pool: &SqlitePool, + provider_id: i64, + project_id: &str, + version_id: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ProviderReleaseRow, - r#" + sqlx::query_as!( + ProviderReleaseRow, + r#" SELECT provider as "provider!: i64", project_id, version_id, hash, display_name, display_version, published_at, mc_versions, mc_loaders FROM provider_releases WHERE provider = ? AND project_id = ? AND version_id = ? "#, - provider_id, - project_id, - version_id - ) - .fetch_optional(pool) - .await + provider_id, + project_id, + version_id + ) + .fetch_optional(pool) + .await } pub async fn get_release_by_hash( - pool: &SqlitePool, - hash: &str, + pool: &SqlitePool, + hash: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ProviderReleaseRow, - r#" + sqlx::query_as!( + ProviderReleaseRow, + r#" SELECT provider as "provider!: i64", project_id, version_id, hash, display_name, display_version, published_at, mc_versions, mc_loaders @@ -228,60 +231,47 @@ pub async fn get_release_by_hash( WHERE hash = ? LIMIT 1 "#, - hash - ) - .fetch_optional(pool) - .await -} - -pub async fn list_release_game_versions( - pool: &SqlitePool, - hash: &str, -) -> Result, sqlx::Error> { - let rows: Vec<(String,)> = - sqlx::query_as("SELECT mc_versions FROM provider_releases WHERE hash = ?") - .bind(hash) - .fetch_all(pool) - .await?; - - Ok(rows.into_iter().map(|(versions,)| versions).collect()) + hash + ) + .fetch_optional(pool) + .await } pub async fn link_cluster_artifact( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, - cluster_file_name: &str, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + cluster_file_name: &str, ) -> Result { - sqlx::query_as!( - ClusterArtifactRow, - r#" + sqlx::query_as!( + ClusterArtifactRow, + r#" INSERT INTO cluster_artifacts (cluster_id, hash, cluster_file_name, enabled) VALUES (?, ?, ?, 1) ON CONFLICT(cluster_id, hash) DO UPDATE SET cluster_file_name = excluded.cluster_file_name RETURNING cluster_id, hash, cluster_file_name, enabled, seen_status "#, - cluster_id, - hash, - cluster_file_name - ) - .fetch_one(pool) - .await + cluster_id, + hash, + cluster_file_name + ) + .fetch_one(pool) + .await } /// `DISTINCT` is required `provider_releases` is keyed by version so one /// artifact joins several rows and would otherwise be reported repeatedly pub async fn list_cluster_artifacts_for_project( - pool: &SqlitePool, - cluster_id: i64, - provider: i64, - project_id: &str, - exclude_hash: &str, + pool: &SqlitePool, + cluster_id: i64, + provider: i64, + project_id: &str, + exclude_hash: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ClusterArtifactRow, - r#" + sqlx::query_as!( + ClusterArtifactRow, + r#" SELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled, ca.seen_status FROM cluster_artifacts ca JOIN provider_releases pr ON pr.hash = ca.hash @@ -290,57 +280,88 @@ pub async fn list_cluster_artifacts_for_project( AND pr.project_id = ? AND ca.hash <> ? "#, - cluster_id, - provider, - project_id, - exclude_hash - ) - .fetch_all(pool) - .await + cluster_id, + provider, + project_id, + exclude_hash + ) + .fetch_all(pool) + .await } pub async fn is_cluster_linked( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, ) -> Result { - let row: Option<(i64,)> = sqlx::query_as( - "SELECT 1 FROM cluster_artifacts WHERE cluster_id = ? AND hash = ? LIMIT 1", - ) - .bind(cluster_id) - .bind(hash) - .fetch_optional(pool) - .await?; + let row: Option<(i64,)> = + sqlx::query_as("SELECT 1 FROM cluster_artifacts WHERE cluster_id = ? AND hash = ? LIMIT 1") + .bind(cluster_id) + .bind(hash) + .fetch_optional(pool) + .await?; - Ok(row.is_some()) + Ok(row.is_some()) +} + +pub async fn list_clusters_linking(pool: &SqlitePool, hash: &str) -> Result, sqlx::Error> { + sqlx::query_scalar("SELECT cluster_id FROM cluster_artifacts WHERE hash = ?") + .bind(hash) + .fetch_all(pool) + .await +} + +pub async fn is_launcher_owned(pool: &SqlitePool, hash: &str) -> Result { + let owned: i64 = sqlx::query_scalar( + r#" + SELECT EXISTS ( + SELECT 1 FROM artifacts a + WHERE a.hash = ? + AND ( + NOT EXISTS (SELECT 1 FROM cluster_artifacts ca WHERE ca.hash = a.hash) + OR EXISTS ( + SELECT 1 FROM cluster_artifacts ca + LEFT JOIN clusters c ON c.id = ca.cluster_id + WHERE ca.hash = a.hash AND (c.id IS NULL OR c.kind = ?) + ) + ) + ) + "#, + ) + .bind(hash) + .bind(ClusterKind::OneClient.as_i64()) + .fetch_one(pool) + .await?; + + Ok(owned != 0) } pub async fn unlink_cluster_artifact( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, ) -> Result<(), sqlx::Error> { - sqlx::query!( - "DELETE FROM cluster_artifacts WHERE cluster_id = ? AND hash = ?", - cluster_id, - hash - ) - .execute(pool) - .await?; + sqlx::query!( + "DELETE FROM cluster_artifacts WHERE cluster_id = ? AND hash = ?", + cluster_id, + hash + ) + .execute(pool) + .await?; - Ok(()) + Ok(()) } /// One artifact can carry several `provider_releases` rows so the release /// columns are taken from a single row picked by a total order rather than /// grouped which lets SQLite mix columns from different rows pub async fn list_cluster_artifacts_detailed( - pool: &SqlitePool, - cluster_id: i64, + pool: &SqlitePool, + cluster_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as!( - LinkedArtifactRow, - r#" + sqlx::query_as!( + LinkedArtifactRow, + r#" SELECT ca.hash AS "hash!: String", ca.cluster_file_name AS "cluster_file_name!: String", @@ -365,211 +386,237 @@ pub async fn list_cluster_artifacts_detailed( ) WHERE ca.cluster_id = ? "#, - cluster_id - ) - .fetch_all(pool) - .await + cluster_id + ) + .fetch_all(pool) + .await } pub async fn get_cluster_artifact( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ClusterArtifactRow, - r#" + sqlx::query_as!( + ClusterArtifactRow, + r#" SELECT cluster_id, hash, cluster_file_name, enabled, seen_status FROM cluster_artifacts WHERE cluster_id = ? AND hash = ? "#, - cluster_id, - hash - ) - .fetch_optional(pool) - .await + cluster_id, + hash + ) + .fetch_optional(pool) + .await } pub async fn update_cluster_artifact( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, - cluster_file_name: &str, - enabled: i64, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + cluster_file_name: &str, + enabled: i64, ) -> Result { - sqlx::query_as!( - ClusterArtifactRow, - r#" + sqlx::query_as!( + ClusterArtifactRow, + r#" UPDATE cluster_artifacts SET cluster_file_name = ?, enabled = ? WHERE cluster_id = ? AND hash = ? RETURNING cluster_id, hash, cluster_file_name, enabled, seen_status "#, - cluster_file_name, - enabled, - cluster_id, - hash - ) - .fetch_one(pool) - .await + cluster_file_name, + enabled, + cluster_id, + hash + ) + .fetch_one(pool) + .await } pub async fn set_seen_status( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, - status: SeenStatus, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + status: SeenStatus, ) -> Result<(), sqlx::Error> { - let status = status.as_i64(); + let status = status.as_i64(); - let result = sqlx::query!( - r#" + let result = sqlx::query!( + r#" UPDATE cluster_artifacts SET seen_status = ? WHERE cluster_id = ? AND hash = ? "#, - status, - cluster_id, - hash - ) - .execute(pool) - .await?; + status, + cluster_id, + hash + ) + .execute(pool) + .await?; - if result.rows_affected() == 0 { - tracing::warn!(cluster_id, hash, "no cluster artifact matched the seen status update"); - } + if result.rows_affected() == 0 { + tracing::warn!( + cluster_id, + hash, + "no cluster artifact matched the seen status update" + ); + } - Ok(()) + Ok(()) } pub async fn mark_all_seen(pool: &SqlitePool) -> Result { - let seen = SeenStatus::Seen.as_i64(); + let seen = SeenStatus::Seen.as_i64(); - let result = sqlx::query!( - "UPDATE cluster_artifacts SET seen_status = ? WHERE seen_status <> ?", - seen, - seen - ) - .execute(pool) - .await?; + let result = sqlx::query!( + "UPDATE cluster_artifacts SET seen_status = ? WHERE seen_status <> ?", + seen, + seen + ) + .execute(pool) + .await?; - Ok(result.rows_affected()) + Ok(result.rows_affected()) } #[cfg(test)] mod tests { - use super::*; - use crate::dao::cluster as cluster_dao; - use crate::models::NewCluster; + use super::*; + use crate::dao::cluster as cluster_dao; + use crate::models::NewCluster; - async fn pool() -> SqlitePool { - let pool = SqlitePool::connect("sqlite::memory:") - .await - .expect("in-memory sqlite"); - sqlx::migrate!().run(&pool).await.expect("migrations run"); - pool - } + async fn pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!().run(&pool).await.expect("migrations run"); + pool + } - async fn seed_cluster(pool: &SqlitePool) -> i64 { - cluster_dao::insert( - pool, - &NewCluster { - name: "26.1 fabric", - folder_name: "26.1 fabric", - mc_version: "26.1", - mc_loader: 1, - mc_loader_version: None, - setting_profile_name: None, - stage: 0, - }, - ) - .await - .expect("insert cluster") - .id - } + async fn seed_cluster(pool: &SqlitePool) -> i64 { + cluster_dao::insert( + pool, + &NewCluster { + name: "26.1 fabric", + folder_name: "26.1 fabric", + mc_version: "26.1", + mc_loader: 1, + mc_loader_version: None, + setting_profile_name: None, + stage: 0, + kind: 0, + user_created: 0, + description: None, + tags: "[]", + cover_path: None, + }, + ) + .await + .expect("insert cluster") + .id + } - #[tokio::test] - async fn detailed_listing_matches_the_lookups_it_replaced() { - let pool = pool().await; - let cluster_id = seed_cluster(&pool).await; + #[tokio::test] + async fn detailed_listing_matches_the_lookups_it_replaced() { + let pool = pool().await; + let cluster_id = seed_cluster(&pool).await; - insert_artifact(&pool, "hash_managed", 0, "a/managed.jar", "managed.jar", Some(1)) - .await - .expect("insert managed artifact"); - insert_artifact(&pool, "hash_local", 0, "a/local.jar", "local.jar", None) - .await - .expect("insert local artifact"); + insert_artifact( + &pool, + "hash_managed", + 0, + "a/managed.jar", + "managed.jar", + Some(1), + ) + .await + .expect("insert managed artifact"); + insert_artifact(&pool, "hash_local", 0, "a/local.jar", "local.jar", None) + .await + .expect("insert local artifact"); - for version in ["v1", "v2"] { - upsert_provider_release( - &pool, - 1, - "sodium", - version, - "hash_managed", - "Sodium", - version, - None, - "[]", - "[]", - ) - .await - .expect("insert release"); - } + for version in ["v1", "v2"] { + upsert_provider_release( + &pool, + 1, + "sodium", + version, + "hash_managed", + "Sodium", + version, + None, + "[]", + "[]", + ) + .await + .expect("insert release"); + } - link_cluster_artifact(&pool, cluster_id, "hash_managed", "managed.jar") - .await - .expect("link managed"); - link_cluster_artifact(&pool, cluster_id, "hash_local", "local.jar") - .await - .expect("link local"); - update_cluster_artifact(&pool, cluster_id, "hash_local", "local.jar", 0) - .await - .expect("disable local"); + link_cluster_artifact(&pool, cluster_id, "hash_managed", "managed.jar") + .await + .expect("link managed"); + link_cluster_artifact(&pool, cluster_id, "hash_local", "local.jar") + .await + .expect("link local"); + update_cluster_artifact(&pool, cluster_id, "hash_local", "local.jar", 0) + .await + .expect("disable local"); - let rows = list_cluster_artifacts_detailed(&pool, cluster_id) - .await - .expect("detailed listing"); + let rows = list_cluster_artifacts_detailed(&pool, cluster_id) + .await + .expect("detailed listing"); - assert_eq!(rows.len(), 2, "one row per link, not per provider release"); + assert_eq!(rows.len(), 2, "one row per link, not per provider release"); - let managed = rows.iter().find(|r| r.hash == "hash_managed").expect("managed row"); - assert_eq!(managed.file_name, "managed.jar"); - assert_eq!(managed.enabled, 1); - assert_eq!(managed.project_id.as_deref(), Some("sodium")); - assert_eq!(managed.provider, Some(1)); - assert_eq!(managed.version_id.as_deref(), Some("v1")); - assert_eq!( - managed.display_version.as_deref(), - Some("v1"), - "every release column comes from the same release row" - ); + let managed = rows + .iter() + .find(|r| r.hash == "hash_managed") + .expect("managed row"); + assert_eq!(managed.file_name, "managed.jar"); + assert_eq!(managed.enabled, 1); + assert_eq!(managed.project_id.as_deref(), Some("sodium")); + assert_eq!(managed.provider, Some(1)); + assert_eq!(managed.version_id.as_deref(), Some("v1")); + assert_eq!( + managed.display_version.as_deref(), + Some("v1"), + "every release column comes from the same release row" + ); - let local = rows.iter().find(|r| r.hash == "hash_local").expect("local row"); - assert_eq!(local.enabled, 0); - assert!(local.project_id.is_none(), "a local file has no provider columns"); + let local = rows + .iter() + .find(|r| r.hash == "hash_local") + .expect("local row"); + assert_eq!(local.enabled, 0); + assert!( + local.project_id.is_none(), + "a local file has no provider columns" + ); - let mut conn = pool.acquire().await.expect("connection"); - sqlx::query("PRAGMA foreign_keys = OFF") - .execute(&mut *conn) - .await - .expect("disable foreign keys"); - sqlx::query!("DELETE FROM artifacts WHERE hash = ?", "hash_local") - .execute(&mut *conn) - .await - .expect("drop artifact row"); - let orphans: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM cluster_artifacts WHERE hash = ?") - .bind("hash_local") - .fetch_one(&mut *conn) - .await - .expect("count orphan link"); - assert_eq!(orphans, 1, "the link must outlive its artifact row"); - drop(conn); + let mut conn = pool.acquire().await.expect("connection"); + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&mut *conn) + .await + .expect("disable foreign keys"); + sqlx::query!("DELETE FROM artifacts WHERE hash = ?", "hash_local") + .execute(&mut *conn) + .await + .expect("drop artifact row"); + let orphans: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM cluster_artifacts WHERE hash = ?") + .bind("hash_local") + .fetch_one(&mut *conn) + .await + .expect("count orphan link"); + assert_eq!(orphans, 1, "the link must outlive its artifact row"); + drop(conn); - let rows = list_cluster_artifacts_detailed(&pool, cluster_id) - .await - .expect("detailed listing after delete"); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].hash, "hash_managed"); - } + let rows = list_cluster_artifacts_detailed(&pool, cluster_id) + .await + .expect("detailed listing after delete"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].hash, "hash_managed"); + } } diff --git a/packages/oneclient_db/src/dao/browser_package_update.rs b/packages/oneclient_db/src/dao/browser_package_update.rs index e8341b7b..919f8fad 100644 --- a/packages/oneclient_db/src/dao/browser_package_update.rs +++ b/packages/oneclient_db/src/dao/browser_package_update.rs @@ -4,12 +4,12 @@ use sqlx::{QueryBuilder, Sqlite, SqlitePool}; use crate::models::BrowserPackageUpdateRow; pub async fn list_for_cluster( - pool: &SqlitePool, - cluster_id: i64, + pool: &SqlitePool, + cluster_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as!( - BrowserPackageUpdateRow, - r#" + sqlx::query_as!( + BrowserPackageUpdateRow, + r#" SELECT cluster_id, hash, provider as "provider!: i64", project_id, installed_version_id, installed_version_name, @@ -18,18 +18,16 @@ pub async fn list_for_cluster( FROM browser_package_updates WHERE cluster_id = ? "#, - cluster_id - ) - .fetch_all(pool) - .await + cluster_id + ) + .fetch_all(pool) + .await } -pub async fn list_all( - pool: &SqlitePool, -) -> Result, sqlx::Error> { - sqlx::query_as!( - BrowserPackageUpdateRow, - r#" +pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + BrowserPackageUpdateRow, + r#" SELECT cluster_id, hash, provider as "provider!: i64", project_id, installed_version_id, installed_version_name, @@ -37,31 +35,31 @@ pub async fn list_all( display_name, checked_at, skipped FROM browser_package_updates "#, - ) - .fetch_all(pool) - .await + ) + .fetch_all(pool) + .await } /// Re-upserting the same `latest_version_id` keeps `skipped` a different one /// resets it since it is a new question for the user #[allow(clippy::too_many_arguments)] pub async fn upsert( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, - provider: i64, - project_id: &str, - installed_version_id: &str, - installed_version_name: &str, - latest_version_id: &str, - latest_version_name: &str, - display_name: &str, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + provider: i64, + project_id: &str, + installed_version_id: &str, + installed_version_name: &str, + latest_version_id: &str, + latest_version_name: &str, + display_name: &str, ) -> Result { - let checked_at = Utc::now().to_rfc3339(); + let checked_at = Utc::now().to_rfc3339(); - sqlx::query_as!( - BrowserPackageUpdateRow, - r#" + sqlx::query_as!( + BrowserPackageUpdateRow, + r#" INSERT INTO browser_package_updates ( cluster_id, hash, provider, project_id, installed_version_id, installed_version_name, @@ -89,158 +87,160 @@ pub async fn upsert( latest_version_id, latest_version_name, display_name, checked_at, skipped "#, - cluster_id, - hash, - provider, - project_id, - installed_version_id, - installed_version_name, - latest_version_id, - latest_version_name, - display_name, - checked_at - ) - .fetch_one(pool) - .await + cluster_id, + hash, + provider, + project_id, + installed_version_id, + installed_version_name, + latest_version_id, + latest_version_name, + display_name, + checked_at + ) + .fetch_one(pool) + .await } pub async fn set_skipped( - pool: &SqlitePool, - cluster_id: i64, - hash: &str, - skipped: bool, + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + skipped: bool, ) -> Result<(), sqlx::Error> { - let skipped = i64::from(skipped); - sqlx::query!( - r#" + let skipped = i64::from(skipped); + sqlx::query!( + r#" UPDATE browser_package_updates SET skipped = ? WHERE cluster_id = ? AND hash = ? "#, - skipped, - cluster_id, - hash - ) - .execute(pool) - .await?; + skipped, + cluster_id, + hash + ) + .execute(pool) + .await?; - Ok(()) + Ok(()) } pub async fn delete(pool: &SqlitePool, cluster_id: i64, hash: &str) -> Result<(), sqlx::Error> { - sqlx::query!( - "DELETE FROM browser_package_updates WHERE cluster_id = ? AND hash = ?", - cluster_id, - hash - ) - .execute(pool) - .await?; + sqlx::query!( + "DELETE FROM browser_package_updates WHERE cluster_id = ? AND hash = ?", + cluster_id, + hash + ) + .execute(pool) + .await?; - Ok(()) + Ok(()) } /// Drops rows for hashes the latest check did not report an empty list clears /// the cluster pub async fn retain_hashes( - pool: &SqlitePool, - cluster_id: i64, - hashes: &[String], + pool: &SqlitePool, + cluster_id: i64, + hashes: &[String], ) -> Result<(), sqlx::Error> { - if hashes.is_empty() { - sqlx::query!( - "DELETE FROM browser_package_updates WHERE cluster_id = ?", - cluster_id - ) - .execute(pool) - .await?; - return Ok(()); - } + if hashes.is_empty() { + sqlx::query!( + "DELETE FROM browser_package_updates WHERE cluster_id = ?", + cluster_id + ) + .execute(pool) + .await?; + return Ok(()); + } - let mut builder: QueryBuilder = - QueryBuilder::new("DELETE FROM browser_package_updates WHERE cluster_id = "); - builder.push_bind(cluster_id); - builder.push(" AND hash NOT IN ("); - let mut separated = builder.separated(", "); - for hash in hashes { - separated.push_bind(hash); - } - separated.push_unseparated(")"); + let mut builder: QueryBuilder = + QueryBuilder::new("DELETE FROM browser_package_updates WHERE cluster_id = "); + builder.push_bind(cluster_id); + builder.push(" AND hash NOT IN ("); + let mut separated = builder.separated(", "); + for hash in hashes { + separated.push_bind(hash); + } + separated.push_unseparated(")"); - builder.build().execute(pool).await?; + builder.build().execute(pool).await?; - Ok(()) + Ok(()) } #[cfg(test)] mod tests { - use super::*; + use super::*; - async fn pool() -> SqlitePool { - let pool = SqlitePool::connect("sqlite::memory:") - .await - .expect("in-memory sqlite"); - sqlx::migrate!().run(&pool).await.expect("migrations run"); - // Runtime query not the macro the offline cache is library-only - sqlx::query( - r#" + async fn pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!().run(&pool).await.expect("migrations run"); + // Runtime query not the macro the offline cache is library-only + sqlx::query( + r#" INSERT INTO clusters (id, name, folder_name, mc_version) VALUES (1, 'test', 'test', '1.21.4') "#, - ) - .execute(&pool) - .await - .expect("cluster row"); - pool - } + ) + .execute(&pool) + .await + .expect("cluster row"); + pool + } - async fn record(pool: &SqlitePool, hash: &str, latest: &str) -> BrowserPackageUpdateRow { - upsert(pool, 1, hash, 0, "proj", "v1", "1.0.0", latest, "2.0.0", "Mod") - .await - .expect("upsert") - } + async fn record(pool: &SqlitePool, hash: &str, latest: &str) -> BrowserPackageUpdateRow { + upsert( + pool, 1, hash, 0, "proj", "v1", "1.0.0", latest, "2.0.0", "Mod", + ) + .await + .expect("upsert") + } - #[tokio::test] - async fn skip_survives_a_recheck_of_the_same_version() { - let pool = pool().await; - record(&pool, "abc", "v2").await; - set_skipped(&pool, 1, "abc", true).await.expect("skip"); + #[tokio::test] + async fn skip_survives_a_recheck_of_the_same_version() { + let pool = pool().await; + record(&pool, "abc", "v2").await; + set_skipped(&pool, 1, "abc", true).await.expect("skip"); - let row = record(&pool, "abc", "v2").await; - assert_eq!(row.skipped, 1, "re-running the check must not re-ask"); - } + let row = record(&pool, "abc", "v2").await; + assert_eq!(row.skipped, 1, "re-running the check must not re-ask"); + } - #[tokio::test] - async fn a_newer_version_asks_again() { - let pool = pool().await; - record(&pool, "abc", "v2").await; - set_skipped(&pool, 1, "abc", true).await.expect("skip"); + #[tokio::test] + async fn a_newer_version_asks_again() { + let pool = pool().await; + record(&pool, "abc", "v2").await; + set_skipped(&pool, 1, "abc", true).await.expect("skip"); - let row = record(&pool, "abc", "v3").await; - assert_eq!(row.skipped, 0, "a different version is a new question"); - } + let row = record(&pool, "abc", "v3").await; + assert_eq!(row.skipped, 0, "a different version is a new question"); + } - #[tokio::test] - async fn retain_drops_hashes_the_check_no_longer_reports() { - let pool = pool().await; - record(&pool, "abc", "v2").await; - record(&pool, "def", "v2").await; + #[tokio::test] + async fn retain_drops_hashes_the_check_no_longer_reports() { + let pool = pool().await; + record(&pool, "abc", "v2").await; + record(&pool, "def", "v2").await; - retain_hashes(&pool, 1, &["abc".to_string()]) - .await - .expect("retain"); + retain_hashes(&pool, 1, &["abc".to_string()]) + .await + .expect("retain"); - let rows = list_for_cluster(&pool, 1).await.expect("list"); - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].hash, "abc"); - } + let rows = list_for_cluster(&pool, 1).await.expect("list"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].hash, "abc"); + } - #[tokio::test] - async fn retain_with_nothing_clears_the_cluster() { - let pool = pool().await; - record(&pool, "abc", "v2").await; + #[tokio::test] + async fn retain_with_nothing_clears_the_cluster() { + let pool = pool().await; + record(&pool, "abc", "v2").await; - retain_hashes(&pool, 1, &[]).await.expect("retain"); + retain_hashes(&pool, 1, &[]).await.expect("retain"); - assert!(list_for_cluster(&pool, 1).await.expect("list").is_empty()); - } + assert!(list_for_cluster(&pool, 1).await.expect("list").is_empty()); + } } diff --git a/packages/oneclient_db/src/dao/cluster.rs b/packages/oneclient_db/src/dao/cluster.rs index 48d770e0..5ef3f74c 100644 --- a/packages/oneclient_db/src/dao/cluster.rs +++ b/packages/oneclient_db/src/dao/cluster.rs @@ -1,335 +1,552 @@ use chrono::Utc; use sqlx::SqlitePool; -use crate::models::{ClusterPatch, ClusterRow, NewCluster}; +use crate::models::{ClusterKind, ClusterPatch, ClusterRow, NewCluster}; pub async fn get_by_id(pool: &SqlitePool, id: i64) -> Result, sqlx::Error> { - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" SELECT id, name, folder_name, setting_profile_name, mc_version, mc_loader, - stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash, + kind, user_created, description, tags, cover_path FROM clusters WHERE id = ? "#, - id - ) - .fetch_optional(pool) - .await + id + ) + .fetch_optional(pool) + .await } pub async fn get_by_folder_name( - pool: &SqlitePool, - folder_name: &str, + pool: &SqlitePool, + folder_name: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" SELECT id, name, folder_name, setting_profile_name, mc_version, mc_loader, - stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash, + kind, user_created, description, tags, cover_path FROM clusters WHERE folder_name = ? "#, - folder_name - ) - .fetch_optional(pool) - .await + folder_name + ) + .fetch_optional(pool) + .await } pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> { - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" SELECT id, name, folder_name, setting_profile_name, mc_version, mc_loader, - stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash, + kind, user_created, description, tags, cover_path FROM clusters ORDER BY last_played IS NULL, last_played DESC, name ASC "# - ) - .fetch_all(pool) - .await + ) + .fetch_all(pool) + .await +} + +pub async fn list_oneclient_ids(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_scalar("SELECT id FROM clusters WHERE kind = ?") + .bind(ClusterKind::OneClient.as_i64()) + .fetch_all(pool) + .await } pub async fn find_by_version_loader( - pool: &SqlitePool, - mc_version: &str, - mc_loader: i64, + pool: &SqlitePool, + mc_version: &str, + mc_loader: i64, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" SELECT id, name, folder_name, setting_profile_name, mc_version, mc_loader, - stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash, + kind, user_created, description, tags, cover_path FROM clusters - WHERE mc_version = ? AND mc_loader = ? + WHERE mc_version = ? AND mc_loader = ? AND user_created = 0 LIMIT 1 "#, - mc_version, - mc_loader - ) - .fetch_optional(pool) - .await + mc_version, + mc_loader + ) + .fetch_optional(pool) + .await +} + +pub async fn dismiss_provision( + pool: &SqlitePool, + mc_version: &str, + mc_loader: i64, +) -> Result<(), sqlx::Error> { + sqlx::query("INSERT OR IGNORE INTO dismissed_provisions (mc_version, mc_loader) VALUES (?, ?)") + .bind(mc_version) + .bind(mc_loader) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn is_provision_dismissed( + pool: &SqlitePool, + mc_version: &str, + mc_loader: i64, +) -> Result { + sqlx::query_scalar::<_, i64>( + "SELECT 1 FROM dismissed_provisions WHERE mc_version = ? AND mc_loader = ?", + ) + .bind(mc_version) + .bind(mc_loader) + .fetch_optional(pool) + .await + .map(|row| row.is_some()) } pub async fn insert(pool: &SqlitePool, new: &NewCluster<'_>) -> Result { - let created_at = Utc::now().to_rfc3339(); + let created_at = Utc::now().to_rfc3339(); - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" INSERT INTO clusters ( name, folder_name, mc_version, mc_loader, mc_loader_version, - setting_profile_name, stage, created_at + setting_profile_name, stage, created_at, + kind, user_created, description, tags, cover_path ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, name, folder_name, setting_profile_name, mc_version, mc_loader, - stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash, + kind, user_created, description, tags, cover_path "#, - new.name, - new.folder_name, - new.mc_version, - new.mc_loader, - new.mc_loader_version, - new.setting_profile_name, - new.stage, - created_at - ) - .fetch_one(pool) - .await + new.name, + new.folder_name, + new.mc_version, + new.mc_loader, + new.mc_loader_version, + new.setting_profile_name, + new.stage, + created_at, + new.kind, + new.user_created, + new.description, + new.tags, + new.cover_path + ) + .fetch_one(pool) + .await } pub async fn update( - pool: &SqlitePool, - id: i64, - patch: &ClusterPatch, + pool: &SqlitePool, + id: i64, + patch: &ClusterPatch, ) -> Result { - let existing = get_by_id(pool, id) - .await? - .ok_or(sqlx::Error::RowNotFound)?; + let existing = get_by_id(pool, id).await?.ok_or(sqlx::Error::RowNotFound)?; - let name = patch.name.as_deref().unwrap_or(&existing.name); - let setting_profile_name = patch - .setting_profile_name - .clone() - .unwrap_or(existing.setting_profile_name); - let mc_loader_version = patch - .mc_loader_version - .clone() - .unwrap_or(existing.mc_loader_version); - let linked_modpack_hash = patch - .linked_modpack_hash - .clone() - .unwrap_or(existing.linked_modpack_hash); + let name = patch.name.as_deref().unwrap_or(&existing.name); + let setting_profile_name = patch + .setting_profile_name + .clone() + .unwrap_or(existing.setting_profile_name); + let mc_loader_version = patch + .mc_loader_version + .clone() + .unwrap_or(existing.mc_loader_version); + let linked_modpack_hash = patch + .linked_modpack_hash + .clone() + .unwrap_or(existing.linked_modpack_hash); + let description = patch.description.clone().unwrap_or(existing.description); + let tags = patch.tags.clone().unwrap_or(existing.tags); + let cover_path = patch.cover_path.clone().unwrap_or(existing.cover_path); - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" UPDATE clusters SET name = ?, setting_profile_name = ?, mc_loader_version = ?, - linked_modpack_hash = ? + linked_modpack_hash = ?, + description = ?, + tags = ?, + cover_path = ? WHERE id = ? RETURNING id, name, folder_name, setting_profile_name, mc_version, mc_loader, - stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash, + kind, user_created, description, tags, cover_path "#, - name, - setting_profile_name, - mc_loader_version, - linked_modpack_hash, - id - ) - .fetch_one(pool) - .await + name, + setting_profile_name, + mc_loader_version, + linked_modpack_hash, + description, + tags, + cover_path, + id + ) + .fetch_one(pool) + .await } -pub async fn migrate_version( - pool: &SqlitePool, - id: i64, - mc_version: &str, - name: Option<&str>, - folder_name: &str, -) -> Result { - let existing = get_by_id(pool, id) - .await? - .ok_or(sqlx::Error::RowNotFound)?; +pub struct ClusterMigration<'a> { + pub mc_version: &'a str, + pub mc_loader: i64, + pub mc_loader_version: Option<&'a str>, + pub stage: i64, + pub name: Option<&'a str>, + pub folder_name: &'a str, +} - let name = name.unwrap_or(&existing.name); - - sqlx::query_as!( - ClusterRow, - r#" +pub async fn migrate_version_if_unchanged( + pool: &SqlitePool, + expected: &ClusterRow, + migration: ClusterMigration<'_>, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ClusterRow>( + r#" UPDATE clusters SET mc_version = ?, - name = ?, + mc_loader = ?, + mc_loader_version = ?, + stage = ?, + name = COALESCE(?, name), folder_name = ? - WHERE id = ? + WHERE id = ? AND mc_version = ? AND mc_loader = ? AND folder_name = ? RETURNING id, name, folder_name, setting_profile_name, mc_version, mc_loader, - stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash, + kind, user_created, description, tags, cover_path "#, - mc_version, - name, - folder_name, - id - ) - .fetch_one(pool) - .await + ) + .bind(migration.mc_version) + .bind(migration.mc_loader) + .bind(migration.mc_loader_version) + .bind(migration.stage) + .bind(migration.name) + .bind(migration.folder_name) + .bind(expected.id) + .bind(&expected.mc_version) + .bind(expected.mc_loader) + .bind(&expected.folder_name) + .fetch_optional(pool) + .await } pub async fn set_stage(pool: &SqlitePool, id: i64, stage: i64) -> Result { - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" UPDATE clusters SET stage = ? WHERE id = ? RETURNING id, name, folder_name, setting_profile_name, mc_version, mc_loader, - stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash, + kind, user_created, description, tags, cover_path "#, - stage, - id - ) - .fetch_one(pool) - .await + stage, + id + ) + .fetch_one(pool) + .await +} + +pub async fn touch_last_played(pool: &SqlitePool, id: i64) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE clusters SET last_played = ? WHERE id = ?") + .bind(Utc::now().to_rfc3339()) + .bind(id) + .execute(pool) + .await?; + Ok(()) } pub async fn add_playtime( - pool: &SqlitePool, - id: i64, - seconds: i64, + pool: &SqlitePool, + id: i64, + seconds: i64, ) -> Result { - let now = Utc::now().to_rfc3339(); + let now = Utc::now().to_rfc3339(); - sqlx::query_as!( - ClusterRow, - r#" + sqlx::query_as!( + ClusterRow, + r#" UPDATE clusters SET overall_played = COALESCE(overall_played, 0) + ?, last_played = ? WHERE id = ? RETURNING id, name, folder_name, setting_profile_name, mc_version, mc_loader, - stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash, + kind, user_created, description, tags, cover_path "#, - seconds, - now, - id - ) - .fetch_one(pool) - .await + seconds, + now, + id + ) + .fetch_one(pool) + .await } pub async fn delete_by_id(pool: &SqlitePool, id: i64) -> Result { - let result = sqlx::query!("DELETE FROM clusters WHERE id = ?", id) - .execute(pool) - .await?; + let result = sqlx::query!("DELETE FROM clusters WHERE id = ?", id) + .execute(pool) + .await?; - Ok(result.rows_affected() > 0) + Ok(result.rows_affected() > 0) } #[cfg(test)] mod tests { - use super::*; - use crate::dao::applied_migration; + use super::*; + use crate::dao::applied_migration; - async fn pool() -> SqlitePool { - let pool = SqlitePool::connect("sqlite::memory:") - .await - .expect("in-memory sqlite"); - sqlx::migrate!().run(&pool).await.expect("migrations run"); - pool - } + async fn pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!().run(&pool).await.expect("migrations run"); + pool + } - async fn seed(pool: &SqlitePool, mc_version: &str, folder: &str) -> ClusterRow { - insert( - pool, - &NewCluster { - name: &format!("{mc_version} fabric"), - folder_name: folder, - mc_version, - mc_loader: 1, - mc_loader_version: None, - setting_profile_name: None, - stage: 0, - }, - ) - .await - .expect("insert cluster") - } + async fn seed(pool: &SqlitePool, mc_version: &str, folder: &str) -> ClusterRow { + insert( + pool, + &NewCluster { + name: &format!("{mc_version} fabric"), + folder_name: folder, + mc_version, + mc_loader: 1, + mc_loader_version: None, + setting_profile_name: None, + stage: 0, + kind: 0, + user_created: 0, + description: None, + tags: "[]", + cover_path: None, + }, + ) + .await + .expect("insert cluster") + } - #[tokio::test] - async fn migrate_version_rewrites_identity_and_keeps_history() { - let pool = pool().await; - let cluster = seed(&pool, "26.1", "26.1 fabric").await; - add_playtime(&pool, cluster.id, 3600) - .await - .expect("record playtime"); + #[tokio::test] + async fn dismissed_provision_is_remembered_per_version_and_loader() { + let pool = pool().await; + assert!(!is_provision_dismissed(&pool, "26.1", 1).await.unwrap()); - let migrated = migrate_version( - &pool, - cluster.id, - "26.1.2", - Some("26.1.2 fabric"), - "26.1.2 fabric", - ) - .await - .expect("migrate"); + dismiss_provision(&pool, "26.1", 1).await.unwrap(); + dismiss_provision(&pool, "26.1", 1).await.unwrap(); - assert_eq!(migrated.id, cluster.id, "must move the row, not replace it"); - assert_eq!(migrated.mc_version, "26.1.2"); - assert_eq!(migrated.folder_name, "26.1.2 fabric"); - assert_eq!(migrated.name, "26.1.2 fabric"); - assert_eq!(migrated.overall_played, Some(3600)); - assert_eq!(migrated.created_at, cluster.created_at); + assert!(is_provision_dismissed(&pool, "26.1", 1).await.unwrap()); + assert!(!is_provision_dismissed(&pool, "26.1", 2).await.unwrap()); + assert!(!is_provision_dismissed(&pool, "26.2", 1).await.unwrap()); + } - assert!( - find_by_version_loader(&pool, "26.1", 1) - .await - .unwrap() - .is_none() - ); - assert_eq!( - find_by_version_loader(&pool, "26.1.2", 1) - .await - .unwrap() - .unwrap() - .id, - cluster.id - ); - } + #[tokio::test] + async fn migrate_version_rewrites_identity_and_keeps_history() { + let pool = pool().await; + let cluster = seed(&pool, "26.1", "26.1 fabric").await; + add_playtime(&pool, cluster.id, 3600) + .await + .expect("record playtime"); - #[tokio::test] - async fn migrate_version_can_leave_name_and_folder_untouched() { - let pool = pool().await; - let cluster = seed(&pool, "26.1", "my cool pack").await; + let migrated = migrate_version_if_unchanged( + &pool, + &cluster, + ClusterMigration { + mc_version: "26.1.2", + mc_loader: 1, + mc_loader_version: None, + stage: 0, + name: Some("26.1.2 fabric"), + folder_name: "26.1.2 fabric", + }, + ) + .await + .expect("migrate") + .expect("row unchanged"); - let migrated = migrate_version(&pool, cluster.id, "26.1.2", None, "my cool pack") - .await - .expect("migrate"); + assert_eq!(migrated.id, cluster.id, "must move the row, not replace it"); + assert_eq!(migrated.mc_version, "26.1.2"); + assert_eq!(migrated.folder_name, "26.1.2 fabric"); + assert_eq!(migrated.name, "26.1.2 fabric"); + assert_eq!(migrated.overall_played, Some(3600)); + assert_eq!(migrated.created_at, cluster.created_at); - assert_eq!(migrated.mc_version, "26.1.2"); - assert_eq!(migrated.name, "26.1 fabric", "custom name must survive"); - assert_eq!(migrated.folder_name, "my cool pack"); - } + assert!( + find_by_version_loader(&pool, "26.1", 1) + .await + .unwrap() + .is_none() + ); + assert_eq!( + find_by_version_loader(&pool, "26.1.2", 1) + .await + .unwrap() + .unwrap() + .id, + cluster.id + ); + } - #[tokio::test] - async fn applied_migrations_ledger_is_idempotent() { - let pool = pool().await; + #[tokio::test] + async fn migrate_version_can_leave_name_and_folder_untouched() { + let pool = pool().await; + let cluster = seed(&pool, "26.1", "my cool pack").await; - assert!(!applied_migration::is_applied(&pool, "rule-a").await.unwrap()); - applied_migration::mark_applied(&pool, "rule-a").await.unwrap(); - assert!(applied_migration::is_applied(&pool, "rule-a").await.unwrap()); + let migrated = migrate_version_if_unchanged( + &pool, + &cluster, + ClusterMigration { + mc_version: "26.1.2", + mc_loader: 1, + mc_loader_version: None, + stage: 0, + name: None, + folder_name: "my cool pack", + }, + ) + .await + .expect("migrate") + .expect("row unchanged"); - applied_migration::mark_applied(&pool, "rule-a").await.unwrap(); - assert!(applied_migration::is_applied(&pool, "rule-a").await.unwrap()); - assert!(!applied_migration::is_applied(&pool, "rule-b").await.unwrap()); - } + assert_eq!(migrated.mc_version, "26.1.2"); + assert_eq!(migrated.name, "26.1 fabric", "custom name must survive"); + assert_eq!(migrated.folder_name, "my cool pack"); + } + + #[tokio::test] + async fn migrate_version_switches_loader_and_resets_preparation() { + let pool = pool().await; + let cluster = seed(&pool, "1.20.1", "1.20.1 Forge").await; + set_stage(&pool, cluster.id, 3).await.expect("mark ready"); + + let migrated = migrate_version_if_unchanged( + &pool, + &cluster, + ClusterMigration { + mc_version: "1.20.1", + mc_loader: 2, + mc_loader_version: None, + stage: 0, + name: Some("1.20.1 NeoForge"), + folder_name: "1.20.1 NeoForge", + }, + ) + .await + .expect("migrate") + .expect("row unchanged"); + + assert_eq!(migrated.id, cluster.id); + assert_eq!(migrated.mc_loader, 2); + assert_eq!(migrated.mc_loader_version, None); + assert_eq!(migrated.stage, 0); + assert!( + find_by_version_loader(&pool, "1.20.1", 1) + .await + .unwrap() + .is_none() + ); + assert_eq!( + find_by_version_loader(&pool, "1.20.1", 2) + .await + .unwrap() + .unwrap() + .id, + cluster.id + ); + } + + #[tokio::test] + async fn conditional_migration_skips_a_row_that_already_moved() { + let pool = pool().await; + let cluster = seed(&pool, "1.20.1", "1.20.1 Forge").await; + let stale = cluster.clone(); + + let first = migrate_version_if_unchanged( + &pool, + &cluster, + ClusterMigration { + mc_version: "1.20.1", + mc_loader: 2, + mc_loader_version: None, + stage: 0, + name: Some("1.20.1 NeoForge"), + folder_name: "1.20.1 NeoForge", + }, + ) + .await + .expect("migrate") + .expect("row matched"); + assert_eq!(first.folder_name, "1.20.1 NeoForge"); + + let second = migrate_version_if_unchanged( + &pool, + &stale, + ClusterMigration { + mc_version: "1.20.1", + mc_loader: 2, + mc_loader_version: None, + stage: 0, + name: None, + folder_name: "1.20.1 Forge", + }, + ) + .await + .expect("migrate"); + assert!(second.is_none()); + + let row = get_by_id(&pool, cluster.id).await.unwrap().unwrap(); + assert_eq!(row.folder_name, "1.20.1 NeoForge"); + assert_eq!(row.name, "1.20.1 NeoForge"); + } + + #[tokio::test] + async fn applied_migrations_ledger_is_idempotent() { + let pool = pool().await; + + assert!( + !applied_migration::is_applied(&pool, "rule-a") + .await + .unwrap() + ); + applied_migration::mark_applied(&pool, "rule-a") + .await + .unwrap(); + assert!( + applied_migration::is_applied(&pool, "rule-a") + .await + .unwrap() + ); + + applied_migration::mark_applied(&pool, "rule-a") + .await + .unwrap(); + assert!( + applied_migration::is_applied(&pool, "rule-a") + .await + .unwrap() + ); + assert!( + !applied_migration::is_applied(&pool, "rule-b") + .await + .unwrap() + ); + } } diff --git a/packages/oneclient_db/src/dao/cluster_bundle.rs b/packages/oneclient_db/src/dao/cluster_bundle.rs index 615d3aab..8651b8e8 100644 --- a/packages/oneclient_db/src/dao/cluster_bundle.rs +++ b/packages/oneclient_db/src/dao/cluster_bundle.rs @@ -243,3 +243,139 @@ pub async fn list_overrides( .fetch_all(pool) .await } + +pub async fn list_type_opt_outs( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + let rows = sqlx::query!( + r#" + SELECT bundle_name, content_type + FROM cluster_bundle_type_opt_outs + WHERE cluster_id = ? + "#, + cluster_id + ) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|r| (r.bundle_name, r.content_type)) + .collect()) +} + +pub async fn set_type_opt_out( + pool: &SqlitePool, + cluster_id: i64, + bundle_name: &str, + content_type: i64, + opted_out: bool, +) -> Result<(), sqlx::Error> { + if opted_out { + sqlx::query!( + r#" + INSERT OR IGNORE INTO cluster_bundle_type_opt_outs (cluster_id, bundle_name, content_type) + VALUES (?, ?, ?) + "#, + cluster_id, + bundle_name, + content_type + ) + .execute(pool) + .await?; + } else { + sqlx::query!( + r#" + DELETE FROM cluster_bundle_type_opt_outs + WHERE cluster_id = ? AND bundle_name = ? AND content_type = ? + "#, + cluster_id, + bundle_name, + content_type + ) + .execute(pool) + .await?; + } + Ok(()) +} + +pub async fn list_bundle_choices( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + let rows = sqlx::query!( + r#" + SELECT bundle_name, accepted + FROM cluster_bundle_choices + WHERE cluster_id = ? + "#, + cluster_id + ) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|r| (r.bundle_name, r.accepted != 0)) + .collect()) +} + +pub async fn save_bundle_choices( + pool: &SqlitePool, + cluster_id: i64, + choices: &[(String, bool)], +) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + for (bundle_name, accepted) in choices { + sqlx::query!( + r#" + INSERT OR REPLACE INTO cluster_bundle_choices (cluster_id, bundle_name, accepted) + VALUES (?, ?, ?) + "#, + cluster_id, + bundle_name, + accepted + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + +pub async fn predates_bundle_choices( + pool: &SqlitePool, + cluster_id: i64, +) -> Result { + sqlx::query_scalar( + r#" + SELECT EXISTS ( + SELECT 1 + FROM clusters c, _sqlx_migrations m + WHERE c.id = ? AND m.version = 20261005120000 + AND (c.created_at IS NULL OR datetime(c.created_at) < m.installed_on) + ) + "#, + ) + .bind(cluster_id) + .fetch_one(pool) + .await +} + +pub async fn copy_bundle_choices( + pool: &SqlitePool, + source_cluster_id: i64, + target_cluster_id: i64, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + INSERT OR IGNORE INTO cluster_bundle_choices (cluster_id, bundle_name, accepted) + SELECT ?, bundle_name, accepted + FROM cluster_bundle_choices + WHERE cluster_id = ? + "#, + target_cluster_id, + source_cluster_id + ) + .execute(pool) + .await?; + Ok(()) +} diff --git a/packages/oneclient_db/src/dao/game_session.rs b/packages/oneclient_db/src/dao/game_session.rs index 55557b20..4c179d75 100644 --- a/packages/oneclient_db/src/dao/game_session.rs +++ b/packages/oneclient_db/src/dao/game_session.rs @@ -2,17 +2,17 @@ use chrono::Utc; use sqlx::SqlitePool; use crate::models::{ - GameSessionRow, GameSessionServerRow, NewGameSession, ServerJoinCount, SessionSpan, - UnfinishedSession, + GameSessionRow, GameSessionServerRow, NewGameSession, ServerJoinCount, SessionSpan, + UnfinishedSession, }; pub async fn insert_session( - pool: &SqlitePool, - new: &NewGameSession<'_>, + pool: &SqlitePool, + new: &NewGameSession<'_>, ) -> Result { - sqlx::query_as!( - GameSessionRow, - r#" + sqlx::query_as!( + GameSessionRow, + r#" INSERT INTO game_sessions ( cluster_id, started_at, ram_allocated_mb, mods_enabled, java_vendor, java_version ) @@ -21,143 +21,150 @@ pub async fn insert_session( cluster_id, started_at, ended_at, exit_code, ram_allocated_mb, mods_enabled, java_vendor, java_version "#, - new.cluster_id, - new.started_at, - new.ram_allocated_mb, - new.mods_enabled, - new.java_vendor, - new.java_version - ) - .fetch_one(pool) - .await + new.cluster_id, + new.started_at, + new.ram_allocated_mb, + new.mods_enabled, + new.java_vendor, + new.java_version + ) + .fetch_one(pool) + .await } pub async fn finish_session( - pool: &SqlitePool, - started_at: &str, - exit_code: Option, + pool: &SqlitePool, + started_at: &str, + exit_code: Option, ) -> Result<(), sqlx::Error> { - finish_session_at(pool, started_at, &Utc::now().to_rfc3339(), exit_code).await + finish_session_at(pool, started_at, &Utc::now().to_rfc3339(), exit_code).await } /// For exit times inferred from the game's log rather than observed live pub async fn finish_session_at( - pool: &SqlitePool, - started_at: &str, - ended_at: &str, - exit_code: Option, + pool: &SqlitePool, + started_at: &str, + ended_at: &str, + exit_code: Option, ) -> Result<(), sqlx::Error> { - sqlx::query!( - r#" + sqlx::query!( + r#" UPDATE game_sessions SET ended_at = ?, exit_code = ? WHERE started_at = ? AND ended_at IS NULL "#, - ended_at, - exit_code, - started_at - ) - .execute(pool) - .await?; - Ok(()) + ended_at, + exit_code, + started_at + ) + .execute(pool) + .await?; + Ok(()) } pub async fn set_session_process( - pool: &SqlitePool, - started_at: &str, - pid: Option, - pid_started_at: Option, + pool: &SqlitePool, + started_at: &str, + pid: Option, + pid_started_at: Option, ) -> Result<(), sqlx::Error> { - sqlx::query!( - r#" + sqlx::query!( + r#" UPDATE game_sessions SET pid = ?, pid_started_at = ? WHERE started_at = ? "#, - pid, - pid_started_at, - started_at - ) - .execute(pool) - .await?; - Ok(()) + pid, + pid_started_at, + started_at + ) + .execute(pool) + .await?; + Ok(()) } pub async fn unfinished_sessions(pool: &SqlitePool) -> Result, sqlx::Error> { - sqlx::query_as!( - UnfinishedSession, - r#" + sqlx::query_as!( + UnfinishedSession, + r#" SELECT cluster_id, started_at, pid, pid_started_at FROM game_sessions WHERE ended_at IS NULL ORDER BY started_at ASC "# - ) - .fetch_all(pool) - .await + ) + .fetch_all(pool) + .await } pub async fn insert_server_join( - pool: &SqlitePool, - session_started_at: &str, - address: &str, - port: Option, + pool: &SqlitePool, + session_started_at: &str, + address: &str, + port: Option, ) -> Result { - insert_server_join_at(pool, session_started_at, address, port, &Utc::now().to_rfc3339()).await + insert_server_join_at( + pool, + session_started_at, + address, + port, + &Utc::now().to_rfc3339(), + ) + .await } pub async fn insert_server_join_at( - pool: &SqlitePool, - session_started_at: &str, - address: &str, - port: Option, - joined_at: &str, + pool: &SqlitePool, + session_started_at: &str, + address: &str, + port: Option, + joined_at: &str, ) -> Result { - sqlx::query!( - r#" + sqlx::query!( + r#" INSERT INTO game_session_servers (session_started_at, address, port, joined_at) VALUES (?, ?, ?, ?) "#, - session_started_at, - address, - port, - joined_at - ) - .execute(pool) - .await?; - Ok(joined_at.to_string()) + session_started_at, + address, + port, + joined_at + ) + .execute(pool) + .await?; + Ok(joined_at.to_string()) } pub async fn finish_server(pool: &SqlitePool, joined_at: &str) -> Result<(), sqlx::Error> { - finish_server_at(pool, joined_at, &Utc::now().to_rfc3339()).await + finish_server_at(pool, joined_at, &Utc::now().to_rfc3339()).await } pub async fn finish_server_at( - pool: &SqlitePool, - joined_at: &str, - disconnected_at: &str, + pool: &SqlitePool, + joined_at: &str, + disconnected_at: &str, ) -> Result<(), sqlx::Error> { - sqlx::query!( - r#" + sqlx::query!( + r#" UPDATE game_session_servers SET disconnected_at = ? WHERE joined_at = ? AND disconnected_at IS NULL "#, - disconnected_at, - joined_at - ) - .execute(pool) - .await?; - Ok(()) + disconnected_at, + joined_at + ) + .execute(pool) + .await?; + Ok(()) } pub async fn list_sessions_for_cluster( - pool: &SqlitePool, - cluster_id: i64, + pool: &SqlitePool, + cluster_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as!( - GameSessionRow, - r#" + sqlx::query_as!( + GameSessionRow, + r#" SELECT cluster_id, started_at, ended_at, exit_code, ram_allocated_mb, mods_enabled, java_vendor, java_version @@ -165,54 +172,54 @@ pub async fn list_sessions_for_cluster( WHERE cluster_id = ? ORDER BY started_at DESC "#, - cluster_id - ) - .fetch_all(pool) - .await + cluster_id + ) + .fetch_all(pool) + .await } pub async fn list_session_servers( - pool: &SqlitePool, - session_started_at: &str, + pool: &SqlitePool, + session_started_at: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - GameSessionServerRow, - r#" + sqlx::query_as!( + GameSessionServerRow, + r#" SELECT session_started_at, address, port, joined_at, disconnected_at FROM game_session_servers WHERE session_started_at = ? ORDER BY joined_at ASC "#, - session_started_at - ) - .fetch_all(pool) - .await + session_started_at + ) + .fetch_all(pool) + .await } /// Lets a log replay rewrite a session's server spans from scratch pub async fn delete_session_servers( - pool: &SqlitePool, - session_started_at: &str, + pool: &SqlitePool, + session_started_at: &str, ) -> Result<(), sqlx::Error> { - sqlx::query!( - r#" + sqlx::query!( + r#" DELETE FROM game_session_servers WHERE session_started_at = ? "#, - session_started_at - ) - .execute(pool) - .await?; - Ok(()) + session_started_at + ) + .execute(pool) + .await?; + Ok(()) } pub async fn server_join_counts( - pool: &SqlitePool, - cluster_id: i64, + pool: &SqlitePool, + cluster_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as!( - ServerJoinCount, - r#" + sqlx::query_as!( + ServerJoinCount, + r#" SELECT s.address AS "address!", COUNT(*) AS "joins!: i64" FROM game_session_servers s JOIN game_sessions g ON g.started_at = s.session_started_at @@ -220,74 +227,74 @@ pub async fn server_join_counts( GROUP BY s.address ORDER BY COUNT(*) DESC "#, - cluster_id - ) - .fetch_all(pool) - .await + cluster_id + ) + .fetch_all(pool) + .await } pub async fn all_session_spans(pool: &SqlitePool) -> Result, sqlx::Error> { - sqlx::query_as!( - SessionSpan, - r#" + sqlx::query_as!( + SessionSpan, + r#" SELECT started_at, ended_at FROM game_sessions WHERE ended_at IS NOT NULL ORDER BY started_at ASC "# - ) - .fetch_all(pool) - .await + ) + .fetch_all(pool) + .await } pub async fn session_spans_for_cluster( - pool: &SqlitePool, - cluster_id: i64, + pool: &SqlitePool, + cluster_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as!( - SessionSpan, - r#" + sqlx::query_as!( + SessionSpan, + r#" SELECT started_at, ended_at FROM game_sessions WHERE cluster_id = ? AND ended_at IS NOT NULL ORDER BY started_at ASC "#, - cluster_id - ) - .fetch_all(pool) - .await + cluster_id + ) + .fetch_all(pool) + .await } pub async fn all_session_servers( - pool: &SqlitePool, + pool: &SqlitePool, ) -> Result, sqlx::Error> { - sqlx::query_as!( - GameSessionServerRow, - r#" + sqlx::query_as!( + GameSessionServerRow, + r#" SELECT session_started_at, address, port, joined_at, disconnected_at FROM game_session_servers ORDER BY joined_at ASC "# - ) - .fetch_all(pool) - .await + ) + .fetch_all(pool) + .await } pub async fn session_servers_for_cluster( - pool: &SqlitePool, - cluster_id: i64, + pool: &SqlitePool, + cluster_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as!( - GameSessionServerRow, - r#" + sqlx::query_as!( + GameSessionServerRow, + r#" SELECT s.session_started_at, s.address, s.port, s.joined_at, s.disconnected_at FROM game_session_servers s JOIN game_sessions g ON g.started_at = s.session_started_at WHERE g.cluster_id = ? ORDER BY s.joined_at ASC "#, - cluster_id - ) - .fetch_all(pool) - .await + cluster_id + ) + .fetch_all(pool) + .await } diff --git a/packages/oneclient_db/src/dao/mod.rs b/packages/oneclient_db/src/dao/mod.rs index 16fd2281..11d84cd8 100644 --- a/packages/oneclient_db/src/dao/mod.rs +++ b/packages/oneclient_db/src/dao/mod.rs @@ -8,4 +8,5 @@ pub mod cluster_optional_mod; pub mod game_session; pub mod java; pub mod package_metadata; +pub mod release_migration_waitlist; pub mod setting_profile; diff --git a/packages/oneclient_db/src/dao/package_metadata.rs b/packages/oneclient_db/src/dao/package_metadata.rs index 3fb45b22..34210171 100644 --- a/packages/oneclient_db/src/dao/package_metadata.rs +++ b/packages/oneclient_db/src/dao/package_metadata.rs @@ -3,43 +3,43 @@ use sqlx::{QueryBuilder, Sqlite, SqlitePool}; use crate::models::PackageMetadataRow; pub async fn get_package_metadata_batch( - pool: &SqlitePool, - provider: i64, - project_ids: &[String], + pool: &SqlitePool, + provider: i64, + project_ids: &[String], ) -> Result, sqlx::Error> { - if project_ids.is_empty() { - return Ok(Vec::new()); - } + if project_ids.is_empty() { + return Ok(Vec::new()); + } - let mut builder: QueryBuilder = QueryBuilder::new( - "SELECT provider, project_id, name, summary, author, icon_url \ + let mut builder: QueryBuilder = QueryBuilder::new( + "SELECT provider, project_id, name, summary, author, icon_url \ FROM package_metadata WHERE provider = ", - ); - builder.push_bind(provider); - builder.push(" AND project_id IN ("); - let mut separated = builder.separated(", "); - for id in project_ids { - separated.push_bind(id); - } - separated.push_unseparated(")"); + ); + builder.push_bind(provider); + builder.push(" AND project_id IN ("); + let mut separated = builder.separated(", "); + for id in project_ids { + separated.push_bind(id); + } + separated.push_unseparated(")"); - builder - .build_query_as::() - .fetch_all(pool) - .await + builder + .build_query_as::() + .fetch_all(pool) + .await } pub async fn upsert_package_metadata( - pool: &SqlitePool, - provider: i64, - project_id: &str, - name: &str, - summary: &str, - author: &str, - icon_url: Option<&str>, + pool: &SqlitePool, + provider: i64, + project_id: &str, + name: &str, + summary: &str, + author: &str, + icon_url: Option<&str>, ) -> Result<(), sqlx::Error> { - sqlx::query!( - r#" + sqlx::query!( + r#" INSERT INTO package_metadata (provider, project_id, name, summary, author, icon_url, updated_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(provider, project_id) DO UPDATE SET @@ -49,15 +49,15 @@ pub async fn upsert_package_metadata( icon_url = excluded.icon_url, updated_at = excluded.updated_at "#, - provider, - project_id, - name, - summary, - author, - icon_url - ) - .execute(pool) - .await?; + provider, + project_id, + name, + summary, + author, + icon_url + ) + .execute(pool) + .await?; - Ok(()) + Ok(()) } diff --git a/packages/oneclient_db/src/dao/release_migration_waitlist.rs b/packages/oneclient_db/src/dao/release_migration_waitlist.rs new file mode 100644 index 00000000..37313cb7 --- /dev/null +++ b/packages/oneclient_db/src/dao/release_migration_waitlist.rs @@ -0,0 +1,88 @@ +use sqlx::SqlitePool; + +use crate::models::ReleaseMigrationWaitlistRow; + +#[allow(clippy::too_many_arguments)] +pub async fn upsert( + pool: &SqlitePool, + target_cluster_id: i64, + provider: i64, + project_id: &str, + content_type: i64, + source_hash: &str, + display_name: &str, + added_at: &str, + expires_at: &str, + enabled: bool, +) -> Result<(), sqlx::Error> { + let enabled = i64::from(enabled); + sqlx::query!( + r" + INSERT INTO release_migration_waitlist ( + target_cluster_id, provider, project_id, content_type, + source_hash, display_name, added_at, expires_at, enabled + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(target_cluster_id, provider, project_id) DO UPDATE SET + content_type = excluded.content_type, + source_hash = excluded.source_hash, + display_name = excluded.display_name, + added_at = excluded.added_at, + expires_at = excluded.expires_at, + enabled = excluded.enabled + ", + target_cluster_id, + provider, + project_id, + content_type, + source_hash, + display_name, + added_at, + expires_at, + enabled + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + ReleaseMigrationWaitlistRow, + r" + SELECT + target_cluster_id, provider, project_id, content_type, + source_hash, display_name, expires_at, enabled + FROM release_migration_waitlist + ", + ) + .fetch_all(pool) + .await +} + +pub async fn delete( + pool: &SqlitePool, + target_cluster_id: i64, + provider: i64, + project_id: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + "DELETE FROM release_migration_waitlist WHERE target_cluster_id = ? AND provider = ? AND project_id = ?", + target_cluster_id, + provider, + project_id + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn delete_expired(pool: &SqlitePool, now: &str) -> Result { + let result = sqlx::query!( + "DELETE FROM release_migration_waitlist WHERE expires_at <= ?", + now + ) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} diff --git a/packages/oneclient_db/src/dao/setting_profile.rs b/packages/oneclient_db/src/dao/setting_profile.rs index 0511ece1..8ecf3636 100644 --- a/packages/oneclient_db/src/dao/setting_profile.rs +++ b/packages/oneclient_db/src/dao/setting_profile.rs @@ -14,7 +14,7 @@ pub async fn list_all(pool: &SqlitePool) -> DbResult> { let rows = sqlx::query_as!( SettingProfileRow, r#" - SELECT name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env, + SELECT name, java_path, resolution, force_fullscreen, mem_max, launch_args, game_args, launch_env, hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode FROM setting_profiles ORDER BY name ASC @@ -30,7 +30,7 @@ pub async fn get_by_name(pool: &SqlitePool, name: &str) -> DbResult = Result; diff --git a/packages/oneclient_db/src/lib.rs b/packages/oneclient_db/src/lib.rs index b56b5a58..ff5e44ab 100644 --- a/packages/oneclient_db/src/lib.rs +++ b/packages/oneclient_db/src/lib.rs @@ -8,4 +8,4 @@ pub mod dao; pub mod models; pub use error::DbError; -pub use pool::{connect, DbPool}; +pub use pool::{DbPool, connect}; diff --git a/packages/oneclient_db/src/migrate.rs b/packages/oneclient_db/src/migrate.rs index abc6f213..8bb88a43 100644 --- a/packages/oneclient_db/src/migrate.rs +++ b/packages/oneclient_db/src/migrate.rs @@ -69,12 +69,13 @@ mod tests { async fn migrates_a_fresh_database() { let (_dir, _path, pool) = migrated().await; - let column: Option = - sqlx::query_scalar("SELECT 1 FROM pragma_table_info('setting_profiles') WHERE name = ?") - .bind("browser_update_mode") - .fetch_optional(&pool) - .await - .expect("probe"); + let column: Option = sqlx::query_scalar( + "SELECT 1 FROM pragma_table_info('setting_profiles') WHERE name = ?", + ) + .bind("browser_update_mode") + .fetch_optional(&pool) + .await + .expect("probe"); assert!(column.is_some()); } @@ -99,7 +100,9 @@ mod tests { .await .expect("record a migration from the future"); - run(&pool, &path).await.expect("a downgrade must still start"); + run(&pool, &path) + .await + .expect("a downgrade must still start"); } #[tokio::test] diff --git a/packages/oneclient_db/src/models/artifact.rs b/packages/oneclient_db/src/models/artifact.rs index ebcfdd25..a48297a5 100644 --- a/packages/oneclient_db/src/models/artifact.rs +++ b/packages/oneclient_db/src/models/artifact.rs @@ -3,85 +3,85 @@ use sqlx::FromRow; #[derive(Debug, Clone, FromRow)] pub struct ArtifactRow { - pub hash: String, - pub content_type: i64, - pub path: String, - pub file_name: String, - pub size_bytes: Option, + pub hash: String, + pub content_type: i64, + pub path: String, + pub file_name: String, + pub size_bytes: Option, } #[derive(Debug, Clone, FromRow)] pub struct ProviderReleaseRow { - pub provider: i64, - pub project_id: String, - pub version_id: String, - pub hash: String, - pub display_name: String, - pub display_version: String, - pub published_at: Option, - pub mc_versions: String, - pub mc_loaders: String, + pub provider: i64, + pub project_id: String, + pub version_id: String, + pub hash: String, + pub display_name: String, + pub display_version: String, + pub published_at: Option, + pub mc_versions: String, + pub mc_loaders: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[repr(i64)] pub enum SeenStatus { - New = 0, - Updated = 1, - #[default] - Seen = 2, + New = 0, + Updated = 1, + #[default] + Seen = 2, } impl SeenStatus { - pub fn as_i64(self) -> i64 { - self as i64 - } + pub fn as_i64(self) -> i64 { + self as i64 + } - pub fn from_repr(value: i64) -> Option { - match value { - 0 => Some(Self::New), - 1 => Some(Self::Updated), - 2 => Some(Self::Seen), - _ => None, - } - } + pub fn from_repr(value: i64) -> Option { + match value { + 0 => Some(Self::New), + 1 => Some(Self::Updated), + 2 => Some(Self::Seen), + _ => None, + } + } } #[derive(Debug, Clone, FromRow)] pub struct ClusterArtifactRow { - pub cluster_id: i64, - pub hash: String, - pub cluster_file_name: String, - pub enabled: i64, - pub seen_status: i64, + pub cluster_id: i64, + pub hash: String, + pub cluster_file_name: String, + pub enabled: i64, + pub seen_status: i64, } impl ClusterArtifactRow { - pub fn status(&self) -> SeenStatus { - SeenStatus::from_repr(self.seen_status).unwrap_or_default() - } + pub fn status(&self) -> SeenStatus { + SeenStatus::from_repr(self.seen_status).unwrap_or_default() + } } #[derive(Debug, Clone, FromRow)] pub struct LinkedArtifactRow { - pub hash: String, - pub cluster_file_name: String, - pub enabled: i64, - pub seen_status: i64, - pub content_type: i64, - pub file_name: String, - pub provider: Option, - pub project_id: Option, - pub version_id: Option, - pub display_name: Option, - pub display_version: Option, - pub published_at: Option, + pub hash: String, + pub cluster_file_name: String, + pub enabled: i64, + pub seen_status: i64, + pub content_type: i64, + pub file_name: String, + pub provider: Option, + pub project_id: Option, + pub version_id: Option, + pub display_name: Option, + pub display_version: Option, + pub published_at: Option, } // used for resourcepacks and shaders #[derive(Debug, Clone, FromRow)] pub struct GlobalArtifactRow { - pub hash: String, - pub file_name: String, - pub enabled: i64, + pub hash: String, + pub file_name: String, + pub enabled: i64, } diff --git a/packages/oneclient_db/src/models/browser_package_update.rs b/packages/oneclient_db/src/models/browser_package_update.rs index 24ca147f..7fb36a50 100644 --- a/packages/oneclient_db/src/models/browser_package_update.rs +++ b/packages/oneclient_db/src/models/browser_package_update.rs @@ -4,15 +4,15 @@ use sqlx::FromRow; /// be attached to a file the cluster no longer has #[derive(Debug, Clone, FromRow)] pub struct BrowserPackageUpdateRow { - pub cluster_id: i64, - pub hash: String, - pub provider: i64, - pub project_id: String, - pub installed_version_id: String, - pub installed_version_name: String, - pub latest_version_id: String, - pub latest_version_name: String, - pub display_name: String, - pub checked_at: String, - pub skipped: i64, + pub cluster_id: i64, + pub hash: String, + pub provider: i64, + pub project_id: String, + pub installed_version_id: String, + pub installed_version_name: String, + pub latest_version_id: String, + pub latest_version_name: String, + pub display_name: String, + pub checked_at: String, + pub skipped: i64, } diff --git a/packages/oneclient_db/src/models/cluster.rs b/packages/oneclient_db/src/models/cluster.rs index 32b1b077..efa05932 100644 --- a/packages/oneclient_db/src/models/cluster.rs +++ b/packages/oneclient_db/src/models/cluster.rs @@ -1,37 +1,98 @@ +use serde::{Deserialize, Serialize}; use sqlx::FromRow; pub type ClusterId = i64; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[repr(i64)] +pub enum ClusterKind { + #[default] + OneClient = 0, + Vanilla = 1, + Modded = 2, +} + +impl ClusterKind { + pub fn as_i64(self) -> i64 { + self as i64 + } + + pub fn from_repr(value: i64) -> Option { + match value { + 0 => Some(Self::OneClient), + 1 => Some(Self::Vanilla), + 2 => Some(Self::Modded), + _ => None, + } + } + + pub fn is_isolated(self) -> bool { + self != Self::OneClient + } + + pub fn uses_bundles(self) -> bool { + self == Self::OneClient + } +} + #[derive(Debug, Clone, FromRow)] pub struct ClusterRow { - pub id: ClusterId, - pub name: String, - pub folder_name: String, - pub setting_profile_name: Option, - pub mc_version: String, - pub mc_loader: i64, - pub stage: i64, - pub mc_loader_version: Option, - pub created_at: Option, - pub last_played: Option, - pub overall_played: Option, - pub linked_modpack_hash: Option, + pub id: ClusterId, + pub name: String, + pub folder_name: String, + pub setting_profile_name: Option, + pub mc_version: String, + pub mc_loader: i64, + pub stage: i64, + pub mc_loader_version: Option, + pub created_at: Option, + pub last_played: Option, + pub overall_played: Option, + pub linked_modpack_hash: Option, + pub kind: i64, + pub user_created: i64, + pub description: Option, + pub tags: String, + pub cover_path: Option, +} + +impl ClusterRow { + pub fn kind(&self) -> ClusterKind { + ClusterKind::from_repr(self.kind).unwrap_or(ClusterKind::Modded) + } + + pub fn is_isolated(&self) -> bool { + self.kind().is_isolated() + } + + pub fn uses_bundles(&self) -> bool { + self.kind().uses_bundles() + } } pub struct NewCluster<'a> { - pub name: &'a str, - pub folder_name: &'a str, - pub mc_version: &'a str, - pub mc_loader: i64, - pub mc_loader_version: Option<&'a str>, - pub setting_profile_name: Option<&'a str>, - pub stage: i64, + pub name: &'a str, + pub folder_name: &'a str, + pub mc_version: &'a str, + pub mc_loader: i64, + pub mc_loader_version: Option<&'a str>, + pub setting_profile_name: Option<&'a str>, + pub stage: i64, + pub kind: i64, + pub user_created: i64, + pub description: Option<&'a str>, + pub tags: &'a str, + pub cover_path: Option<&'a str>, } #[derive(Debug, Clone, Default)] pub struct ClusterPatch { - pub name: Option, - pub setting_profile_name: Option>, - pub mc_loader_version: Option>, - pub linked_modpack_hash: Option>, + pub name: Option, + pub setting_profile_name: Option>, + pub mc_loader_version: Option>, + pub linked_modpack_hash: Option>, + pub description: Option>, + pub tags: Option, + pub cover_path: Option>, } diff --git a/packages/oneclient_db/src/models/game_session.rs b/packages/oneclient_db/src/models/game_session.rs index 04a19ab3..26beed9b 100644 --- a/packages/oneclient_db/src/models/game_session.rs +++ b/packages/oneclient_db/src/models/game_session.rs @@ -4,50 +4,50 @@ pub type GameSessionId = String; #[derive(Debug, Clone, FromRow)] pub struct GameSessionRow { - pub cluster_id: i64, - pub started_at: String, - pub ended_at: Option, - pub exit_code: Option, - pub ram_allocated_mb: i64, - pub mods_enabled: i64, - pub java_vendor: Option, - pub java_version: Option, + pub cluster_id: i64, + pub started_at: String, + pub ended_at: Option, + pub exit_code: Option, + pub ram_allocated_mb: i64, + pub mods_enabled: i64, + pub java_vendor: Option, + pub java_version: Option, } pub struct NewGameSession<'a> { - pub cluster_id: i64, - pub started_at: &'a str, - pub ram_allocated_mb: i64, - pub mods_enabled: i64, - pub java_vendor: Option<&'a str>, - pub java_version: Option<&'a str>, + pub cluster_id: i64, + pub started_at: &'a str, + pub ram_allocated_mb: i64, + pub mods_enabled: i64, + pub java_vendor: Option<&'a str>, + pub java_version: Option<&'a str>, } #[derive(Debug, Clone, FromRow)] pub struct UnfinishedSession { - pub cluster_id: i64, - pub started_at: String, - pub pid: Option, - pub pid_started_at: Option, + pub cluster_id: i64, + pub started_at: String, + pub pid: Option, + pub pid_started_at: Option, } #[derive(Debug, Clone, FromRow)] pub struct GameSessionServerRow { - pub session_started_at: String, - pub address: String, - pub port: Option, - pub joined_at: String, - pub disconnected_at: Option, + pub session_started_at: String, + pub address: String, + pub port: Option, + pub joined_at: String, + pub disconnected_at: Option, } #[derive(Debug, Clone, FromRow)] pub struct ServerJoinCount { - pub address: String, - pub joins: i64, + pub address: String, + pub joins: i64, } #[derive(Debug, Clone, FromRow)] pub struct SessionSpan { - pub started_at: String, - pub ended_at: Option, + pub started_at: String, + pub ended_at: Option, } diff --git a/packages/oneclient_db/src/models/java.rs b/packages/oneclient_db/src/models/java.rs index 54c11188..db77db85 100644 --- a/packages/oneclient_db/src/models/java.rs +++ b/packages/oneclient_db/src/models/java.rs @@ -2,11 +2,11 @@ use sqlx::FromRow; #[derive(Debug, Clone, FromRow)] pub struct JavaVersionRow { - pub absolute_path: String, - pub major: i64, - pub version: String, - pub vendor: String, - pub os_arch: String, - pub is_jdk: bool, - pub probe_version: i64, + pub absolute_path: String, + pub major: i64, + pub version: String, + pub vendor: String, + pub os_arch: String, + pub is_jdk: bool, + pub probe_version: i64, } diff --git a/packages/oneclient_db/src/models/mod.rs b/packages/oneclient_db/src/models/mod.rs index 9e83ac64..0a0561f4 100644 --- a/packages/oneclient_db/src/models/mod.rs +++ b/packages/oneclient_db/src/models/mod.rs @@ -7,22 +7,23 @@ mod cluster_optional_mod; mod game_session; mod java; mod package_metadata; +mod release_migration_waitlist; mod setting_profile; pub use artifact::{ - ArtifactRow, ClusterArtifactRow, LinkedArtifactRow, ProviderReleaseRow, SeenStatus, GlobalArtifactRow + ArtifactRow, ClusterArtifactRow, GlobalArtifactRow, LinkedArtifactRow, ProviderReleaseRow, + SeenStatus, }; pub use browser_package_update::BrowserPackageUpdateRow; -pub use package_metadata::PackageMetadataRow; pub use bundle::{BundleRow, NewBundle}; -pub use cluster::{ClusterId, ClusterPatch, ClusterRow, NewCluster}; -pub use cluster_bundle::{ - BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType, -}; +pub use cluster::{ClusterId, ClusterKind, ClusterPatch, ClusterRow, NewCluster}; +pub use cluster_bundle::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType}; pub use cluster_optional_mod::{ClusterOptionalModRow, OptionalModStatus}; pub use game_session::{ GameSessionId, GameSessionRow, GameSessionServerRow, NewGameSession, ServerJoinCount, SessionSpan, UnfinishedSession, }; pub use java::JavaVersionRow; +pub use package_metadata::PackageMetadataRow; +pub use release_migration_waitlist::ReleaseMigrationWaitlistRow; pub use setting_profile::SettingProfileRow; diff --git a/packages/oneclient_db/src/models/package_metadata.rs b/packages/oneclient_db/src/models/package_metadata.rs index a3e3b462..fa922345 100644 --- a/packages/oneclient_db/src/models/package_metadata.rs +++ b/packages/oneclient_db/src/models/package_metadata.rs @@ -2,10 +2,10 @@ use sqlx::FromRow; #[derive(Debug, Clone, FromRow)] pub struct PackageMetadataRow { - pub provider: i64, - pub project_id: String, - pub name: String, - pub summary: String, - pub author: String, - pub icon_url: Option, + pub provider: i64, + pub project_id: String, + pub name: String, + pub summary: String, + pub author: String, + pub icon_url: Option, } diff --git a/packages/oneclient_db/src/models/release_migration_waitlist.rs b/packages/oneclient_db/src/models/release_migration_waitlist.rs new file mode 100644 index 00000000..28118c91 --- /dev/null +++ b/packages/oneclient_db/src/models/release_migration_waitlist.rs @@ -0,0 +1,13 @@ +use sqlx::FromRow; + +#[derive(Debug, Clone, FromRow)] +pub struct ReleaseMigrationWaitlistRow { + pub target_cluster_id: i64, + pub provider: i64, + pub project_id: String, + pub content_type: i64, + pub source_hash: String, + pub display_name: String, + pub expires_at: String, + pub enabled: i64, +} diff --git a/packages/oneclient_db/src/models/setting_profile.rs b/packages/oneclient_db/src/models/setting_profile.rs index f236bb04..d6ad248c 100644 --- a/packages/oneclient_db/src/models/setting_profile.rs +++ b/packages/oneclient_db/src/models/setting_profile.rs @@ -2,17 +2,18 @@ use sqlx::FromRow; #[derive(Debug, Clone, FromRow)] pub struct SettingProfileRow { - pub name: String, - pub java_path: Option, - pub resolution: Option, - pub force_fullscreen: Option, - pub mem_max: Option, - pub launch_args: Option, - pub launch_env: Option, - pub hook_pre: Option, - pub hook_wrapper: Option, - pub hook_post: Option, - pub os_extra: Option, - /// `PackageUpdateMode::as_str` NULL inherits the global setting - pub browser_update_mode: Option, + pub name: String, + pub java_path: Option, + pub resolution: Option, + pub force_fullscreen: Option, + pub mem_max: Option, + pub launch_args: Option, + pub game_args: Option, + pub launch_env: Option, + pub hook_pre: Option, + pub hook_wrapper: Option, + pub hook_post: Option, + pub os_extra: Option, + /// `PackageUpdateMode::as_str` NULL inherits the global setting + pub browser_update_mode: Option, } diff --git a/packages/oneclient_discord/src/lib.rs b/packages/oneclient_discord/src/lib.rs index 1ed17a42..f8b41280 100644 --- a/packages/oneclient_discord/src/lib.rs +++ b/packages/oneclient_discord/src/lib.rs @@ -14,213 +14,213 @@ const LARGE_IMAGE: &str = "onelauncher_logo_512"; #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum Presence { - #[default] - Idle, - Playing { - cluster: String, - mc_version: String, - }, + #[default] + Idle, + Playing { + cluster: String, + mc_version: String, + }, } impl Presence { - fn details(&self) -> &'static str { - match self { - Self::Idle => "Idle", - Self::Playing { .. } => "Playing Minecraft", - } - } + fn details(&self) -> &'static str { + match self { + Self::Idle => "Idle", + Self::Playing { .. } => "Playing Minecraft", + } + } - fn state(&self) -> String { - match self { - Self::Idle => "In the launcher".to_owned(), - Self::Playing { - cluster, - mc_version, - } => format!("{cluster} ({mc_version})"), - } - } + fn state(&self) -> String { + match self { + Self::Idle => "In the launcher".to_owned(), + Self::Playing { + cluster, + mc_version, + } => format!("{cluster} ({mc_version})"), + } + } } enum Command { - SetPresence(Presence), - SetEnabled(bool), - Shutdown, + SetPresence(Presence), + SetEnabled(bool), + Shutdown, } #[derive(Clone)] pub struct DiscordRpc { - tx: Sender, + tx: Sender, } impl DiscordRpc { - pub fn spawn(enabled: bool) -> Self { - let (tx, rx) = mpsc::channel(); + pub fn spawn(enabled: bool) -> Self { + let (tx, rx) = mpsc::channel(); - std::thread::Builder::new() - .name("discord-rpc".to_owned()) - .spawn(move || Worker::new(enabled).run(&rx)) - .expect("failed to spawn the Discord RPC thread"); + std::thread::Builder::new() + .name("discord-rpc".to_owned()) + .spawn(move || Worker::new(enabled).run(&rx)) + .expect("failed to spawn the Discord RPC thread"); - Self { tx } - } + Self { tx } + } - pub fn set_presence(&self, presence: Presence) { - self.send(Command::SetPresence(presence)); - } + pub fn set_presence(&self, presence: Presence) { + self.send(Command::SetPresence(presence)); + } - pub fn set_enabled(&self, enabled: bool) { - self.send(Command::SetEnabled(enabled)); - } + pub fn set_enabled(&self, enabled: bool) { + self.send(Command::SetEnabled(enabled)); + } - pub fn shutdown(&self) { - self.send(Command::Shutdown); - } + pub fn shutdown(&self) { + self.send(Command::Shutdown); + } - fn send(&self, command: Command) { - if self.tx.send(command).is_err() { - tracing::debug!("Discord RPC worker is gone; dropping command"); - } - } + fn send(&self, command: Command) { + if self.tx.send(command).is_err() { + tracing::debug!("Discord RPC worker is gone; dropping command"); + } + } } struct Worker { - client: DiscordIpcClient, - enabled: bool, - connected: bool, - ever_connected: bool, - presence: Presence, - presence_since: i64, - next_attempt: Instant, + client: DiscordIpcClient, + enabled: bool, + connected: bool, + ever_connected: bool, + presence: Presence, + presence_since: i64, + next_attempt: Instant, } impl Worker { - fn new(enabled: bool) -> Self { - Self { - client: DiscordIpcClient::new(DISCORD_CLIENT_ID), - enabled, - connected: false, - ever_connected: false, - presence: Presence::Idle, - presence_since: chrono::Utc::now().timestamp(), - next_attempt: Instant::now(), - } - } + fn new(enabled: bool) -> Self { + Self { + client: DiscordIpcClient::new(DISCORD_CLIENT_ID), + enabled, + connected: false, + ever_connected: false, + presence: Presence::Idle, + presence_since: chrono::Utc::now().timestamp(), + next_attempt: Instant::now(), + } + } - fn run(mut self, rx: &mpsc::Receiver) { - self.publish(); + fn run(mut self, rx: &mpsc::Receiver) { + self.publish(); - loop { - match rx.recv_timeout(self.poll_interval()) { - Ok(Command::SetPresence(presence)) => self.set_presence(presence), - Ok(Command::SetEnabled(enabled)) => self.set_enabled(enabled), - Ok(Command::Shutdown) | Err(RecvTimeoutError::Disconnected) => break, - Err(RecvTimeoutError::Timeout) => self.publish(), - } - } + loop { + match rx.recv_timeout(self.poll_interval()) { + Ok(Command::SetPresence(presence)) => self.set_presence(presence), + Ok(Command::SetEnabled(enabled)) => self.set_enabled(enabled), + Ok(Command::Shutdown) | Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => self.publish(), + } + } - self.disconnect(); - } + self.disconnect(); + } - fn poll_interval(&self) -> Duration { - if !self.enabled || self.connected { - return IDLE_POLL; - } + fn poll_interval(&self) -> Duration { + if !self.enabled || self.connected { + return IDLE_POLL; + } - self.next_attempt - .saturating_duration_since(Instant::now()) - .max(Duration::from_millis(100)) - } + self.next_attempt + .saturating_duration_since(Instant::now()) + .max(Duration::from_millis(100)) + } - fn set_presence(&mut self, presence: Presence) { - if self.presence == presence { - return; - } + fn set_presence(&mut self, presence: Presence) { + if self.presence == presence { + return; + } - self.presence = presence; - self.presence_since = chrono::Utc::now().timestamp(); - self.publish(); - } + self.presence = presence; + self.presence_since = chrono::Utc::now().timestamp(); + self.publish(); + } - fn set_enabled(&mut self, enabled: bool) { - if self.enabled == enabled { - return; - } + fn set_enabled(&mut self, enabled: bool) { + if self.enabled == enabled { + return; + } - self.enabled = enabled; + self.enabled = enabled; - if enabled { - self.next_attempt = Instant::now(); - self.publish(); - } else { - self.disconnect(); - } - } + if enabled { + self.next_attempt = Instant::now(); + self.publish(); + } else { + self.disconnect(); + } + } - fn publish(&mut self) { - if !self.enabled || !self.ensure_connected() { - return; - } + fn publish(&mut self) { + if !self.enabled || !self.ensure_connected() { + return; + } - let state = self.presence.state(); - let activity = Activity::new() - .details(self.presence.details()) - .state(&state) - .timestamps(Timestamps::new().start(self.presence_since)) - .assets( - Assets::new() - .large_image(LARGE_IMAGE) - .large_text("OneClient"), - ) - .buttons(vec![Button::new("Website", "https://polyfrost.org/")]); + let state = self.presence.state(); + let activity = Activity::new() + .details(self.presence.details()) + .state(&state) + .timestamps(Timestamps::new().start(self.presence_since)) + .assets( + Assets::new() + .large_image(LARGE_IMAGE) + .large_text("OneClient"), + ) + .buttons(vec![Button::new("Website", "https://polyfrost.org/")]); - if let Err(err) = self.client.set_activity(activity) { - tracing::warn!("Discord RPC set_activity failed: {err}"); - self.drop_connection(); - } - } + if let Err(err) = self.client.set_activity(activity) { + tracing::warn!("Discord RPC set_activity failed: {err}"); + self.drop_connection(); + } + } - fn ensure_connected(&mut self) -> bool { - if self.connected { - return true; - } + fn ensure_connected(&mut self) -> bool { + if self.connected { + return true; + } - if Instant::now() < self.next_attempt { - return false; - } - self.next_attempt = Instant::now() + RECONNECT_BACKOFF; + if Instant::now() < self.next_attempt { + return false; + } + self.next_attempt = Instant::now() + RECONNECT_BACKOFF; - let result = if self.ever_connected { - self.client.reconnect() - } else { - self.client.connect() - }; + let result = if self.ever_connected { + self.client.reconnect() + } else { + self.client.connect() + }; - match result { - Ok(()) => { - tracing::info!("Discord RPC connected"); - self.connected = true; - self.ever_connected = true; - true - } - Err(err) => { - tracing::debug!("Discord RPC connect failed (is Discord running?): {err}"); - false - } - } - } + match result { + Ok(()) => { + tracing::info!("Discord RPC connected"); + self.connected = true; + self.ever_connected = true; + true + } + Err(err) => { + tracing::debug!("Discord RPC connect failed (is Discord running?): {err}"); + false + } + } + } - fn drop_connection(&mut self) { - self.connected = false; - self.next_attempt = Instant::now() + RECONNECT_BACKOFF; - } + fn drop_connection(&mut self) { + self.connected = false; + self.next_attempt = Instant::now() + RECONNECT_BACKOFF; + } - fn disconnect(&mut self) { - if !self.connected { - return; - } + fn disconnect(&mut self) { + if !self.connected { + return; + } - let _ = self.client.clear_activity(); - let _ = self.client.close(); - self.connected = false; - } + let _ = self.client.clear_activity(); + let _ = self.client.close(); + self.connected = false; + } } diff --git a/packages/oneclient_events/src/bus.rs b/packages/oneclient_events/src/bus.rs index 15645bb1..54be020e 100644 --- a/packages/oneclient_events/src/bus.rs +++ b/packages/oneclient_events/src/bus.rs @@ -2,295 +2,300 @@ use tokio::sync::{mpsc, oneshot}; use uuid::Uuid; use crate::error::{EventError, EventResult}; -use crate::event::{Event, GameEvent, LaunchStage, Level, Message, Notification, ProgressEvent, Signal}; +use crate::event::{ + Event, GameEvent, LaunchStage, Level, Message, Notification, ProgressEvent, Signal, +}; use crate::prompt::{Answer, Chosen, Prompt, PromptRequest}; /// Cheap to clone every clone feeds the same channel /// Hand clones to subsystems rather than wrapping this in an `Arc` #[derive(Clone, Debug)] pub struct EventBus { - tx: mpsc::UnboundedSender, + tx: mpsc::UnboundedSender, } pub type EventReceiver = mpsc::UnboundedReceiver; impl EventBus { - #[must_use] - pub fn new(tx: mpsc::UnboundedSender) -> Self { - Self { tx } - } + #[must_use] + pub fn new(tx: mpsc::UnboundedSender) -> Self { + Self { tx } + } - #[must_use] - pub fn channel() -> (Self, EventReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); - (Self::new(tx), rx) - } + #[must_use] + pub fn channel() -> (Self, EventReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + (Self::new(tx), rx) + } - /// A closed bus is logged rather than returned use [`EventBus::ask`] when - /// you need to know whether the other end is alive - pub fn emit(&self, event: impl Into) { - if let Err(err) = self.tx.send(event.into()) { - tracing::debug!("dropping event, bus is closed: {err}"); - } - } + /// A closed bus is logged rather than returned use [`EventBus::ask`] when + /// you need to know whether the other end is alive + pub fn emit(&self, event: impl Into) { + if let Err(err) = self.tx.send(event.into()) { + tracing::debug!("dropping event, bus is closed: {err}"); + } + } - #[must_use] - pub fn is_open(&self) -> bool { - !self.tx.is_closed() - } + #[must_use] + pub fn is_open(&self) -> bool { + !self.tx.is_closed() + } - /// ```ignore - /// events.notify("Install failed").body(err.to_string()).error().send(); - /// ``` - pub fn notify(&self, title: impl Into) -> NotificationBuilder<'_> { - NotificationBuilder { - bus: self, - message: Message { - title: title.into(), - body: String::new(), - level: Level::Info, - }, - } - } + /// ```ignore + /// events.notify("Install failed").body(err.to_string()).error().send(); + /// ``` + pub fn notify(&self, title: impl Into) -> NotificationBuilder<'_> { + NotificationBuilder { + bus: self, + message: Message { + title: title.into(), + body: String::new(), + level: Level::Info, + }, + } + } - /// Repeated calls with the same `id` replace one another - pub fn progress(&self, id: Uuid, label: impl Into, current: u64, total: u64) { - self.emit(ProgressEvent::Update { - id, - label: label.into(), - current, - total, - }); - } + /// Repeated calls with the same `id` replace one another + pub fn progress(&self, id: Uuid, label: impl Into, current: u64, total: u64) { + self.emit(ProgressEvent::Update { + id, + label: label.into(), + current, + total, + }); + } - pub fn finish_progress(&self, id: Uuid, title: impl Into, body: impl Into) { - self.emit(ProgressEvent::Complete { - id, - title: title.into(), - body: body.into(), - }); - } + pub fn finish_progress(&self, id: Uuid, title: impl Into, body: impl Into) { + self.emit(ProgressEvent::Complete { + id, + title: title.into(), + body: body.into(), + }); + } - pub fn game_stage(&self, cluster_id: i64, stage: LaunchStage) { - self.emit(GameEvent::Stage { cluster_id, stage }); - } + pub fn game_stage(&self, cluster_id: i64, stage: LaunchStage) { + self.emit(GameEvent::Stage { cluster_id, stage }); + } - pub fn game_log(&self, cluster_id: i64, line: impl Into) { - self.emit(GameEvent::Log { - cluster_id, - line: line.into(), - }); - } + pub fn game_log(&self, cluster_id: i64, line: impl Into) { + self.emit(GameEvent::Log { + cluster_id, + line: line.into(), + }); + } - pub fn game_failed(&self, cluster_id: i64, message: impl Into) { - self.emit(GameEvent::Failed { - cluster_id, - message: message.into(), - }); - } + pub fn game_failed(&self, cluster_id: i64, message: impl Into) { + self.emit(GameEvent::Failed { + cluster_id, + message: message.into(), + }); + } - pub fn signal(&self, signal: Signal) { - self.emit(signal); - } + pub fn signal(&self, signal: Signal) { + self.emit(signal); + } - /// Returns `Ok(None)` when the prompt was dismissed - #[tracing::instrument(level = "debug", skip_all, fields(title = %prompt.title))] - pub async fn ask(&self, prompt: Prompt) -> EventResult>> { - let Prompt { - title, - body, - options, - dismiss, - } = prompt; + /// Returns `Ok(None)` when the prompt was dismissed + #[tracing::instrument(level = "debug", skip_all, fields(title = %prompt.title))] + pub async fn ask(&self, prompt: Prompt) -> EventResult>> { + let Prompt { + title, + body, + options, + dismiss, + } = prompt; - let (choices, values): (Vec<_>, Vec<_>) = options.into_iter().unzip(); - let (reply, reply_rx) = oneshot::channel(); + let (choices, values): (Vec<_>, Vec<_>) = options.into_iter().unzip(); + let (reply, reply_rx) = oneshot::channel(); - self.tx - .send(Event::Notification(Notification::Prompt(PromptRequest { - title, - body, - choices: choices.clone(), - dismiss, - reply, - }))) - .map_err(|_| EventError::BusClosed)?; + self.tx + .send(Event::Notification(Notification::Prompt(PromptRequest { + title, + body, + choices: choices.clone(), + dismiss, + reply, + }))) + .map_err(|_| EventError::BusClosed)?; - // A dropped sender means the UI went away without answering - let Some(answer) = reply_rx.await.map_err(|_| EventError::BusClosed)? else { - return Ok(None); - }; + // A dropped sender means the UI went away without answering + let Some(answer) = reply_rx.await.map_err(|_| EventError::BusClosed)? else { + return Ok(None); + }; - let Answer { id, input } = answer; - let index = choices - .iter() - .position(|choice| choice.id == id) - .ok_or(EventError::UnknownChoice(id))?; + let Answer { id, input } = answer; + let index = choices + .iter() + .position(|choice| choice.id == id) + .ok_or(EventError::UnknownChoice(id))?; - let value = values - .into_iter() - .nth(index) - .ok_or(EventError::UnknownChoice(id))?; + let value = values + .into_iter() + .nth(index) + .ok_or(EventError::UnknownChoice(id))?; - Ok(Some(Chosen { value, input })) - } + Ok(Some(Chosen { value, input })) + } } #[must_use = "the notification is not emitted until `.send()` is called"] pub struct NotificationBuilder<'a> { - bus: &'a EventBus, - message: Message, + bus: &'a EventBus, + message: Message, } impl NotificationBuilder<'_> { - pub fn body(mut self, body: impl Into) -> Self { - self.message.body = body.into(); - self - } + pub fn body(mut self, body: impl Into) -> Self { + self.message.body = body.into(); + self + } - pub fn level(mut self, level: Level) -> Self { - self.message.level = level; - self - } + pub fn level(mut self, level: Level) -> Self { + self.message.level = level; + self + } - pub fn info(self) -> Self { - self.level(Level::Info) - } + pub fn info(self) -> Self { + self.level(Level::Info) + } - pub fn error(self) -> Self { - self.level(Level::Error) - } + pub fn error(self) -> Self { + self.level(Level::Error) + } - pub fn send(self) { - self.bus.emit(self.message); - } + pub fn send(self) { + self.bus.emit(self.message); + } } #[cfg(test)] mod tests { - use super::*; - use crate::prompt::{Choice, InputValue}; + use super::*; + use crate::prompt::{Choice, InputValue}; - #[derive(Debug, PartialEq, Eq)] - enum Answered { - Download, - Folder, - } + #[derive(Debug, PartialEq, Eq)] + enum Answered { + Download, + Folder, + } - fn prompt() -> Prompt { - Prompt::new("Java required", "No Java 21 runtime was found.") - .option( - Choice::primary("download", "Download").picks_selection("java-vendor"), - Answered::Download, - ) - .option( - Choice::new("folder", "Choose folder").picks_folder("Pick a folder"), - Answered::Folder, - ) - .dismiss("Cancel") - } + fn prompt() -> Prompt { + Prompt::new("Java required", "No Java 21 runtime was found.") + .option( + Choice::primary("download", "Download").picks_selection("java-vendor"), + Answered::Download, + ) + .option( + Choice::new("folder", "Choose folder").picks_folder("Pick a folder"), + Answered::Folder, + ) + .dismiss("Cancel") + } - #[tokio::test] - async fn ask_maps_the_answer_back_to_the_callers_value() { - let (bus, mut rx) = EventBus::channel(); + #[tokio::test] + async fn ask_maps_the_answer_back_to_the_callers_value() { + let (bus, mut rx) = EventBus::channel(); - let task = tokio::spawn(async move { bus.ask(prompt()).await }); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); - let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { - panic!("expected a prompt"); - }; - assert_eq!(request.choices.len(), 2); - assert_eq!(request.dismiss.as_deref(), Some("Cancel")); - request - .reply - .send(Some(Answer::new("folder").with_folder("/opt/jdk21"))) - .unwrap(); + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + assert_eq!(request.choices.len(), 2); + assert_eq!(request.dismiss.as_deref(), Some("Cancel")); + request + .reply + .send(Some(Answer::new("folder").with_folder("/opt/jdk21"))) + .unwrap(); - let chosen = task.await.unwrap().unwrap().unwrap(); - assert_eq!(chosen.value, Answered::Folder); - assert_eq!(chosen.folder(), Some(std::path::Path::new("/opt/jdk21"))); - } + let chosen = task.await.unwrap().unwrap().unwrap(); + assert_eq!(chosen.value, Answered::Folder); + assert_eq!(chosen.folder(), Some(std::path::Path::new("/opt/jdk21"))); + } - #[tokio::test] - async fn selection_input_round_trips() { - let (bus, mut rx) = EventBus::channel(); - let task = tokio::spawn(async move { bus.ask(prompt()).await }); + #[tokio::test] + async fn selection_input_round_trips() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); - let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { - panic!("expected a prompt"); - }; - request - .reply - .send(Some(Answer::new("download").with_selection("adoptium"))) - .unwrap(); + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request + .reply + .send(Some(Answer::new("download").with_selection("adoptium"))) + .unwrap(); - let chosen = task.await.unwrap().unwrap().unwrap(); - assert_eq!(chosen.value, Answered::Download); - assert_eq!(chosen.selection(), Some("adoptium")); - assert_eq!( - chosen.input, - Some(InputValue::Selection("adoptium".to_string())) - ); - } + let chosen = task.await.unwrap().unwrap().unwrap(); + assert_eq!(chosen.value, Answered::Download); + assert_eq!(chosen.selection(), Some("adoptium")); + assert_eq!( + chosen.input, + Some(InputValue::Selection("adoptium".to_string())) + ); + } - #[tokio::test] - async fn dismissing_yields_none() { - let (bus, mut rx) = EventBus::channel(); - let task = tokio::spawn(async move { bus.ask(prompt()).await }); + #[tokio::test] + async fn dismissing_yields_none() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); - let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { - panic!("expected a prompt"); - }; - request.reply.send(None).unwrap(); + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request.reply.send(None).unwrap(); - assert!(task.await.unwrap().unwrap().is_none()); - } + assert!(task.await.unwrap().unwrap().is_none()); + } - #[tokio::test] - async fn unknown_choice_is_an_error_not_a_dismissal() { - let (bus, mut rx) = EventBus::channel(); - let task = tokio::spawn(async move { bus.ask(prompt()).await }); + #[tokio::test] + async fn unknown_choice_is_an_error_not_a_dismissal() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); - let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { - panic!("expected a prompt"); - }; - request.reply.send(Some(Answer::new("nonsense"))).unwrap(); + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request.reply.send(Some(Answer::new("nonsense"))).unwrap(); - assert_eq!( - task.await.unwrap().unwrap_err(), - EventError::UnknownChoice("nonsense") - ); - } + assert_eq!( + task.await.unwrap().unwrap_err(), + EventError::UnknownChoice("nonsense") + ); + } - #[tokio::test] - async fn dropping_the_receiver_unblocks_the_asker() { - let (bus, rx) = EventBus::channel(); - let task = tokio::spawn(async move { bus.ask(prompt()).await }); - drop(rx); + #[tokio::test] + async fn dropping_the_receiver_unblocks_the_asker() { + let (bus, rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + drop(rx); - assert_eq!(task.await.unwrap().unwrap_err(), EventError::BusClosed); - } + assert_eq!(task.await.unwrap().unwrap_err(), EventError::BusClosed); + } - #[test] - fn emitting_on_a_closed_bus_does_not_panic() { - let (bus, rx) = EventBus::channel(); - drop(rx); + #[test] + fn emitting_on_a_closed_bus_does_not_panic() { + let (bus, rx) = EventBus::channel(); + drop(rx); - assert!(!bus.is_open()); - bus.notify("gone").body("nobody is listening").error().send(); - bus.game_log(1, "still fine"); - } + assert!(!bus.is_open()); + bus.notify("gone") + .body("nobody is listening") + .error() + .send(); + bus.game_log(1, "still fine"); + } - #[tokio::test] - async fn notification_builder_defaults_to_info_with_an_empty_body() { - let (bus, mut rx) = EventBus::channel(); - bus.notify("Done").send(); + #[tokio::test] + async fn notification_builder_defaults_to_info_with_an_empty_body() { + let (bus, mut rx) = EventBus::channel(); + bus.notify("Done").send(); - let Some(Event::Notification(Notification::Message(message))) = rx.recv().await else { - panic!("expected a message"); - }; - assert_eq!(message.title, "Done"); - assert_eq!(message.body, ""); - assert_eq!(message.level, Level::Info); - } + let Some(Event::Notification(Notification::Message(message))) = rx.recv().await else { + panic!("expected a message"); + }; + assert_eq!(message.title, "Done"); + assert_eq!(message.body, ""); + assert_eq!(message.level, Level::Info); + } } diff --git a/packages/oneclient_events/src/error.rs b/packages/oneclient_events/src/error.rs index d36fc4ec..048199c8 100644 --- a/packages/oneclient_events/src/error.rs +++ b/packages/oneclient_events/src/error.rs @@ -2,11 +2,11 @@ pub type EventResult = Result; #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum EventError { - #[error("the event bus is closed")] - BusClosed, + #[error("the event bus is closed")] + BusClosed, - /// A wrong answer rather than a missing one so it must not be read as - /// "dismissed" - #[error("prompt answered with an unknown choice '{0}'")] - UnknownChoice(&'static str), + /// A wrong answer rather than a missing one so it must not be read as + /// "dismissed" + #[error("prompt answered with an unknown choice '{0}'")] + UnknownChoice(&'static str), } diff --git a/packages/oneclient_events/src/event.rs b/packages/oneclient_events/src/event.rs index 6c87722d..9684dfe9 100644 --- a/packages/oneclient_events/src/event.rs +++ b/packages/oneclient_events/src/event.rs @@ -9,123 +9,128 @@ use crate::prompt::PromptRequest; /// front-end's call #[derive(Debug)] pub enum Event { - Notification(Notification), - Progress(ProgressEvent), - Game(GameEvent), - /// State changed elsewhere whoever caches it should refetch - Signal(Signal), + Notification(Notification), + Progress(ProgressEvent), + Game(GameEvent), + /// State changed elsewhere whoever caches it should refetch + Signal(Signal), } #[derive(Debug)] pub enum Notification { - Message(Message), - Prompt(PromptRequest), + Message(Message), + Prompt(PromptRequest), } impl From for Event { - fn from(value: Notification) -> Self { - Self::Notification(value) - } + fn from(value: Notification) -> Self { + Self::Notification(value) + } } impl From for Event { - fn from(value: Message) -> Self { - Self::Notification(Notification::Message(value)) - } + fn from(value: Message) -> Self { + Self::Notification(Notification::Message(value)) + } } impl From for Event { - fn from(value: PromptRequest) -> Self { - Self::Notification(Notification::Prompt(value)) - } + fn from(value: PromptRequest) -> Self { + Self::Notification(Notification::Prompt(value)) + } } impl From for Event { - fn from(value: ProgressEvent) -> Self { - Self::Progress(value) - } + fn from(value: ProgressEvent) -> Self { + Self::Progress(value) + } } impl From for Event { - fn from(value: GameEvent) -> Self { - Self::Game(value) - } + fn from(value: GameEvent) -> Self { + Self::Game(value) + } } impl From for Event { - fn from(value: Signal) -> Self { - Self::Signal(value) - } + fn from(value: Signal) -> Self { + Self::Signal(value) + } } impl From for Event { - fn from(value: GroupedProgressEvent) -> Self { - Self::Progress(ProgressEvent::Grouped(value)) - } + fn from(value: GroupedProgressEvent) -> Self { + Self::Progress(ProgressEvent::Grouped(value)) + } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Message { - pub title: String, - pub body: String, - pub level: Level, + pub title: String, + pub body: String, + pub level: Level, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] pub enum Level { - #[default] - Info, - Error, + #[default] + Info, + Error, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProgressEvent { - /// Keyed by `id` so repeated updates replace one another rather than stack - Update { - id: Uuid, - label: String, - current: u64, - total: u64, - }, + /// Keyed by `id` so repeated updates replace one another rather than stack + Update { + id: Uuid, + label: String, + current: u64, + total: u64, + }, - /// Turns the in-flight [`ProgressEvent::Update`] with this `id` into a - /// finished message in place rather than emitting a second card - Complete { - id: Uuid, - title: String, - body: String, - }, + /// Turns the in-flight [`ProgressEvent::Update`] with this `id` into a + /// finished message in place rather than emitting a second card + Complete { + id: Uuid, + title: String, + body: String, + }, - Grouped(GroupedProgressEvent), + Grouped(GroupedProgressEvent), } #[derive(Debug, Clone, PartialEq, Eq)] pub enum GameEvent { - Stage { cluster_id: i64, stage: LaunchStage }, - Log { cluster_id: i64, line: String }, - Failed { cluster_id: i64, message: String }, + Stage { cluster_id: i64, stage: LaunchStage }, + Log { cluster_id: i64, line: String }, + Failed { cluster_id: i64, message: String }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LaunchStage { - Checking, - Downloading, - Launching, - Running, - Exited, + Checking, + /// Queued behind another cluster's launch + Waiting, + Downloading, + Launching, + Running, + Exited, } impl LaunchStage { - #[must_use] - pub fn is_busy(self) -> bool { - matches!(self, Self::Checking | Self::Downloading | Self::Launching) - } + #[must_use] + pub fn is_busy(self) -> bool { + matches!( + self, + Self::Checking | Self::Waiting | Self::Downloading | Self::Launching + ) + } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum Signal { - ClustersChanged, - JavaChanged, - /// Initial background sync finished - SyncComplete, + ClustersChanged, + JavaChanged, + /// Initial background sync finished + SyncComplete, } diff --git a/packages/oneclient_events/src/lib.rs b/packages/oneclient_events/src/lib.rs index d6c15444..43d79479 100644 --- a/packages/oneclient_events/src/lib.rs +++ b/packages/oneclient_events/src/lib.rs @@ -13,9 +13,11 @@ pub mod prompt; pub use bus::{EventBus, EventReceiver, NotificationBuilder}; pub use error::{EventError, EventResult}; pub use event::{ - Event, GameEvent, LaunchStage, Level, Message, Notification, ProgressEvent, Signal, + Event, GameEvent, LaunchStage, Level, Message, Notification, ProgressEvent, Signal, }; pub use progress::{ - GroupedProgressChild, GroupedProgressEvent, GroupedProgressSession, TaskCategory, TaskPhase, + GroupedProgressChild, GroupedProgressEvent, GroupedProgressSession, TaskCategory, TaskPhase, +}; +pub use prompt::{ + Answer, Choice, ChoiceInput, ChoiceStyle, Chosen, InputValue, Prompt, PromptRequest, }; -pub use prompt::{Answer, Choice, ChoiceInput, ChoiceStyle, Chosen, InputValue, Prompt, PromptRequest}; diff --git a/packages/oneclient_events/src/progress.rs b/packages/oneclient_events/src/progress.rs index d7663c9d..f810dd20 100644 --- a/packages/oneclient_events/src/progress.rs +++ b/packages/oneclient_events/src/progress.rs @@ -172,14 +172,12 @@ impl GroupedProgressSession { if count == 0 { return; } - self.inner - .events - .emit(GroupedProgressEvent::Expect { - session_id: self.inner.session_id, - category, - count, - total, - }); + self.inner.events.emit(GroupedProgressEvent::Expect { + session_id: self.inner.session_id, + category, + count, + total, + }); } pub fn child( @@ -192,15 +190,13 @@ impl GroupedProgressSession { let label = label.into(); let total = total.max(1); - self.inner - .events - .emit(GroupedProgressEvent::AddChild { - session_id: self.inner.session_id, - child_id, - label: label.clone(), - total, - category, - }); + self.inner.events.emit(GroupedProgressEvent::AddChild { + session_id: self.inner.session_id, + child_id, + label: label.clone(), + total, + category, + }); GroupedProgressChild { inner: Arc::new(ChildInner { @@ -258,13 +254,11 @@ impl GroupedProgressChild { { return; } - self.inner - .events - .emit(GroupedProgressEvent::SetChildPhase { - session_id: self.inner.session_id, - child_id: self.inner.child_id, - phase, - }); + self.inner.events.emit(GroupedProgressEvent::SetChildPhase { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + phase, + }); } pub fn set_progress(&self, current: u64, total: Option) { @@ -283,14 +277,12 @@ impl GroupedProgressChild { if total > stored { self.inner.total.store(total, Ordering::Relaxed); } - self.inner - .events - .emit(GroupedProgressEvent::UpdateChild { - session_id: self.inner.session_id, - child_id: self.inner.child_id, - current, - total, - }); + self.inner.events.emit(GroupedProgressEvent::UpdateChild { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + current, + total, + }); } pub fn finish(&self) { @@ -302,12 +294,10 @@ impl GroupedProgressChild { return; } - self.inner - .events - .emit(GroupedProgressEvent::FinishChild { - session_id: self.inner.session_id, - child_id: self.inner.child_id, - }); + self.inner.events.emit(GroupedProgressEvent::FinishChild { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + }); } } @@ -359,10 +349,9 @@ mod tests { child.set_progress(50, Some(100)); let events = drain(&mut rx); assert!( - events.iter().any(|e| matches!( - e, - GroupedProgressEvent::UpdateChild { current: 50, .. } - )), + events + .iter() + .any(|e| matches!(e, GroupedProgressEvent::UpdateChild { current: 50, .. })), "progress must still be reported after a clone is dropped: {events:?}" ); @@ -382,7 +371,13 @@ mod tests { let _ = drain(&mut rx); child.finish(); - assert_eq!(drain(&mut rx).iter().filter(|e| matches!(e, GroupedProgressEvent::FinishChild { .. })).count(), 1); + assert_eq!( + drain(&mut rx) + .iter() + .filter(|e| matches!(e, GroupedProgressEvent::FinishChild { .. })) + .count(), + 1 + ); drop(child); assert!(!has_finish(&drain(&mut rx)), "drop must not finish twice"); diff --git a/packages/oneclient_events/src/prompt.rs b/packages/oneclient_events/src/prompt.rs index 2f54e859..4491b14f 100644 --- a/packages/oneclient_events/src/prompt.rs +++ b/packages/oneclient_events/src/prompt.rs @@ -10,75 +10,79 @@ use tokio::sync::oneshot; /// Only a hint the event layer has no business knowing what a button looks like #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ChoiceStyle { - Primary, - #[default] - Secondary, - Danger, + Primary, + #[default] + Secondary, + Danger, } /// Something the UI must collect before the choice can be answered #[derive(Debug, Clone, PartialEq, Eq)] pub enum ChoiceInput { - Folder { title: String }, - /// `hint` names the kind of thing being picked so the front-end can route to - /// the right picker its meaning belongs to whoever built the prompt - Selection { hint: String }, + Folder { + title: String, + }, + /// `hint` names the kind of thing being picked so the front-end can route to + /// the right picker its meaning belongs to whoever built the prompt + Selection { + hint: String, + }, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum InputValue { - Folder(PathBuf), - Selection(String), + Folder(PathBuf), + Selection(String), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Choice { - /// Stable identifier matched when the answer comes back - pub id: &'static str, - pub label: String, - pub style: ChoiceStyle, - pub input: Option, + /// Stable identifier matched when the answer comes back + pub id: &'static str, + pub label: String, + pub style: ChoiceStyle, + pub input: Option, } impl Choice { - pub fn new(id: &'static str, label: impl Into) -> Self { - Self { - id, - label: label.into(), - style: ChoiceStyle::Secondary, - input: None, - } - } + pub fn new(id: &'static str, label: impl Into) -> Self { + Self { + id, + label: label.into(), + style: ChoiceStyle::Secondary, + input: None, + } + } - #[must_use] - pub fn primary(id: &'static str, label: impl Into) -> Self { - Self::new(id, label).style(ChoiceStyle::Primary) - } + #[must_use] + pub fn primary(id: &'static str, label: impl Into) -> Self { + Self::new(id, label).style(ChoiceStyle::Primary) + } - #[must_use] - pub fn danger(id: &'static str, label: impl Into) -> Self { - Self::new(id, label).style(ChoiceStyle::Danger) - } + #[must_use] + pub fn danger(id: &'static str, label: impl Into) -> Self { + Self::new(id, label).style(ChoiceStyle::Danger) + } - #[must_use] - pub fn style(mut self, style: ChoiceStyle) -> Self { - self.style = style; - self - } + #[must_use] + pub fn style(mut self, style: ChoiceStyle) -> Self { + self.style = style; + self + } - #[must_use] - pub fn picks_folder(mut self, title: impl Into) -> Self { - self.input = Some(ChoiceInput::Folder { - title: title.into(), - }); - self - } + #[must_use] + pub fn picks_folder(mut self, title: impl Into) -> Self { + self.input = Some(ChoiceInput::Folder { + title: title.into(), + }); + self + } - #[must_use] - pub fn picks_selection(mut self, hint: impl Into) -> Self { - self.input = Some(ChoiceInput::Selection { hint: hint.into() }); - self - } + #[must_use] + pub fn picks_selection(mut self, hint: impl Into) -> Self { + self.input = Some(ChoiceInput::Selection { hint: hint.into() }); + self + } } /// ```ignore @@ -99,88 +103,88 @@ impl Choice { /// ``` #[must_use = "a prompt is not shown until it is passed to `EventBus::ask`"] pub struct Prompt { - pub(crate) title: String, - pub(crate) body: String, - pub(crate) options: Vec<(Choice, T)>, - pub(crate) dismiss: Option, + pub(crate) title: String, + pub(crate) body: String, + pub(crate) options: Vec<(Choice, T)>, + pub(crate) dismiss: Option, } impl Prompt { - pub fn new(title: impl Into, body: impl Into) -> Self { - Self { - title: title.into(), - body: body.into(), - options: Vec::new(), - dismiss: None, - } - } + pub fn new(title: impl Into, body: impl Into) -> Self { + Self { + title: title.into(), + body: body.into(), + options: Vec::new(), + dismiss: None, + } + } - pub fn option(mut self, choice: Choice, value: T) -> Self { - self.options.push((choice, value)); - self - } + pub fn option(mut self, choice: Choice, value: T) -> Self { + self.options.push((choice, value)); + self + } - /// A prompt without this can still be dismissed by closing it so callers - /// must always handle `None` - pub fn dismiss(mut self, label: impl Into) -> Self { - self.dismiss = Some(label.into()); - self - } + /// A prompt without this can still be dismissed by closing it so callers + /// must always handle `None` + pub fn dismiss(mut self, label: impl Into) -> Self { + self.dismiss = Some(label.into()); + self + } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Chosen { - pub value: T, - pub input: Option, + pub value: T, + pub input: Option, } impl Chosen { - pub fn folder(&self) -> Option<&Path> { - match &self.input { - Some(InputValue::Folder(path)) => Some(path), - _ => None, - } - } + pub fn folder(&self) -> Option<&Path> { + match &self.input { + Some(InputValue::Folder(path)) => Some(path), + _ => None, + } + } - pub fn selection(&self) -> Option<&str> { - match &self.input { - Some(InputValue::Selection(value)) => Some(value), - _ => None, - } - } + pub fn selection(&self) -> Option<&str> { + match &self.input { + Some(InputValue::Selection(value)) => Some(value), + _ => None, + } + } } /// The untyped prompt as it crosses the bus `T` never leaves the caller's crate #[derive(Debug)] pub struct PromptRequest { - pub title: String, - pub body: String, - pub choices: Vec, - pub dismiss: Option, - pub reply: oneshot::Sender>, + pub title: String, + pub body: String, + pub choices: Vec, + pub dismiss: Option, + pub reply: oneshot::Sender>, } /// `None` on the wire means dismissed #[derive(Debug, Clone, PartialEq, Eq)] pub struct Answer { - pub id: &'static str, - pub input: Option, + pub id: &'static str, + pub input: Option, } impl Answer { - pub fn new(id: &'static str) -> Self { - Self { id, input: None } - } + pub fn new(id: &'static str) -> Self { + Self { id, input: None } + } - #[must_use] - pub fn with_folder(mut self, path: impl Into) -> Self { - self.input = Some(InputValue::Folder(path.into())); - self - } + #[must_use] + pub fn with_folder(mut self, path: impl Into) -> Self { + self.input = Some(InputValue::Folder(path.into())); + self + } - #[must_use] - pub fn with_selection(mut self, value: impl Into) -> Self { - self.input = Some(InputValue::Selection(value.into())); - self - } + #[must_use] + pub fn with_selection(mut self, value: impl Into) -> Self { + self.input = Some(InputValue::Selection(value.into())); + self + } } diff --git a/packages/oneclient_java/src/checker.rs b/packages/oneclient_java/src/checker.rs index 56b4773c..c2f07ddc 100644 --- a/packages/oneclient_java/src/checker.rs +++ b/packages/oneclient_java/src/checker.rs @@ -40,7 +40,9 @@ pub async fn check_java_runtime(absolute_path: String) -> JavaResult JavaResult JavaResult JavaResult) -> bool { - if !probe_flag(info, "java.awt.link") { - tracing::warn!("java.awt.link returned false. ignoring") - } + if !probe_flag(info, "java.awt.link") { + tracing::warn!("java.awt.link returned false. ignoring") + } - probe_flag(info, "java.awt") - && probe_flag(info, "java.awt.natives") + probe_flag(info, "java.awt") && probe_flag(info, "java.awt.natives") } fn probe_flag(info: &HashMap, key: &str) -> bool { diff --git a/packages/oneclient_java/src/data.rs b/packages/oneclient_java/src/data.rs index 236703be..c4618e60 100644 --- a/packages/oneclient_java/src/data.rs +++ b/packages/oneclient_java/src/data.rs @@ -7,74 +7,73 @@ use crate::vendors::JavaVendor; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct JavaRuntime { - pub absolute_path: String, - pub major: u32, - pub version: String, - pub vendor: JavaVendor, - pub os_arch: String, - pub is_jdk: bool, - pub probe_version: u32, + pub absolute_path: String, + pub major: u32, + pub version: String, + pub vendor: JavaVendor, + pub os_arch: String, + pub is_jdk: bool, + pub probe_version: u32, } - #[derive(Debug, Clone, PartialEq, Eq)] pub struct JavaPackage { - pub download_url: String, - pub name: String, - pub java_version: Vec, - pub vendor: JavaVendor, - pub archive: PackageArchive, - /// Algorithm varies per vendor - /// `None` when vendor metadata omitted or malformed it - /// installs unverified rather than failing - pub checksum: Option, - /// Bytes when the vendor publishes it - pub size: Option, + pub download_url: String, + pub name: String, + pub java_version: Vec, + pub vendor: JavaVendor, + pub archive: PackageArchive, + /// Algorithm varies per vendor + /// `None` when vendor metadata omitted or malformed it + /// installs unverified rather than failing + pub checksum: Option, + /// Bytes when the vendor publishes it + pub size: Option, } impl JavaPackage { - /// Drops malformed vendor checksums which would otherwise make every - /// install fail as a corrupt download - #[must_use] - pub fn with_checksum(mut self, checksum: Option) -> Self { - self.checksum = checksum.filter(|sum| { - let ok = sum.is_well_formed(); - if !ok { - tracing::warn!( - algorithm = sum.algorithm.name(), - hex = %sum.hex, - "vendor published a malformed checksum; installing unverified" - ); - } - ok - }); - self - } + /// Drops malformed vendor checksums which would otherwise make every + /// install fail as a corrupt download + #[must_use] + pub fn with_checksum(mut self, checksum: Option) -> Self { + self.checksum = checksum.filter(|sum| { + let ok = sum.is_well_formed(); + if !ok { + tracing::warn!( + algorithm = sum.algorithm.name(), + hex = %sum.hex, + "vendor published a malformed checksum; installing unverified" + ); + } + ok + }); + self + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PackageArchive { - Zip, - TarGz, + Zip, + TarGz, } impl PackageArchive { - pub fn from_filename(name: &str) -> Self { - if name.ends_with(".tar.gz") || name.ends_with(".tgz") { - Self::TarGz - } else { - Self::Zip - } - } + pub fn from_filename(name: &str) -> Self { + if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + Self::TarGz + } else { + Self::Zip + } + } } pub fn java_executable_relative_path() -> PathBuf { - #[cfg(target_os = "macos")] - { - PathBuf::from("Contents/Home/bin").join(oneclient_common::constants::JAVA_BIN) - } - #[cfg(not(target_os = "macos"))] - { - PathBuf::from("bin").join(oneclient_common::constants::JAVA_BIN) - } + #[cfg(target_os = "macos")] + { + PathBuf::from("Contents/Home/bin").join(oneclient_common::constants::JAVA_BIN) + } + #[cfg(not(target_os = "macos"))] + { + PathBuf::from("bin").join(oneclient_common::constants::JAVA_BIN) + } } diff --git a/packages/oneclient_java/src/error.rs b/packages/oneclient_java/src/error.rs index a26affa6..ecd4ec0e 100644 --- a/packages/oneclient_java/src/error.rs +++ b/packages/oneclient_java/src/error.rs @@ -4,116 +4,116 @@ pub type JavaResult = Result; #[derive(Debug, Error)] pub enum JavaError { - #[error(transparent)] - Store(#[from] crate::store::StoreError), + #[error(transparent)] + Store(#[from] crate::store::StoreError), - #[error(transparent)] - Request(#[from] oneclient_net::RequestError), + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), - #[error(transparent)] - Events(#[from] oneclient_events::EventError), + #[error(transparent)] + Events(#[from] oneclient_events::EventError), - #[error(transparent)] - Paths(#[from] oneclient_common::PathsError), + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), - #[error("Invalid URL: {0}")] - Url(#[from] url::ParseError), + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), - #[error("Error spawning Java runtime from path '{path}': {source}")] - RuntimeCheckError { - #[source] - source: std::io::Error, - path: String, - }, + #[error("Error spawning Java runtime from path '{path}': {source}")] + RuntimeCheckError { + #[source] + source: std::io::Error, + path: String, + }, - #[error("IO Error: {0}")] - PolyIOError(#[from] polyio::IOError), + #[error("IO Error: {0}")] + PolyIOError(#[from] polyio::IOError), - #[error("no java package found for major version {major}")] - PackageNotFound { major: u32 }, + #[error("no java package found for major version {major}")] + PackageNotFound { major: u32 }, - #[error("no java installations available")] - MissingJava, + #[error("no java installations available")] + MissingJava, - #[error("java setup was cancelled")] - Cancelled, + #[error("java setup was cancelled")] + Cancelled, - #[error("selected path is not a valid java installation: {path}")] - InvalidJavaPath { path: String }, + #[error("selected path is not a valid java installation: {path}")] + InvalidJavaPath { path: String }, - #[error( - "java installation at '{path}' has no usable java.awt (the java.desktop module or its native libraries are missing)" - )] - MissingAwtSupport { path: String }, + #[error( + "java installation at '{path}' has no usable java.awt (the java.desktop module or its native libraries are missing)" + )] + MissingAwtSupport { path: String }, - #[error("selected java is version {found}, but version {expected} is required")] - VersionMismatch { expected: u32, found: u32 }, + #[error("selected java is version {found}, but version {expected} is required")] + VersionMismatch { expected: u32, found: u32 }, - #[error("failed to parse java version '{version}'")] - ParseVersion { version: String }, + #[error("failed to parse java version '{version}'")] + ParseVersion { version: String }, - #[error("failed to extract archive '{archive}': {source}")] - ArchiveExtract { - archive: String, - source: std::io::Error, - }, + #[error("failed to extract archive '{archive}': {source}")] + ArchiveExtract { + archive: String, + source: std::io::Error, + }, - #[error("failed to extract archive '{archive}'")] - ArchiveExtractFailed { archive: String }, + #[error("failed to extract archive '{archive}'")] + ArchiveExtractFailed { archive: String }, } impl JavaError { - #[must_use] - pub fn is_invalid_installation(&self) -> bool { - matches!( - self, - JavaError::InvalidJavaPath { .. } - | JavaError::MissingAwtSupport { .. } - | JavaError::ParseVersion { .. } - ) - } + #[must_use] + pub fn is_invalid_installation(&self) -> bool { + matches!( + self, + JavaError::InvalidJavaPath { .. } + | JavaError::MissingAwtSupport { .. } + | JavaError::ParseVersion { .. } + ) + } - /// Whether this is an expected outcome or the user's environment rather - /// than a launcher bug - /// Callers decide what to do with that (e.g. suppress error reporting) - #[must_use] - pub fn is_transient(&self) -> bool { - match self { - JavaError::PackageNotFound { .. } | JavaError::Cancelled => true, - JavaError::RuntimeCheckError { source, .. } - | JavaError::ArchiveExtract { source, .. } => is_transient_io(source), - JavaError::PolyIOError(polyio::IOError::IOError(source)) - | JavaError::PolyIOError(polyio::IOError::PathIOError { source, .. }) => { - is_transient_io(source) - } - JavaError::Request(source) => source.is_transient(), - _ => false, - } - } + /// Whether this is an expected outcome or the user's environment rather + /// than a launcher bug + /// Callers decide what to do with that (e.g. suppress error reporting) + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + JavaError::PackageNotFound { .. } | JavaError::Cancelled => true, + JavaError::RuntimeCheckError { source, .. } + | JavaError::ArchiveExtract { source, .. } => is_transient_io(source), + JavaError::PolyIOError(polyio::IOError::IOError(source)) + | JavaError::PolyIOError(polyio::IOError::PathIOError { source, .. }) => { + is_transient_io(source) + } + JavaError::Request(source) => source.is_transient(), + _ => false, + } + } } fn is_transient_io(error: &std::io::Error) -> bool { - use std::io::ErrorKind; + use std::io::ErrorKind; - if let Some(code) = error.raw_os_error() { - #[cfg(unix)] - if code == 28 { - // ENOSPC - return true; - } - #[cfg(windows)] - if code == 112 || code == 39 { - return true; - } - let _ = code; - } + if let Some(code) = error.raw_os_error() { + #[cfg(unix)] + if code == 28 { + // ENOSPC + return true; + } + #[cfg(windows)] + if code == 112 || code == 39 { + return true; + } + let _ = code; + } - matches!( - error.kind(), - ErrorKind::ConnectionRefused - | ErrorKind::ConnectionReset - | ErrorKind::ConnectionAborted - | ErrorKind::NotConnected - | ErrorKind::TimedOut - ) -} \ No newline at end of file + matches!( + error.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) +} diff --git a/packages/oneclient_java/src/install.rs b/packages/oneclient_java/src/install.rs index dba03be9..8f4e35dd 100644 --- a/packages/oneclient_java/src/install.rs +++ b/packages/oneclient_java/src/install.rs @@ -1,103 +1,91 @@ use std::path::{Path, PathBuf}; -use oneclient_net::ResponseNotifyOptions; -use crate::data::{ - java_executable_relative_path, JavaPackage, PackageArchive, -}; -use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase}; +use crate::data::{JavaPackage, PackageArchive, java_executable_relative_path}; +use crate::error::JavaResult; use oneclient_common::paths; use oneclient_events::EventBus; +use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase}; use oneclient_net::RequestClient; -use crate::error::JavaResult; +use oneclient_net::ResponseNotifyOptions; #[tracing::instrument(level = "debug", skip(net, events, progress))] pub async fn install_package( - package: &JavaPackage, - net: &RequestClient, - events: &EventBus, - progress: Option<&GroupedProgressSession>, + package: &JavaPackage, + net: &RequestClient, + events: &EventBus, + progress: Option<&GroupedProgressSession>, ) -> JavaResult { - let java_dir = paths::java_dir()?; - polyio::create_dir_all(&java_dir).await?; + let java_dir = paths::java_dir()?; + polyio::create_dir_all(&java_dir).await?; - let archive_path = java_dir.join(polyio::sanitize_path(&package.name)); + let archive_path = java_dir.join(polyio::sanitize_path(&package.name)); - let major = package.java_version.first().copied().unwrap_or(0); + let major = package.java_version.first().copied().unwrap_or(0); - // 0 means unknown the real total comes from Content-Length once headers land - let expected_size = package.size.unwrap_or(0); - let child = progress.map(|session| { - session.child( - format!("{} {}", package.vendor, major), - expected_size, - TaskCategory::Java, - ) - }); + // 0 means unknown the real total comes from Content-Length once headers land + let expected_size = package.size.unwrap_or(0); + let child = progress.map(|session| { + session.child( + format!("{} {}", package.vendor, major), + expected_size, + TaskCategory::Java, + ) + }); - let notify = match &child { - Some(child) => ResponseNotifyOptions::grouped(child.clone()), - None => ResponseNotifyOptions::standalone(format!( - "Installing {} {}", - package.vendor, major - )) - .done_label(format!("Installed {} {}", package.vendor, major)), - }; + let notify = match &child { + Some(child) => ResponseNotifyOptions::grouped(child.clone()), + None => { + ResponseNotifyOptions::standalone(format!("Installing {} {}", package.vendor, major)) + .done_label(format!("Installed {} {}", package.vendor, major)) + } + }; - if package.checksum.is_none() { - tracing::warn!( - vendor = %package.vendor, - major, - "vendor published no usable checksum; installing runtime unverified" - ); - } + if package.checksum.is_none() { + tracing::warn!( + vendor = %package.vendor, + major, + "vendor published no usable checksum; installing runtime unverified" + ); + } - oneclient_net::download_verified( - net, - events, - &package.download_url, - &archive_path, - package.checksum.as_ref(), - expected_size, - Some(notify), - ) - .await?; + oneclient_net::download_verified( + net, + events, + &package.download_url, + &archive_path, + package.checksum.as_ref(), + expected_size, + Some(notify), + ) + .await?; - let extract_root = java_dir.join(stem_without_archive(&package.name)); + let extract_root = java_dir.join(stem_without_archive(&package.name)); - if let Some(child) = &child { - child.set_phase(TaskPhase::Extracting); - } - //events.notify("Extracting package...").body(format!("{} {}", package.vendor, major)).send(); + if let Some(child) = &child { + child.set_phase(TaskPhase::Extracting); + } + //events.notify("Extracting package...").body(format!("{} {}", package.vendor, major)).send(); - match package.archive { - PackageArchive::Zip => polyio::extract_zip(&archive_path, &extract_root).await?, - PackageArchive::TarGz => polyio::extract_tar_gz(&archive_path, &extract_root).await?, - } + match package.archive { + PackageArchive::Zip => polyio::extract_zip(&archive_path, &extract_root).await?, + PackageArchive::TarGz => polyio::extract_tar_gz(&archive_path, &extract_root).await?, + } - // Walks the extract root and stats candidates, so it stays off the caller's - // executor for the same reason the extraction above does - let executable = { - let extract_root = extract_root.clone(); - let package = package.clone(); - tokio::task::spawn_blocking(move || resolve_installed_executable(&extract_root, &package)) - .await - .map_err(|err| polyio::IOError::from(std::io::Error::other(err)))? - }; + // Walks the extract root and stats candidates, so it stays off the caller's + // executor for the same reason the extraction above does + let executable = { + let extract_root = extract_root.clone(); + let package = package.clone(); + tokio::task::spawn_blocking(move || resolve_installed_executable(&extract_root, &package)) + .await + .map_err(|err| polyio::IOError::from(std::io::Error::other(err)))? + }; - #[cfg(unix)] - { - let _ = tokio::process::Command::new("chmod") - .arg("755") - .arg(&executable) - .output() - .await; - } + let _ = polyio::remove_file(&archive_path).await; - let _ = polyio::remove_file(&archive_path).await; + tracing::info!(vendor = %package.vendor, major, "installed Java runtime"); - tracing::info!(vendor = %package.vendor, major, "installed Java runtime"); - - Ok(executable) + Ok(executable) } /// The directory this crate extracted for `executable`, or `None` when the @@ -105,53 +93,99 @@ pub async fn install_package( /// user added themselves points #[tracing::instrument(level = "debug")] fn managed_install_root(executable: &Path) -> JavaResult> { - let java_dir = paths::java_dir()?; + let java_dir = paths::java_dir()?; - let Ok(root) = polyio::canonicalize(&java_dir) else { - return Ok(None); - }; + let Ok(root) = polyio::canonicalize(&java_dir) else { + return Ok(None); + }; - // Refuses to resolve once the executable is gone which is the safe answer - // there is no way left to prove what the path pointed at - let canon = match polyio::ensure_under(executable, [&root]) { - Ok(Some(canon)) => canon, - Ok(None) => return Ok(None), - Err(err) => { - tracing::debug!("could not resolve the java runtime path: {err}"); - return Ok(None); - } - }; + // Refuses to resolve once the executable is gone which is the safe answer + // there is no way left to prove what the path pointed at + let canon = match polyio::ensure_under(executable, [&root]) { + Ok(Some(canon)) => canon, + Ok(None) => return Ok(None), + Err(err) => { + tracing::debug!("could not resolve the java runtime path: {err}"); + return Ok(None); + } + }; - let Ok(relative) = canon.strip_prefix(&root) else { - return Ok(None); - }; + let Ok(relative) = canon.strip_prefix(&root) else { + return Ok(None); + }; - match relative.components().next() { - Some(std::path::Component::Normal(name)) => Ok(Some(root.join(name))), - _ => Ok(None), - } + match relative.components().next() { + Some(std::path::Component::Normal(name)) => Ok(Some(root.join(name))), + _ => Ok(None), + } } /// Whether [`remove_installed_package`] would take this runtime's files with /// it so the UI can say up front what removing it costs #[must_use] pub fn is_launcher_managed(executable: &Path) -> bool { - managed_install_root(executable).is_ok_and(|root| root.is_some()) + managed_install_root(executable).is_ok_and(|root| root.is_some()) +} + +#[cfg(unix)] +pub(crate) fn restore_executable_bits(executable: &Path) { + use std::os::unix::fs::PermissionsExt; + + let Ok(executable) = polyio::canonicalize(executable) else { + return; + }; + if !is_launcher_managed(&executable) { + return; + } + let Some(home) = executable.parent().and_then(Path::parent) else { + return; + }; + let jre = home.join("jre"); + + let entries = |dir: PathBuf| { + std::fs::read_dir(dir) + .into_iter() + .flatten() + .flatten() + .map(|entry| entry.path()) + }; + let tools = entries(home.join("bin")).chain(entries(jre.join("bin"))); + let helpers = ["jspawnhelper", "jexec"].map(|name| home.join("lib").join(name)); + let jre_helpers = std::iter::once(jre.join("lib")) + .chain(entries(jre.join("lib"))) + .map(|dir| dir.join("jspawnhelper")); + + for path in tools.chain(helpers).chain(jre_helpers) { + let Ok(metadata) = std::fs::metadata(&path) else { + continue; + }; + let mode = metadata.permissions().mode(); + if !metadata.is_file() || mode & 0o111 != 0 { + continue; + } + + match std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode | 0o111)) { + Ok(()) => tracing::info!(path = %path.display(), "restored missing executable bit"), + Err(err) => { + tracing::warn!(path = %path.display(), "could not restore executable bit: {err}"); + } + } + } } /// Only ever touches OneClient's own java dir a runtime the user added from /// their own folder is left on disk untouched #[tracing::instrument(level = "debug")] pub async fn remove_installed_package(executable: &Path) -> JavaResult { - let Some(install_root) = managed_install_root(executable)? else { - tracing::debug!("java runtime is not launcher-managed leaving its files alone"); - return Ok(false); - }; + let Some(install_root) = managed_install_root(executable)? else { + tracing::debug!("java runtime is not launcher-managed leaving its files alone"); + return Ok(false); + }; - polyio::remove_dir_all(&install_root).await?; + polyio::remove_dir_all(&install_root).await?; - tracing::info!(path = %install_root.display(), "removed installed Java runtime files"); - Ok(true) + tracing::info!(path = %install_root.display(), "removed installed Java runtime files"); + Ok(true) } #[tracing::instrument(level = "debug")] @@ -187,143 +221,145 @@ fn resolve_installed_executable(extract_root: &Path, package: &JavaPackage) -> P if !base_path.join("Contents").join("Home").exists() && let Ok(entries) = std::fs::read_dir(&base_path) - && let Some(dir) = entries.flatten().find(|entry| { - let file_name = entry.file_name(); - let name = file_name.to_string_lossy(); - (name.ends_with(".jre") || name.ends_with(".jdk") || name.contains("zulu")) - && entry.path().join("Contents").join("Home").exists() - }) { - base_path = dir.path(); - } + && let Some(dir) = entries.flatten().find(|entry| { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + (name.ends_with(".jre") || name.ends_with(".jdk") || name.contains("zulu")) + && entry.path().join("Contents").join("Home").exists() + }) + { + base_path = dir.path(); + } if package.vendor == JavaVendor::Zulu - && let Some(major) = package.java_version.first() { - let zulu_bundle = base_path.join(format!("zulu-{major}.jre")); - if zulu_bundle.join("Contents").join("Home").exists() { - base_path = zulu_bundle; - } + && let Some(major) = package.java_version.first() + { + let zulu_bundle = base_path.join(format!("zulu-{major}.jre")); + if zulu_bundle.join("Contents").join("Home").exists() { + base_path = zulu_bundle; } + } base_path.join(java_executable_relative_path()) } } fn stem_without_archive(name: &str) -> String { - let path = std::path::Path::new(name); - let stem = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(name); + let path = std::path::Path::new(name); + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name); - if stem.ends_with(".tar") { - std::path::Path::new(stem) - .file_stem() - .and_then(|s| s.to_str()) - .map(String::from) - .unwrap_or_else(|| stem.to_string()) - } else { - stem.to_string() - } + if stem.ends_with(".tar") { + std::path::Path::new(stem) + .file_stem() + .and_then(|s| s.to_str()) + .map(String::from) + .unwrap_or_else(|| stem.to_string()) + } else { + stem.to_string() + } } #[cfg(test)] mod tests { - use super::*; - use std::sync::atomic::{AtomicU32, Ordering}; + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; - /// Keeps the whole suite inside a temp tree the override is a `OnceLock` so - /// the first caller wins and every case works under one java dir - fn java_dir() -> PathBuf { - static ONCE: std::sync::Once = std::sync::Once::new(); - ONCE.call_once(|| { - paths::set_launcher_dir( - std::env::temp_dir().join(format!("oneclient-java-test-{}", std::process::id())), - ); - }); + /// Keeps the whole suite inside a temp tree the override is a `OnceLock` so + /// the first caller wins and every case works under one java dir + fn java_dir() -> PathBuf { + static ONCE: std::sync::Once = std::sync::Once::new(); + ONCE.call_once(|| { + paths::set_launcher_dir( + std::env::temp_dir().join(format!("oneclient-java-test-{}", std::process::id())), + ); + }); - let dir = paths::java_dir().unwrap(); - std::fs::create_dir_all(&dir).unwrap(); - dir - } + let dir = paths::java_dir().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + dir + } - fn install_runtime(root: &Path) -> (PathBuf, PathBuf) { - static N: AtomicU32 = AtomicU32::new(0); - let install = root.join(format!("zulu21-{}", N.fetch_add(1, Ordering::Relaxed))); - let bin = install.join("zulu21.0.12").join("bin"); - std::fs::create_dir_all(&bin).unwrap(); + fn install_runtime(root: &Path) -> (PathBuf, PathBuf) { + static N: AtomicU32 = AtomicU32::new(0); + let install = root.join(format!("zulu21-{}", N.fetch_add(1, Ordering::Relaxed))); + let bin = install.join("zulu21.0.12").join("bin"); + std::fs::create_dir_all(&bin).unwrap(); - let executable = bin.join("java"); - std::fs::write(&executable, b"").unwrap(); - (install, executable) - } + let executable = bin.join("java"); + std::fs::write(&executable, b"").unwrap(); + (install, executable) + } - #[test] - fn resolves_the_extracted_directory_not_the_executable_parent() { - let java_dir = java_dir(); - let (install, executable) = install_runtime(&java_dir); + #[test] + fn resolves_the_extracted_directory_not_the_executable_parent() { + let java_dir = java_dir(); + let (install, executable) = install_runtime(&java_dir); - let resolved = managed_install_root(&executable).unwrap().unwrap(); + let resolved = managed_install_root(&executable).unwrap().unwrap(); - assert_eq!(resolved, polyio::canonicalize(&install).unwrap()); - } + assert_eq!(resolved, polyio::canonicalize(&install).unwrap()); + } - #[tokio::test] - async fn removing_a_managed_runtime_deletes_its_files() { - let java_dir = java_dir(); - let (install, executable) = install_runtime(&java_dir); + #[tokio::test] + async fn removing_a_managed_runtime_deletes_its_files() { + let java_dir = java_dir(); + let (install, executable) = install_runtime(&java_dir); - assert!(remove_installed_package(&executable).await.unwrap()); + assert!(remove_installed_package(&executable).await.unwrap()); - assert!(!install.exists()); - assert!(java_dir.exists(), "the java dir itself must survive"); - } + assert!(!install.exists()); + assert!(java_dir.exists(), "the java dir itself must survive"); + } - #[tokio::test] - async fn a_runtime_outside_the_java_dir_keeps_its_files() { - let java_dir = java_dir(); - let elsewhere = java_dir.parent().unwrap().join("user-picked-jdk"); - let (_, executable) = install_runtime(&elsewhere); + #[tokio::test] + async fn a_runtime_outside_the_java_dir_keeps_its_files() { + let java_dir = java_dir(); + let elsewhere = java_dir.parent().unwrap().join("user-picked-jdk"); + let (_, executable) = install_runtime(&elsewhere); - assert_eq!(managed_install_root(&executable).unwrap(), None); - assert!(!remove_installed_package(&executable).await.unwrap()); - assert!(executable.exists(), "a folder the user added is not ours to delete"); + assert_eq!(managed_install_root(&executable).unwrap(), None); + assert!(!remove_installed_package(&executable).await.unwrap()); + assert!( + executable.exists(), + "a folder the user added is not ours to delete" + ); - std::fs::remove_dir_all(&elsewhere).unwrap(); - } + std::fs::remove_dir_all(&elsewhere).unwrap(); + } - #[test] - fn a_traversal_back_out_of_the_java_dir_is_not_managed() { - let java_dir = java_dir(); - let elsewhere = java_dir.parent().unwrap().join("traversal-jdk"); - let (_, executable) = install_runtime(&elsewhere); + #[test] + fn a_traversal_back_out_of_the_java_dir_is_not_managed() { + let java_dir = java_dir(); + let elsewhere = java_dir.parent().unwrap().join("traversal-jdk"); + let (_, executable) = install_runtime(&elsewhere); - let sneaky = java_dir - .join("..") - .join("traversal-jdk") - .join(executable.strip_prefix(&elsewhere).unwrap()); + let sneaky = java_dir + .join("..") + .join("traversal-jdk") + .join(executable.strip_prefix(&elsewhere).unwrap()); - assert_eq!(managed_install_root(&sneaky).unwrap(), None); + assert_eq!(managed_install_root(&sneaky).unwrap(), None); - std::fs::remove_dir_all(&elsewhere).unwrap(); - } + std::fs::remove_dir_all(&elsewhere).unwrap(); + } - #[test] - fn the_managed_flag_matches_what_removal_would_actually_delete() { - let java_dir = java_dir(); - let (_, ours) = install_runtime(&java_dir); - let elsewhere = java_dir.parent().unwrap().join("flagged-jdk"); - let (_, theirs) = install_runtime(&elsewhere); + #[test] + fn the_managed_flag_matches_what_removal_would_actually_delete() { + let java_dir = java_dir(); + let (_, ours) = install_runtime(&java_dir); + let elsewhere = java_dir.parent().unwrap().join("flagged-jdk"); + let (_, theirs) = install_runtime(&elsewhere); - assert!(is_launcher_managed(&ours)); - assert!(!is_launcher_managed(&theirs)); + assert!(is_launcher_managed(&ours)); + assert!(!is_launcher_managed(&theirs)); - std::fs::remove_dir_all(&elsewhere).unwrap(); - } + std::fs::remove_dir_all(&elsewhere).unwrap(); + } - #[test] - fn the_java_dir_itself_is_never_managed() { - let java_dir = java_dir(); + #[test] + fn the_java_dir_itself_is_never_managed() { + let java_dir = java_dir(); - assert_eq!(managed_install_root(&java_dir).unwrap(), None); - } + assert_eq!(managed_install_root(&java_dir).unwrap(), None); + } } diff --git a/packages/oneclient_java/src/lib.rs b/packages/oneclient_java/src/lib.rs index 26b3cbbf..2cbed06e 100644 --- a/packages/oneclient_java/src/lib.rs +++ b/packages/oneclient_java/src/lib.rs @@ -18,10 +18,10 @@ pub use data::{JavaPackage, JavaRuntime, PackageArchive, java_executable_relativ pub use error::{JavaError, JavaResult}; pub use install::{install_package, is_launcher_managed}; pub use locate::{LocatedJava, best_for_major, locate_java}; -pub use platform::{HostArch, HostOs, HostTarget, prefer_dedicated_gpu}; +pub use platform::{HostArch, HostOs, HostTarget, set_gpu_preference}; pub use service::{ - AvailableJava, INSTALLABLE_MAJORS, JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JAVA_VENDOR_HINT, - JavaService, + AvailableJava, INSTALLABLE_MAJORS, JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JAVA_VENDOR_HINT, + JavaService, }; pub use store::{JavaStore, MemoryJavaStore, StoreError, StoreResult}; pub use vendors::JavaVendor; diff --git a/packages/oneclient_java/src/locate.rs b/packages/oneclient_java/src/locate.rs index 2194b490..2d937d62 100644 --- a/packages/oneclient_java/src/locate.rs +++ b/packages/oneclient_java/src/locate.rs @@ -1,73 +1,72 @@ use std::cmp::Ordering; use std::path::PathBuf; - -use oneclient_common::constants::JAVA_BIN; use crate::checker::{JavaCheckInfo, check_java_runtime}; use crate::data::java_executable_relative_path; use crate::error::JavaResult; use crate::service::parse_major_version; +use oneclient_common::constants::JAVA_BIN; pub type LocatedJava = (PathBuf, JavaCheckInfo); /// Best first #[tracing::instrument(level = "debug")] pub async fn locate_java() -> JavaResult> { - let paths = internal_locate_java(); - let mut valid: Vec = Vec::new(); + let paths = internal_locate_java(); + let mut valid: Vec = Vec::new(); - for path in paths { - if valid.iter().any(|(existing, _)| existing == &path) { - continue; - } + for path in paths { + if valid.iter().any(|(existing, _)| existing == &path) { + continue; + } - let Ok(info) = check_java_runtime(path.display().to_string()).await else { - tracing::warn!("java installation at '{}' is not valid", path.display()); - continue; - }; - valid.push((path, info)); - } + let Ok(info) = check_java_runtime(path.display().to_string()).await else { + tracing::warn!("java installation at '{}' is not valid", path.display()); + continue; + }; + valid.push((path, info)); + } - valid.sort_by(|(_, a), (_, b)| compare_candidates(a, b)); + valid.sort_by(|(_, a), (_, b)| compare_candidates(a, b)); - tracing::debug!(count = valid.len(), "located Java runtimes"); + tracing::debug!(count = valid.len(), "located Java runtimes"); - Ok(valid) + Ok(valid) } /// `major` is a filter not a ranking input /// a newer JDK never stands in for the requested major #[must_use] pub fn best_for_major(candidates: &[LocatedJava], major: u32) -> Option<&LocatedJava> { - candidates - .iter() - .filter(|(_, info)| parse_major_version(&info.version).is_ok_and(|found| found == major)) - .min_by(|(_, a), (_, b)| compare_candidates(a, b)) + candidates + .iter() + .filter(|(_, info)| parse_major_version(&info.version).is_ok_and(|found| found == major)) + .min_by(|(_, a), (_, b)| compare_candidates(a, b)) } /// Best first a JDK beats a JRE outright then the newer version wins fn compare_candidates(a: &JavaCheckInfo, b: &JavaCheckInfo) -> Ordering { - b.is_jdk - .cmp(&a.is_jdk) - .then_with(|| version_key(b).cmp(&version_key(a))) + b.is_jdk + .cmp(&a.is_jdk) + .then_with(|| version_key(b).cmp(&version_key(a))) } /// Numeric so `21.0.10` sorts above `21.0.9` fn version_key(info: &JavaCheckInfo) -> (u32, Vec) { - let major = parse_major_version(&info.version).unwrap_or(0); - let parts = info - .version - .split(|c: char| !c.is_ascii_digit()) - .filter_map(|part| part.parse::().ok()) - .collect(); + let major = parse_major_version(&info.version).unwrap_or(0); + let parts = info + .version + .split(|c: char| !c.is_ascii_digit()) + .filter_map(|part| part.parse::().ok()) + .collect(); - (major, parts) + (major, parts) } #[cfg(target_os = "windows")] #[tracing::instrument(level = "debug")] fn internal_locate_java() -> Vec { - let mut java_homes = Vec::new(); + let mut java_homes = Vec::new(); let windows_paths = { let mut paths = vec![ @@ -89,42 +88,42 @@ fn internal_locate_java() -> Vec { paths }; - for base_path in windows_paths { - if let Ok(entries) = std::fs::read_dir(base_path) { - for entry in entries.flatten() { - if entry.file_type().is_ok_and(|ft| ft.is_dir()) { - let java_exe = entry.path().join(java_executable_relative_path()); - if java_exe.exists() { - java_homes.push(java_exe); - } - } - } - } - } + for base_path in windows_paths { + if let Ok(entries) = std::fs::read_dir(base_path) { + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|ft| ft.is_dir()) { + let java_exe = entry.path().join(java_executable_relative_path()); + if java_exe.exists() { + java_homes.push(java_exe); + } + } + } + } + } - if let Ok(java_home) = std::env::var("JAVA_HOME") { - let java_exe = PathBuf::from(java_home).join(java_executable_relative_path()); - if java_exe.exists() { - java_homes.push(java_exe); - } - } + if let Ok(java_home) = std::env::var("JAVA_HOME") { + let java_exe = PathBuf::from(java_home).join(java_executable_relative_path()); + if java_exe.exists() { + java_homes.push(java_exe); + } + } - if let Ok(path) = std::env::var("PATH") { - for path_entry in path.split(';') { - let java_exe = PathBuf::from(path_entry).join(JAVA_BIN); - if java_exe.exists() && !java_homes.contains(&java_exe) { - java_homes.push(java_exe); - } - } - } + if let Ok(path) = std::env::var("PATH") { + for path_entry in path.split(';') { + let java_exe = PathBuf::from(path_entry).join(JAVA_BIN); + if java_exe.exists() && !java_homes.contains(&java_exe) { + java_homes.push(java_exe); + } + } + } - java_homes + java_homes } #[cfg(target_os = "macos")] #[tracing::instrument(level = "debug")] fn internal_locate_java() -> Vec { - let mut found = Vec::new(); + let mut found = Vec::new(); let macos_paths = { let mut paths = vec![ @@ -138,31 +137,35 @@ fn internal_locate_java() -> Vec { PathBuf::from("/usr/local/opt"), ]; if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { - paths.push(home.join("Library").join("Java").join("JavaVirtualMachines")); + paths.push( + home.join("Library") + .join("Java") + .join("JavaVirtualMachines"), + ); paths.push(home.join(".jdks")); paths.push(home.join(".sdkman").join("candidates").join("java")); } paths }; - for path in macos_paths { - if let Ok(children) = std::fs::read_dir(path) { - for child in children.flatten() { - let java_exe = child.path().join(java_executable_relative_path()); - if java_exe.exists() { - found.push(java_exe); - } - } - } - } + for path in macos_paths { + if let Ok(children) = std::fs::read_dir(path) { + for child in children.flatten() { + let java_exe = child.path().join(java_executable_relative_path()); + if java_exe.exists() { + found.push(java_exe); + } + } + } + } - find_java_in_path(found) + find_java_in_path(found) } #[cfg(target_os = "linux")] #[tracing::instrument(level = "debug")] fn internal_locate_java() -> Vec { - let mut found = Vec::new(); + let mut found = Vec::new(); let linux_paths = { let mut paths = vec![ @@ -182,104 +185,94 @@ fn internal_locate_java() -> Vec { paths }; - for path in linux_paths { - if let Ok(children) = std::fs::read_dir(path) { - for child in children.flatten() { - let java_exe = child.path().join(java_executable_relative_path()); - if java_exe.exists() { - found.push(java_exe); - } - } - } - } + for path in linux_paths { + if let Ok(children) = std::fs::read_dir(path) { + for child in children.flatten() { + let java_exe = child.path().join(java_executable_relative_path()); + if java_exe.exists() { + found.push(java_exe); + } + } + } + } - find_java_in_path(found) + find_java_in_path(found) } #[cfg(unix)] fn find_java_in_path(mut found: Vec) -> Vec { - if let Ok(path) = std::env::var("PATH") { - for path_entry in path.split(':') { - let java = PathBuf::from(path_entry).join(JAVA_BIN); - if java.exists() && java.is_file() && !found.contains(&java) { - found.push(java); - } - } - } - found + if let Ok(path) = std::env::var("PATH") { + for path_entry in path.split(':') { + let java = PathBuf::from(path_entry).join(JAVA_BIN); + if java.exists() && java.is_file() && !found.contains(&java) { + found.push(java); + } + } + } + found } #[cfg(test)] mod tests { - use super::*; + use super::*; - fn candidate(path: &str, version: &str, is_jdk: bool) -> LocatedJava { - ( - PathBuf::from(path), - JavaCheckInfo { - version: version.to_string(), - vendor: String::from("Test"), - os_arch: String::from("x64"), - is_jdk, - }, - ) - } + fn candidate(path: &str, version: &str, is_jdk: bool) -> LocatedJava { + ( + PathBuf::from(path), + JavaCheckInfo { + version: version.to_string(), + vendor: String::from("Test"), + os_arch: String::from("x64"), + is_jdk, + }, + ) + } - fn ranked(mut candidates: Vec) -> Vec { - candidates.sort_by(|(_, a), (_, b)| compare_candidates(a, b)); - candidates - .into_iter() - .map(|(path, _)| path.display().to_string()) - .collect() - } + fn ranked(mut candidates: Vec) -> Vec { + candidates.sort_by(|(_, a), (_, b)| compare_candidates(a, b)); + candidates + .into_iter() + .map(|(path, _)| path.display().to_string()) + .collect() + } - #[test] - fn a_kit_outranks_a_runtime_even_when_it_is_older() { - let ranking = ranked(vec![ - candidate("/jre-21", "21.0.9", false), - candidate("/jdk-21", "21.0.1", true), - ]); + #[test] + fn patch_levels_are_compared_as_numbers() { + let ranking = ranked(vec![ + candidate("/jdk-21.0.9", "21.0.9", true), + candidate("/jdk-21.0.10", "21.0.10", true), + ]); - assert_eq!(ranking, vec!["/jdk-21", "/jre-21"]); - } + assert_eq!(ranking, vec!["/jdk-21.0.10", "/jdk-21.0.9"]); + } - #[test] - fn patch_levels_are_compared_as_numbers() { - let ranking = ranked(vec![ - candidate("/jdk-21.0.9", "21.0.9", true), - candidate("/jdk-21.0.10", "21.0.10", true), - ]); + #[test] + fn the_requested_major_still_wins_over_a_newer_kit() { + let candidates = vec![ + candidate("/jdk-21", "21.0.3", true), + candidate("/jre-8", "1.8.0_412", false), + ]; - assert_eq!(ranking, vec!["/jdk-21.0.10", "/jdk-21.0.9"]); - } + let (path, _) = best_for_major(&candidates, 8).expect("a java 8 candidate"); + assert_eq!(path, &PathBuf::from("/jre-8")); + } - #[test] - fn the_requested_major_still_wins_over_a_newer_kit() { - let candidates = vec![ - candidate("/jdk-21", "21.0.3", true), - candidate("/jre-8", "1.8.0_412", false), - ]; + #[test] + fn the_kit_wins_among_candidates_of_the_requested_major() { + let candidates = vec![ + candidate("/jre-17-newer", "17.0.11", false), + candidate("/jdk-17", "17.0.2", true), + candidate("/jdk-21", "21.0.3", true), + ]; - let (path, _) = best_for_major(&candidates, 8).expect("a java 8 candidate"); - assert_eq!(path, &PathBuf::from("/jre-8")); - } + let (path, _) = best_for_major(&candidates, 17).expect("a java 17 candidate"); + assert_eq!(path, &PathBuf::from("/jdk-17")); + } - #[test] - fn the_kit_wins_among_candidates_of_the_requested_major() { - let candidates = vec![ - candidate("/jre-17-newer", "17.0.11", false), - candidate("/jdk-17", "17.0.2", true), - candidate("/jdk-21", "21.0.3", true), - ]; + #[test] + fn a_major_nobody_satisfies_has_no_best() { + let candidates = vec![candidate("/jdk-21", "21.0.3", true)]; - let (path, _) = best_for_major(&candidates, 17).expect("a java 17 candidate"); - assert_eq!(path, &PathBuf::from("/jdk-17")); - } - - #[test] - fn a_major_nobody_satisfies_has_no_best() { - let candidates = vec![candidate("/jdk-21", "21.0.3", true)]; - - assert!(best_for_major(&candidates, 8).is_none()); - } + assert!(best_for_major(&candidates, 8).is_none()); + } } diff --git a/packages/oneclient_java/src/platform.rs b/packages/oneclient_java/src/platform.rs index 8e23395c..eaac93e0 100644 --- a/packages/oneclient_java/src/platform.rs +++ b/packages/oneclient_java/src/platform.rs @@ -5,75 +5,75 @@ use crate::data::PackageArchive; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HostArch { - X86, - X86_64, - Arm, - Aarch64, + X86, + X86_64, + Arm, + Aarch64, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HostOs { - Windows, - MacOs, - /// Several vendors publish musl builds under a separate name - Linux { - musl: bool, - }, + Windows, + MacOs, + /// Several vendors publish musl builds under a separate name + Linux { + musl: bool, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct HostTarget { - pub arch: HostArch, - pub os: HostOs, + pub arch: HostArch, + pub os: HostOs, } impl HostTarget { - pub const CURRENT: Self = Self { - arch: cfg_select! { - target_arch = "x86" => HostArch::X86, - target_arch = "x86_64" => HostArch::X86_64, - target_arch = "arm" => HostArch::Arm, - target_arch = "aarch64" => HostArch::Aarch64, - }, - os: cfg_select! { - target_os = "windows" => HostOs::Windows, - target_os = "macos" => HostOs::MacOs, - target_os = "linux" => cfg_select! { - target_env = "musl" => HostOs::Linux { musl: true }, - _ => HostOs::Linux { musl: false }, - }, - }, - }; + pub const CURRENT: Self = Self { + arch: cfg_select! { + target_arch = "x86" => HostArch::X86, + target_arch = "x86_64" => HostArch::X86_64, + target_arch = "arm" => HostArch::Arm, + target_arch = "aarch64" => HostArch::Aarch64, + }, + os: cfg_select! { + target_os = "windows" => HostOs::Windows, + target_os = "macos" => HostOs::MacOs, + target_os = "linux" => cfg_select! { + target_env = "musl" => HostOs::Linux { musl: true }, + _ => HostOs::Linux { musl: false }, + }, + }, + }; - /// Liberica takes architecture and bitness as separate query parameters - #[must_use] - pub const fn bitness(self) -> &'static str { - match self.arch { - HostArch::X86 | HostArch::Arm => "32", - HostArch::X86_64 | HostArch::Aarch64 => "64", - } - } + /// Liberica takes architecture and bitness as separate query parameters + #[must_use] + pub const fn bitness(self) -> &'static str { + match self.arch { + HostArch::X86 | HostArch::Arm => "32", + HostArch::X86_64 | HostArch::Aarch64 => "64", + } + } - #[must_use] - pub const fn archive(self) -> PackageArchive { - match self.os { - HostOs::Windows => PackageArchive::Zip, - _ => PackageArchive::TarGz, - } - } + #[must_use] + pub const fn archive(self) -> PackageArchive { + match self.os { + HostOs::Windows => PackageArchive::Zip, + _ => PackageArchive::TarGz, + } + } - #[must_use] - pub const fn archive_ext(self) -> &'static str { - match self.os { - HostOs::Windows => "zip", - _ => "tar.gz", - } - } + #[must_use] + pub const fn archive_ext(self) -> &'static str { + match self.os { + HostOs::Windows => "zip", + _ => "tar.gz", + } + } - #[must_use] - pub const fn is_musl(self) -> bool { - matches!(self.os, HostOs::Linux { musl: true }) - } + #[must_use] + pub const fn is_musl(self) -> bool { + matches!(self.os, HostOs::Linux { musl: true }) + } } #[cfg(windows)] @@ -86,155 +86,144 @@ const GPU_PREFERENCE_EXECUTABLES: [&str; 2] = ["javaw.exe", "java.exe"]; /// Absolute so a `reg.exe` earlier in the search order cannot run in its place #[cfg(windows)] fn reg_command() -> tokio::process::Command { - const NO_WINDOW: u32 = 0x0800_0000; + const NO_WINDOW: u32 = 0x0800_0000; - let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into()); - let exe = std::path::Path::new(&system_root) - .join("System32") - .join("reg.exe"); + let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into()); + let exe = std::path::Path::new(&system_root) + .join("System32") + .join("reg.exe"); - let mut command = tokio::process::Command::new(exe); - command.creation_flags(NO_WINDOW); - command + let mut command = tokio::process::Command::new(exe); + command.creation_flags(NO_WINDOW); + command } #[cfg(windows)] fn registered_value<'a>(query: &'a str, name: &str) -> Option<&'a str> { - query.lines().find_map(|line| { - let (value_name, data) = line.split_once("REG_SZ")?; - if value_name.trim().eq_ignore_ascii_case(name) { - Some(data.trim()) - } else { - None - } - }) -} - -/// Windows writes `GpuPreference=0;` ("let Windows decide") for any app added -/// through Settings, so only 1 or 2 is a choice worth leaving alone -#[cfg(windows)] -fn has_explicit_preference(data: &str) -> bool { - data.split(';') - .find_map(|part| part.trim().strip_prefix("GpuPreference=")) - .is_some_and(|preference| matches!(preference.trim(), "1" | "2")) + query.lines().find_map(|line| { + let (value_name, data) = line.split_once("REG_SZ")?; + if value_name.trim().eq_ignore_ascii_case(name) { + Some(data.trim()) + } else { + None + } + }) } #[cfg(windows)] -pub async fn prefer_dedicated_gpu(java_path: &std::path::Path) { - let Some(dir) = java_path.parent() else { return }; +pub async fn set_gpu_preference(java_path: &std::path::Path, preference: u8) { + let Some(dir) = java_path.parent() else { + return; + }; - let mut targets = Vec::new(); - for exe in GPU_PREFERENCE_EXECUTABLES { - let path = dir.join(exe); - if polyio::try_exists(&path).await.unwrap_or(false) { - targets.push(path); - } - } + let mut targets = Vec::new(); + for exe in GPU_PREFERENCE_EXECUTABLES { + let path = dir.join(exe); + if polyio::try_exists(&path).await.unwrap_or(false) { + targets.push(path); + } + } - if targets.is_empty() { - return; - } + if targets.is_empty() { + return; + } - // One query for the whole key instead of one per executable - let query = match reg_command() - .args(["query", GPU_PREFERENCES_KEY]) - .output() - .await - { - Ok(out) => String::from_utf8_lossy(&out.stdout).into_owned(), - Err(err) => { - tracing::warn!(%err, "could not run reg.exe"); - return; - } - }; + // One query for the whole key instead of one per executable + let query = match reg_command() + .args(["query", GPU_PREFERENCES_KEY]) + .output() + .await + { + Ok(out) => String::from_utf8_lossy(&out.stdout).into_owned(), + Err(err) => { + tracing::warn!(%err, "could not run reg.exe"); + return; + } + }; - for path in targets { - if registered_value(&query, &path.to_string_lossy()).is_some_and(has_explicit_preference) { - continue; - } + let data = format!("GpuPreference={preference};"); + for path in targets { + if registered_value(&query, &path.to_string_lossy()) == Some(data.as_str()) { + continue; + } - let added = reg_command() - .args(["add", GPU_PREFERENCES_KEY, "/v"]) - .arg(&path) - .args(["/t", "REG_SZ", "/d", "GpuPreference=2;", "/f"]) - .output() - .await; + let added = reg_command() + .args(["add", GPU_PREFERENCES_KEY, "/v"]) + .arg(&path) + .args(["/t", "REG_SZ", "/d", data.as_str(), "/f"]) + .output() + .await; - match added { - Ok(out) if out.status.success() => { - tracing::info!(path = %path.display(), "registered JVM for high-performance GPU"); - } - Ok(out) => tracing::warn!( - path = %path.display(), - stderr = %String::from_utf8_lossy(&out.stderr).trim(), - "could not set GPU preference" - ), - Err(err) => tracing::warn!(%err, "could not run reg.exe"), - } - } + match added { + Ok(out) if out.status.success() => { + tracing::info!(path = %path.display(), preference, "registered JVM GPU preference"); + } + Ok(out) => tracing::warn!( + path = %path.display(), + stderr = %String::from_utf8_lossy(&out.stderr).trim(), + "could not set GPU preference" + ), + Err(err) => tracing::warn!(%err, "could not run reg.exe"), + } + } } /// Runtimes install into version-stamped folders so without this the key keeps /// a dead entry for every upgrade #[cfg(windows)] pub async fn forget_dedicated_gpu(java_path: &std::path::Path) { - let Some(dir) = java_path.parent() else { return }; + let Some(dir) = java_path.parent() else { + return; + }; - for exe in GPU_PREFERENCE_EXECUTABLES { - let path = dir.join(exe); - let removed = reg_command() - .args(["delete", GPU_PREFERENCES_KEY, "/v"]) - .arg(&path) - .arg("/f") - .output() - .await; + for exe in GPU_PREFERENCE_EXECUTABLES { + let path = dir.join(exe); + let removed = reg_command() + .args(["delete", GPU_PREFERENCES_KEY, "/v"]) + .arg(&path) + .arg("/f") + .output() + .await; - match removed { - Ok(out) if out.status.success() => { - tracing::info!(path = %path.display(), "removed GPU preference"); - } - // A value that was never written is reported as an error - Ok(_) => {} - Err(err) => tracing::warn!(%err, "could not run reg.exe"), - } - } + match removed { + Ok(out) if out.status.success() => { + tracing::info!(path = %path.display(), "removed GPU preference"); + } + // A value that was never written is reported as an error + Ok(_) => {} + Err(err) => tracing::warn!(%err, "could not run reg.exe"), + } + } } #[cfg(not(windows))] -pub async fn prefer_dedicated_gpu(_java_path: &std::path::Path) {} +pub async fn set_gpu_preference(_java_path: &std::path::Path, _preference: u8) {} #[cfg(not(windows))] pub async fn forget_dedicated_gpu(_java_path: &std::path::Path) {} #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn bitness_matches_the_architecture() { - assert_eq!(HostTarget::CURRENT.bitness(), { - if cfg!(target_pointer_width = "64") { - "64" - } else { - "32" - } - }); - } + #[test] + fn bitness_matches_the_architecture() { + assert_eq!(HostTarget::CURRENT.bitness(), { + if cfg!(target_pointer_width = "64") { + "64" + } else { + "32" + } + }); + } - #[test] - fn windows_ships_zips_and_everything_else_tarballs() { - let expected = if cfg!(windows) { - PackageArchive::Zip - } else { - PackageArchive::TarGz - }; - assert_eq!(HostTarget::CURRENT.archive(), expected); - } - - #[test] - fn musl_is_only_ever_reported_on_linux() { - if HostTarget::CURRENT.is_musl() { - assert!(matches!(HostTarget::CURRENT.os, HostOs::Linux { .. })); - } - } + #[test] + fn windows_ships_zips_and_everything_else_tarballs() { + let expected = if cfg!(windows) { + PackageArchive::Zip + } else { + PackageArchive::TarGz + }; + assert_eq!(HostTarget::CURRENT.archive(), expected); + } } diff --git a/packages/oneclient_java/src/resolve.rs b/packages/oneclient_java/src/resolve.rs index a5e671eb..0c4b85b5 100644 --- a/packages/oneclient_java/src/resolve.rs +++ b/packages/oneclient_java/src/resolve.rs @@ -1,67 +1,63 @@ use std::path::{Path, PathBuf}; - -use oneclient_common::constants::JAVA_BIN; use crate::data::java_executable_relative_path; use crate::error::{JavaError, JavaResult}; +use oneclient_common::constants::JAVA_BIN; #[tracing::instrument(level = "debug", skip_all)] pub fn resolve_java_executable(selection: impl AsRef) -> JavaResult { - let selection = selection.as_ref(); + let selection = selection.as_ref(); - if selection.is_file() { - return validate_executable(selection); - } + if selection.is_file() { + return validate_executable(selection); + } - let relative = java_executable_relative_path(); - let candidates = [ - selection.join(&relative), - selection.join(JAVA_BIN), - #[cfg(target_os = "macos")] - selection - .join("Contents") - .join("Home") - .join(&relative), - ]; + let relative = java_executable_relative_path(); + let candidates = [ + selection.join(&relative), + selection.join(JAVA_BIN), + #[cfg(target_os = "macos")] + selection.join("Contents").join("Home").join(&relative), + ]; - for candidate in candidates { - if candidate.is_file() { - return validate_executable(&candidate); - } - } + for candidate in candidates { + if candidate.is_file() { + return validate_executable(&candidate); + } + } - if let Ok(entries) = std::fs::read_dir(selection) { - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } + if let Ok(entries) = std::fs::read_dir(selection) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } - let nested = path.join(&relative); - if nested.is_file() { - return validate_executable(&nested); - } - } - } + let nested = path.join(&relative); + if nested.is_file() { + return validate_executable(&nested); + } + } + } - tracing::warn!(path = %selection.display(), "no Java executable found in selection"); + tracing::warn!(path = %selection.display(), "no Java executable found in selection"); - Err(JavaError::InvalidJavaPath { - path: selection.display().to_string(), - }) + Err(JavaError::InvalidJavaPath { + path: selection.display().to_string(), + }) } fn validate_executable(path: &Path) -> JavaResult { - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or_default(); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); - if file_name != JAVA_BIN && file_name != "java.exe" && file_name != "javaw.exe" { - return Err(JavaError::InvalidJavaPath { - path: path.display().to_string(), - }); - } + if file_name != JAVA_BIN && file_name != "java.exe" && file_name != "javaw.exe" { + return Err(JavaError::InvalidJavaPath { + path: path.display().to_string(), + }); + } - Ok(path.to_path_buf()) + Ok(path.to_path_buf()) } diff --git a/packages/oneclient_java/src/service.rs b/packages/oneclient_java/src/service.rs index b96ee29f..73588cd5 100644 --- a/packages/oneclient_java/src/service.rs +++ b/packages/oneclient_java/src/service.rs @@ -27,290 +27,294 @@ pub const PROBE_VERSION: u32 = 1; const _: () = assert!(PROBE_VERSION > 0); enum JavaPromptAnswer { - Download, - PickFolder, + Download, + PickFolder, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct AvailableJava { - pub major: u32, - pub package: JavaPackage, + pub major: u32, + pub package: JavaPackage, } pub struct JavaService { - store: Arc, - net: RequestClient, - events: EventBus, + store: Arc, + net: RequestClient, + events: EventBus, } impl JavaService { - #[must_use] - pub fn new(store: Arc, net: RequestClient, events: EventBus) -> Self { - Self { store, net, events } - } + #[must_use] + pub fn new(store: Arc, net: RequestClient, events: EventBus) -> Self { + Self { store, net, events } + } - #[must_use] - pub fn net(&self) -> &RequestClient { - &self.net - } + #[must_use] + pub fn net(&self) -> &RequestClient { + &self.net + } - #[must_use] - pub fn events(&self) -> &EventBus { - &self.events - } + #[must_use] + pub fn events(&self) -> &EventBus { + &self.events + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn list_runtimes(&self) -> JavaResult> { - Ok(self.store.list().await?) - } + #[tracing::instrument(level = "debug", skip(self))] + pub async fn list_runtimes(&self) -> JavaResult> { + Ok(self.store.list().await?) + } - /// Checks if file wasn't deleted manually - #[tracing::instrument(level = "debug", skip(self))] - pub async fn has_vendor_runtime( - &self, - vendor: &JavaVendor, - major: Option, - ) -> JavaResult { - Ok(self.list_runtimes().await?.iter().any(|runtime| { - &runtime.vendor == vendor - && major.is_none_or(|major| runtime.major == major) - && Path::new(&runtime.absolute_path).is_file() - })) - } + /// Checks if file wasn't deleted manually + #[tracing::instrument(level = "debug", skip(self))] + pub async fn has_vendor_runtime( + &self, + vendor: &JavaVendor, + major: Option, + ) -> JavaResult { + Ok(self.list_runtimes().await?.iter().any(|runtime| { + &runtime.vendor == vendor + && major.is_none_or(|major| runtime.major == major) + && Path::new(&runtime.absolute_path).is_file() + })) + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn runtime_for_profile( - &self, - java_path: Option<&str>, - ) -> JavaResult> { - let Some(path) = java_path else { - return Ok(None); - }; - let Some(runtime) = self.store.get_by_path(path).await? else { - return Ok(None); - }; - self.revalidate(runtime).await - } + #[tracing::instrument(level = "debug", skip(self))] + pub async fn runtime_for_profile( + &self, + java_path: Option<&str>, + ) -> JavaResult> { + let Some(path) = java_path else { + return Ok(None); + }; + let Some(runtime) = self.store.get_by_path(path).await? else { + return Ok(None); + }; + self.revalidate(runtime).await + } - #[tracing::instrument(level = "debug", skip(self))] - async fn revalidate(&self, runtime: JavaRuntime) -> JavaResult> { - if runtime.probe_version == PROBE_VERSION { - if Path::new(&runtime.absolute_path).is_file() { - return Ok(Some(runtime)); - } + #[tracing::instrument(level = "debug", skip(self))] + async fn revalidate(&self, runtime: JavaRuntime) -> JavaResult> { + #[cfg(unix)] + crate::install::restore_executable_bits(Path::new(&runtime.absolute_path)); - tracing::warn!( - path = %runtime.absolute_path, - "forgetting recorded Java runtime: the executable is gone" - ); - self.store.delete_by_path(&runtime.absolute_path).await?; - return Ok(None); - } + if runtime.probe_version == PROBE_VERSION { + if Path::new(&runtime.absolute_path).is_file() { + return Ok(Some(runtime)); + } - match checker::check_java_runtime(runtime.absolute_path.clone()).await { - Ok(info) => Ok(Some( - self.persist(Path::new(&runtime.absolute_path), &info).await?, - )), - Err(err) if err.is_invalid_installation() => { - tracing::warn!( - path = %runtime.absolute_path, - "forgetting recorded Java runtime: {err}" - ); - self.store.delete_by_path(&runtime.absolute_path).await?; - Ok(None) - } - Err(err) => { - tracing::warn!( - path = %runtime.absolute_path, - "could not re-probe recorded Java runtime, trusting the record: {err}" - ); - Ok(Some(runtime)) - } - } - } + tracing::warn!( + path = %runtime.absolute_path, + "forgetting recorded Java runtime: the executable is gone" + ); + self.store.delete_by_path(&runtime.absolute_path).await?; + return Ok(None); + } - #[tracing::instrument(level = "debug", skip(self))] - pub async fn available_versions(&self, vendor: &JavaVendor) -> JavaResult> { - let Some(provider) = provider_for_vendor(vendor) else { - return Ok(Vec::new()); - }; + match checker::check_java_runtime(runtime.absolute_path.clone()).await { + Ok(info) => Ok(Some( + self.persist(Path::new(&runtime.absolute_path), &info) + .await?, + )), + Err(err) if err.is_invalid_installation() => { + tracing::warn!( + path = %runtime.absolute_path, + "forgetting recorded Java runtime: {err}" + ); + self.store.delete_by_path(&runtime.absolute_path).await?; + Ok(None) + } + Err(err) => { + tracing::warn!( + path = %runtime.absolute_path, + "could not re-probe recorded Java runtime, trusting the record: {err}" + ); + Ok(Some(runtime)) + } + } + } - let packages = provider.list_packages(None, &self.net).await?; + #[tracing::instrument(level = "debug", skip(self))] + pub async fn available_versions(&self, vendor: &JavaVendor) -> JavaResult> { + let Some(provider) = provider_for_vendor(vendor) else { + return Ok(Vec::new()); + }; - let mut by_major = BTreeMap::::new(); - for package in packages { - let Some(&major) = package.java_version.first() else { - continue; - }; - by_major.entry(major).or_insert(package); - } + let packages = provider.list_packages(None, &self.net).await?; - let mut available: Vec = by_major - .into_iter() - .map(|(major, package)| AvailableJava { major, package }) - .collect(); + let mut by_major = BTreeMap::::new(); + for package in packages { + let Some(&major) = package.java_version.first() else { + continue; + }; + by_major.entry(major).or_insert(package); + } - available.sort_by_key(|entry| std::cmp::Reverse(entry.major)); - Ok(available) - } + let mut available: Vec = by_major + .into_iter() + .map(|(major, package)| AvailableJava { major, package }) + .collect(); - /// `None` when the vendor publishes nothing for this major on this host - #[tracing::instrument(level = "debug", skip(self))] - pub async fn latest_package( - &self, - vendor: &JavaVendor, - major: u32, - ) -> JavaResult> { - let Some(provider) = provider_for_vendor(vendor) else { - return Ok(None); - }; + available.sort_by_key(|entry| std::cmp::Reverse(entry.major)); + Ok(available) + } - provider.latest_package_by_major(major, &self.net).await - } + /// `None` when the vendor publishes nothing for this major on this host + #[tracing::instrument(level = "debug", skip(self))] + pub async fn latest_package( + &self, + vendor: &JavaVendor, + major: u32, + ) -> JavaResult> { + let Some(provider) = provider_for_vendor(vendor) else { + return Ok(None); + }; - #[tracing::instrument(level = "debug", skip(self))] - pub async fn rescan(&self) -> JavaResult<()> { - self.register_located(&crate::locate::locate_java().await?) - .await - } + provider.latest_package_by_major(major, &self.net).await + } - #[tracing::instrument(skip(self))] - pub async fn add_custom_runtime(&self, folder: PathBuf) -> JavaResult { - let executable = resolve_java_executable(&folder)?; - self.register_checked(&executable, None).await - } + #[tracing::instrument(level = "debug", skip(self))] + pub async fn rescan(&self) -> JavaResult<()> { + self.register_located(&crate::locate::locate_java().await?) + .await + } - #[tracing::instrument(skip(self))] - pub async fn remove_runtime(&self, absolute_path: &str) -> JavaResult<()> { - self.store.delete_by_path(absolute_path).await?; + #[tracing::instrument(skip(self))] + pub async fn add_custom_runtime(&self, folder: PathBuf) -> JavaResult { + let executable = resolve_java_executable(&folder)?; + self.register_checked(&executable, None).await + } - crate::platform::forget_dedicated_gpu(Path::new(absolute_path)).await; + #[tracing::instrument(skip(self))] + pub async fn remove_runtime(&self, absolute_path: &str) -> JavaResult<()> { + self.store.delete_by_path(absolute_path).await?; - let removed_files = - match crate::install::remove_installed_package(Path::new(absolute_path)).await { - Ok(removed) => removed, - Err(err) => { - tracing::warn!("could not remove the installed Java files: {err:#}"); - false - } - }; + crate::platform::forget_dedicated_gpu(Path::new(absolute_path)).await; - tracing::info!(removed_files, "removed Java runtime"); - Ok(()) - } + let removed_files = + match crate::install::remove_installed_package(Path::new(absolute_path)).await { + Ok(removed) => removed, + Err(err) => { + tracing::warn!("could not remove the installed Java files: {err:#}"); + false + } + }; - /// A recorded JRE is kept only as a fallback so a system scan gets its - /// chance to turn up a JDK of the same major first - #[tracing::instrument(level = "debug", skip(self, progress))] - pub async fn prepare( - &self, - major: u32, - search_system: bool, - auto_install: bool, - progress: Option<&GroupedProgressSession>, - ) -> JavaResult { - let recorded = self.best_recorded_for_major(major).await?; + tracing::info!(removed_files, "removed Java runtime"); + Ok(()) + } - if let Some(runtime) = &recorded - && runtime.is_jdk - { - return Ok(runtime.clone()); - } + /// A recorded JRE is kept only as a fallback so a system scan gets its + /// chance to turn up a JDK of the same major first + #[tracing::instrument(level = "debug", skip(self, progress))] + pub async fn prepare( + &self, + major: u32, + search_system: bool, + auto_install: bool, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + let recorded = self.best_recorded_for_major(major).await?; - if search_system { - let located = crate::locate::locate_java().await?; - self.register_located(&located).await?; + if let Some(runtime) = &recorded + && runtime.is_jdk + { + return Ok(runtime.clone()); + } - // A located runtime only displaces a recorded one by being a JDK - if let Some((path, info)) = crate::locate::best_for_major(&located, major) - && (info.is_jdk || recorded.is_none()) - { - return self.persist(path, info).await; - } - } + if search_system { + let located = crate::locate::locate_java().await?; + self.register_located(&located).await?; - if let Some(runtime) = recorded { - return Ok(runtime); - } + // A located runtime only displaces a recorded one by being a JDK + if let Some((path, info)) = crate::locate::best_for_major(&located, major) + && (info.is_jdk || recorded.is_none()) + { + return self.persist(path, info).await; + } + } - if auto_install { - return self.download_and_register(major, progress).await; - } + if let Some(runtime) = recorded { + return Ok(runtime); + } - self.prompt_and_install(major, progress).await - } + if auto_install { + return self.download_and_register(major, progress).await; + } - #[tracing::instrument(level = "debug", skip(self))] - async fn best_recorded_for_major(&self, major: u32) -> JavaResult> { - let preferred = vendors::default_vendor(); + self.prompt_and_install(major, progress).await + } - let mut candidates: Vec = self - .list_runtimes() - .await? - .into_iter() - .filter(|runtime| runtime.major == major) - .collect(); + #[tracing::instrument(level = "debug", skip(self))] + async fn best_recorded_for_major(&self, major: u32) -> JavaResult> { + let preferred = vendors::default_vendor(); - candidates.sort_by_key(|b| std::cmp::Reverse(pick_order(b, &preferred))); + let mut candidates: Vec = self + .list_runtimes() + .await? + .into_iter() + .filter(|runtime| runtime.major == major) + .collect(); - for candidate in candidates { - if let Some(valid) = self.revalidate(candidate).await? { - return Ok(Some(valid)); - } - } + candidates.sort_by_key(|a| std::cmp::Reverse(pick_order(a, &preferred))); - Ok(None) - } + for candidate in candidates { + if let Some(valid) = self.revalidate(candidate).await? { + return Ok(Some(valid)); + } + } - #[tracing::instrument(skip(self, progress))] - pub async fn install_runtime( - &self, - major: u32, - progress: Option<&GroupedProgressSession>, - ) -> JavaResult { - self.download_and_register(major, progress).await - } + Ok(None) + } - #[tracing::instrument(skip(self))] - pub async fn install_runtime_from( - &self, - vendor: &JavaVendor, - major: u32, - ) -> JavaResult { - self.install_vendor_runtime(vendor, major, None).await - } + #[tracing::instrument(skip(self, progress))] + pub async fn install_runtime( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + self.download_and_register(major, progress).await + } - #[tracing::instrument(level = "debug", skip(self, progress))] - async fn install_vendor_runtime( - &self, - vendor: &JavaVendor, - major: u32, - progress: Option<&GroupedProgressSession>, - ) -> JavaResult { - let provider = provider_for_vendor(vendor).ok_or(JavaError::PackageNotFound { major })?; - let package = provider - .latest_package_by_major(major, &self.net) - .await? - .ok_or(JavaError::PackageNotFound { major })?; + #[tracing::instrument(skip(self))] + pub async fn install_runtime_from( + &self, + vendor: &JavaVendor, + major: u32, + ) -> JavaResult { + self.install_vendor_runtime(vendor, major, None).await + } - let owned = progress.is_none().then(|| { - GroupedProgressSession::start(&self.events, format!("Installing Java {major}")) - }); - let session = progress.or(owned.as_ref()).expect("session present"); + #[tracing::instrument(level = "debug", skip(self, progress))] + async fn install_vendor_runtime( + &self, + vendor: &JavaVendor, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + let provider = provider_for_vendor(vendor).ok_or(JavaError::PackageNotFound { major })?; + let package = provider + .latest_package_by_major(major, &self.net) + .await? + .ok_or(JavaError::PackageNotFound { major })?; - let executable = provider - .install_package(&package, &self.net, &self.events, Some(session)) - .await?; + let owned = progress.is_none().then(|| { + GroupedProgressSession::start(&self.events, format!("Installing Java {major}")) + }); + let session = progress.or(owned.as_ref()).expect("session present"); - self.register_checked(&executable, Some(major)).await - } + let executable = provider + .install_package(&package, &self.net, &self.events, Some(session)) + .await?; - async fn prompt_and_install( - &self, - major: u32, - progress: Option<&GroupedProgressSession>, - ) -> JavaResult { - let prompt = Prompt::new( + self.register_checked(&executable, Some(major)).await + } + + async fn prompt_and_install( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + let prompt = Prompt::new( "Java required", format!( "No Java {major} runtime was found. Download it automatically, choose an existing installation folder, or cancel?" @@ -327,249 +331,249 @@ impl JavaService { ) .dismiss("Cancel"); - let Some(chosen) = self.events.ask(prompt).await? else { - return Err(JavaError::Cancelled); - }; + let Some(chosen) = self.events.ask(prompt).await? else { + return Err(JavaError::Cancelled); + }; - match chosen.value { - JavaPromptAnswer::Download => match chosen.selection() { - Some(vendor) => { - let vendor = JavaVendor::from_str(vendor) - .unwrap_or_else(|_| JavaVendor::Other(vendor.to_string())); - self.install_vendor_runtime(&vendor, major, progress).await - } - None => self.download_and_register(major, progress).await, - }, - JavaPromptAnswer::PickFolder => match chosen.folder() { - Some(folder) => { - let executable = resolve_java_executable(folder)?; - self.register_checked(&executable, Some(major)).await - } - None => Err(JavaError::Cancelled), - }, - } - } + match chosen.value { + JavaPromptAnswer::Download => match chosen.selection() { + Some(vendor) => { + let vendor = JavaVendor::from_str(vendor) + .unwrap_or_else(|_| JavaVendor::Other(vendor.to_string())); + self.install_vendor_runtime(&vendor, major, progress).await + } + None => self.download_and_register(major, progress).await, + }, + JavaPromptAnswer::PickFolder => match chosen.folder() { + Some(folder) => { + let executable = resolve_java_executable(folder)?; + self.register_checked(&executable, Some(major)).await + } + None => Err(JavaError::Cancelled), + }, + } + } - #[tracing::instrument(level = "debug", skip(self, progress))] - async fn download_and_register( - &self, - major: u32, - progress: Option<&GroupedProgressSession>, - ) -> JavaResult { - for provider in vendors::runtime_providers() { - let vendor = provider.vendor(); + #[tracing::instrument(level = "debug", skip(self, progress))] + async fn download_and_register( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + for provider in vendors::runtime_providers() { + let vendor = provider.vendor(); - let packages = provider.list_packages(Some(major), &self.net).await; - let package = match &packages { - Ok(packages) => match packages - .iter() - .find(|p| p.java_version.first() == Some(&major)) - .or_else(|| packages.first()) - { - Some(package) => package, - None => { - tracing::warn!(?vendor, major, "no packages found"); - continue; - } - }, - Err(err) => { - tracing::warn!(?vendor, major, "failed to query packages: {err}"); - continue; - } - }; + let packages = provider.list_packages(Some(major), &self.net).await; + let package = match &packages { + Ok(packages) => match packages + .iter() + .find(|p| p.java_version.first() == Some(&major)) + .or_else(|| packages.first()) + { + Some(package) => package, + None => { + tracing::warn!(?vendor, major, "no packages found"); + continue; + } + }, + Err(err) => { + tracing::warn!(?vendor, major, "failed to query packages: {err}"); + continue; + } + }; - tracing::info!(?vendor, major, "downloading Java runtime"); - match provider - .install_package(package, &self.net, &self.events, progress) - .await - { - Ok(executable) => return self.register_checked(&executable, Some(major)).await, - Err(err) => tracing::warn!(?vendor, major, "install failed: {err}"), - } - } + tracing::info!(?vendor, major, "downloading Java runtime"); + match provider + .install_package(package, &self.net, &self.events, progress) + .await + { + Ok(executable) => return self.register_checked(&executable, Some(major)).await, + Err(err) => tracing::warn!(?vendor, major, "install failed: {err}"), + } + } - Err(JavaError::PackageNotFound { major }) - } + Err(JavaError::PackageNotFound { major }) + } - #[tracing::instrument(level = "debug", skip(self))] - async fn register_checked( - &self, - executable: &Path, - expected_major: Option, - ) -> JavaResult { - let info = checker::check_java_runtime(executable.display().to_string()).await?; - let major = parse_major_version(&info.version)?; + #[tracing::instrument(level = "debug", skip(self))] + async fn register_checked( + &self, + executable: &Path, + expected_major: Option, + ) -> JavaResult { + let info = checker::check_java_runtime(executable.display().to_string()).await?; + let major = parse_major_version(&info.version)?; - if let Some(expected) = expected_major - && major != expected - { - return Err(JavaError::VersionMismatch { - expected, - found: major, - }); - } + if let Some(expected) = expected_major + && major != expected + { + return Err(JavaError::VersionMismatch { + expected, + found: major, + }); + } - self.persist(executable, &info).await - } + self.persist(executable, &info).await + } - /// Skips anything that fails to probe a broken JDK must not abort the scan - #[tracing::instrument(level = "debug", skip_all)] - async fn register_located(&self, located: &[(PathBuf, JavaCheckInfo)]) -> JavaResult<()> { - for (path, info) in located { - if let Err(err) = self.persist(path, info).await { - tracing::warn!( - path = %path.display(), - version = %info.version, - "skipping located Java runtime: {err}" - ); - } - } - Ok(()) - } + /// Skips anything that fails to probe a broken JDK must not abort the scan + #[tracing::instrument(level = "debug", skip_all)] + async fn register_located(&self, located: &[(PathBuf, JavaCheckInfo)]) -> JavaResult<()> { + for (path, info) in located { + if let Err(err) = self.persist(path, info).await { + tracing::warn!( + path = %path.display(), + version = %info.version, + "skipping located Java runtime: {err}" + ); + } + } + Ok(()) + } - async fn persist(&self, executable: &Path, info: &JavaCheckInfo) -> JavaResult { - let runtime = JavaRuntime { - absolute_path: executable.to_string_lossy().into_owned(), - major: parse_major_version(&info.version)?, - version: info.version.clone(), - vendor: JavaVendor::from_str(&info.vendor) - .unwrap_or_else(|_| JavaVendor::Other(info.vendor.clone())), - os_arch: info.os_arch.clone(), - is_jdk: info.is_jdk, - probe_version: PROBE_VERSION, - }; + async fn persist(&self, executable: &Path, info: &JavaCheckInfo) -> JavaResult { + let runtime = JavaRuntime { + absolute_path: executable.to_string_lossy().into_owned(), + major: parse_major_version(&info.version)?, + version: info.version.clone(), + vendor: JavaVendor::from_str(&info.vendor) + .unwrap_or_else(|_| JavaVendor::Other(info.vendor.clone())), + os_arch: info.os_arch.clone(), + is_jdk: info.is_jdk, + probe_version: PROBE_VERSION, + }; - Ok(self.store.upsert(&runtime).await?) - } + Ok(self.store.upsert(&runtime).await?) + } } /// Ranked highest-first the default vendor beats every other, a kit beats a /// runtime of the same vendor, and only then does the newest build win fn pick_order(runtime: &JavaRuntime, preferred: &JavaVendor) -> (bool, bool, Vec) { - ( - &runtime.vendor == preferred, - runtime.is_jdk, - version_key(&runtime.version), - ) + ( + &runtime.vendor == preferred, + runtime.is_jdk, + version_key(&runtime.version), + ) } /// `21.0.9` sorts above `21.0.12` as text which is backwards so the components /// are compared as numbers Legacy `1.8.0_412` keeps its build as the last one fn version_key(version: &str) -> Vec { - version - .split(|c: char| !c.is_ascii_digit()) - .filter_map(|part| part.parse().ok()) - .collect() + version + .split(|c: char| !c.is_ascii_digit()) + .filter_map(|part| part.parse().ok()) + .collect() } fn provider_for_vendor(vendor: &JavaVendor) -> Option> { - vendors::runtime_providers() - .into_iter() - .find(|provider| &provider.vendor() == vendor) + vendors::runtime_providers() + .into_iter() + .find(|provider| &provider.vendor() == vendor) } /// Java 8 and earlier report `1.8.0_412` where the major is the *second* /// component 9 and later report `21.0.3` where it is the first pub(crate) fn parse_major_version(version: &str) -> Result { - if let Some(rest) = version.strip_prefix("1.") { - let digit = rest.chars().next().ok_or_else(|| JavaError::ParseVersion { - version: version.to_string(), - })?; - return digit.to_digit(10).ok_or_else(|| JavaError::ParseVersion { - version: version.to_string(), - }); - } + if let Some(rest) = version.strip_prefix("1.") { + let digit = rest.chars().next().ok_or_else(|| JavaError::ParseVersion { + version: version.to_string(), + })?; + return digit.to_digit(10).ok_or_else(|| JavaError::ParseVersion { + version: version.to_string(), + }); + } - let head = version.split('.').next().unwrap_or(version); - head.parse::().map_err(|_| JavaError::ParseVersion { - version: version.to_string(), - }) + let head = version.split('.').next().unwrap_or(version); + head.parse::().map_err(|_| JavaError::ParseVersion { + version: version.to_string(), + }) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn legacy_versions_take_the_second_component() { - assert_eq!(parse_major_version("1.8.0_412").unwrap(), 8); - assert_eq!(parse_major_version("1.7.0").unwrap(), 7); - } + #[test] + fn legacy_versions_take_the_second_component() { + assert_eq!(parse_major_version("1.8.0_412").unwrap(), 8); + assert_eq!(parse_major_version("1.7.0").unwrap(), 7); + } - #[test] - fn modern_versions_take_the_first() { - assert_eq!(parse_major_version("21.0.3").unwrap(), 21); - assert_eq!(parse_major_version("17").unwrap(), 17); - } + #[test] + fn modern_versions_take_the_first() { + assert_eq!(parse_major_version("21.0.3").unwrap(), 21); + assert_eq!(parse_major_version("17").unwrap(), 17); + } - #[test] - fn garbage_is_rejected_rather_than_defaulted() { - assert!(parse_major_version("not-a-version").is_err()); - assert!(parse_major_version("1.x").is_err()); - } + #[test] + fn garbage_is_rejected_rather_than_defaulted() { + assert!(parse_major_version("not-a-version").is_err()); + assert!(parse_major_version("1.x").is_err()); + } - fn runtime(vendor: JavaVendor, version: &str, is_jdk: bool) -> JavaRuntime { - JavaRuntime { - absolute_path: format!("/java/{vendor}-{version}/bin/java"), - major: parse_major_version(version).unwrap(), - version: version.to_string(), - vendor, - os_arch: "x64".to_string(), - is_jdk, - probe_version: PROBE_VERSION, - } - } + fn runtime(vendor: JavaVendor, version: &str, is_jdk: bool) -> JavaRuntime { + JavaRuntime { + absolute_path: format!("/java/{vendor}-{version}/bin/java"), + major: parse_major_version(version).unwrap(), + version: version.to_string(), + vendor, + os_arch: "x64".to_string(), + is_jdk, + probe_version: PROBE_VERSION, + } + } - /// Highest first the same way `best_recorded_for_major` sorts - fn best(mut candidates: Vec, preferred: &JavaVendor) -> JavaRuntime { - candidates.sort_by(|a, b| pick_order(b, preferred).cmp(&pick_order(a, preferred))); - candidates.remove(0) - } + /// Highest first the same way `best_recorded_for_major` sorts + fn best(mut candidates: Vec, preferred: &JavaVendor) -> JavaRuntime { + candidates.sort_by_key(|a| std::cmp::Reverse(pick_order(a, preferred))); + candidates.remove(0) + } - #[test] - fn patch_numbers_are_compared_as_numbers_not_as_text() { - assert!(version_key("21.0.12") > version_key("21.0.9")); - assert_eq!(version_key("1.8.0_412"), vec![1, 8, 0, 412]); - } + #[test] + fn patch_numbers_are_compared_as_numbers_not_as_text() { + assert!(version_key("21.0.12") > version_key("21.0.9")); + assert_eq!(version_key("1.8.0_412"), vec![1, 8, 0, 412]); + } - #[test] - fn the_default_vendor_wins_even_against_a_newer_build() { - let picked = best( - vec![ - runtime(JavaVendor::Zulu, "21.0.99", true), - runtime(JavaVendor::Microsoft, "21.0.1", true), - ], - &JavaVendor::Microsoft, - ); + #[test] + fn the_default_vendor_wins_even_against_a_newer_build() { + let picked = best( + vec![ + runtime(JavaVendor::Zulu, "21.0.99", true), + runtime(JavaVendor::Microsoft, "21.0.1", true), + ], + &JavaVendor::Microsoft, + ); - assert_eq!(picked.vendor, JavaVendor::Microsoft); - } + assert_eq!(picked.vendor, JavaVendor::Microsoft); + } - #[test] - fn a_kit_beats_a_runtime_of_the_same_vendor() { - let picked = best( - vec![ - runtime(JavaVendor::Microsoft, "21.0.5", false), - runtime(JavaVendor::Microsoft, "21.0.2", true), - ], - &JavaVendor::Microsoft, - ); + #[test] + fn a_kit_beats_a_runtime_of_the_same_vendor() { + let picked = best( + vec![ + runtime(JavaVendor::Microsoft, "21.0.5", false), + runtime(JavaVendor::Microsoft, "21.0.2", true), + ], + &JavaVendor::Microsoft, + ); - assert!(picked.is_jdk); - } + assert!(picked.is_jdk); + } - /// Nothing from the preferred vendor means the ranking falls through to the - /// newest build rather than to whatever the store happened to return - #[test] - fn without_the_default_vendor_the_newest_build_wins() { - let picked = best( - vec![ - runtime(JavaVendor::Zulu, "21.0.9", true), - runtime(JavaVendor::Adoptium, "21.0.12", true), - ], - &JavaVendor::Microsoft, - ); + /// Nothing from the preferred vendor means the ranking falls through to the + /// newest build rather than to whatever the store happened to return + #[test] + fn without_the_default_vendor_the_newest_build_wins() { + let picked = best( + vec![ + runtime(JavaVendor::Zulu, "21.0.9", true), + runtime(JavaVendor::Adoptium, "21.0.12", true), + ], + &JavaVendor::Microsoft, + ); - assert_eq!(picked.version, "21.0.12"); - } + assert_eq!(picked.version, "21.0.12"); + } } diff --git a/packages/oneclient_java/src/store.rs b/packages/oneclient_java/src/store.rs index ef582ce6..abaae97e 100644 --- a/packages/oneclient_java/src/store.rs +++ b/packages/oneclient_java/src/store.rs @@ -11,74 +11,78 @@ use crate::data::JavaRuntime; pub struct StoreError(#[source] pub Box); impl StoreError { - pub fn new(source: impl std::error::Error + Send + Sync + 'static) -> Self { - Self(Box::new(source)) - } + pub fn new(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self(Box::new(source)) + } } pub type StoreResult = Result; #[async_trait::async_trait] pub trait JavaStore: Send + Sync { - async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult; + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult; - async fn get_by_path(&self, absolute_path: &str) -> StoreResult>; + async fn get_by_path(&self, absolute_path: &str) -> StoreResult>; - async fn latest_by_major(&self, major: u32) -> StoreResult>; + async fn latest_by_major(&self, major: u32) -> StoreResult>; - async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()>; + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()>; - async fn list(&self) -> StoreResult>; + async fn list(&self) -> StoreResult>; } #[derive(Debug, Default)] pub struct MemoryJavaStore { - runtimes: std::sync::Mutex>, + runtimes: std::sync::Mutex>, } impl MemoryJavaStore { - #[must_use] - pub fn new() -> Arc { - Arc::new(Self::default()) - } + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } } #[async_trait::async_trait] impl JavaStore for MemoryJavaStore { - async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult { - let mut runtimes = self.runtimes.lock().expect("java store poisoned"); - runtimes.retain(|existing| existing.absolute_path != runtime.absolute_path); - runtimes.push(runtime.clone()); - Ok(runtime.clone()) - } + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult { + let mut runtimes = self.runtimes.lock().expect("java store poisoned"); + runtimes.retain(|existing| existing.absolute_path != runtime.absolute_path); + runtimes.push(runtime.clone()); + Ok(runtime.clone()) + } - async fn get_by_path(&self, absolute_path: &str) -> StoreResult> { - let runtimes = self.runtimes.lock().expect("java store poisoned"); - Ok(runtimes - .iter() - .find(|runtime| runtime.absolute_path == absolute_path) - .cloned()) - } + async fn get_by_path(&self, absolute_path: &str) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + Ok(runtimes + .iter() + .find(|runtime| runtime.absolute_path == absolute_path) + .cloned()) + } - async fn latest_by_major(&self, major: u32) -> StoreResult> { - let runtimes = self.runtimes.lock().expect("java store poisoned"); - Ok(runtimes - .iter() - .filter(|runtime| runtime.major == major) - .max_by(|a, b| a.version.cmp(&b.version)) - .cloned()) - } + async fn latest_by_major(&self, major: u32) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + Ok(runtimes + .iter() + .filter(|runtime| runtime.major == major) + .max_by(|a, b| a.version.cmp(&b.version)) + .cloned()) + } - async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()> { - let mut runtimes = self.runtimes.lock().expect("java store poisoned"); - runtimes.retain(|runtime| runtime.absolute_path != absolute_path); - Ok(()) - } + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()> { + let mut runtimes = self.runtimes.lock().expect("java store poisoned"); + runtimes.retain(|runtime| runtime.absolute_path != absolute_path); + Ok(()) + } - async fn list(&self) -> StoreResult> { - let runtimes = self.runtimes.lock().expect("java store poisoned"); - let mut out = runtimes.clone(); - out.sort_by(|a, b| b.major.cmp(&a.major).then_with(|| b.version.cmp(&a.version))); - Ok(out) - } + async fn list(&self) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + let mut out = runtimes.clone(); + out.sort_by(|a, b| { + b.major + .cmp(&a.major) + .then_with(|| b.version.cmp(&a.version)) + }); + Ok(out) + } } diff --git a/packages/oneclient_java/src/vendors/corretto.rs b/packages/oneclient_java/src/vendors/corretto.rs index 92b7801b..b62a23d8 100644 --- a/packages/oneclient_java/src/vendors/corretto.rs +++ b/packages/oneclient_java/src/vendors/corretto.rs @@ -59,13 +59,10 @@ impl JavaRuntimeProvider for CorrettoRuntimeProvider { // Prefer the versioned URL the `latest` redirect resolves at // download time and can move away from this index's checksum - let download_url = entry - .get("resource") - .and_then(Value::as_str) - .map_or_else( - || latest_url(this_major, CORRETTO_EXT.0), - |resource| format!("https://corretto.aws{resource}"), - ); + let download_url = entry.get("resource").and_then(Value::as_str).map_or_else( + || latest_url(this_major, CORRETTO_EXT.0), + |resource| format!("https://corretto.aws{resource}"), + ); let checksum = entry .get("checksum_sha256") diff --git a/packages/oneclient_java/src/vendors/microsoft.rs b/packages/oneclient_java/src/vendors/microsoft.rs index a66a5ba0..9746b9ff 100644 --- a/packages/oneclient_java/src/vendors/microsoft.rs +++ b/packages/oneclient_java/src/vendors/microsoft.rs @@ -37,9 +37,7 @@ impl JavaRuntimeProvider for MicrosoftRuntimeProvider { net: &RequestClient, ) -> JavaResult> { let Some(arch) = MICROSOFT_ARCH else { - tracing::debug!( - "Microsoft publishes no build for this architecture; listing nothing" - ); + tracing::debug!("Microsoft publishes no build for this architecture; listing nothing"); return Ok(Vec::new()); }; @@ -157,9 +155,7 @@ fn parse_checksum_file(body: &str, major: u32) -> Option { /// Splitting the whole filename the way the other vendors split their version /// strings would fold the `64` of `x64` into the version fn version_from_filename(filename: &str) -> Option> { - let (version, _) = filename - .strip_prefix("microsoft-jdk-")? - .split_once('-')?; + let (version, _) = filename.strip_prefix("microsoft-jdk-")?.split_once('-')?; let parts: Vec = version .split('.') @@ -216,8 +212,7 @@ const MICROSOFT_EXT: (&str, PackageArchive) = ( mod tests { use super::*; - const LINE: &str = - "bf27a5d6298c736af8daf5b8c883098e83291446e5766118d8a5ea6a2617195d microsoft-jdk-21.0.12-windows-x64.zip"; + const LINE: &str = "bf27a5d6298c736af8daf5b8c883098e83291446e5766118d8a5ea6a2617195d microsoft-jdk-21.0.12-windows-x64.zip"; #[test] fn only_kits_are_ever_requested() { diff --git a/packages/oneclient_java/src/vendors/mod.rs b/packages/oneclient_java/src/vendors/mod.rs index 8a83cfff..c3cc2c8e 100644 --- a/packages/oneclient_java/src/vendors/mod.rs +++ b/packages/oneclient_java/src/vendors/mod.rs @@ -1,6 +1,5 @@ use std::{path::PathBuf, str::FromStr}; - use oneclient_events::{EventBus, GroupedProgressSession}; use oneclient_net::RequestClient; @@ -22,7 +21,7 @@ pub use zulu::ZuluRuntimeProvider; pub fn runtime_providers() -> Vec> { vec![ - Box::new(MicrosoftRuntimeProvider), + Box::new(MicrosoftRuntimeProvider), Box::new(ZuluRuntimeProvider), Box::new(AdoptiumRuntimeProvider), Box::new(CorrettoRuntimeProvider), @@ -41,13 +40,13 @@ pub fn default_vendor() -> JavaVendor { #[async_trait::async_trait] pub trait JavaRuntimeProvider: Send + Sync { - fn vendor(&self) -> JavaVendor; + fn vendor(&self) -> JavaVendor; - async fn list_packages( - &self, - major: Option, - net: &RequestClient, - ) -> JavaResult>; + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult>; #[tracing::instrument(level = "debug", skip(self, net))] async fn latest_package_by_major( @@ -56,31 +55,33 @@ pub trait JavaRuntimeProvider: Send + Sync { net: &RequestClient, ) -> JavaResult> { let packages = self.list_packages(Some(major), net).await?; - Ok(packages.into_iter().find(|p| p.java_version.first() == Some(&major))) + Ok(packages + .into_iter() + .find(|p| p.java_version.first() == Some(&major))) } - #[tracing::instrument(level = "debug", skip_all)] - async fn install_package( - &self, - package: &JavaPackage, - net: &RequestClient, - events: &EventBus, - progress: Option<&GroupedProgressSession>, - ) -> JavaResult { + #[tracing::instrument(level = "debug", skip_all)] + async fn install_package( + &self, + package: &JavaPackage, + net: &RequestClient, + events: &EventBus, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { crate::install::install_package(package, net, events, progress).await } } #[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash)] pub enum JavaVendor { - Zulu, - Corretto, - Oracle, - Microsoft, - Adoptium, - Liberica, - OpenJDK, - Other(String), + Zulu, + Corretto, + Oracle, + Microsoft, + Adoptium, + Liberica, + OpenJDK, + Other(String), } impl std::fmt::Display for JavaVendor { @@ -99,37 +100,39 @@ impl std::fmt::Display for JavaVendor { } impl FromStr for JavaVendor { - type Err = (); + type Err = (); - fn from_str(s: &str) -> Result { - let v = s.to_lowercase(); + fn from_str(s: &str) -> Result { + let v = s.to_lowercase(); - Ok(if v.contains("adoptium") || v.contains("adoptopenjdk") || v.contains("temurin") { - Self::Adoptium - } else if v.contains("liberica") || v.contains("bellsoft") { - Self::Liberica - } else if v.contains("microsoft") { - Self::Microsoft - } else if v.contains("openjdk") { - Self::OpenJDK - } else if v.contains("oracle") { - Self::Oracle - } else if v.contains("amazon") || v.contains("corretto") { - Self::Corretto - } else if v.contains("azul") || v.contains("zulu") { - Self::Zulu - } else { - Self::Other(s.to_string()) - }) - } + Ok( + if v.contains("adoptium") || v.contains("adoptopenjdk") || v.contains("temurin") { + Self::Adoptium + } else if v.contains("liberica") || v.contains("bellsoft") { + Self::Liberica + } else if v.contains("microsoft") { + Self::Microsoft + } else if v.contains("openjdk") { + Self::OpenJDK + } else if v.contains("oracle") { + Self::Oracle + } else if v.contains("amazon") || v.contains("corretto") { + Self::Corretto + } else if v.contains("azul") || v.contains("zulu") { + Self::Zulu + } else { + Self::Other(s.to_string()) + }, + ) + } } impl<'de> Deserialize<'de> for JavaVendor { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - Ok(Self::from_str(&s).unwrap()) - } + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Self::from_str(&s).unwrap()) + } } diff --git a/packages/oneclient_java/src/vendors/zulu.rs b/packages/oneclient_java/src/vendors/zulu.rs index 60bf753a..6eab2afe 100644 --- a/packages/oneclient_java/src/vendors/zulu.rs +++ b/packages/oneclient_java/src/vendors/zulu.rs @@ -14,32 +14,38 @@ pub struct ZuluRuntimeProvider; #[derive(Debug, Deserialize)] struct ZuluPackage { - download_url: String, - name: String, - java_version: Vec, - /// Only present because `include_fields` asks for it - #[serde(default)] - sha256_hash: Option, - #[serde(default)] - size: Option, + download_url: String, + name: String, + java_version: Vec, + /// Only present because `include_fields` asks for it + #[serde(default)] + sha256_hash: Option, + #[serde(default)] + size: Option, } #[async_trait::async_trait] impl JavaRuntimeProvider for ZuluRuntimeProvider { - fn vendor(&self) -> JavaVendor { - JavaVendor::Zulu - } + fn vendor(&self) -> JavaVendor { + JavaVendor::Zulu + } - #[tracing::instrument(level = "debug", skip(self, net))] - async fn list_packages(&self, major: Option, net: &RequestClient) -> JavaResult> { + #[tracing::instrument(level = "debug", skip(self, net))] + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult> { let url = zulu_url(major)?; - let packages = net.send_as::>(Request::new(Method::GET, url)).await?; + let packages = net + .send_as::>(Request::new(Method::GET, url)) + .await?; let packages: Vec = packages.into_iter().map(map_zulu_package).collect(); tracing::debug!(count = packages.len(), "listed Zulu packages"); Ok(packages) - } + } } fn map_zulu_package(pkg: ZuluPackage) -> JavaPackage { @@ -113,4 +119,4 @@ mod tests { .is_some_and(|q| q.contains("java_package_type=jre")) ); } -} \ No newline at end of file +} diff --git a/packages/oneclient_java/tests/vendor_checksums.rs b/packages/oneclient_java/tests/vendor_checksums.rs index 0f33700b..6746c23f 100644 --- a/packages/oneclient_java/tests/vendor_checksums.rs +++ b/packages/oneclient_java/tests/vendor_checksums.rs @@ -88,8 +88,7 @@ async fn liberica_publishes_sha1_and_the_rest_publish_sha256() { }; assert_eq!( - checksum.algorithm, - expected, + checksum.algorithm, expected, "{vendor} changed the algorithm it publishes" ); } diff --git a/packages/oneclient_macro/src/lib.rs b/packages/oneclient_macro/src/lib.rs index 15a3e973..efae3076 100644 --- a/packages/oneclient_macro/src/lib.rs +++ b/packages/oneclient_macro/src/lib.rs @@ -9,4 +9,4 @@ pub fn derive_icon_named(input: proc_macro::TokenStream) -> proc_macro::TokenStr freya::expand_icon_named(input) .unwrap_or_else(|err| err.to_compile_error()) .into() -} \ No newline at end of file +} diff --git a/packages/oneclient_mc/src/arguments.rs b/packages/oneclient_mc/src/arguments.rs index 4b22f5bb..df327ef2 100644 --- a/packages/oneclient_mc/src/arguments.rs +++ b/packages/oneclient_mc/src/arguments.rs @@ -5,16 +5,15 @@ use interfrost::api::minecraft::{Argument, ArgumentValue, Library, VersionType}; use interfrost::api::modded::SidedDataEntry; use interfrost::utils::get_path_from_artifact; +use crate::error::McError; +use crate::error::McResult; +use crate::rules::validate_rules; +use oneclient_common::Resolution; use oneclient_common::constants::{self, DUMMY_REPLACE_NEWLINE}; use oneclient_common::paths; -use crate::rules::validate_rules; -use crate::error::McError; -use oneclient_common::Resolution; -use crate::error::McResult; #[allow(clippy::too_many_arguments)] pub fn java_arguments( - version_updated: bool, arguments: Option<&[Argument]>, natives_path: &Path, libraries_path: &Path, @@ -28,7 +27,6 @@ pub fn java_arguments( let mut parsed = Vec::new(); if let Some(args) = arguments { parse_arguments( - version_updated, args, &mut parsed, |a| { @@ -64,6 +62,10 @@ pub fn java_arguments( parsed.extend(performance_flags(java_major, java_arch, mem_max, &custom)); + if java_major >= 21 { + parsed.push("--add-modules=jdk.incubator.vector".to_string()); + } + parsed.push(format!("-Xmx{mem_max}M")); parsed.extend(custom); @@ -168,7 +170,6 @@ fn split_custom_args(raw: &str) -> Vec { #[allow(clippy::too_many_arguments)] pub fn minecraft_arguments( - version_updated: bool, args: Option<&[Argument]>, legacy_args: Option<&str>, access_token: &str, @@ -205,7 +206,6 @@ pub fn minecraft_arguments( if let Some(args) = args { parse_arguments( - version_updated, args, &mut parsed, |arg| { @@ -226,21 +226,52 @@ pub fn minecraft_arguments( )?; } + drop_repeated_arguments(&mut parsed); + Ok(parsed) } +fn drop_repeated_arguments(args: &mut Vec) { + let mut seen = HashSet::new(); + retain_arguments(args, |option, value| { + seen.insert((option.to_owned(), value.map(str::to_owned))) + }); +} + +fn retain_arguments(args: &mut Vec, mut keep: impl FnMut(&str, Option<&str>) -> bool) { + let mut kept = Vec::with_capacity(args.len()); + let mut tokens = std::mem::take(args).into_iter().peekable(); + + while let Some(token) = tokens.next() { + if !token.starts_with("--") { + kept.push(token); + continue; + } + + let value = tokens.next_if(|next| !next.starts_with("--")); + if keep(&token, value.as_deref()) { + kept.push(token); + kept.extend(value); + } + } + + *args = kept; +} + pub fn append_profile_game_arguments( args: &mut Vec, force_fullscreen: Option, - launch_args: Option<&str>, + game_args: Option<&str>, ) { if force_fullscreen.unwrap_or(false) { args.push("--fullscreen".to_string()); } - if let Some(extra) = launch_args.map(str::trim).filter(|s| !s.is_empty()) { - args.push(extra.to_string()); - } + let extra = game_args.map(split_custom_args).unwrap_or_default(); + retain_arguments(args, |option, _| { + option == "--tweakClass" || !extra.iter().any(|arg| arg == option) + }); + args.extend(extra); } pub fn processor_arguments, S: std::hash::BuildHasher>( @@ -251,70 +282,76 @@ pub fn processor_arguments, S: std::hash::BuildHasher>( let mut parsed = Vec::new(); for arg in args { - let a = &arg.as_ref()[1..arg.as_ref().len() - 1]; - if arg.as_ref().starts_with('{') { - if let Some(entry) = data.get(a) { - parsed.push(if entry.client.starts_with('[') { - get_library( - libraries_path, - &entry.client[1..entry.client.len() - 1], - true, - )? - } else { - entry.client.clone() - }); - } - } else if arg.as_ref().starts_with('[') { - parsed.push(get_library(libraries_path, a, true)?); - } else { - parsed.push(arg.as_ref().to_string()); + let arg = arg.as_ref(); + + if let Some(coordinate) = arg.strip_prefix('[').and_then(|a| a.strip_suffix(']')) { + parsed.push(get_library(libraries_path, coordinate, true)?); + continue; } + + parsed.push(expand_data_placeholders(libraries_path, arg, data)?); } Ok(parsed) } -#[tracing::instrument(skip_all, level = "debug")] -pub async fn main_class(path: impl AsRef) -> McResult> { - let data = polyio::read(path.as_ref()).await?; - let mut class_name = None; +fn expand_data_placeholders( + libraries_path: &Path, + arg: &str, + data: &HashMap, +) -> McResult { + let mut out = String::with_capacity(arg.len()); + let mut rest = arg; - use futures_util::TryStreamExt; + while let Some(open) = rest.find('{') { + let Some(close) = rest[open..].find('}').map(|at| open + at) else { + break; + }; - let stream = polyio::stream_zip_entries_bytes(data); - let mut stream = std::pin::pin!(stream); + out.push_str(&rest[..open]); + let key = &rest[open + 1..close]; - while let Some(item) = stream.try_next().await? { - let (index, entry, reader) = item; - if entry.dir().map_err(polyio::IOError::from)? { - continue; - } - - if entry.filename().as_str().map_err(polyio::IOError::from)? != "META-INF/MANIFEST.MF" { - continue; - } - - let mut buf = String::new(); - let mut entry_reader = reader - .reader_without_entry(index) - .await - .map_err(polyio::IOError::from)?; - futures_util::AsyncReadExt::read_to_string(&mut entry_reader, &mut buf) - .await - .map_err(polyio::IOError::from)?; - - for line in buf.lines() { - let line = line.trim(); - if line.starts_with("Main-Class:") - && let Some(class) = line.split(':').nth(1) - { - class_name = Some(class.trim().to_string()); - break; + match data.get(key) { + Some(entry) => { + match entry + .client + .strip_prefix('[') + .and_then(|c| c.strip_suffix(']')) + { + Some(coordinate) => { + out.push_str(&get_library(libraries_path, coordinate, true)?); + } + None => out.push_str(&entry.client), + } } + None => out.push_str(&rest[open..=close]), } + + rest = &rest[close + 1..]; } - Ok(class_name) + out.push_str(rest); + Ok(out) +} + +#[tracing::instrument(skip_all, level = "debug")] +pub async fn main_class(path: impl AsRef) -> McResult> { + let file = tokio::fs::File::open(path.as_ref()).await?; + let manifest = match polyio::try_read_zip_entry_bytes( + tokio::io::BufReader::new(file), + "META-INF/MANIFEST.MF", + ) + .await + { + Err(polyio::IOError::FileNotFoundInZip { .. }) => return Ok(None), + result => result?, + }; + + Ok(String::from_utf8_lossy(&manifest).lines().find_map(|line| { + line.trim() + .strip_prefix("Main-Class:") + .map(|class| class.trim().to_string()) + })) } #[allow(clippy::too_many_arguments)] @@ -338,7 +375,10 @@ pub fn parse_minecraft_argument( .replace("${auth_xuid}", "0") .replace("${auth_uuid}", &uuid.simple().to_string()) .replace("${uuid}", &uuid.simple().to_string()) - .replace("${clientid}", oneclient_common::constants::MICROSOFT_CLIENT_ID) + .replace( + "${clientid}", + oneclient_common::constants::MICROSOFT_CLIENT_ID, + ) .replace("${user_properties}", "{}") .replace("${user_type}", "msa") .replace("${version_name}", version) @@ -349,7 +389,9 @@ pub fn parse_minecraft_argument( ) .replace( "${assets_root}", - &polyio::canonicalize(assets_directory)?.display().to_string(), + &polyio::canonicalize(assets_directory)? + .display() + .to_string(), ) .replace("${game_assets}", &legacy_assets_path()?) .replace("${version_type}", version_type.as_str()) @@ -397,7 +439,7 @@ pub fn classpaths( java_arch: &str, updated: bool, ) -> McResult { - let mut chosen: HashMap, &str)> = HashMap::new(); + let mut chosen: HashMap = HashMap::new(); for lib in libraries { if let Some(rules) = &lib.rules && !validate_rules(rules, java_arch, updated) @@ -413,17 +455,20 @@ pub fn classpaths( } let (artifact, version) = split_artifact_version(&lib.name); - let ver_key = version_key(version); - match chosen.get(&artifact) { - Some((existing, existing_name)) if *existing >= ver_key => { + let bundled = is_legacy_asm_bundle(&artifact); + let slot = classpath_slot(artifact); + let rank = (!bundled, version_key(version)); + + match chosen.get(&slot) { + Some((existing, existing_name)) if *existing >= rank => { tracing::debug!( skipped = %lib.name, kept = %existing_name, - "classpath: dropping older duplicate library" + "classpath: dropping superseded library" ); } _ => { - chosen.insert(artifact, (ver_key, &lib.name)); + chosen.insert(slot, (rank, &lib.name)); } } } @@ -433,11 +478,7 @@ pub fn classpaths( .map(|(_, name)| get_library(libraries_path, name, false)) .collect::, _>>()?; - classpaths.insert( - polyio::canonicalize(client_path)? - .display() - .to_string(), - ); + classpaths.insert(polyio::canonicalize(client_path)?.display().to_string()); tracing::debug!(entries = classpaths.len(), "classpath resolved"); @@ -447,6 +488,25 @@ pub fn classpaths( .join(constants::CLASSPATH_SEPARATOR)) } +type ClasspathRank = (bool, Vec); + +const ASM_SLOT: &str = "org.ow2.asm:asm"; + +fn is_legacy_asm_bundle(artifact: &str) -> bool { + matches!( + artifact, + "org.ow2.asm:asm-all" | "org.ow2.asm:asm-debug-all" | "asm:asm-all" | "asm:asm-debug-all" + ) +} + +fn classpath_slot(artifact: String) -> String { + if is_legacy_asm_bundle(&artifact) { + return ASM_SLOT.to_string(); + } + + artifact +} + fn split_artifact_version(name: &str) -> (String, &str) { match name.rsplit_once(':') { Some((key, version)) if version.starts_with(|c: char| c.is_ascii_digit()) => { @@ -475,25 +535,27 @@ pub fn get_classpath_library>( Ok(classpaths.join(constants::CLASSPATH_SEPARATOR)) } -pub fn get_library( - libraries_path: &Path, - library: &str, - error_exist: bool, -) -> McResult { +pub fn get_library(libraries_path: &Path, library: &str, allow_missing: bool) -> McResult { let mut path = libraries_path.to_path_buf(); - path.push(get_path_from_artifact(library).map_err(|_| { - McError::LibraryPath(library.to_string()) - })?); + path.push( + get_path_from_artifact(library).map_err(|_| McError::LibraryPath(library.to_string()))?, + ); - if !path.exists() && error_exist { - return Ok(path.display().to_string()); + if !path.exists() { + if allow_missing { + return Ok(path.display().to_string()); + } + + return Err(McError::MissingLibrary { + library: library.to_string(), + path: path.display().to_string(), + }); } Ok(polyio::canonicalize(&path)?.display().to_string()) } fn parse_arguments( - version_updated: bool, args: &[Argument], parsed: &mut Vec, parse_function: ParseFn, @@ -511,7 +573,7 @@ where } } Argument::Ruled { rules, value } => { - if validate_rules(rules, java_arch, version_updated) { + if validate_rules(rules, java_arch, true) { match value { ArgumentValue::Single(arg) => { parsed.push(parse_function(&arg.replace(' ', DUMMY_REPLACE_NEWLINE))?); @@ -535,8 +597,9 @@ where #[cfg(test)] mod tests { use super::{ - Library, ZGC_MIN_HEAP_MB, classpaths, is_collector_flag, java_arguments, - minecraft_arguments, performance_flags, split_custom_args, + HashMap, Library, Path, SidedDataEntry, ZGC_MIN_HEAP_MB, append_profile_game_arguments, + classpaths, drop_repeated_arguments, get_library, is_collector_flag, java_arguments, + minecraft_arguments, performance_flags, processor_arguments, split_custom_args, }; use oneclient_common::Resolution; @@ -562,6 +625,32 @@ mod tests { ); } + #[test] + fn game_arguments_are_split_after_fullscreen() { + let mut args = ["--username", "Steve", "--width", "854", "--height", "480"] + .map(String::from) + .to_vec(); + append_profile_game_arguments( + &mut args, + Some(true), + Some("--tracy --tracyNoImages --width 1920 --fullscreen"), + ); + assert_eq!( + args, + vec![ + "--username", + "Steve", + "--height", + "480", + "--tracy", + "--tracyNoImages", + "--width", + "1920", + "--fullscreen" + ] + ); + } + #[test] fn an_empty_quoted_value_survives() { assert_eq!(split_custom_args(r#"-Dempty="""#), vec!["-Dempty="]); @@ -772,34 +861,15 @@ mod tests { fn a_modern_runtime_gets_the_full_set() { assert_eq!( flags(25, "aarch64", 16384), - vec![ - "-Xms512M", - "-XX:+UseZGC", - "-XX:+UseCompactObjectHeaders" - ] + vec!["-Xms512M", "-XX:+UseZGC", "-XX:+UseCompactObjectHeaders"] ); } - #[test] - fn every_tier_starts_small_and_grows_into_the_memory_setting() { - for major in [8, 17, 21, 25] { - assert_eq!( - flags(major, "amd64", 8192).first().unwrap(), - "-Xms512M", - "java {major} should not commit the ceiling up front" - ); - } - } - #[test] fn a_tiny_profile_never_starts_above_its_ceiling() { assert_eq!( flags(21, "amd64", 256), - vec![ - "-Xms256M", - "-XX:+UseG1GC", - "-XX:+ParallelRefProcEnabled", - ], + vec!["-Xms256M", "-XX:+UseG1GC", "-XX:+ParallelRefProcEnabled",], "a start size above the maximum is refused by the JVM outright" ); } @@ -809,6 +879,135 @@ mod tests { assert_eq!(flags(7, "amd64", 4096), vec!["-Xms512M"]); } + fn install_jars(tag: &str, jars: &[&str]) -> (std::path::PathBuf, std::path::PathBuf) { + let dir = std::env::temp_dir().join(tag); + for jar in jars { + let path = dir.join(jar); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"").unwrap(); + } + let client = dir.join("client.jar"); + std::fs::write(&client, b"").unwrap(); + (dir, client) + } + + #[test] + fn a_legacy_asm_bundle_loses_to_the_split_modules() { + let libraries: Vec = serde_json::from_str( + r#"[ + { "name": "org.ow2.asm:asm-all:4.1" }, + { "name": "org.ow2.asm:asm:9.10.1" }, + { "name": "org.ow2.asm:asm-tree:9.10.1" }, + { "name": "net.minecraft:launchwrapper:1.5" } + ]"#, + ) + .unwrap(); + + let (dir, client) = install_jars( + "oneclient-classpath-asm-all", + &[ + "org/ow2/asm/asm-all/4.1/asm-all-4.1.jar", + "org/ow2/asm/asm/9.10.1/asm-9.10.1.jar", + "org/ow2/asm/asm-tree/9.10.1/asm-tree-9.10.1.jar", + "net/minecraft/launchwrapper/1.5/launchwrapper-1.5.jar", + ], + ); + + let cp = classpaths(&dir, &libraries, &client, "x86", false).unwrap(); + + assert!( + !cp.contains("asm-all-4.1.jar"), + "fabric aborts on two copies of org/objectweb/asm/ClassReader.class: {cp}" + ); + assert!(cp.contains("asm-9.10.1.jar"), "{cp}"); + assert!(cp.contains("asm-tree-9.10.1.jar"), "{cp}"); + assert!(cp.contains("launchwrapper-1.5.jar"), "{cp}"); + } + + #[test] + fn a_legacy_asm_bundle_stays_when_nothing_supersedes_it() { + let libraries: Vec = serde_json::from_str( + r#"[ + { "name": "org.ow2.asm:asm-all:4.1" }, + { "name": "net.minecraft:launchwrapper:1.5" } + ]"#, + ) + .unwrap(); + + let (dir, client) = install_jars( + "oneclient-classpath-asm-only", + &[ + "org/ow2/asm/asm-all/4.1/asm-all-4.1.jar", + "net/minecraft/launchwrapper/1.5/launchwrapper-1.5.jar", + ], + ); + + let cp = classpaths(&dir, &libraries, &client, "x86", false).unwrap(); + + assert!(cp.contains("asm-all-4.1.jar"), "{cp}"); + } + + #[test] + fn a_newer_bundle_does_not_outrank_an_older_split_module() { + let libraries: Vec = serde_json::from_str( + r#"[ + { "name": "org.ow2.asm:asm-debug-all:5.2" }, + { "name": "org.ow2.asm:asm:5.0.3" } + ]"#, + ) + .unwrap(); + + let (dir, client) = install_jars( + "oneclient-classpath-asm-debug", + &[ + "org/ow2/asm/asm-debug-all/5.2/asm-debug-all-5.2.jar", + "org/ow2/asm/asm/5.0.3/asm-5.0.3.jar", + ], + ); + + let cp = classpaths(&dir, &libraries, &client, "x86", false).unwrap(); + + assert!(!cp.contains("asm-debug-all"), "{cp}"); + assert!(cp.contains("asm-5.0.3.jar"), "{cp}"); + } + + #[test] + fn a_missing_classpath_jar_names_the_library_it_could_not_find() { + let libraries: Vec = serde_json::from_str( + r#"[ + { "name": "net.fabricmc:fabric-loader:0.19.5" }, + { "name": "net.fabricmc:intermediary:1.8.9" } + ]"#, + ) + .unwrap(); + + let dir = std::env::temp_dir().join("oneclient-classpath-missing"); + let present = dir.join("net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar"); + std::fs::create_dir_all(present.parent().unwrap()).unwrap(); + std::fs::write(&present, b"").unwrap(); + let client = dir.join("client.jar"); + std::fs::write(&client, b"").unwrap(); + + let err = classpaths(&dir, &libraries, &client, "x86", false) + .expect_err("a jar that never downloaded must not reach the jvm as a silent gap"); + let message = err.to_string(); + + assert!( + message.contains("net.fabricmc:intermediary:1.8.9"), + "{message}" + ); + assert!(message.contains("intermediary-1.8.9.jar"), "{message}"); + } + + #[test] + fn a_tolerated_missing_library_keeps_its_expected_path() { + let dir = std::env::temp_dir().join("oneclient-library-tolerated"); + let path = get_library(&dir, "net.fabricmc:intermediary:1.8.9", true) + .expect("allow_missing hands back the path it would have used"); + + assert!(path.ends_with("intermediary-1.8.9.jar"), "{path}"); + } + #[test] fn a_natives_only_library_is_not_a_classpath_entry() { let libraries: Vec = serde_json::from_str( @@ -825,8 +1024,10 @@ mod tests { let dir = std::env::temp_dir().join("oneclient-classpath-test"); let client = dir.join("client.jar"); - for lib in ["net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar", - "org/lwjgl/lwjgl/lwjgl/2.9.4+legacyfabric.15/lwjgl-2.9.4+legacyfabric.15.jar"] { + for lib in [ + "net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar", + "org/lwjgl/lwjgl/lwjgl/2.9.4+legacyfabric.15/lwjgl-2.9.4+legacyfabric.15.jar", + ] { let path = dir.join(lib); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, b"").unwrap(); @@ -850,7 +1051,6 @@ mod tests { let natives = std::env::temp_dir(); let args = java_arguments( - false, Some(&loader_jvm), &natives, &natives, @@ -869,6 +1069,34 @@ mod tests { assert!(args.iter().any(|a| a.starts_with("-Djava.library.path="))); } + #[test] + fn an_osx_ruled_loader_flag_reaches_the_jvm_only_on_macos() { + let loader_jvm: Vec = serde_json::from_str( + r#"["-Dfabric.fixPackageAccess=true", "-Dfabric.gameVersion=1.8.9", + {"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": "-XstartOnFirstThread"}]"#, + ) + .unwrap(); + let natives = std::env::temp_dir(); + + let args = java_arguments( + Some(&loader_jvm), + &natives, + &natives, + "/libs/fabric-loader.jar", + "1.8.9", + 2048, + String::new(), + "aarch64", + 25, + ) + .unwrap(); + + assert_eq!( + args.contains(&"-XstartOnFirstThread".to_string()), + cfg!(target_os = "macos") + ); + } + #[test] fn a_loader_game_argument_does_not_replace_the_legacy_string() { let loader_game: Vec = @@ -876,7 +1104,6 @@ mod tests { let dir = std::env::temp_dir(); let args = minecraft_arguments( - false, Some(&loader_game), Some("--username ${auth_player_name} --gameDir ${game_directory}"), "token", @@ -896,4 +1123,100 @@ mod tests { assert_eq!(args[1], "player"); assert_eq!(args.last().unwrap(), "--fabric"); } + + #[test] + fn a_loader_repeating_the_legacy_string_is_not_passed_twice() { + let loader_game: Vec = serde_json::from_str( + r#"["--username", "${auth_player_name}", "--gameDir", "${game_directory}", + "--tweakClass", "net.minecraftforge.fml.common.launcher.FMLTweaker"]"#, + ) + .unwrap(); + let dir = std::env::temp_dir(); + + let args = minecraft_arguments( + Some(&loader_game), + Some( + "--username ${auth_player_name} --gameDir ${game_directory} --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker", + ), + "token", + "player", + uuid::Uuid::nil(), + "1.11", + "1.11", + &dir, + &dir, + interfrost::api::minecraft::VersionType::Release, + Resolution::default(), + "arm64", + ) + .unwrap(); + + assert_eq!(args.iter().filter(|a| *a == "--gameDir").count(), 1); + assert_eq!(args.iter().filter(|a| *a == "--username").count(), 1); + assert_eq!(args.iter().filter(|a| *a == "--tweakClass").count(), 1); + } + + #[test] + fn an_embedded_placeholder_keeps_its_surrounding_text() { + let mut data = HashMap::new(); + data.insert( + "ROOT".to_string(), + SidedDataEntry { + client: "/meta".to_string(), + server: String::new(), + }, + ); + data.insert( + "BINPATCH".to_string(), + SidedDataEntry { + client: "/meta/libraries/patch.lzma".to_string(), + server: String::new(), + }, + ); + + let args = [ + "--task", + "PROCESS_MINECRAFT_JAR", + "--extract-libraries-to", + "{ROOT}/libraries/", + "--apply-patches", + "{BINPATCH}", + ]; + + let parsed = processor_arguments(Path::new("/libs"), &args, &data).unwrap(); + + assert_eq!(parsed.len(), args.len()); + assert_eq!(parsed[3], "/meta/libraries/"); + assert_eq!(parsed[4], "--apply-patches"); + assert_eq!(parsed[5], "/meta/libraries/patch.lzma"); + } + + #[test] + fn an_unknown_placeholder_is_left_alone_rather_than_dropped() { + let data: HashMap = HashMap::new(); + let args = ["--flag", "{NOPE}", "--after"]; + + let parsed = processor_arguments(Path::new("/libs"), &args, &data).unwrap(); + + assert_eq!(parsed, vec!["--flag", "{NOPE}", "--after"]); + } + + #[test] + fn distinct_tweakers_both_survive() { + let mut args = vec![ + "--tweakClass".to_string(), + "forge.FMLTweaker".to_string(), + "--tweakClass".to_string(), + "optifine.OptiFineTweaker".to_string(), + "--gameDir".to_string(), + "/games".to_string(), + "--gameDir".to_string(), + "/games".to_string(), + ]; + + drop_repeated_arguments(&mut args); + + assert_eq!(args.iter().filter(|a| *a == "--tweakClass").count(), 2); + assert_eq!(args.iter().filter(|a| *a == "--gameDir").count(), 1); + } } diff --git a/packages/oneclient_mc/src/error.rs b/packages/oneclient_mc/src/error.rs index 5c860db4..598bf54f 100644 --- a/packages/oneclient_mc/src/error.rs +++ b/packages/oneclient_mc/src/error.rs @@ -5,85 +5,90 @@ pub type McResult = Result; #[derive(Debug, Error)] pub enum McError { - #[error("no client download exists for version {0}")] - NoClientDownload(String), + #[error("no client download exists for version {0}")] + NoClientDownload(String), - #[error("failed to resolve library artifact path for {0}")] - LibraryPath(String), + #[error("failed to resolve library artifact path for {0}")] + LibraryPath(String), - #[error("invalid game version {0}")] - InvalidVersion(String), + #[error( + "library {library} is missing from the install; the game cannot start without it (expected at {path})" + )] + MissingLibrary { library: String, path: String }, - #[error("failed to fetch metadata")] - FetchError, + #[error("invalid game version {0}")] + InvalidVersion(String), - #[error("loader {0} does not use a modded manifest")] - NotModdedManifest(GameLoader), + #[error("failed to fetch metadata")] + FetchError, - #[error("loader {0} does not use a vanilla manifest")] - NotVanillaManifest(GameLoader), + #[error("loader {0} does not use a modded manifest")] + NotModdedManifest(GameLoader), - #[error("no matching loader found")] - NoMatchingLoader, + #[error("loader {0} does not use a vanilla manifest")] + NotVanillaManifest(GameLoader), - #[error("requested loader version '{requested}' was not found")] - RequestedLoaderVersionNotFound { requested: String }, + #[error("no matching loader found")] + NoMatchingLoader, - #[error("no matching version found")] - NoMatchingVersion, + #[error("requested loader version '{requested}' was not found")] + RequestedLoaderVersionNotFound { requested: String }, - #[error( - "{version} ships no native libraries for {platform}; this platform is not supported \ + #[error("{loader} has no build for Minecraft {version}")] + NoMatchingVersion { loader: GameLoader, version: String }, + + #[error( + "{version} ships no native libraries for {platform}; this platform is not supported \ by this version" - )] - NoNativesForPlatform { version: String, platform: String }, + )] + NoNativesForPlatform { version: String, platform: String }, - #[error("cancelled: {failed} file(s) could not be downloaded")] - IncompleteInstallCancelled { failed: usize }, + #[error("cancelled: {failed} file(s) could not be downloaded")] + IncompleteInstallCancelled { failed: usize }, - /// A Mojang response was well-formed JSON but not what the API documents - #[error("minecraft: {0}")] - Minecraft(String), + /// A Mojang response was well-formed JSON but not what the API documents + #[error("minecraft: {0}")] + Minecraft(String), - #[error("failed to parse metadata: {0}")] - ParseError(#[from] serde_json::Error), + #[error("failed to parse metadata: {0}")] + ParseError(#[from] serde_json::Error), - #[error(transparent)] - Request(#[from] oneclient_net::RequestError), + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), - #[error(transparent)] - Io(#[from] polyio::IOError), + #[error(transparent)] + Io(#[from] polyio::IOError), - #[error(transparent)] - StdIo(#[from] std::io::Error), + #[error(transparent)] + StdIo(#[from] std::io::Error), - #[error(transparent)] - Paths(#[from] oneclient_common::PathsError), + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), - #[error("Invalid URL: {0}")] - Url(#[from] url::ParseError), + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), } impl McError { - /// Environment fault rather than a launcher bug drives Sentry policy upstream - #[must_use] - pub fn is_transient(&self) -> bool { - match self { - Self::Request(source) => source.is_transient(), - Self::Io(polyio::IOError::IOError(source)) - | Self::Io(polyio::IOError::PathIOError { source, .. }) - | Self::StdIo(source) => { - use std::io::ErrorKind; - matches!( - source.kind(), - ErrorKind::ConnectionRefused - | ErrorKind::ConnectionReset - | ErrorKind::ConnectionAborted - | ErrorKind::NotConnected - | ErrorKind::TimedOut - ) - } - _ => false, - } - } + /// Environment fault rather than a launcher bug drives Sentry policy upstream + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + Self::Request(source) => source.is_transient(), + Self::Io(polyio::IOError::IOError(source)) + | Self::Io(polyio::IOError::PathIOError { source, .. }) + | Self::StdIo(source) => { + use std::io::ErrorKind; + matches!( + source.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) + } + _ => false, + } + } } diff --git a/packages/oneclient_mc/src/install.rs b/packages/oneclient_mc/src/install.rs index 6f3bf560..e9bcfe81 100644 --- a/packages/oneclient_mc/src/install.rs +++ b/packages/oneclient_mc/src/install.rs @@ -1,4 +1,6 @@ +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; +use std::sync::Mutex; use futures_util::{StreamExt, stream}; use interfrost::api::minecraft::{ @@ -10,13 +12,13 @@ use reqwest::Method; use crate::download::{download_to_path, fetch_bytes_verified}; use crate::rules::validate_rules; -use crate::manifest::MetadataStore; -use oneclient_common::os_ext::OsExt; -use oneclient_common::domain::GameLoader; -use oneclient_events::{Choice, GroupedProgressSession, Prompt, TaskCategory, TaskPhase}; -use oneclient_common::paths; use crate::McCtx; use crate::error::{McError, McResult}; +use crate::manifest::{MetadataStore, entry_matches_version, manifest_supports_version}; +use oneclient_common::domain::GameLoader; +use oneclient_common::os_ext::OsExt; +use oneclient_common::paths; +use oneclient_events::{Choice, GroupedProgressSession, Prompt, TaskCategory, TaskPhase}; /// Asset objects are tiny (median ~10 KiB) and latency-bound so throughput /// scales with how many are in flight not with bandwidth @@ -24,6 +26,12 @@ const ASSET_DOWNLOAD_CONCURRENCY: usize = 32; /// Libraries are larger and fewer less fan-out is needed to saturate the link const LIBRARY_DOWNLOAD_CONCURRENCY: usize = 16; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativesMode { + Extract, + LeaveInPlace, +} + #[derive(Debug, Clone)] pub struct PlannedLibrary { pub library: Library, @@ -165,6 +173,7 @@ pub async fn plan_downloads( java_arch: &str, minecraft_updated: bool, force: bool, + natives_mode: NativesMode, ) -> McResult { let legacy = uses_legacy_assets(&version.assets); let asset_dir = if legacy { @@ -225,7 +234,12 @@ pub async fn plan_downloads( &lib_dir.join(&artifact_path), library_artifact_size(&lib), )); - let natives = force || !natives_extracted(&natives_dir, &lib, &java_arch); + let natives = match natives_mode { + NativesMode::Extract => { + force || !natives_extracted(&natives_dir, &lib, &java_arch) + } + NativesMode::LeaveInPlace => false, + }; if !artifact && !natives { continue; @@ -267,6 +281,7 @@ pub struct VerifyReport { /// Mismatched the manifest and were removed pub corrupt: usize, pub missing: usize, + pub natives_unextracted: usize, } impl VerifyReport { @@ -291,6 +306,7 @@ fn arch_segment(java_arch: &str) -> &'static str { pub struct NativesReport { pub repairable: Vec, pub unsupported: Vec, + pub unextracted: Vec, } impl NativesReport { @@ -315,10 +331,7 @@ async fn inspect_jar(path: &Path, java_arch: &str) -> JarVerdict { } }; - let natives: Vec<&String> = names - .iter() - .filter(|name| is_native_file(name)) - .collect(); + let natives: Vec<&String> = names.iter().filter(|name| is_native_file(name)).collect(); if natives.is_empty() { return JarVerdict::WrongArch; @@ -378,9 +391,10 @@ pub async fn check_natives( // One scan for the whole sweep the directory is the same for every library let unpacked = any_native_unpacked(&natives_dir).await; - let mut families: Vec<(String, Vec)> = Vec::new(); + let mut families: Vec<(String, Vec<(JarVerdict, bool)>)> = Vec::new(); for lib in host_natives { - let verdict = if native_download(lib, java_arch).is_some() { + let extracted = native_download(lib, java_arch).is_some(); + let verdict = if extracted { if unpacked && natives_extracted(&natives_dir, lib, java_arch) { JarVerdict::Usable } else { @@ -395,17 +409,23 @@ pub async fn check_natives( let family = natives_family(lib); match families.iter_mut().find(|(name, _)| name == &family) { - Some((_, verdicts)) => verdicts.push(verdict), - None => families.push((family, vec![verdict])), + Some((_, verdicts)) => verdicts.push((verdict, extracted)), + None => families.push((family, vec![(verdict, extracted)])), } } let mut report = NativesReport::default(); for (family, verdicts) in families { - if verdicts.iter().any(|v| matches!(v, JarVerdict::Usable)) { + if verdicts.iter().any(|(v, _)| matches!(v, JarVerdict::Usable)) { continue; } - if verdicts.iter().any(|v| matches!(v, JarVerdict::Unreadable)) { + if verdicts.iter().any(|(v, _)| matches!(v, JarVerdict::Unreadable)) { + if verdicts + .iter() + .any(|(v, extracted)| *extracted && matches!(v, JarVerdict::Unreadable)) + { + report.unextracted.push(family.clone()); + } report.repairable.push(family); } else { report.unsupported.push(family); @@ -503,7 +523,9 @@ pub async fn verify_game_files( } match polyio::sha1_file_sync(path) { - Ok(actual) if polyio::normalize_hash(&actual) == polyio::normalize_hash(expected) => { + Ok(actual) + if polyio::normalize_hash(&actual) == polyio::normalize_hash(expected) => + { report.checked += 1; } Ok(actual) => { @@ -553,6 +575,7 @@ pub async fn verify_game_files( Err(err) => return Err(err), }; report.missing += natives.repairable.len(); + report.natives_unextracted = natives.unextracted.len(); report.checked += natives.repairable.len() + natives.unsupported.len(); tracing::info!( @@ -576,16 +599,26 @@ pub async fn download_minecraft( java_arch: &str, minecraft_updated: bool, force: bool, + natives_mode: NativesMode, ) -> McResult<()> { let started = std::time::Instant::now(); let asset_index = download_assets_index(ctx, progress, version, force).await?; - let plan = plan_downloads(version, asset_index, java_arch, minecraft_updated, force).await?; + let plan = plan_downloads( + version, + asset_index, + java_arch, + minecraft_updated, + force, + natives_mode, + ) + .await?; tracing::info!( version = %version.id, assets = plan.assets.len(), libraries = plan.libraries.len(), bytes = plan.total_bytes(), + ?natives_mode, "planned minecraft download" ); @@ -601,27 +634,20 @@ pub async fn download_minecraft( plan.libraries.len() as u64, plan.library_bytes, ); - progress.expect(TaskCategory::Client, u64::from(plan.client), plan.client_bytes); + progress.expect( + TaskCategory::Client, + u64::from(plan.client), + plan.client_bytes, + ); let DownloadPlan { assets, libraries, .. } = plan; let (failed_assets, _client, failed_libraries) = tokio::try_join!( - download_assets( - ctx, - progress, - uses_legacy_assets(&version.assets), - assets, - ), + download_assets(ctx, progress, uses_legacy_assets(&version.assets), assets,), download_client(ctx, progress, version, force), - download_libraries( - ctx, - progress, - version.id.clone(), - libraries, - java_arch, - ), + download_libraries(ctx, progress, version.id.clone(), libraries, java_arch,), )?; confirm_incomplete_install(ctx, failed_assets, failed_libraries).await?; @@ -708,11 +734,14 @@ pub async fn confirm_incomplete_install( } } +static VERSION_INFO_REFRESHED: Mutex> = Mutex::new(BTreeSet::new()); + #[tracing::instrument(skip(ctx, progress), level = "debug")] pub async fn download_version_info( ctx: &McCtx, progress: Option<&GroupedProgressSession>, version: &Version, + game_loader: GameLoader, loader: Option<&LoaderVersion>, force: bool, ) -> McResult { @@ -720,22 +749,77 @@ pub async fn download_version_info( .map(|it| format!("{}-{}", version.id, it.id)) .unwrap_or_else(|| version.id.clone()); - let path = paths::versions_dir()? - .join(&version_id) - .join(format!("{version_id}.json")); + let dir = paths::versions_dir()?.join(&version_id); + let format_version = game_loader.get_format_version(); + let stem = match loader { + Some(_) => format!("{version_id}.{}", game_loader.get_format_name()), + None => version_id.clone(), + }; + let path = dir.join(version_info_file_name(&stem, format_version)); - if path.exists() && !force { - match polyio::read_json::(&path).await { - Ok(cached) => return Ok(cached), - Err(err) => { - tracing::warn!( - path = %path.display(), - "cached version metadata is unusable, redownloading: {err}" - ); - } + let cached = if force { + None + } else { + let mut cached = read_cached_version_info(&path).await; + if cached.is_none() && loader.is_some() { + let legacy = version_info_file_name(&version_id, format_version); + cached = read_cached_version_info(&dir.join(legacy)) + .await + .filter(|info| profile_matches_loader(&info.libraries, game_loader)); } + cached + }; + + if let Some(cached) = cached { + if loader.is_some() + && oneclient_net::status::current().online + && VERSION_INFO_REFRESHED.lock().unwrap().insert(path.clone()) + { + let ctx = ctx.clone(); + let version = version.clone(); + let loader = loader.cloned(); + let installed = processor_inputs(&cached); + tokio::spawn(async move { + let refresh = async { + let info = + fetch_version_info(&ctx, None, &version, loader.as_ref(), &version_id) + .await?; + if processor_inputs(&info) != installed { + tracing::warn!( + version_id = %version_id, + "loader processors changed upstream, keeping cached profile until reinstall" + ); + return Ok(()); + } + polyio::write_json_atomic(&path, &info).await?; + McResult::Ok(()) + }; + if let Err(err) = refresh.await { + tracing::warn!( + version_id = %version_id, + "could not refresh version metadata, keeping cached copy: {err}" + ); + } + }); + } + return Ok(cached); } + let info = fetch_version_info(ctx, progress, version, loader, &version_id).await?; + polyio::write_json_atomic(&path, &info).await?; + if loader.is_some() { + VERSION_INFO_REFRESHED.lock().unwrap().insert(path); + } + Ok(info) +} + +async fn fetch_version_info( + ctx: &McCtx, + progress: Option<&GroupedProgressSession>, + version: &Version, + loader: Option<&LoaderVersion>, + version_id: &str, +) -> McResult { tracing::debug!( version_id = %version_id, "downloading Minecraft version metadata" @@ -751,17 +835,18 @@ pub async fn download_version_info( 1, TaskCategory::Metadata, |child| { - let requester = requester.clone(); - async move { - child.set_progress(0, Some(1)); - let result = requester - .send_json(Method::GET, version_url, None, &[]) - .await - .map_err(McError::from)?; - child.set_progress(1, Some(1)); - Ok::(result) - } - }) + let requester = requester.clone(); + async move { + child.set_progress(0, Some(1)); + let result = requester + .send_json(Method::GET, version_url, None, &[]) + .await + .map_err(McError::from)?; + child.set_progress(1, Some(1)); + Ok::(result) + } + }, + ) .await? } None => requester @@ -781,19 +866,18 @@ pub async fn download_version_info( 1, TaskCategory::Metadata, |child| { - let requester = requester.clone(); - async move { - child.set_progress(0, Some(1)); - let result = requester - .send_json(Method::GET, loader_url, None, &[]) - .await - .map_err(McError::from)?; - child.set_progress(1, Some(1)); - Ok::( - result, - ) - } - }) + let requester = requester.clone(); + async move { + child.set_progress(0, Some(1)); + let result = requester + .send_json(Method::GET, loader_url, None, &[]) + .await + .map_err(McError::from)?; + child.set_progress(1, Some(1)); + Ok::(result) + } + }, + ) .await? } None => requester @@ -813,9 +897,7 @@ pub async fn download_version_info( } } - info.id.clone_from(&version_id); - - polyio::write_json_atomic(&path, &info).await?; + info.id = version_id.to_owned(); Ok(info) } @@ -1314,12 +1396,12 @@ pub async fn get_loader_versions( } let manifest = metadata.get_modded_or_fetch(ctx, loader).await?; + if !manifest_supports_version(manifest, mc_version) { + return Ok(Vec::new()); + } + for entry in &manifest.game_versions { - let id = entry - .id - .replace("${interpulse.gameVersion}", mc_version) - .replace(interfrost::api::modded::DUMMY_REPLACE_STRING, mc_version); - if id == mc_version { + if entry_matches_version(&entry.id, mc_version) && !entry.loaders.is_empty() { return Ok(entry.loaders.iter().map(|l| l.id.clone()).collect()); } } @@ -1331,15 +1413,14 @@ fn resolve_loader_from_manifest( mc_version: &str, loader_version: Option<&str>, ) -> (bool, Option) { + if !manifest_supports_version(manifest, mc_version) { + return (false, None); + } + let mut saw_matching_game_version = false; for entry in &manifest.game_versions { - if entry - .id - .replace("${interpulse.gameVersion}", mc_version) - .replace(interfrost::api::modded::DUMMY_REPLACE_STRING, mc_version) - != mc_version - { + if !entry_matches_version(&entry.id, mc_version) { continue; } @@ -1396,10 +1477,9 @@ pub async fn get_loader_version( return Ok(None); } - let resolve_from_manifest = - |manifest: &interfrost::api::modded::Manifest| { - resolve_loader_from_manifest(manifest, mc_version, loader_version) - }; + let resolve_from_manifest = |manifest: &interfrost::api::modded::Manifest| { + resolve_loader_from_manifest(manifest, mc_version, loader_version) + }; let mut manifest = metadata.get_modded_or_fetch(ctx, loader).await?; let (mut saw_matching, mut resolved) = resolve_from_manifest(manifest); @@ -1416,9 +1496,14 @@ pub async fn get_loader_version( } } + let no_matching_version = || McError::NoMatchingVersion { + loader, + version: mc_version.to_string(), + }; + if let Some(requested) = loader_version { if !saw_matching { - return Err(McError::NoMatchingVersion); + return Err(no_matching_version()); } return Err(McError::RequestedLoaderVersionNotFound { requested: requested.to_string(), @@ -1428,7 +1513,7 @@ pub async fn get_loader_version( if saw_matching { Err(McError::NoMatchingLoader) } else { - Err(McError::NoMatchingVersion) + Err(no_matching_version()) } } @@ -1467,6 +1552,61 @@ pub async fn get_game_versions( Ok(manifest.versions.clone()) } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum GameVersionKind { + Release, + Snapshot, + Beta, + Alpha, +} + +impl GameVersionKind { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Release => "Release", + Self::Snapshot => "Snapshot", + Self::Beta => "Beta", + Self::Alpha => "Alpha", + } + } + + #[must_use] + pub const fn is_release(self) -> bool { + matches!(self, Self::Release) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GameVersionInfo { + pub id: String, + pub kind: GameVersionKind, + pub released: chrono::DateTime, +} + +#[tracing::instrument(skip(metadata, ctx), level = "debug")] +pub async fn get_version_ids( + metadata: &mut MetadataStore, + ctx: &McCtx, +) -> McResult> { + use interfrost::api::minecraft::VersionType; + + Ok(get_game_versions(metadata, ctx) + .await? + .into_iter() + .map(|version| GameVersionInfo { + id: version.id, + kind: match version.type_ { + VersionType::Release => GameVersionKind::Release, + VersionType::Snapshot => GameVersionKind::Snapshot, + VersionType::OldBeta => GameVersionKind::Beta, + VersionType::OldAlpha => GameVersionKind::Alpha, + }, + released: version.release_time, + }) + .collect()) +} + #[tracing::instrument(skip(metadata, ctx), level = "debug")] pub async fn get_loaders_for_version( metadata: &mut MetadataStore, @@ -1476,20 +1616,161 @@ pub async fn get_loaders_for_version( metadata.get_loaders_for_version(ctx, mc_version).await } +#[tracing::instrument(skip(metadata, ctx), level = "debug")] +pub async fn get_versions_for_loader( + metadata: &mut MetadataStore, + ctx: &McCtx, + loader: GameLoader, +) -> McResult>> { + metadata.get_versions_for_loader(ctx, loader).await +} + #[must_use] pub fn is_version_updated(version_index: usize, versions: &[Version]) -> bool { version_index <= versions.iter().position(|x| x.id == "22w16a").unwrap_or(0) } +async fn read_cached_version_info(path: &Path) -> Option { + if !path.exists() { + return None; + } + + polyio::read_json::(path) + .await + .inspect_err(|err| { + tracing::warn!( + path = %path.display(), + "cached version metadata is unusable, redownloading: {err}" + ); + }) + .ok() +} + +fn processor_inputs(info: &VersionInfo) -> Option { + let processors = info.processors.as_deref().unwrap_or_default(); + let refs = processors + .iter() + .flat_map(|it| &it.args) + .chain(info.data.iter().flatten().flat_map(|(_, it)| [&it.client, &it.server])) + .filter_map(|it| it.strip_prefix('[')?.strip_suffix(']')); + let named: BTreeSet<&str> = processors + .iter() + .flat_map(|it| std::iter::once(&it.jar).chain(&it.classpath)) + .map(String::as_str) + .chain(refs) + .collect(); + let libraries: Vec<&Library> = info + .libraries + .iter() + .filter(|lib| named.contains(lib.name.as_str())) + .collect(); + serde_json::to_value((&info.processors, &info.data, libraries)).ok() +} + +fn profile_matches_loader(libraries: &[Library], loader: GameLoader) -> bool { + let has = |group: &str| libraries.iter().any(|lib| lib.name.starts_with(group)); + match loader { + GameLoader::Forge => !has("net.neoforged"), + GameLoader::NeoForge => has("net.neoforged"), + GameLoader::Fabric => !has("org.quiltmc") && !has("net.ornithemc"), + GameLoader::Quilt => has("org.quiltmc") && !has("net.ornithemc"), + GameLoader::Ornithe => has("net.ornithemc"), + GameLoader::Vanilla => true, + } +} + +fn version_info_file_name(stem: &str, format_version: usize) -> String { + if format_version == 0 { + format!("{stem}.json") + } else { + format!("{stem}.v{format_version}.json") + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_format_bump_moves_the_cached_version_info() { + assert_eq!(version_info_file_name("1.8.9", 0), "1.8.9.json"); + assert_eq!( + version_info_file_name("1.8.9-0.19.5", 1), + "1.8.9-0.19.5.v1.json" + ); + } + + #[test] + fn a_shared_legacy_profile_only_stands_in_for_its_own_loader() { + let libs = |names: &[&str]| -> Vec { + names + .iter() + .map(|name| serde_json::from_value(serde_json::json!({ "name": name })).unwrap()) + .collect() + }; + let forge = libs(&["net.minecraftforge:forge:1.20.1-47.1.106"]); + let neo = libs(&[ + "net.minecraftforge:eventbus:6.0.5", + "net.neoforged:forge:1.20.1-47.1.106", + ]); + let fabric = libs(&["net.fabricmc:fabric-loader:0.16.0"]); + let quilt = libs(&[ + "net.fabricmc:intermediary:1.20.1", + "org.quiltmc:quilt-loader:0.26.0", + ]); + + assert!(profile_matches_loader(&forge, GameLoader::Forge)); + assert!(!profile_matches_loader(&forge, GameLoader::NeoForge)); + assert!(profile_matches_loader(&neo, GameLoader::NeoForge)); + assert!(!profile_matches_loader(&neo, GameLoader::Forge)); + assert!(profile_matches_loader(&fabric, GameLoader::Fabric)); + assert!(!profile_matches_loader(&fabric, GameLoader::Quilt)); + assert!(profile_matches_loader(&quilt, GameLoader::Quilt)); + assert!(!profile_matches_loader(&quilt, GameLoader::Fabric)); + + let ornithe = libs(&[ + "net.fabricmc:fabric-loader:0.16.5", + "net.ornithemc:calamus-intermediary:1.14.4", + ]); + assert!(profile_matches_loader(&ornithe, GameLoader::Ornithe)); + assert!(!profile_matches_loader(&ornithe, GameLoader::Fabric)); + assert!(!profile_matches_loader(&fabric, GameLoader::Ornithe)); + } + + #[test] + fn a_changed_processor_library_counts_as_changed_input() { + let info = |installer: &str, lwjgl: &str| -> VersionInfo { + serde_json::from_value(serde_json::json!({ + "assetIndex": { "id": "1", "sha1": "", "size": 0, "totalSize": 0, "url": "" }, + "assets": "1", + "downloads": {}, + "id": "1.20.1-47.1.106", + "libraries": [ + { "name": "net.minecraftforge:installertools:1.3.0", "url": installer }, + { "name": "de.oceanlabs.mcp:mcp_config:1.20.1@zip", "url": installer }, + { "name": "org.lwjgl:lwjgl:3.3.1", "url": lwjgl }, + ], + "mainClass": "cpw.mods.bootstraplauncher.BootstrapLauncher", + "minimumLauncherVersion": 0, + "releaseTime": "2023-06-12T00:00:00Z", + "time": "2023-06-12T00:00:00Z", + "type": "release", + "data": { "MCP": { "client": "[de.oceanlabs.mcp:mcp_config:1.20.1@zip]", "server": "" } }, + "processors": [ + { "jar": "net.minecraftforge:installertools:1.3.0", "classpath": [], "args": ["{MCP}"] }, + ], + })) + .unwrap() + }; + + let installed = processor_inputs(&info("a", "a")); + assert_eq!(processor_inputs(&info("a", "b")), installed); + assert_ne!(processor_inputs(&info("b", "a")), installed); + } + fn scratch(tag: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!( - "oneclient-install-{tag}-{}", - uuid::Uuid::new_v4() - )); + let dir = + std::env::temp_dir().join(format!("oneclient-install-{tag}-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); dir } @@ -1567,6 +1848,51 @@ mod tests { assert!(!saw && resolved.is_none()); } + #[test] + fn a_wildcard_only_supplies_loaders_for_versions_the_manifest_lists() { + let manifest = serde_json::from_str::( + r#"{"gameVersions": [ + { "id": "${interfrost.gameVersion}", "stable": true, "loaders": [ + { "id": "0.19.5", "url": "https://meta.example/0.19.5.json", "stable": true }, + { "id": "0.19.4", "url": "https://meta.example/0.19.4.json", "stable": false } + ]}, + { "id": "1.21.1", "stable": true, "loaders": [] }, + { "id": "1.14", "stable": true, "loaders": [] } + ]}"#, + ) + .unwrap(); + + let (saw, resolved) = resolve_loader_from_manifest(&manifest, "1.21.1", None); + assert!(saw); + assert_eq!(resolved.unwrap().id, "0.19.5"); + + let (saw, resolved) = resolve_loader_from_manifest(&manifest, "1.14", Some("0.19.4")); + assert!(saw); + assert_eq!(resolved.unwrap().id, "0.19.4"); + + let (saw, resolved) = resolve_loader_from_manifest(&manifest, "1.8.9", None); + assert!( + !saw && resolved.is_none(), + "fabric publishes no intermediary below 1.14 so the wildcard must not claim 1.8.9" + ); + } + + #[test] + fn a_manifest_that_is_only_a_wildcard_still_covers_every_version() { + let manifest = serde_json::from_str::( + r#"{"gameVersions": [ + { "id": "${interfrost.gameVersion}", "stable": true, "loaders": [ + { "id": "0.19.5", "url": "https://meta.example/0.19.5.json", "stable": true } + ]} + ]}"#, + ) + .unwrap(); + + let (saw, resolved) = resolve_loader_from_manifest(&manifest, "1.8.9", None); + assert!(saw); + assert_eq!(resolved.unwrap().id, "0.19.5"); + } + #[test] fn a_complete_assets_tree_needs_no_repair() { let dir = scratch("assets-ok"); @@ -1578,10 +1904,10 @@ mod tests { } #[test] - fn a_deleted_assets_directory_asks_for_a_repair() { - let dir = scratch("assets-deleted"); + fn a_deleted_objects_directory_asks_for_a_repair() { + let dir = scratch("assets-no-objects"); let (index, objects) = install_assets(&dir); - std::fs::remove_dir_all(dir.join("assets")).unwrap(); + std::fs::remove_dir_all(&objects).unwrap(); assert!(assets_tree_missing(&index, &objects)); @@ -1646,7 +1972,10 @@ mod tests { /// The bus is drained by the test so `ask` has somebody to talk to no /// request is ever sent through the client - fn test_ctx() -> (McCtx, tokio::sync::mpsc::UnboundedReceiver) { + fn test_ctx() -> ( + McCtx, + tokio::sync::mpsc::UnboundedReceiver, + ) { let (events, rx) = oneclient_events::EventBus::channel(); let net = oneclient_net::RequestClient::new(oneclient_net::NetConfig::default()) .expect("a client"); @@ -1673,9 +2002,9 @@ mod tests { let asking = tokio::spawn(async move { confirm_incomplete_install(&ctx, 3, 0).await }); - let Some(oneclient_events::Event::Notification( - oneclient_events::Notification::Prompt(request), - )) = rx.recv().await + let Some(oneclient_events::Event::Notification(oneclient_events::Notification::Prompt( + request, + ))) = rx.recv().await else { panic!("expected a prompt"); }; @@ -1694,17 +2023,13 @@ mod tests { let asking = tokio::spawn(async move { confirm_incomplete_install(&ctx, 0, 2).await }); - let Some(oneclient_events::Event::Notification( - oneclient_events::Notification::Prompt(request), - )) = rx.recv().await + let Some(oneclient_events::Event::Notification(oneclient_events::Notification::Prompt( + request, + ))) = rx.recv().await else { panic!("expected a prompt"); }; - assert!( - request.body.contains("fail to start"), - "{}", - request.body - ); + assert!(request.body.contains("fail to start"), "{}", request.body); request.reply.send(None).unwrap(); let err = asking.await.unwrap().expect_err("dismissal must cancel"); diff --git a/packages/oneclient_mc/src/lib.rs b/packages/oneclient_mc/src/lib.rs index 69d04563..25ac2f1e 100644 --- a/packages/oneclient_mc/src/lib.rs +++ b/packages/oneclient_mc/src/lib.rs @@ -6,15 +6,15 @@ use oneclient_net::RequestClient; #[derive(Clone)] pub struct McCtx { - pub net: RequestClient, - pub events: EventBus, + pub net: RequestClient, + pub events: EventBus, } impl McCtx { - #[must_use] - pub fn new(net: RequestClient, events: EventBus) -> Self { - Self { net, events } - } + #[must_use] + pub fn new(net: RequestClient, events: EventBus) -> Self { + Self { net, events } + } } mod arguments; @@ -31,7 +31,7 @@ pub use error::{McError, McResult}; pub use install::*; pub use manifest::MetadataStore; pub use profile::{ - MojangCape, MojangFullPlayerProfile, MojangPlayerProfile, MojangSkin, PlayerProfileView, - SkinVariant, fetch_logged_in_profile, fetch_player_profile, fetch_player_profile_view, + MojangCape, MojangFullPlayerProfile, MojangPlayerProfile, MojangSkin, PlayerProfileView, + SkinVariant, fetch_logged_in_profile, fetch_player_profile, fetch_player_profile_view, }; pub use rules::validate_rules; diff --git a/packages/oneclient_mc/src/manifest.rs b/packages/oneclient_mc/src/manifest.rs index 5a8a1009..cebf3399 100644 --- a/packages/oneclient_mc/src/manifest.rs +++ b/packages/oneclient_mc/src/manifest.rs @@ -6,21 +6,22 @@ use interfrost::api::modded::Manifest as ModdedManifest; use reqwest::Method; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use tokio::sync::oneshot; - -use oneclient_common::domain::GameLoader; -use oneclient_common::paths; use crate::McCtx; use crate::error::{McError, McResult}; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; #[derive(Debug, Default)] pub struct MetadataStore { initialized: bool, inner: MetadataInner, version_loader_cache: HashMap>, + pending: Option>, } -#[derive(Debug, Default, Serialize, Deserialize)] +#[derive(Debug, Default, Clone, Serialize, Deserialize)] struct MetadataInner { minecraft: Option, forge: Option, @@ -42,13 +43,8 @@ impl MetadataStore { } #[tracing::instrument(level = "debug", skip(self, ctx))] - pub async fn get_vanilla_or_fetch( - &mut self, - ctx: &McCtx, - ) -> McResult<&VanillaManifest> { - if !self.initialized() { - self.initialize(ctx).await?; - } + pub async fn get_vanilla_or_fetch(&mut self, ctx: &McCtx) -> McResult<&VanillaManifest> { + self.ensure_initialized(ctx).await?; if self.inner.minecraft.is_none() { self.refetch_errored(ctx).await; @@ -67,9 +63,7 @@ impl MetadataStore { return Err(McError::NotModdedManifest(loader)); } - if !self.initialized() { - self.initialize(ctx).await?; - } + self.ensure_initialized(ctx).await?; self.get_modded(loader) } @@ -100,16 +94,40 @@ impl MetadataStore { #[tracing::instrument(skip_all)] pub async fn initialize(&mut self, ctx: &McCtx) -> McResult<()> { let path = paths::caches_dir()?.join("metadata.json"); - let mut save_file = false; let mut metadata = Self::default(); match polyio::read_json::(&path).await { Ok(inner) => { metadata.inner = inner; - if metadata.refetch_errored(ctx).await > 0 { - save_file = true; + polyio::write_json_atomic(&path, &metadata.inner).await?; } + + let (tx, rx) = oneshot::channel(); + metadata.pending = Some(rx); + let ctx = ctx.clone(); + let mut saved = metadata.inner.clone(); + tokio::spawn(async move { + let mut status = oneclient_net::status::subscribe(); + let fresh = loop { + if status.wait_for(|status| status.online).await.is_err() { + return; + } + let mut fresh = MetadataInner::default(); + fresh.fetch_all(&ctx).await; + if fresh.has_any() { + break fresh; + } + if status.wait_for(|status| !status.online).await.is_err() { + return; + } + }; + saved.merge(fresh.clone()); + if let Err(err) = polyio::write_json_atomic(&path, &saved).await { + tracing::warn!("failed to save refreshed metadata manifest: {err}"); + } + let _ = tx.send(fresh); + }); } Err(err) => { if path.exists() { @@ -118,31 +136,47 @@ impl MetadataStore { "cached metadata manifest is unusable, refetching: {err}" ); } - - metadata.fetch_all(ctx).await; - save_file = true; + metadata.inner.fetch_all(ctx).await; + polyio::write_json_atomic(&path, &metadata.inner).await?; } } - if save_file { - polyio::write_json_atomic(&path, &metadata.inner).await?; - } - *self = metadata; self.initialized = true; Ok(()) } + async fn ensure_initialized(&mut self, ctx: &McCtx) -> McResult<()> { + if !self.initialized() { + self.initialize(ctx).await?; + } + + if let Some(pending) = &mut self.pending { + match pending.try_recv() { + Ok(fresh) => { + self.inner.merge(fresh); + self.version_loader_cache.clear(); + self.pending = None; + } + Err(oneshot::error::TryRecvError::Empty) => {} + Err(oneshot::error::TryRecvError::Closed) => self.pending = None, + } + } + + Ok(()) + } + #[tracing::instrument(level = "debug", skip_all)] pub async fn refetch_errored(&mut self, ctx: &McCtx) -> u8 { let mut changed: u8 = 0; if self.inner.minecraft.is_none() - && let Ok(data) = fetch_vanilla_manifest(ctx).await { - self.inner.minecraft = Some(data); - changed += 1; - } + && let Ok(data) = fetch_vanilla_manifest(ctx).await + { + self.inner.minecraft = Some(data); + changed += 1; + } macro_rules! check_modded { ($var:ident) => { @@ -154,11 +188,7 @@ impl MetadataStore { changed += 1; } Err(err) => { - tracing::error!( - "failed to fetch manifest for {}: {}", - loader, - err - ); + tracing::error!("failed to fetch manifest for {}: {}", loader, err); } } } @@ -177,21 +207,7 @@ impl MetadataStore { #[tracing::instrument(level = "debug", skip_all)] pub async fn fetch_all(&mut self, ctx: &McCtx) { - let (minecraft, forge, neo, fabric, quilt, ornithe) = tokio::join!( - fetch_vanilla_manifest(ctx), - fetch_modded_manifest(ctx, GameLoader::Forge), - fetch_modded_manifest(ctx, GameLoader::NeoForge), - fetch_modded_manifest(ctx, GameLoader::Fabric), - fetch_modded_manifest(ctx, GameLoader::Quilt), - fetch_modded_manifest(ctx, GameLoader::Ornithe), - ); - - keep_fetched(&mut self.inner.minecraft, minecraft); - keep_fetched(&mut self.inner.forge, forge); - keep_fetched(&mut self.inner.neo, neo); - keep_fetched(&mut self.inner.fabric, fabric); - keep_fetched(&mut self.inner.quilt, quilt); - keep_fetched(&mut self.inner.ornithe, ornithe); + self.inner.fetch_all(ctx).await; } #[tracing::instrument(level = "debug", skip(self, ctx))] @@ -200,9 +216,7 @@ impl MetadataStore { ctx: &McCtx, mc_version: &str, ) -> McResult> { - if !self.initialized() { - self.initialize(ctx).await?; - } + self.ensure_initialized(ctx).await?; if let Some(hit) = self.version_loader_cache.get(mc_version) { return Ok(hit.clone()); @@ -214,15 +228,7 @@ impl MetadataStore { continue; }; - let found = manifest.game_versions.iter().any(|entry| { - entry - .id - .replace("${interpulse.gameVersion}", mc_version) - .replace(interfrost::api::modded::DUMMY_REPLACE_STRING, mc_version) - == mc_version - }); - - if found { + if manifest_supports_version(manifest, mc_version) { loaders.push(*loader); } } @@ -232,6 +238,135 @@ impl MetadataStore { Ok(loaders) } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn loader_supports_version( + &mut self, + ctx: &McCtx, + loader: GameLoader, + mc_version: &str, + ) -> McResult { + if !loader.is_modded() { + return Ok(true); + } + + let manifest = self.get_modded_or_fetch(ctx, loader).await?; + Ok(manifest_supports_version(manifest, mc_version)) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn get_versions_for_loader( + &mut self, + ctx: &McCtx, + loader: GameLoader, + ) -> McResult>> { + self.ensure_initialized(ctx).await?; + + if loader == GameLoader::Vanilla { + return Ok(None); + } + + let Ok(manifest) = self.get_modded(loader) else { + return Ok(None); + }; + + Ok(concrete_version_ids(manifest)) + } +} + +impl MetadataInner { + async fn fetch_all(&mut self, ctx: &McCtx) { + let (minecraft, forge, neo, fabric, quilt, ornithe) = tokio::join!( + fetch_vanilla_manifest(ctx), + fetch_modded_manifest(ctx, GameLoader::Forge), + fetch_modded_manifest(ctx, GameLoader::NeoForge), + fetch_modded_manifest(ctx, GameLoader::Fabric), + fetch_modded_manifest(ctx, GameLoader::Quilt), + fetch_modded_manifest(ctx, GameLoader::Ornithe), + ); + + keep_fetched(&mut self.minecraft, minecraft); + keep_fetched(&mut self.forge, forge); + keep_fetched(&mut self.neo, neo); + keep_fetched(&mut self.fabric, fabric); + keep_fetched(&mut self.quilt, quilt); + keep_fetched(&mut self.ornithe, ornithe); + } + + const fn has_any(&self) -> bool { + self.minecraft.is_some() + || self.forge.is_some() + || self.neo.is_some() + || self.fabric.is_some() + || self.quilt.is_some() + || self.ornithe.is_some() + } + + fn merge(&mut self, fresh: Self) { + let Self { + minecraft, + forge, + neo, + fabric, + quilt, + ornithe, + } = fresh; + self.minecraft = minecraft.or(self.minecraft.take()); + self.forge = forge.or(self.forge.take()); + self.neo = neo.or(self.neo.take()); + self.fabric = fabric.or(self.fabric.take()); + self.quilt = quilt.or(self.quilt.take()); + self.ornithe = ornithe.or(self.ornithe.take()); + } +} + +const LEGACY_DUMMY_REPLACE_STRING: &str = "${interpulse.gameVersion}"; + +#[must_use] +pub(crate) fn is_version_placeholder(entry_id: &str) -> bool { + entry_id.contains(LEGACY_DUMMY_REPLACE_STRING) + || entry_id.contains(interfrost::api::modded::DUMMY_REPLACE_STRING) +} + +#[must_use] +pub(crate) fn entry_matches_version(entry_id: &str, mc_version: &str) -> bool { + entry_id + .replace(LEGACY_DUMMY_REPLACE_STRING, mc_version) + .replace(interfrost::api::modded::DUMMY_REPLACE_STRING, mc_version) + == mc_version +} + +#[must_use] +pub(crate) fn concrete_version_ids(manifest: &ModdedManifest) -> Option> { + let mut ids = Vec::with_capacity(manifest.game_versions.len()); + + for entry in &manifest.game_versions { + if is_version_placeholder(&entry.id) { + continue; + } + ids.push(entry.id.clone()); + } + + (!ids.is_empty()).then_some(ids) +} + +#[must_use] +pub(crate) fn manifest_supports_version(manifest: &ModdedManifest, mc_version: &str) -> bool { + let mut saw_concrete = false; + + for entry in &manifest.game_versions { + if is_version_placeholder(&entry.id) { + continue; + } + + saw_concrete = true; + + if entry.id == mc_version { + return true; + } + } + + !saw_concrete } fn keep_fetched(slot: &mut Option, fetched: McResult) { @@ -253,8 +388,7 @@ async fn fetch_vanilla_manifest(ctx: &McCtx) -> McResult { let url = interfrost::api::minecraft::VERSION_MANIFEST_URL .parse() .map_err(McError::Url)?; - ctx - .net + ctx.net .send_json(Method::GET, url, None, &[]) .await .map_err(McError::from) @@ -264,18 +398,12 @@ async fn fetch_vanilla_manifest(ctx: &McCtx) -> McResult { } #[tracing::instrument(level = "debug", skip_all)] -async fn fetch_modded_manifest( - ctx: &McCtx, - loader: GameLoader, -) -> McResult { +async fn fetch_modded_manifest(ctx: &McCtx, loader: GameLoader) -> McResult { fetch_manifest(ctx, loader).await } #[tracing::instrument(level = "debug", skip_all)] -async fn fetch_manifest( - ctx: &McCtx, - loader: GameLoader, -) -> McResult { +async fn fetch_manifest(ctx: &McCtx, loader: GameLoader) -> McResult { let url = format!( "{}/{}/v{}/manifest.json", ctx.net.config().metadata_api_url, @@ -284,9 +412,77 @@ async fn fetch_manifest( ); let parsed = url.parse().map_err(McError::Url)?; - ctx - .net + ctx.net .send_json(Method::GET, parsed, None, &[]) .await .map_err(McError::from) } + +#[cfg(test)] +mod tests { + use super::{MetadataInner, ModdedManifest, concrete_version_ids, manifest_supports_version}; + + fn manifest(raw: &str) -> ModdedManifest { + serde_json::from_str(raw).unwrap() + } + + fn fabric_shaped() -> ModdedManifest { + manifest( + r#"{"gameVersions": [ + { "id": "${interfrost.gameVersion}", "stable": true, "loaders": [ + { "id": "0.19.5", "url": "https://meta.example/0.19.5.json", "stable": true } + ]}, + { "id": "1.21.1", "stable": true, "loaders": [] }, + { "id": "1.14", "stable": true, "loaders": [] } + ]}"#, + ) + } + + #[test] + fn the_concrete_entries_are_the_supported_set() { + let manifest = fabric_shaped(); + + assert!(manifest_supports_version(&manifest, "1.21.1")); + assert!(manifest_supports_version(&manifest, "1.14")); + assert!(!manifest_supports_version(&manifest, "1.8.9")); + } + + #[test] + fn a_manifest_of_nothing_but_placeholders_covers_everything() { + let manifest = manifest( + r#"{"gameVersions": [ + { "id": "${interfrost.gameVersion}", "stable": true, "loaders": [] } + ]}"#, + ); + + assert!(manifest_supports_version(&manifest, "1.8.9")); + assert_eq!(concrete_version_ids(&manifest), None); + } + + #[test] + fn the_version_list_drops_the_placeholder_rather_than_going_unbounded() { + assert_eq!( + concrete_version_ids(&fabric_shaped()), + Some(vec!["1.21.1".to_string(), "1.14".to_string()]) + ); + } + + #[test] + fn a_failed_background_fetch_keeps_what_the_store_already_has() { + let mut store = MetadataInner { + forge: Some(fabric_shaped()), + fabric: Some(fabric_shaped()), + ..Default::default() + }; + let fresh = MetadataInner { + fabric: Some(manifest(r#"{"gameVersions": []}"#)), + ..Default::default() + }; + + store.merge(fresh); + + assert_eq!(store.forge.unwrap().game_versions.len(), 3); + assert!(store.fabric.unwrap().game_versions.is_empty()); + assert!(store.neo.is_none()); + } +} diff --git a/packages/oneclient_mc/src/profile.rs b/packages/oneclient_mc/src/profile.rs index 96e1361d..4e69b320 100644 --- a/packages/oneclient_mc/src/profile.rs +++ b/packages/oneclient_mc/src/profile.rs @@ -3,8 +3,8 @@ use chrono::{DateTime, TimeDelta, Utc}; use reqwest::Method; use serde::{Deserialize, Serialize}; -use oneclient_net::RequestClient; use crate::error::{McError, McResult}; +use oneclient_net::RequestClient; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MojangPlayerProfile { @@ -68,8 +68,7 @@ impl From for PlayerProfileView { Self { uuid: profile.id, username: profile.username, - is_slim: active_skin - .is_some_and(|skin| skin.variant == SkinVariant::Slim), + is_slim: active_skin.is_some_and(|skin| skin.variant == SkinVariant::Slim), skin_url: active_skin.map(|skin| skin.url.clone()), cape_url: active_cape.map(|cape| cape.url.clone()), skins: profile.skins, @@ -120,7 +119,8 @@ fn normalize_texture_url(url: &str) -> String { } fn normalize_uuid(uuid: &str) -> &str { - uuid.trim().trim_matches(|c: char| c == '-' || c.is_whitespace()) + uuid.trim() + .trim_matches(|c: char| c == '-' || c.is_whitespace()) } fn auth_headers(access_token: &str) -> [(&'static str, String); 1] { @@ -187,9 +187,7 @@ pub async fn fetch_player_profile( let decoded = base64::prelude::BASE64_STANDARD .decode(texture_property.value.as_bytes()) - .map_err(|_| { - McError::Minecraft("failed to decode profile texture property".into()) - })?; + .map_err(|_| McError::Minecraft("failed to decode profile texture property".into()))?; let decoded = serde_json::from_slice::(&decoded) .map_err(McError::ParseError)? @@ -204,14 +202,8 @@ pub async fn fetch_player_profile( uuid: response.id, username: response.name, is_slim, - skin_url: decoded - .skin - .as_ref() - .map(|s| normalize_texture_url(&s.url)), - cape_url: decoded - .cape - .as_ref() - .map(|c| normalize_texture_url(&c.url)), + skin_url: decoded.skin.as_ref().map(|s| normalize_texture_url(&s.url)), + cape_url: decoded.cape.as_ref().map(|c| normalize_texture_url(&c.url)), }) } @@ -317,7 +309,11 @@ pub async fn fetch_player_profile_view( fn profile_cache_path(uuid: &str) -> Option { let key = polyio::sha1_bytes(normalize_uuid(uuid).as_bytes()); - Some(oneclient_common::paths::profiles_cache_dir().ok()?.join(format!("{key}.json"))) + Some( + oneclient_common::paths::profiles_cache_dir() + .ok()? + .join(format!("{key}.json")), + ) } async fn write_cached_profile(uuid: &str, view: &PlayerProfileView) { @@ -359,7 +355,8 @@ mod tests { use super::*; fn temp_launcher_dir() { - let dir = std::env::temp_dir().join(format!("oneclient-profile-test-{}", uuid::Uuid::new_v4())); + let dir = + std::env::temp_dir().join(format!("oneclient-profile-test-{}", uuid::Uuid::new_v4())); oneclient_common::paths::set_launcher_dir(dir); } @@ -412,7 +409,9 @@ mod tests { let view = sample_view("Legacy"); let path = profile_cache_path(&uuid).unwrap(); - polyio::create_dir_all(path.parent().unwrap()).await.unwrap(); + polyio::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); polyio::write(&path, &serde_json::to_vec(&view).unwrap()) .await .unwrap(); @@ -422,6 +421,9 @@ mod tests { .expect("legacy entry should still parse"); assert_eq!(cached.view, view); - assert!(!cached.is_fresh(), "legacy entry should be due for revalidation"); + assert!( + !cached.is_fresh(), + "legacy entry should be due for revalidation" + ); } } diff --git a/packages/oneclient_net/src/cache.rs b/packages/oneclient_net/src/cache.rs index 593df3f7..964744fe 100644 --- a/packages/oneclient_net/src/cache.rs +++ b/packages/oneclient_net/src/cache.rs @@ -7,132 +7,132 @@ use crate::service::RequestClient; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EtagPolicy { - CommitNow, - /// Hands the ETag back on [`Fetched::etag`] - /// Use when work downstream of the body can still fail so an interrupted run - /// retries instead of getting 304 - Defer, + CommitNow, + /// Hands the ETag back on [`Fetched::etag`] + /// Use when work downstream of the body can still fail so an interrupted run + /// retries instead of getting 304 + Defer, } #[derive(Debug, Clone)] pub struct Fetched { - pub bytes: Vec, - /// `false` when the server answered 304 or the request failed and the - /// cached copy was served instead - pub changed: bool, - /// Present only for a fresh body under [`EtagPolicy::Defer`] - pub etag: Option, + pub bytes: Vec, + /// `false` when the server answered 304 or the request failed and the + /// cached copy was served instead + pub changed: bool, + /// Present only for a fresh body under [`EtagPolicy::Defer`] + pub etag: Option, } impl Fetched { - pub fn json(&self) -> Result { - serde_json::from_slice(&self.bytes).map_err(|source| RequestError::DeserializeError { - source, - type_name: std::any::type_name::().to_string(), - url: String::new(), - status: 200, - snippet: crate::error::body_snippet(&self.bytes), - }) - } + pub fn json(&self) -> Result { + serde_json::from_slice(&self.bytes).map_err(|source| RequestError::DeserializeError { + source, + type_name: std::any::type_name::().to_string(), + url: String::new(), + status: 200, + snippet: crate::error::body_snippet(&self.bytes), + }) + } - /// Lossy a bad byte becomes a replacement character rather than an error - #[must_use] - pub fn text(&self) -> String { - String::from_utf8_lossy(&self.bytes).into_owned() - } + /// Lossy a bad byte becomes a replacement character rather than an error + #[must_use] + pub fn text(&self) -> String { + String::from_utf8_lossy(&self.bytes).into_owned() + } } fn etag_path(cache_path: &Path) -> PathBuf { - let mut path = cache_path.as_os_str().to_os_string(); - path.push(".etag"); - PathBuf::from(path) + let mut path = cache_path.as_os_str().to_os_string(); + path.push(".etag"); + PathBuf::from(path) } async fn read_sidecar_etag(cache_path: &Path) -> Option { - polyio::read(etag_path(cache_path)) - .await - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok()) - .filter(|etag| !etag.is_empty()) + polyio::read(etag_path(cache_path)) + .await + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .filter(|etag| !etag.is_empty()) } /// Callers using [`EtagPolicy::Defer`] call this once the work depending on the /// body has succeeded pub async fn commit_etag(cache_path: &Path, etag: &str) { - if let Err(err) = polyio::write_atomic(etag_path(cache_path), etag.as_bytes()).await { - tracing::warn!("failed to write etag sidecar: {err}"); - } + if let Err(err) = polyio::write_atomic(etag_path(cache_path), etag.as_bytes()).await { + tracing::warn!("failed to write etag sidecar: {err}"); + } } /// Returns `Ok(None)` only when the request failed *and* there is no cached /// copy #[tracing::instrument(level = "debug", skip(client), fields(%url))] pub async fn fetch_cached( - client: &RequestClient, - url: &str, - cache_path: &Path, - policy: EtagPolicy, + client: &RequestClient, + url: &str, + cache_path: &Path, + policy: EtagPolicy, ) -> Result, RequestError> { - let stored_etag = read_sidecar_etag(cache_path).await; - let mut request = reqwest::Request::new(Method::GET, url.parse()?); + let stored_etag = read_sidecar_etag(cache_path).await; + let mut request = reqwest::Request::new(Method::GET, url.parse()?); - if let Some(etag) = &stored_etag - && let Ok(value) = header::HeaderValue::from_str(etag) - { - request.headers_mut().insert(header::IF_NONE_MATCH, value); - } + if let Some(etag) = &stored_etag + && let Ok(value) = header::HeaderValue::from_str(etag) + { + request.headers_mut().insert(header::IF_NONE_MATCH, value); + } - let cached = || async { - match polyio::read(cache_path).await { - Ok(bytes) => Ok(Some(Fetched { - bytes, - changed: false, - etag: None, - })), - Err(err) => { - tracing::debug!("no usable cache at {}: {err}", cache_path.display()); - Ok(None) - } - } - }; + let cached = || async { + match polyio::read(cache_path).await { + Ok(bytes) => Ok(Some(Fetched { + bytes, + changed: false, + etag: None, + })), + Err(err) => { + tracing::debug!("no usable cache at {}: {err}", cache_path.display()); + Ok(None) + } + } + }; - match client.send(request).await { - Ok(res) if res.status() == StatusCode::NOT_MODIFIED => { - tracing::debug!("cache hit (304)"); - cached().await - } - Ok(res) if res.status().is_success() => { - let server_etag = res - .headers() - .get(header::ETAG) - .and_then(|value| value.to_str().ok()) - .map(str::to_string); + match client.send(request).await { + Ok(res) if res.status() == StatusCode::NOT_MODIFIED => { + tracing::debug!("cache hit (304)"); + cached().await + } + Ok(res) if res.status().is_success() => { + let server_etag = res + .headers() + .get(header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); - let bytes = res.bytes().await?.to_vec(); - polyio::write_atomic(cache_path, &bytes).await?; + let bytes = res.bytes().await?.to_vec(); + polyio::write_atomic(cache_path, &bytes).await?; - let etag = match (&server_etag, policy) { - (Some(etag), EtagPolicy::CommitNow) => { - commit_etag(cache_path, etag).await; - None - } - (_, EtagPolicy::Defer) => server_etag, - (None, EtagPolicy::CommitNow) => None, - }; + let etag = match (&server_etag, policy) { + (Some(etag), EtagPolicy::CommitNow) => { + commit_etag(cache_path, etag).await; + None + } + (_, EtagPolicy::Defer) => server_etag, + (None, EtagPolicy::CommitNow) => None, + }; - Ok(Some(Fetched { - bytes, - changed: true, - etag, - })) - } - Ok(res) => { - tracing::warn!(status = %res.status(), "unexpected response; using cache if present"); - cached().await - } - Err(err) => { - tracing::debug!("fetch failed, using cache if present: {err}"); - cached().await - } - } + Ok(Some(Fetched { + bytes, + changed: true, + etag, + })) + } + Ok(res) => { + tracing::warn!(status = %res.status(), "unexpected response; using cache if present"); + cached().await + } + Err(err) => { + tracing::debug!("fetch failed, using cache if present: {err}"); + cached().await + } + } } diff --git a/packages/oneclient_net/src/config.rs b/packages/oneclient_net/src/config.rs index 6cd1b94e..f7d03ef0 100644 --- a/packages/oneclient_net/src/config.rs +++ b/packages/oneclient_net/src/config.rs @@ -2,48 +2,48 @@ use oneclient_common::constants; #[derive(Clone, Debug, PartialEq, Eq)] pub struct NetConfig { - pub curseforge_api_key: String, - pub modrinth_api_key: Option, - pub metadata_api_url: String, - /// Never has a trailing slash - pub meta_url_base: String, + pub curseforge_api_key: String, + pub modrinth_api_key: Option, + pub metadata_api_url: String, + /// Never has a trailing slash + pub meta_url_base: String, } impl Default for NetConfig { - fn default() -> Self { - Self { - curseforge_api_key: constants::CURSEFORGE_API_KEY.to_string(), - modrinth_api_key: None, - metadata_api_url: constants::METADATA_API_URL.to_string(), - meta_url_base: constants::META_URL_BASE.to_string(), - } - } + fn default() -> Self { + Self { + curseforge_api_key: constants::CURSEFORGE_API_KEY.to_string(), + modrinth_api_key: None, + metadata_api_url: constants::METADATA_API_URL.to_string(), + meta_url_base: constants::META_URL_BASE.to_string(), + } + } } impl NetConfig { - /// Blank is treated as absent so clearing a field in the settings UI - /// restores the default rather than sending an empty key - #[must_use] - pub fn with_overrides( - mut self, - curseforge_api_key: Option<&str>, - modrinth_api_key: Option<&str>, - metadata_api_url: Option<&str>, - meta_url_base: Option<&str>, - ) -> Self { - if let Some(value) = non_empty(curseforge_api_key) { - self.curseforge_api_key = value; - } - self.modrinth_api_key = non_empty(modrinth_api_key); - if let Some(value) = non_empty(metadata_api_url) { - self.metadata_api_url = value; - } - if let Some(value) = non_empty(meta_url_base) { - self.meta_url_base = value; - } - self.meta_url_base = self.meta_url_base.trim_end_matches('/').to_string(); - self - } + /// Blank is treated as absent so clearing a field in the settings UI + /// restores the default rather than sending an empty key + #[must_use] + pub fn with_overrides( + mut self, + curseforge_api_key: Option<&str>, + modrinth_api_key: Option<&str>, + metadata_api_url: Option<&str>, + meta_url_base: Option<&str>, + ) -> Self { + if let Some(value) = non_empty(curseforge_api_key) { + self.curseforge_api_key = value; + } + self.modrinth_api_key = non_empty(modrinth_api_key); + if let Some(value) = non_empty(metadata_api_url) { + self.metadata_api_url = value; + } + if let Some(value) = non_empty(meta_url_base) { + self.meta_url_base = value; + } + self.meta_url_base = self.meta_url_base.trim_end_matches('/').to_string(); + self + } #[must_use] pub fn modrinth_headers(&self) -> Vec<(String, String)> { @@ -52,46 +52,109 @@ impl NetConfig { None => Vec::new(), } } + + /// Whether `url` targets a host the user explicitly configured as the + /// custom API endpoint or meta URL base. + #[must_use] + pub fn allows_host(&self, url: &reqwest::Url) -> bool { + let Some(host) = url.host_str() else { + return false; + }; + let port = url.port_or_known_default(); + + [self.metadata_api_url.as_str(), self.meta_url_base.as_str()] + .into_iter() + .filter_map(|base| base.parse::().ok()) + .any(|base| { + base.host_str() == Some(host) && base.port_or_known_default() == port + }) + } } fn non_empty(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|v| !v.is_empty()) - .map(str::to_string) + value + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn blank_overrides_fall_back_to_defaults() { - let config = NetConfig::default().with_overrides(Some(" "), Some(""), None, None); + #[test] + fn blank_overrides_fall_back_to_defaults() { + let config = NetConfig::default().with_overrides(Some(" "), Some(""), None, None); - assert_eq!(config.curseforge_api_key, constants::CURSEFORGE_API_KEY); - assert_eq!(config.modrinth_api_key, None); - assert_eq!(config.metadata_api_url, constants::METADATA_API_URL); - } + assert_eq!(config.curseforge_api_key, constants::CURSEFORGE_API_KEY); + assert_eq!(config.modrinth_api_key, None); + assert_eq!(config.metadata_api_url, constants::METADATA_API_URL); + } - #[test] - fn meta_url_base_never_keeps_a_trailing_slash() { - let config = NetConfig::default() - .with_overrides(None, None, None, Some("https://example.test/base/")); + #[test] + fn meta_url_base_never_keeps_a_trailing_slash() { + let config = NetConfig::default().with_overrides( + None, + None, + None, + Some("https://example.test/base/"), + ); - assert_eq!(config.meta_url_base, "https://example.test/base"); - } + assert_eq!(config.meta_url_base, "https://example.test/base"); + } - #[test] - fn overrides_replace_defaults() { - let config = - NetConfig::default().with_overrides(Some("key"), Some("token"), Some("https://a"), None); + #[test] + fn overrides_replace_defaults() { + let config = NetConfig::default().with_overrides( + Some("key"), + Some("token"), + Some("https://a"), + None, + ); - assert_eq!(config.curseforge_api_key, "key"); - assert_eq!( - config.modrinth_headers(), - vec![("Authorization".to_string(), "token".to_string())] - ); - assert_eq!(config.metadata_api_url, "https://a"); - } + assert_eq!(config.curseforge_api_key, "key"); + assert_eq!( + config.modrinth_headers(), + vec![("Authorization".to_string(), "token".to_string())] + ); + assert_eq!(config.metadata_api_url, "https://a"); + } + + fn parsed(url: &str) -> reqwest::Url { + url.parse().unwrap() + } + + #[test] + fn allows_host_matches_a_configured_local_endpoint() { + let config = NetConfig::default().with_overrides(None, None, None, Some("http://localhost:8000")); + + assert!(config.allows_host(&parsed("http://localhost:8000/icon.png"))); + assert!(config.allows_host(&parsed("http://localhost:8000/"))); + } + + #[test] + fn allows_host_requires_the_same_port() { + let config = NetConfig::default().with_overrides(None, None, None, Some("http://localhost:8000")); + + assert!( + !config.allows_host(&parsed("http://localhost:9090/icon.png")), + "a different port must not be treated as the configured endpoint" + ); + } + + #[test] + fn allows_host_ignores_hosts_that_were_not_configured() { + let config = NetConfig::default().with_overrides(None, None, Some("http://localhost:8000"), None); + + assert!(config.allows_host(&parsed("http://localhost:8000/icon.png"))); + assert!(!config.allows_host(&parsed("http://127.0.0.1:8000/icon.png"))); + assert!(!config.allows_host(&parsed("http://example.com/icon.png"))); + } + + #[test] + fn allows_host_is_false_when_no_custom_endpoint_is_set() { + let config = NetConfig::default(); + + assert!(!config.allows_host(&parsed("http://localhost:8000/icon.png"))); + } } diff --git a/packages/oneclient_net/src/download.rs b/packages/oneclient_net/src/download.rs index 014b0e05..25b7b831 100644 --- a/packages/oneclient_net/src/download.rs +++ b/packages/oneclient_net/src/download.rs @@ -30,206 +30,206 @@ fn retry_delay(attempt: u32) -> std::time::Duration { /// Returns `false` when the file is missing or unreadable pub async fn matches_on_disk(path: &Path, expected_sha1: &str) -> bool { - if !path.is_file() { - return false; - } + if !path.is_file() { + return false; + } - match polyio::sha1_file(path).await { - Ok(actual) => normalize_hash(&actual) == normalize_hash(expected_sha1), - Err(err) => { - tracing::debug!("could not hash {}: {err}", path.display()); - false - } - } + match polyio::sha1_file(path).await { + Ok(actual) => normalize_hash(&actual) == normalize_hash(expected_sha1), + Err(err) => { + tracing::debug!("could not hash {}: {err}", path.display()); + false + } + } } /// `dest` is only created once the hash matches so a failed attempt leaves /// nothing behind for a later `exists()` check to trip over #[tracing::instrument(level = "debug", skip(client, events, notify), fields(%url))] pub async fn download_verified( - client: &RequestClient, - events: &EventBus, - url: &str, - dest: &Path, - expected: Option<&Checksum>, - expected_size: u64, - notify: Option, + client: &RequestClient, + events: &EventBus, + url: &str, + dest: &Path, + expected: Option<&Checksum>, + expected_size: u64, + notify: Option, ) -> Result<(), RequestError> { - // Callers fanning out over thousands of files pre-create the tree so this - // stat avoids a blocking-pool round trip per file - if let Some(parent) = dest.parent() - && !parent.is_dir() - { - polyio::create_dir_all(parent).await?; - } + // Callers fanning out over thousands of files pre-create the tree so this + // stat avoids a blocking-pool round trip per file + if let Some(parent) = dest.parent() + && !parent.is_dir() + { + polyio::create_dir_all(parent).await?; + } - // Needs a fixed id up front or each retry opens a new UI entry - let notify = notify.map(ResponseNotifyOptions::pinned); + // Needs a fixed id up front or each retry opens a new UI entry + let notify = notify.map(ResponseNotifyOptions::pinned); - let mut attempt = 1; - loop { - match download_attempt( - client, - events, - url, - dest, - expected, - expected_size, - notify.as_ref(), - ) - .await - { - Ok(()) => break, - Err(err) if attempt < MAX_DOWNLOAD_ATTEMPTS && worth_retrying(&err) => { - tracing::warn!( - attempt, - %url, - "download failed, retrying: {err}" - ); - tokio::time::sleep(retry_delay(attempt)).await; - attempt += 1; - } - Err(err) => return Err(err), - } - } + let mut attempt = 1; + loop { + match download_attempt( + client, + events, + url, + dest, + expected, + expected_size, + notify.as_ref(), + ) + .await + { + Ok(()) => break, + Err(err) if attempt < MAX_DOWNLOAD_ATTEMPTS && worth_retrying(&err) => { + tracing::warn!( + attempt, + %url, + "download failed, retrying: {err}" + ); + tokio::time::sleep(retry_delay(attempt)).await; + attempt += 1; + } + Err(err) => return Err(err), + } + } - if let Some(child) = notify.as_ref().and_then(ResponseNotifyOptions::child) { - child.finish(); - } + if let Some(child) = notify.as_ref().and_then(ResponseNotifyOptions::child) { + child.finish(); + } - Ok(()) + Ok(()) } /// The progress child is shared across attempts and reports absolute byte /// counts so a restarted attempt rewinds its bar rather than double-counting #[allow(clippy::too_many_arguments)] async fn download_attempt( - client: &RequestClient, - events: &EventBus, - url: &str, - dest: &Path, - expected: Option<&Checksum>, - expected_size: u64, - notify: Option<&ResponseNotifyOptions>, + client: &RequestClient, + events: &EventBus, + url: &str, + dest: &Path, + expected: Option<&Checksum>, + expected_size: u64, + notify: Option<&ResponseNotifyOptions>, ) -> Result<(), RequestError> { - let child = notify.and_then(ResponseNotifyOptions::child); - if let Some(child) = child { - child.set_phase(TaskPhase::Downloading); - } + let child = notify.and_then(ResponseNotifyOptions::child); + if let Some(child) = child { + child.set_phase(TaskPhase::Downloading); + } - let request = reqwest::Request::new(Method::GET, url.parse()?); - let response = client.send(request).await?; + let request = reqwest::Request::new(Method::GET, url.parse()?); + let response = client.send(request).await?; - // Without this an error body is written to disk under the requested file's name - let status = response.status(); - if !status.is_success() { - let bytes = response.bytes().await?; - return Err(RequestError::HttpStatus { - status: status.as_u16(), - url: url.to_string(), - snippet: crate::error::body_snippet(&bytes), - }); - } + // Without this an error body is written to disk under the requested file's name + let status = response.status(); + if !status.is_success() { + let bytes = response.bytes().await?; + return Err(RequestError::HttpStatus { + status: status.as_u16(), + url: url.to_string(), + snippet: crate::error::body_snippet(&bytes), + }); + } - // Prefer the manifest size Content-Length is absent for compressed or chunked bodies - let declared = if expected_size > 0 { - Some(expected_size) - } else { - response.content_length() - }; - let total = declared.unwrap_or(0).max(1); + // Prefer the manifest size Content-Length is absent for compressed or chunked bodies + let declared = if expected_size > 0 { + Some(expected_size) + } else { + response.content_length() + }; + let total = declared.unwrap_or(0).max(1); - let options = ResponseOptions { - notify: notify.cloned(), - }; - let stream = response.stream(options, events).await?; + let options = ResponseOptions { + notify: notify.cloned(), + }; + let stream = response.stream(options, events).await?; - let mut hasher = expected.map(|expected| ChecksumStream::new(expected.algorithm)); - let mut received = 0u64; - { - let stream = futures_lite::StreamExt::map(stream, |item| { - if let Ok(chunk) = &item { - received += chunk.len() as u64; - if let Some(hasher) = hasher.as_mut() { - hasher.update(chunk); - } - } - item - }); - let stream = std::pin::pin!(stream); - // Writes to a scratch sibling and renames so `dest` is never truncated - polyio::write_stream(dest, stream, Some(total)).await?; - } + let mut hasher = expected.map(|expected| ChecksumStream::new(expected.algorithm)); + let mut received = 0u64; + { + let stream = futures_lite::StreamExt::map(stream, |item| { + if let Ok(chunk) = &item { + received += chunk.len() as u64; + if let Some(hasher) = hasher.as_mut() { + hasher.update(chunk); + } + } + item + }); + let stream = std::pin::pin!(stream); + // Writes to a scratch sibling and renames so `dest` is never truncated + polyio::write_stream(dest, stream, Some(total)).await?; + } - if let (Some(expected), Some(hasher)) = (expected, hasher) { - if let Some(child) = child { - child.set_phase(TaskPhase::Verifying); - } + if let (Some(expected), Some(hasher)) = (expected, hasher) { + if let Some(child) = child { + child.set_phase(TaskPhase::Verifying); + } - let actual = hasher.finish(); - if !expected.matches(&actual) { - // Remove it rather than leave it for the next `exists()` check - let _ = polyio::remove_file(dest).await; - return Err(RequestError::HashMismatch { - source_desc: dest.display().to_string(), - expected: format!("{} {}", expected.algorithm.name(), expected.hex), - actual, - }); - } - } else if let Some(declared) = declared - && received != declared - { - // With no hash the byte count is the only evidence the transfer finished - let _ = polyio::remove_file(dest).await; - return Err(RequestError::IncompleteBody { - source_desc: dest.display().to_string(), - expected: declared, - actual: received, - }); - } + let actual = hasher.finish(); + if !expected.matches(&actual) { + // Remove it rather than leave it for the next `exists()` check + let _ = polyio::remove_file(dest).await; + return Err(RequestError::HashMismatch { + source_desc: dest.display().to_string(), + expected: format!("{} {}", expected.algorithm.name(), expected.hex), + actual, + }); + } + } else if let Some(declared) = declared + && received != declared + { + // With no hash the byte count is the only evidence the transfer finished + let _ = polyio::remove_file(dest).await; + return Err(RequestError::IncompleteBody { + source_desc: dest.display().to_string(), + expected: declared, + actual: received, + }); + } - Ok(()) + Ok(()) } #[tracing::instrument(level = "debug", skip(client, events, progress), fields(%url))] pub async fn fetch_verified( - client: &RequestClient, - events: &EventBus, - url: &str, - expected_sha1: &str, - progress: Option, + client: &RequestClient, + events: &EventBus, + url: &str, + expected_sha1: &str, + progress: Option, ) -> Result, RequestError> { - let request = reqwest::Request::new(Method::GET, url.parse()?); - let response = client.send(request).await?; + let request = reqwest::Request::new(Method::GET, url.parse()?); + let response = client.send(request).await?; - let options = ResponseOptions { - notify: progress.clone().map(ResponseNotifyOptions::grouped), - }; - let stream = response.stream(options, events).await?; + let options = ResponseOptions { + notify: progress.clone().map(ResponseNotifyOptions::grouped), + }; + let stream = response.stream(options, events).await?; - use futures_util::StreamExt; - let mut stream = std::pin::pin!(stream); - let mut bytes = Vec::new(); - while let Some(chunk) = stream.next().await { - bytes.extend_from_slice(&chunk?); - } + use futures_util::StreamExt; + let mut stream = std::pin::pin!(stream); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + bytes.extend_from_slice(&chunk?); + } - if let Some(child) = &progress { - child.set_phase(TaskPhase::Verifying); - } + if let Some(child) = &progress { + child.set_phase(TaskPhase::Verifying); + } - let actual = polyio::sha1_bytes(&bytes); - if normalize_hash(&actual) != normalize_hash(expected_sha1) { - return Err(RequestError::HashMismatch { - source_desc: url.to_string(), - expected: expected_sha1.to_string(), - actual, - }); - } + let actual = polyio::sha1_bytes(&bytes); + if normalize_hash(&actual) != normalize_hash(expected_sha1) { + return Err(RequestError::HashMismatch { + source_desc: url.to_string(), + expected: expected_sha1.to_string(), + actual, + }); + } - if let Some(child) = progress { - child.finish(); - } + if let Some(child) = progress { + child.finish(); + } - Ok(bytes) + Ok(bytes) } diff --git a/packages/oneclient_net/src/error.rs b/packages/oneclient_net/src/error.rs index 93938f32..be8b224d 100644 --- a/packages/oneclient_net/src/error.rs +++ b/packages/oneclient_net/src/error.rs @@ -82,7 +82,7 @@ pub enum RequestError { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NetworkFailure { Certificate, - Handshake, + Handshake, Dns, Generic, } diff --git a/packages/oneclient_net/src/response.rs b/packages/oneclient_net/src/response.rs index 35cbbd1c..f2e4e69d 100644 --- a/packages/oneclient_net/src/response.rs +++ b/packages/oneclient_net/src/response.rs @@ -2,7 +2,7 @@ use reqwest::Response; use uuid::Uuid; use crate::error::RequestError; -use oneclient_events::{GroupedProgressChild, EventBus}; +use oneclient_events::{EventBus, GroupedProgressChild}; const PROGRESS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50); @@ -85,22 +85,20 @@ impl ResponseExt for Response { let total = self.content_length().unwrap_or(0).max(1); let mut current = 0u64; - let grouped_child = options - .notify - .as_ref() - .and_then(|n| n.child.clone()); + let grouped_child = options.notify.as_ref().and_then(|n| n.child.clone()); let standalone_label = options .notify .as_ref() .and_then(|n| n.standalone_label.clone()); - let done_label = options - .notify - .as_ref() - .and_then(|n| n.done_label.clone()); + let done_label = options.notify.as_ref().and_then(|n| n.done_label.clone()); - let standalone_id = standalone_label - .as_ref() - .map(|_| options.notify.as_ref().and_then(|n| n.standalone_id).unwrap_or_else(Uuid::new_v4)); + let standalone_id = standalone_label.as_ref().map(|_| { + options + .notify + .as_ref() + .and_then(|n| n.standalone_id) + .unwrap_or_else(Uuid::new_v4) + }); if let Some(ref child) = grouped_child { child.set_progress(0, Some(total)); @@ -112,34 +110,32 @@ impl ResponseExt for Response { // One event per chunk would bury the UI in tens of thousands of updates // so sample the last one always emits so the bar lands on complete let mut last_emit: Option = None; - let stream = futures_lite::StreamExt::map(self.bytes_stream(), move |item| { - match item { - Ok(chunk) => { - current += chunk.len() as u64; + let stream = futures_lite::StreamExt::map(self.bytes_stream(), move |item| match item { + Ok(chunk) => { + current += chunk.len() as u64; - let now = std::time::Instant::now(); - let due = current >= total - || last_emit.is_none_or(|last| now.duration_since(last) >= PROGRESS_INTERVAL); + let now = std::time::Instant::now(); + let due = current >= total + || last_emit.is_none_or(|last| now.duration_since(last) >= PROGRESS_INTERVAL); - if due { - last_emit = Some(now); - if let Some(ref child) = grouped_child { - child.set_progress(current, Some(total)); - } else if let (Some(id), Some(label)) = (&standalone_id, &standalone_label) { - let done = current >= total; - let label = if done { - done_label.as_deref().unwrap_or(label) - } else { - label - }; - events.progress(*id, label, current, total); - } + if due { + last_emit = Some(now); + if let Some(ref child) = grouped_child { + child.set_progress(current, Some(total)); + } else if let (Some(id), Some(label)) = (&standalone_id, &standalone_label) { + let done = current >= total; + let label = if done { + done_label.as_deref().unwrap_or(label) + } else { + label + }; + events.progress(*id, label, current, total); } - - Ok(chunk) } - Err(err) => Err(RequestError::from(err)), + + Ok(chunk) } + Err(err) => Err(RequestError::from(err)), }); Ok(stream) diff --git a/packages/oneclient_net/src/service.rs b/packages/oneclient_net/src/service.rs index 9868561c..145700e2 100644 --- a/packages/oneclient_net/src/service.rs +++ b/packages/oneclient_net/src/service.rs @@ -243,7 +243,10 @@ impl RequestClient { } #[tracing::instrument(level = "debug", skip_all)] - pub async fn send_as(&self, request: impl Into) -> Result { + pub async fn send_as( + &self, + request: impl Into, + ) -> Result { let res = self.send(request).await?; let status = res.status(); let url = res.url().to_string(); @@ -344,8 +347,10 @@ mod tests { #[test] fn does_not_override_explicit_key() { let mut req = request("https://api.curseforge.com/v1/mods"); - req.headers_mut() - .insert("x-api-key", reqwest::header::HeaderValue::from_static("mine")); + req.headers_mut().insert( + "x-api-key", + reqwest::header::HeaderValue::from_static("mine"), + ); apply_curseforge_auth(&mut req, oneclient_common::constants::CURSEFORGE_API_KEY).unwrap(); assert_eq!(req.headers()["x-api-key"], "mine"); } diff --git a/packages/oneclient_net/src/status.rs b/packages/oneclient_net/src/status.rs index 07d55a19..e1c8006d 100644 --- a/packages/oneclient_net/src/status.rs +++ b/packages/oneclient_net/src/status.rs @@ -1,5 +1,5 @@ -use std::sync::{OnceLock, RwLock}; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{OnceLock, RwLock}; use std::time::Duration; use tokio::sync::{Notify, watch}; @@ -114,12 +114,7 @@ pub fn start(client: RequestClient) { } async fn reachable(client: &reqwest::Client, url: &str) -> bool { - client - .get(url) - .timeout(PROBE_TIMEOUT) - .send() - .await - .is_ok() + client.get(url).timeout(PROBE_TIMEOUT).send().await.is_ok() } pub async fn check_service_status(requester: &RequestClient) -> ServiceStatus { diff --git a/packages/oneclient_polyplus/Cargo.toml b/packages/oneclient_polyplus/Cargo.toml index b62e0684..b8806189 100644 --- a/packages/oneclient_polyplus/Cargo.toml +++ b/packages/oneclient_polyplus/Cargo.toml @@ -34,5 +34,5 @@ url.workspace = true uuid.workspace = true [dev-dependencies] -async-tungstenite = { version = "=0.32.1", features = ["tokio-runtime"] } +async-tungstenite = { version = "=0.35.0", features = ["tokio-runtime"] } tokio = { workspace = true } diff --git a/packages/oneclient_polyplus/src/lib.rs b/packages/oneclient_polyplus/src/lib.rs index d1d522ce..955b67c0 100644 --- a/packages/oneclient_polyplus/src/lib.rs +++ b/packages/oneclient_polyplus/src/lib.rs @@ -234,10 +234,7 @@ where Ok(()) } -async fn login( - client: &reqwest::Client, - account: &MinecraftAccount, -) -> Result { +async fn login(client: &reqwest::Client, account: &MinecraftAccount) -> Result { if plus_blocked() { return Err(PlusError::ConsentRequired); } @@ -355,9 +352,12 @@ mod tests { let pumped = tokio::time::timeout( Duration::from_secs(10), - pump(&mut websocket, Duration::from_millis(50), Uuid::nil(), || async { - false - }), + pump( + &mut websocket, + Duration::from_millis(50), + Uuid::nil(), + || async { false }, + ), ) .await .expect("pump should return once the server closes the socket"); diff --git a/packages/polyio/Cargo.toml b/packages/polyio/Cargo.toml index d13badf3..fd84a08d 100644 --- a/packages/polyio/Cargo.toml +++ b/packages/polyio/Cargo.toml @@ -29,7 +29,6 @@ futures-lite.workspace = true async-tempfile.workspace = true async-compression.workspace = true -async-stream.workspace = true async_zip.workspace = true astral-tokio-tar.workspace = true sanitize-filename.workspace = true diff --git a/packages/polyio/src/archive.rs b/packages/polyio/src/archive.rs index fd716cca..3e9f215a 100644 --- a/packages/polyio/src/archive.rs +++ b/packages/polyio/src/archive.rs @@ -1,65 +1,33 @@ -use std::sync::Arc; +use std::borrow::Cow; +use std::path::Path; -use async_stream::try_stream; -use async_zip::StoredZipEntry; -use async_zip::base::read::WithoutEntry; -use futures_util::{Stream, TryStreamExt, pin_mut}; -use tokio_util::compat::TokioAsyncWriteCompatExt; +use async_zip::base::read1::ZipOptions; +use async_zip::base::read1::seek::ZipArchiveReader; +use async_zip::spec::constructs::CDR; +use futures_lite::{AsyncBufRead, AsyncSeek}; +use tokio_util::compat::{Compat, TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use crate::{IOError, PolyIOResult}; -#[tracing::instrument(skip(data, f), level = "debug")] -pub async fn read_zip_entries_bytes(data: Vec, mut f: F) -> PolyIOResult<()> -where - F: AsyncFnMut( - usize, - &StoredZipEntry, - &mut async_zip::base::read::ZipEntryReader< - '_, - futures_lite::io::Cursor<&[u8]>, - WithoutEntry, - >, - ) -> PolyIOResult<()>, -{ - let reader = async_zip::base::read::mem::ZipFileReader::new(data).await?; +type FileZipReader = ZipArchiveReader>>; - let entries = reader.file().entries(); - for index in 0..entries.len() { - let entry = entries.get(index).expect("expected more zip entries"); - let mut entry_reader = reader - .reader_without_entry(index) - .await - .unwrap_or_else(|_| panic!("expected zip entry at index '{index}'")); - - f(index, entry, &mut entry_reader).await?; - } - - Ok(()) +async fn open_zip( + reader: R, +) -> PolyIOResult> { + Ok(ZipArchiveReader::open_with_options(reader, ZipOptions::untrusted()).await?) } -#[tracing::instrument(skip(data), level = "debug")] -pub fn stream_zip_entries_bytes( - data: Vec, -) -> impl Stream< - Item = Result< - ( - usize, - StoredZipEntry, - Arc, - ), - IOError, - >, -> { - try_stream! { - let reader = Arc::new(async_zip::base::read::mem::ZipFileReader::new(data).await?); - let entries = reader.file().entries(); +async fn open_zip_file(zip_path: &Path) -> PolyIOResult { + let file = tokio::fs::File::open(zip_path).await?; + open_zip(tokio::io::BufReader::new(file).compat()).await +} - for index in 0..entries.len() { - let entry = entries.get(index).expect("expected more zip entries"); +fn entry_name(cdr: &CDR) -> Cow<'_, str> { + String::from_utf8_lossy(cdr.insecure_file_name.as_bytes()) +} - yield (index, entry.clone(), reader.clone()); - } - } +fn unix_permissions(cdr: &CDR) -> Option { + (cdr.cdrh.v_made_by >> 8 == 3).then_some(cdr.cdrh.exter_attr >> 16) } #[tracing::instrument( @@ -70,10 +38,10 @@ pub fn stream_zip_entries_bytes( ) )] pub async fn unzip_bytes( - data: Vec, - dest_path: impl AsRef, + data: Vec, + dest_path: impl AsRef, ) -> PolyIOResult<()> { - unzip_bytes_filtered(data, None:: bool>, dest_path).await + unzip_bytes_filtered(data, None:: bool>, dest_path).await } #[tracing::instrument( @@ -84,43 +52,19 @@ pub async fn unzip_bytes( ) )] pub async fn unzip_bytes_filtered( - data: Vec, - filter_entries: Option bool + Send + Sync>, - dest_path: impl AsRef, + data: Vec, + filter_entries: Option bool + Send + Sync>, + dest_path: impl AsRef, ) -> PolyIOResult<()> { - let stream = stream_zip_entries_bytes(data); - pin_mut!(stream); + let reader = open_zip(futures_lite::io::Cursor::new(data)).await?; - while let Some((index, entry, reader)) = stream.try_next().await? { - let file_name = entry.filename().as_str()?; - - if let Some(filter) = &filter_entries - && !filter(file_name) - { - continue; - } - - let path = dest_path.as_ref().join(crate::sanitize_path(file_name)); - - let is_dir = entry.dir()?; - - if is_dir { - crate::create_dir_all(path).await?; - } else { - if let Some(parent) = path.parent() { - crate::create_dir_all(parent).await?; - } - - let entry_reader = reader.reader_without_entry(index).await?; - - let file = tokio::fs::File::create(&path).await?; - let writer = tokio::io::BufWriter::new(file); - - futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?; - } - } - - Ok(()) + extract_entries( + reader, + dest_path.as_ref(), + filter_entries, + None:: String>, + ) + .await } #[tracing::instrument( @@ -132,22 +76,22 @@ pub async fn unzip_bytes_filtered( ) )] pub async fn extract_zip( - zip_path: impl AsRef, - dest_path: impl AsRef, + zip_path: impl AsRef, + dest_path: impl AsRef, ) -> PolyIOResult<()> { - let zip_path = zip_path.as_ref().to_path_buf(); - let dest_path = dest_path.as_ref().to_path_buf(); + let zip_path = zip_path.as_ref().to_path_buf(); + let dest_path = dest_path.as_ref().to_path_buf(); - off_caller_thread(move || async move { - extract_zip_filtered( - zip_path, - dest_path, - None:: bool>, - None:: String>, - ) - .await - }) - .await + off_caller_thread(move || async move { + extract_zip_filtered( + zip_path, + dest_path, + None:: bool>, + None:: String>, + ) + .await + }) + .await } /// Inflating an archive is CPU work sitting between short async writes, and @@ -155,14 +99,14 @@ pub async fn extract_zip( /// run inline stalls rendering for as long as it takes async fn off_caller_thread(work: F) -> PolyIOResult<()> where - F: FnOnce() -> Fut + Send + 'static, - Fut: std::future::Future>, + F: FnOnce() -> Fut + Send + 'static, + Fut: std::future::Future>, { - let handle = tokio::runtime::Handle::current(); + let handle = tokio::runtime::Handle::current(); - tokio::task::spawn_blocking(move || handle.block_on(work())) - .await - .map_err(std::io::Error::other)? + tokio::task::spawn_blocking(move || handle.block_on(work())) + .await + .map_err(std::io::Error::other)? } #[tracing::instrument( @@ -174,52 +118,69 @@ where ) )] pub async fn extract_zip_filtered( - zip_path: impl AsRef, - dest_path: impl AsRef, - filter_entries: Option bool + Send + Sync>, - modify_entry_name: Option String>, + zip_path: impl AsRef, + dest_path: impl AsRef, + filter_entries: Option bool + Send + Sync>, + modify_entry_name: Option String>, ) -> PolyIOResult<()> { - let zip_path = zip_path.as_ref(); - let dest_path = dest_path.as_ref(); + let reader = open_zip_file(zip_path.as_ref()).await?; - let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path).await?; - let entries = reader.file().entries(); + extract_entries( + reader, + dest_path.as_ref(), + filter_entries, + modify_entry_name, + ) + .await +} - for index in 0..entries.len() { - let entry = entries.get(index).expect("expected more zip entries"); +async fn extract_entries( + mut reader: ZipArchiveReader, + dest_path: &Path, + filter_entries: Option bool>, + modify_entry_name: Option String>, +) -> PolyIOResult<()> { + let inner = reader.inner().clone(); - if let Some(filter) = &filter_entries - && !filter(entry) - { - continue; - } + for (index, cdr) in inner.cdrs().iter().enumerate() { + let old_name = entry_name(cdr); - let old_name = entry.filename().as_str()?; - let name: String = modify_entry_name - .as_ref() - .map_or_else(|| old_name.to_string(), |modify| modify(old_name)); + if let Some(filter) = &filter_entries + && !filter(&old_name) + { + continue; + } - let name = crate::sanitize_path(name); + let name: String = modify_entry_name + .as_ref() + .map_or_else(|| old_name.to_string(), |modify| modify(&old_name)); - let path = dest_path.join(name); - let is_dir = entry.dir()?; + let path = dest_path.join(crate::sanitize_path(name)); - if is_dir { - crate::create_dir_all(&path).await?; - } else { - if let Some(parent) = path.parent() { - crate::create_dir_all(parent).await?; - } + if old_name.ends_with('/') { + crate::create_dir_all(&path).await?; + } else { + if let Some(parent) = path.parent() { + crate::create_dir_all(parent).await?; + } - let file = tokio::fs::File::create(&path).await?; - let writer = tokio::io::BufWriter::new(file); - let entry_reader = reader.reader_without_entry(index).await?; + let file = tokio::fs::File::create(&path).await?; + let writer = tokio::io::BufWriter::new(file); + let entry_reader = reader.file(index).await?; - futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?; - } - } + futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?; - Ok(()) + #[cfg(unix)] + if let Some(mode) = unix_permissions(cdr).filter(|mode| mode & 0o777 != 0) { + use std::os::unix::fs::PermissionsExt; + + let mode = (mode & 0o777) | 0o600; + tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).await?; + } + } + } + + Ok(()) } /// Ceiling on a single entry read by [`read_zip_file_entries`] mod manifests, @@ -236,54 +197,119 @@ pub const MAX_ZIP_ENTRY_BYTES: u64 = 16 * 1024 * 1024; fields(zip_path = %zip_path.as_ref().display()) )] pub async fn read_zip_file_entries( - zip_path: impl AsRef, - filter: impl Fn(&str) -> bool, + zip_path: impl AsRef, + filter: impl Fn(&str) -> bool, ) -> PolyIOResult)>> { - let zip_path = zip_path.as_ref(); - let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path).await?; - let entries = reader.file().entries(); + let zip_path = zip_path.as_ref(); + let mut reader = open_zip_file(zip_path).await?; + let inner = reader.inner().clone(); - let mut out = Vec::new(); - for index in 0..entries.len() { - let entry = entries.get(index).expect("expected more zip entries"); - let Ok(name) = entry.filename().as_str() else { - continue; - }; - let name = name.to_string(); + let mut out = Vec::new(); + for (index, cdr) in inner.cdrs().iter().enumerate() { + let Some(name) = cdr.insecure_file_name.as_str() else { + continue; + }; + let name = name.to_string(); - if entry.dir().unwrap_or(false) || !filter(&name) { - continue; - } + if name.ends_with('/') || !filter(&name) { + continue; + } - if entry.uncompressed_size() > MAX_ZIP_ENTRY_BYTES { - tracing::warn!( - "skipping '{name}' in {}: declares {} bytes over the {MAX_ZIP_ENTRY_BYTES} byte entry cap", - zip_path.display(), - entry.uncompressed_size() - ); - continue; - } + let uncompressed_size = cdr.uncompressed_size()?; + if uncompressed_size > MAX_ZIP_ENTRY_BYTES { + tracing::warn!( + "skipping '{name}' in {}: declares {uncompressed_size} bytes over the {MAX_ZIP_ENTRY_BYTES} byte entry cap", + zip_path.display(), + ); + continue; + } - let entry_reader = reader.reader_without_entry(index).await?; - let mut data = Vec::new(); - futures_lite::AsyncReadExt::read_to_end( - &mut futures_lite::AsyncReadExt::take(entry_reader, MAX_ZIP_ENTRY_BYTES + 1), - &mut data, - ) - .await?; + let entry_reader = reader.file(index).await?; + let mut data = Vec::new(); + futures_lite::AsyncReadExt::read_to_end( + &mut futures_lite::AsyncReadExt::take(entry_reader, MAX_ZIP_ENTRY_BYTES + 1), + &mut data, + ) + .await?; - if data.len() as u64 > MAX_ZIP_ENTRY_BYTES { - tracing::warn!( - "skipping '{name}' in {}: decompressed past the {MAX_ZIP_ENTRY_BYTES} byte entry cap", - zip_path.display() - ); - continue; - } + if data.len() as u64 > MAX_ZIP_ENTRY_BYTES { + tracing::warn!( + "skipping '{name}' in {}: decompressed past the {MAX_ZIP_ENTRY_BYTES} byte entry cap", + zip_path.display() + ); + continue; + } - out.push((name, data)); - } + out.push((name, data)); + } - Ok(out) + Ok(out) +} + +pub const MAX_STREAMED_ENTRY_BYTES: u64 = 1024 * 1024 * 1024; + +pub struct ZipEntryCursor { + reader: FileZipReader, + indices: std::collections::HashMap, +} + +impl ZipEntryCursor { + #[tracing::instrument(level = "debug", skip(zip_path, filter), fields(zip_path = %zip_path.as_ref().display()))] + pub async fn open( + zip_path: impl AsRef, + filter: impl Fn(&str) -> bool, + ) -> PolyIOResult { + let reader = open_zip_file(zip_path.as_ref()).await?; + + let mut indices = std::collections::HashMap::new(); + for (index, cdr) in reader.cdrs().iter().enumerate() { + let Some(name) = cdr.insecure_file_name.as_str() else { + continue; + }; + if name.ends_with('/') || !filter(name) { + continue; + } + indices.insert(name.to_string(), index); + } + + Ok(Self { reader, indices }) + } + + pub fn names(&self) -> impl Iterator { + self.indices.keys() + } + + pub async fn read(&mut self, name: &str) -> PolyIOResult> { + let index = *self + .indices + .get(name) + .ok_or_else(|| IOError::FileNotFoundInZip { + file_name: name.to_string(), + })?; + + let declared = self.reader.cdrs()[index].uncompressed_size()?; + if declared > MAX_STREAMED_ENTRY_BYTES { + return Err(IOError::IOError(std::io::Error::other(format!( + "'{name}' declares {declared} bytes, over the {MAX_STREAMED_ENTRY_BYTES} byte limit" + )))); + } + + let entry_reader = self.reader.file(index).await?; + let mut data = Vec::with_capacity(usize::try_from(declared).unwrap_or_default()); + futures_lite::AsyncReadExt::read_to_end( + &mut futures_lite::AsyncReadExt::take(entry_reader, MAX_STREAMED_ENTRY_BYTES + 1), + &mut data, + ) + .await?; + + if data.len() as u64 > MAX_STREAMED_ENTRY_BYTES { + return Err(IOError::IOError(std::io::Error::other(format!( + "'{name}' decompressed past the {MAX_STREAMED_ENTRY_BYTES} byte limit" + )))); + } + + Ok(data) + } } #[tracing::instrument( @@ -291,49 +317,38 @@ pub async fn read_zip_file_entries( skip(zip_path), fields(zip_path = %zip_path.as_ref().display()) )] -pub async fn zip_entry_names( - zip_path: impl AsRef, -) -> PolyIOResult> { - let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path.as_ref()).await?; +pub async fn zip_entry_names(zip_path: impl AsRef) -> PolyIOResult> { + let reader = open_zip_file(zip_path.as_ref()).await?; - Ok(reader - .file() - .entries() - .iter() - .filter_map(|entry| entry.filename().as_str().ok().map(ToString::to_string)) - .collect()) + Ok(reader + .cdrs() + .iter() + .filter_map(|cdr| cdr.insecure_file_name.as_str().map(ToString::to_string)) + .collect()) } /// Returns a zip file entry's bytes without reading the entire file into memory -#[tracing::instrument( - level = "debug", - skip(reader) -)] +#[tracing::instrument(level = "debug", skip(reader))] pub async fn try_read_zip_entry_bytes(reader: R, file_name: &str) -> PolyIOResult> where - R: tokio::io::AsyncRead + tokio::io::AsyncBufRead + tokio::io::AsyncSeek + Unpin, + R: tokio::io::AsyncRead + tokio::io::AsyncBufRead + tokio::io::AsyncSeek + Unpin, { - use tokio_util::compat::TokioAsyncReadCompatExt; - let compat = reader.compat(); + let mut zip_reader = open_zip(reader.compat()).await?; - let mut zip_reader = async_zip::base::read::seek::ZipFileReader::new(compat).await?; - - let index = zip_reader - .file() - .entries() - .iter() - .position(|entry| entry.filename().as_str().is_ok_and(|n| n == file_name)) - .ok_or_else(|| IOError::FileNotFoundInZip { - file_name: file_name.to_string() + let index = zip_reader + .find(file_name.as_bytes())? + .next() + .ok_or_else(|| IOError::FileNotFoundInZip { + file_name: file_name.to_string(), })?; - let mut entry_reader = zip_reader.reader_without_entry(index).await?; + let mut entry_reader = zip_reader.file(index).await?; - let mut data: Vec = Vec::new(); + let mut data: Vec = Vec::new(); - futures_lite::AsyncReadExt::read_to_end(&mut entry_reader, &mut data).await?; + futures_lite::AsyncReadExt::read_to_end(&mut entry_reader, &mut data).await?; - Ok(data) + Ok(data) } #[tracing::instrument( @@ -344,21 +359,26 @@ where dest = %dest.as_ref().display(), ) )] -pub async fn extract_tar_gz(archive: impl AsRef, dest: impl AsRef) -> PolyIOResult<()> { - let archive = archive.as_ref().to_path_buf(); - let dest = dest.as_ref().to_path_buf(); +pub async fn extract_tar_gz( + archive: impl AsRef, + dest: impl AsRef, +) -> PolyIOResult<()> { + let archive = archive.as_ref().to_path_buf(); + let dest = dest.as_ref().to_path_buf(); - off_caller_thread(move || async move { - crate::create_dir_all(&dest).await?; + off_caller_thread(move || async move { + crate::create_dir_all(&dest).await?; - let file = tokio::fs::File::open(archive).await?; - let buf_reader = tokio::io::BufReader::new(file); - let gzip_decoder = async_compression::tokio::bufread::GzipDecoder::new(buf_reader); + let file = tokio::fs::File::open(archive).await?; + let buf_reader = tokio::io::BufReader::new(file); + let gzip_decoder = async_compression::tokio::bufread::GzipDecoder::new(buf_reader); - let mut tar_archive = tokio_tar::Archive::new(gzip_decoder); - tar_archive.unpack(dest).await?; + let mut tar_archive = tokio_tar::ArchiveBuilder::new(gzip_decoder) + .set_preserve_permissions(true) + .build(); + tar_archive.unpack(dest).await?; - Ok(()) - }) - .await -} \ No newline at end of file + Ok(()) + }) + .await +} diff --git a/packages/polyio/src/error.rs b/packages/polyio/src/error.rs index 9ba72aa3..f77f1d5d 100644 --- a/packages/polyio/src/error.rs +++ b/packages/polyio/src/error.rs @@ -4,52 +4,39 @@ pub type PolyIOResult = Result; #[derive(Debug, thiserror::Error)] pub enum IOError { - #[error("Invalid absolute path '{path}'")] - InvalidAbsolutePath { - path: PathBuf, + #[error("Invalid absolute path '{path}'")] + InvalidAbsolutePath { path: PathBuf }, + + #[error("Couldn't find file '{file_name}' in zip")] + FileNotFoundInZip { file_name: String }, + + #[error("An error occurred whilst accessing path '{path}': {source}")] + PathIOError { + #[source] + source: std::io::Error, + path: String, }, - #[error("Couldn't find file '{file_name}' in zip")] - FileNotFoundInZip { - file_name: String, - }, + #[error(transparent)] + IOError(#[from] std::io::Error), - #[error("An error occurred whilst accessing path '{path}': {source}")] - PathIOError { - #[source] - source: std::io::Error, - path: String, - }, - - #[error(transparent)] - IOError( - #[from] - std::io::Error, - ), - - #[error("Failed to parse JSON for file '{file}': {source}")] - JsonFileParseError { - #[source] - source: serde_json::Error, - file: PathBuf + #[error("Failed to parse JSON for file '{file}': {source}")] + JsonFileParseError { + #[source] + source: serde_json::Error, + file: PathBuf, }, #[error("Failed to convert to JSON for file '{file}': {source}")] JsonFileWrite { #[source] source: serde_json::Error, - file: PathBuf + file: PathBuf, }, - #[error(transparent)] - AsyncZipError( - #[from] - async_zip::error::ZipError, - ), + #[error(transparent)] + AsyncZipError(#[from] async_zip::error::ZipError), - #[error("Temporary file error: {0}")] - TempFileError( - #[from] - async_tempfile::Error, - ), -} \ No newline at end of file + #[error("Temporary file error: {0}")] + TempFileError(#[from] async_tempfile::Error), +} diff --git a/packages/polyio/src/file.rs b/packages/polyio/src/file.rs index ec5834b8..1b8660fd 100644 --- a/packages/polyio/src/file.rs +++ b/packages/polyio/src/file.rs @@ -1,6 +1,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; -use std::{fs::Metadata, path::{Path, PathBuf}}; +use std::{ + fs::Metadata, + path::{Path, PathBuf}, +}; use async_tempfile::{TempDir, TempFile}; use serde::{Serialize, de::DeserializeOwned}; @@ -16,10 +19,10 @@ const DEFAULT_WRITE_BUFFER: usize = 64 * 1024; /// Sizing the buffer to the file is a memory-footprint choice not a throughput /// one a fixed 256 KiB costs ~8 MiB across 32 concurrent small-asset downloads fn write_buffer_size(size_hint: Option) -> usize { - match size_hint { - Some(0) | None => DEFAULT_WRITE_BUFFER, - Some(size) => (size.min(MAX_WRITE_BUFFER as u64) as usize).max(MIN_WRITE_BUFFER), - } + match size_hint { + Some(0) | None => DEFAULT_WRITE_BUFFER, + Some(size) => (size.min(MAX_WRITE_BUFFER as u64) as usize).max(MIN_WRITE_BUFFER), + } } #[tracing::instrument( @@ -28,14 +31,14 @@ fn write_buffer_size(size_hint: Option) -> usize { fields(path = %path.as_ref().display()) )] pub async fn read_dir(path: impl AsRef) -> PolyIOResult { - let path = path.as_ref(); + let path = path.as_ref(); - tokio::fs::read_dir(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + tokio::fs::read_dir(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -44,17 +47,17 @@ pub async fn read_dir(path: impl AsRef) -> PolyIOResult) -> PolyIOResult<()> { - let path = path.as_ref(); - if path.exists() { - return Ok(()); - } + let path = path.as_ref(); + if path.exists() { + return Ok(()); + } - tokio::fs::create_dir(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + tokio::fs::create_dir(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -63,13 +66,13 @@ pub async fn create_dir(path: impl AsRef) -> PolyIOResult<()> { fields(path = %path.as_ref().display()) )] pub async fn create_dir_all(path: impl AsRef) -> PolyIOResult<()> { - let path = path.as_ref(); - tokio::fs::create_dir_all(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + let path = path.as_ref(); + tokio::fs::create_dir_all(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -78,14 +81,14 @@ pub async fn create_dir_all(path: impl AsRef) -> PolyIOResult<( fields(path = %path.as_ref().display()) )] pub async fn remove_dir_all(path: impl AsRef) -> PolyIOResult<()> { - let path = path.as_ref(); + let path = path.as_ref(); - tokio::fs::remove_dir_all(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + tokio::fs::remove_dir_all(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } /// Fails if the directory is non-empty making "remove if empty" atomic with no @@ -96,14 +99,14 @@ pub async fn remove_dir_all(path: impl AsRef) -> PolyIOResult<( fields(path = %path.as_ref().display()) )] pub async fn remove_dir(path: impl AsRef) -> PolyIOResult<()> { - let path = path.as_ref(); + let path = path.as_ref(); - tokio::fs::remove_dir(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + tokio::fs::remove_dir(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -114,10 +117,11 @@ pub async fn remove_dir(path: impl AsRef) -> PolyIOResult<()> { pub async fn try_exists(path: impl AsRef) -> PolyIOResult { let path = path.as_ref(); - tokio::fs::try_exists(path).await + tokio::fs::try_exists(path) + .await .map_err(|e| IOError::PathIOError { source: e, - path: path.to_string_lossy().to_string() + path: path.to_string_lossy().to_string(), }) } @@ -127,15 +131,15 @@ pub async fn try_exists(path: impl AsRef) -> PolyIOResult fields(path = %path.as_ref().display()) )] pub async fn read_gz_to_string(path: impl AsRef) -> PolyIOResult { - let mut f = tokio::fs::File::open(path).await?; - let mut buf = vec![]; - tokio::io::AsyncReadExt::read_to_end(&mut f, &mut buf).await?; + let mut f = tokio::fs::File::open(path).await?; + let mut buf = vec![]; + tokio::io::AsyncReadExt::read_to_end(&mut f, &mut buf).await?; - let mut decoder = async_compression::tokio::bufread::GzipDecoder::new(buf.as_slice()); - let mut dst = String::new(); - tokio::io::AsyncReadExt::read_to_string(&mut decoder, &mut dst).await?; + let mut decoder = async_compression::tokio::bufread::GzipDecoder::new(buf.as_slice()); + let mut dst = String::new(); + tokio::io::AsyncReadExt::read_to_string(&mut decoder, &mut dst).await?; - Ok(dst) + Ok(dst) } #[tracing::instrument( @@ -144,14 +148,14 @@ pub async fn read_gz_to_string(path: impl AsRef) -> PolyIOResul fields(path = %path.as_ref().display()) )] pub async fn read_to_string(path: impl AsRef) -> PolyIOResult { - let path = path.as_ref(); + let path = path.as_ref(); - tokio::fs::read_to_string(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + tokio::fs::read_to_string(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -160,14 +164,14 @@ pub async fn read_to_string(path: impl AsRef) -> PolyIOResult) -> PolyIOResult> { - let path = path.as_ref(); + let path = path.as_ref(); - tokio::fs::read(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + tokio::fs::read(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -175,14 +179,11 @@ pub async fn read(path: impl AsRef) -> PolyIOResult> { skip(path), fields(path = %path.as_ref().display()) )] -pub async fn read_json( - path: impl AsRef, -) -> PolyIOResult { - serde_json::from_slice(&read(&path).await?) - .map_err(|err| IOError::JsonFileParseError { - source: err, - file: path.as_ref().to_path_buf() - }) +pub async fn read_json(path: impl AsRef) -> PolyIOResult { + serde_json::from_slice(&read(&path).await?).map_err(|err| IOError::JsonFileParseError { + source: err, + file: path.as_ref().to_path_buf(), + }) } #[tracing::instrument( @@ -190,18 +191,15 @@ pub async fn read_json( skip(path, data), fields(path = %path.as_ref().display()) )] -pub async fn write( - path: impl AsRef, - data: impl AsRef<[u8]>, -) -> PolyIOResult<()> { - let path = path.as_ref(); +pub async fn write(path: impl AsRef, data: impl AsRef<[u8]>) -> PolyIOResult<()> { + let path = path.as_ref(); - tokio::fs::write(path, data) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + tokio::fs::write(path, data) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -215,18 +213,20 @@ where F: for<'a> FnOnce(&'a mut tokio::io::BufWriter) -> Fut, Fut: std::future::Future>, { - let path = path.as_ref(); - let file = tokio::fs::File::create(path).await.map_err(IOError::from)?; - let mut writer = tokio::io::BufWriter::new(file); + let path = path.as_ref(); + let file = tokio::fs::File::create(path).await.map_err(IOError::from)?; + let mut writer = tokio::io::BufWriter::new(file); - let write_result = f(&mut writer).await; + let write_result = f(&mut writer).await; - let flush_result = tokio::io::AsyncWriteExt::flush(&mut writer).await.map_err(IOError::from); + let flush_result = tokio::io::AsyncWriteExt::flush(&mut writer) + .await + .map_err(IOError::from); write_result?; flush_result?; - Ok(()) + Ok(()) } /// Streams into a scratch sibling renamed over `path` on success so `path` @@ -289,15 +289,17 @@ where fields(path = %path.as_ref().display()) )] pub async fn write_json( - path: impl AsRef, - data: T, + path: impl AsRef, + data: T, ) -> PolyIOResult<()> { - write(&path, serde_json::to_vec(&data) - .map_err(|err| IOError::JsonFileParseError { + write( + &path, + serde_json::to_vec(&data).map_err(|err| IOError::JsonFileParseError { source: err, - file: path.as_ref().to_path_buf() - })? - ).await + file: path.as_ref().to_path_buf(), + })?, + ) + .await } /// Keeps two concurrent atomic writes to the same path off the same scratch file @@ -306,33 +308,33 @@ static ATOMIC_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0); /// Sibling rather than temp dir `rename` across mount points fails with /// `EXDEV` and `/tmp` is often a separate tmpfs on Linux fn temp_sibling(path: &Path) -> PathBuf { - let n = ATOMIC_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed); - let stem = path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "tmp".to_string()); + let n = ATOMIC_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed); + let stem = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "tmp".to_string()); - let name = format!(".{stem}.{}.{n}.tmp", std::process::id()); - match path.parent() { - Some(parent) => parent.join(name), - None => PathBuf::from(name), - } + let name = format!(".{stem}.{}.{n}.tmp", std::process::id()); + match path.parent() { + Some(parent) => parent.join(name), + None => PathBuf::from(name), + } } // Time for a file to be flagged as abandoned const STALE_TEMP_AGE: Duration = Duration::from_secs(15 * 60); fn temp_sibling_pid(name: &str) -> Option { - let inner = name.strip_prefix('.')?.strip_suffix(".tmp")?; - let (head, counter) = inner.rsplit_once('.')?; - let (stem, pid) = head.rsplit_once('.')?; + let inner = name.strip_prefix('.')?.strip_suffix(".tmp")?; + let (head, counter) = inner.rsplit_once('.')?; + let (stem, pid) = head.rsplit_once('.')?; - counter.parse::().ok()?; - if stem.is_empty() { - return None; - } + counter.parse::().ok()?; + if stem.is_empty() { + return None; + } - pid.parse().ok() + pid.parse().ok() } /// Returns the number of bytes reclaimed non-recursive and never an error for a @@ -343,68 +345,68 @@ fn temp_sibling_pid(name: &str) -> Option { fields(dir = %dir.as_ref().display()) )] pub async fn sweep_temp_files(dir: impl AsRef) -> PolyIOResult { - let dir = dir.as_ref(); - let path_err = |source| IOError::PathIOError { - source, - path: dir.to_string_lossy().to_string(), - }; + let dir = dir.as_ref(); + let path_err = |source| IOError::PathIOError { + source, + path: dir.to_string_lossy().to_string(), + }; - let mut entries = match tokio::fs::read_dir(dir).await { - Ok(entries) => entries, - // Nothing has ever been written here so there is nothing to sweep - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(0), - Err(err) => return Err(path_err(err)), - }; + let mut entries = match tokio::fs::read_dir(dir).await { + Ok(entries) => entries, + // Nothing has ever been written here so there is nothing to sweep + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(err) => return Err(path_err(err)), + }; - let current_pid = std::process::id(); - let mut files = 0usize; - let mut bytes = 0u64; + let current_pid = std::process::id(); + let mut files = 0usize; + let mut bytes = 0u64; - while let Some(entry) = entries.next_entry().await.map_err(path_err)? { - let name = entry.file_name(); - let Some(pid) = name.to_str().and_then(temp_sibling_pid) else { - continue; - }; + while let Some(entry) = entries.next_entry().await.map_err(path_err)? { + let name = entry.file_name(); + let Some(pid) = name.to_str().and_then(temp_sibling_pid) else { + continue; + }; - if pid == current_pid { - continue; - } + if pid == current_pid { + continue; + } - let Ok(metadata) = entry.metadata().await else { - continue; - }; - if !metadata.is_file() { - continue; - } + let Ok(metadata) = entry.metadata().await else { + continue; + }; + if !metadata.is_file() { + continue; + } - let stale = metadata - .modified() - .ok() - .and_then(|modified| modified.elapsed().ok()) - .is_some_and(|age| age >= STALE_TEMP_AGE); - if !stale { - continue; - } + let stale = metadata + .modified() + .ok() + .and_then(|modified| modified.elapsed().ok()) + .is_some_and(|age| age >= STALE_TEMP_AGE); + if !stale { + continue; + } - let size = metadata.len(); - match tokio::fs::remove_file(entry.path()).await { - Ok(()) => { - files += 1; - bytes += size; - } - // Another process may have just renamed it out from under us - Err(err) => tracing::debug!( - path = %entry.path().display(), - "could not remove stale scratch file: {err}" - ), - } - } + let size = metadata.len(); + match tokio::fs::remove_file(entry.path()).await { + Ok(()) => { + files += 1; + bytes += size; + } + // Another process may have just renamed it out from under us + Err(err) => tracing::debug!( + path = %entry.path().display(), + "could not remove stale scratch file: {err}" + ), + } + } - if files > 0 { - tracing::info!(files, bytes, dir = %dir.display(), "removed stale scratch files"); - } + if files > 0 { + tracing::info!(files, bytes, dir = %dir.display(), "removed stale scratch files"); + } - Ok(bytes) + Ok(bytes) } #[tracing::instrument( @@ -412,45 +414,42 @@ pub async fn sweep_temp_files(dir: impl AsRef) -> PolyIOResult { skip(path, data), fields(path = %path.as_ref().display()) )] -pub async fn write_atomic( - path: impl AsRef, - data: impl AsRef<[u8]>, -) -> PolyIOResult<()> { - let path = path.as_ref(); +pub async fn write_atomic(path: impl AsRef, data: impl AsRef<[u8]>) -> PolyIOResult<()> { + let path = path.as_ref(); - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - create_dir_all(parent).await?; - } + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + create_dir_all(parent).await?; + } - let tmp = temp_sibling(path); - let ctx = |e: std::io::Error| IOError::PathIOError { - source: e, - path: tmp.to_string_lossy().to_string(), - }; + let tmp = temp_sibling(path); + let ctx = |e: std::io::Error| IOError::PathIOError { + source: e, + path: tmp.to_string_lossy().to_string(), + }; - let write = async { - let mut file = tokio::fs::File::create(&tmp).await.map_err(ctx)?; - tokio::io::AsyncWriteExt::write_all(&mut file, data.as_ref()) - .await - .map_err(ctx)?; - file.sync_all().await.map_err(ctx)?; - Ok::<_, IOError>(()) - } - .await; + let write = async { + let mut file = tokio::fs::File::create(&tmp).await.map_err(ctx)?; + tokio::io::AsyncWriteExt::write_all(&mut file, data.as_ref()) + .await + .map_err(ctx)?; + file.sync_all().await.map_err(ctx)?; + Ok::<_, IOError>(()) + } + .await; - if let Err(err) = write { - let _ = tokio::fs::remove_file(&tmp).await; - return Err(err); - } + if let Err(err) = write { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(err); + } - if let Err(err) = rename(&tmp, path).await { - let _ = tokio::fs::remove_file(&tmp).await; - return Err(err); - } + if let Err(err) = rename(&tmp, path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(err); + } - Ok(()) + Ok(()) } #[tracing::instrument( @@ -458,16 +457,13 @@ pub async fn write_atomic( skip(path, data), fields(path = %path.as_ref().display()) )] -pub async fn write_json_atomic( - path: impl AsRef, - data: T, -) -> PolyIOResult<()> { - let bytes = serde_json::to_vec(&data).map_err(|err| IOError::JsonFileWrite { - source: err, - file: path.as_ref().to_path_buf(), - })?; +pub async fn write_json_atomic(path: impl AsRef, data: T) -> PolyIOResult<()> { + let bytes = serde_json::to_vec(&data).map_err(|err| IOError::JsonFileWrite { + source: err, + file: path.as_ref().to_path_buf(), + })?; - write_atomic(path, bytes).await + write_atomic(path, bytes).await } #[tracing::instrument( @@ -476,69 +472,69 @@ pub async fn write_json_atomic( fields(path = %path.as_ref().display()) )] pub fn ensure_under>( - path: impl AsRef, - roots: impl IntoIterator, + path: impl AsRef, + roots: impl IntoIterator, ) -> PolyIOResult> { - let canon = crate::canonicalize(path)?; + let canon = crate::canonicalize(path)?; - for root in roots { - if let Ok(root) = crate::canonicalize(root) - && canon.starts_with(&root) - { - return Ok(Some(canon)); - } - } + for root in roots { + if let Ok(root) = crate::canonicalize(root) + && canon.starts_with(&root) + { + return Ok(Some(canon)); + } + } - Ok(None) + Ok(None) } #[tracing::instrument(level = "debug", skip(exclude_top))] pub async fn copy_dir(src: &Path, dst: &Path, exclude_top: &[&str]) -> PolyIOResult<()> { - let mut stack: Vec<(PathBuf, PathBuf, bool)> = - vec![(src.to_path_buf(), dst.to_path_buf(), true)]; + let mut stack: Vec<(PathBuf, PathBuf, bool)> = + vec![(src.to_path_buf(), dst.to_path_buf(), true)]; - while let Some((cur_src, cur_dst, is_top)) = stack.pop() { - let mut entries = read_dir(&cur_src).await?; - while let Some(entry) = entries.next_entry().await? { - let name = entry.file_name(); + while let Some((cur_src, cur_dst, is_top)) = stack.pop() { + let mut entries = read_dir(&cur_src).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); - if is_top - && let Some(name_str) = name.to_str() - && exclude_top.iter().any(|e| e.eq_ignore_ascii_case(name_str)) - { - continue; - } + if is_top + && let Some(name_str) = name.to_str() + && exclude_top.iter().any(|e| e.eq_ignore_ascii_case(name_str)) + { + continue; + } - let child_src = entry.path(); - let child_dst = cur_dst.join(&name); - let file_type = entry.file_type().await?; + let child_src = entry.path(); + let child_dst = cur_dst.join(&name); + let file_type = entry.file_type().await?; - if file_type.is_dir() { - create_dir_all(&child_dst).await?; + if file_type.is_dir() { + create_dir_all(&child_dst).await?; - stack.push((child_src, child_dst, false)); - } else if file_type.is_file() { - if let Some(parent) = child_dst.parent() { - create_dir_all(parent).await?; - } + stack.push((child_src, child_dst, false)); + } else if file_type.is_file() { + if let Some(parent) = child_dst.parent() { + create_dir_all(parent).await?; + } - copy(&child_src, &child_dst).await?; - } - } - } + copy(&child_src, &child_dst).await?; + } + } + } - Ok(()) + Ok(()) } pub async fn dir_has_content(dir: &Path) -> bool { - if !dir.is_dir() { - return false; - } + if !dir.is_dir() { + return false; + } - match read_dir(dir).await { - Ok(mut entries) => matches!(entries.next_entry().await, Ok(Some(_))), - Err(_) => false, - } + match read_dir(dir).await { + Ok(mut entries) => matches!(entries.next_entry().await, Ok(Some(_))), + Err(_) => false, + } } /// Replaces `to` if it already exists @@ -551,18 +547,18 @@ pub async fn dir_has_content(dir: &Path) -> bool { ) )] pub async fn rename( - from: impl AsRef, - to: impl AsRef, + from: impl AsRef, + to: impl AsRef, ) -> PolyIOResult<()> { - let from = from.as_ref(); - let to = to.as_ref(); + let from = from.as_ref(); + let to = to.as_ref(); - tokio::fs::rename(from, to) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: from.to_string_lossy().to_string(), - }) + tokio::fs::rename(from, to) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: from.to_string_lossy().to_string(), + }) } /// Also copies permission bits and overwrites `to` @@ -575,18 +571,18 @@ pub async fn rename( ) )] pub async fn copy( - from: impl AsRef, - to: impl AsRef, + from: impl AsRef, + to: impl AsRef, ) -> PolyIOResult { - let from = from.as_ref(); - let to = to.as_ref(); + let from = from.as_ref(); + let to = to.as_ref(); - tokio::fs::copy(from, to) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: from.to_string_lossy().to_string(), - }) + tokio::fs::copy(from, to) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: from.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -595,13 +591,13 @@ pub async fn copy( fields(path = %path.as_ref().display()) )] pub async fn remove_file(path: impl AsRef) -> PolyIOResult<()> { - let path = path.as_ref(); - tokio::fs::remove_file(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + let path = path.as_ref(); + tokio::fs::remove_file(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } /// Unlike [`stat`] returns metadata about the link itself not its target @@ -611,17 +607,17 @@ pub async fn remove_file(path: impl AsRef) -> PolyIOResult<()> fields(path = %path.as_ref().display()) )] pub async fn symlink_metadata(path: impl AsRef) -> PolyIOResult { - let path = path.as_ref(); - tokio::fs::symlink_metadata(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + let path = path.as_ref(); + tokio::fs::symlink_metadata(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } fn resolved_target(original: &std::path::Path) -> PathBuf { - crate::canonicalize(original).unwrap_or_else(|_| original.to_path_buf()) + crate::canonicalize(original).unwrap_or_else(|_| original.to_path_buf()) } /// Symlink on Unix hard link on Windows so both paths must be on one volume @@ -634,22 +630,22 @@ fn resolved_target(original: &std::path::Path) -> PathBuf { ) )] pub async fn symlink_file( - original: impl AsRef, - link: impl AsRef, + original: impl AsRef, + link: impl AsRef, ) -> PolyIOResult<()> { - let original = resolved_target(original.as_ref()); - let original = original.as_path(); - let link = link.as_ref(); + let original = resolved_target(original.as_ref()); + let original = original.as_path(); + let link = link.as_ref(); - #[cfg(windows)] - let res = tokio::fs::hard_link(original, link).await; - #[cfg(not(windows))] - let res = tokio::fs::symlink(original, link).await; + #[cfg(windows)] + let res = tokio::fs::hard_link(original, link).await; + #[cfg(not(windows))] + let res = tokio::fs::symlink(original, link).await; - res.map_err(|e| IOError::PathIOError { - source: e, - path: link.to_string_lossy().to_string(), - }) + res.map_err(|e| IOError::PathIOError { + source: e, + path: link.to_string_lossy().to_string(), + }) } // what a file is on disk rather than what it is called @@ -660,47 +656,47 @@ pub async fn symlink_file( fields(path = %path.as_ref().display()) )] pub async fn file_id(path: impl AsRef) -> PolyIOResult<(u64, u64)> { - use std::os::windows::io::AsRawHandle; + use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{ - BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, - }; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; - let path = path.as_ref().to_path_buf(); - let display = path.to_string_lossy().to_string(); + let path = path.as_ref().to_path_buf(); + let display = path.to_string_lossy().to_string(); - tokio::task::spawn_blocking(move || { - let file = std::fs::File::open(&path)?; - let mut info = BY_HANDLE_FILE_INFORMATION { - dwFileAttributes: 0, - ftCreationTime: unsafe { std::mem::zeroed() }, - ftLastAccessTime: unsafe { std::mem::zeroed() }, - ftLastWriteTime: unsafe { std::mem::zeroed() }, - dwVolumeSerialNumber: 0, - nFileSizeHigh: 0, - nFileSizeLow: 0, - nNumberOfLinks: 0, - nFileIndexHigh: 0, - nFileIndexLow: 0, - }; + tokio::task::spawn_blocking(move || { + let file = std::fs::File::open(&path)?; + let mut info = BY_HANDLE_FILE_INFORMATION { + dwFileAttributes: 0, + ftCreationTime: unsafe { std::mem::zeroed() }, + ftLastAccessTime: unsafe { std::mem::zeroed() }, + ftLastWriteTime: unsafe { std::mem::zeroed() }, + dwVolumeSerialNumber: 0, + nFileSizeHigh: 0, + nFileSizeLow: 0, + nNumberOfLinks: 0, + nFileIndexHigh: 0, + nFileIndexLow: 0, + }; - let filled = unsafe { - GetFileInformationByHandle(file.as_raw_handle().cast(), std::ptr::from_mut(&mut info)) - }; + let filled = unsafe { + GetFileInformationByHandle(file.as_raw_handle().cast(), std::ptr::from_mut(&mut info)) + }; - if filled == 0 { - return Err(std::io::Error::last_os_error()); - } + if filled == 0 { + return Err(std::io::Error::last_os_error()); + } - let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow); - Ok((u64::from(info.dwVolumeSerialNumber), index)) - }) - .await - .map_err(std::io::Error::other)? - .map_err(|e| IOError::PathIOError { - source: e, - path: display, - }) + let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow); + Ok((u64::from(info.dwVolumeSerialNumber), index)) + }) + .await + .map_err(std::io::Error::other)? + .map_err(|e| IOError::PathIOError { + source: e, + path: display, + }) } #[cfg(not(windows))] @@ -710,10 +706,10 @@ pub async fn file_id(path: impl AsRef) -> PolyIOResult<(u64, u6 fields(path = %path.as_ref().display()) )] pub async fn file_id(path: impl AsRef) -> PolyIOResult<(u64, u64)> { - use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::MetadataExt; - let meta = stat(path).await?; - Ok((meta.dev(), meta.ino())) + let meta = stat(path).await?; + Ok((meta.dev(), meta.ino())) } #[tracing::instrument( @@ -725,31 +721,31 @@ pub async fn file_id(path: impl AsRef) -> PolyIOResult<(u64, u6 ) )] pub async fn symlink_dir( - original: impl AsRef, - link: impl AsRef, + original: impl AsRef, + link: impl AsRef, ) -> PolyIOResult<()> { - let original = resolved_target(original.as_ref()); - let original = original.as_path(); - let link = link.as_ref(); + let original = resolved_target(original.as_ref()); + let original = original.as_path(); + let link = link.as_ref(); - #[cfg(windows)] - { - let path = link.to_string_lossy().to_string(); - let original = original.to_path_buf(); - let link = link.to_path_buf(); - return tokio::task::spawn_blocking(move || junction::create(&original, &link)) - .await - .map_err(std::io::Error::other)? - .map_err(|e| IOError::PathIOError { source: e, path }); - } + #[cfg(windows)] + { + let path = link.to_string_lossy().to_string(); + let original = original.to_path_buf(); + let link = link.to_path_buf(); + return tokio::task::spawn_blocking(move || junction::create(&original, &link)) + .await + .map_err(std::io::Error::other)? + .map_err(|e| IOError::PathIOError { source: e, path }); + } - #[cfg(not(windows))] - tokio::fs::symlink(original, link) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: link.to_string_lossy().to_string(), - }) + #[cfg(not(windows))] + tokio::fs::symlink(original, link) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: link.to_string_lossy().to_string(), + }) } #[tracing::instrument( @@ -758,26 +754,26 @@ pub async fn symlink_dir( fields(path = %path.as_ref().display()) )] pub async fn read_link(path: impl AsRef) -> PolyIOResult { - let path = path.as_ref(); + let path = path.as_ref(); - let target = tokio::fs::read_link(path) - .await - .map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - })?; + let target = tokio::fs::read_link(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; - #[cfg(windows)] - { - let text = target.to_string_lossy().into_owned(); - for prefix in [r"\??\", r"\\?\"] { - if let Some(rest) = text.strip_prefix(prefix) { - return Ok(std::path::PathBuf::from(rest)); - } - } - } + #[cfg(windows)] + { + let text = target.to_string_lossy().into_owned(); + for prefix in [r"\??\", r"\\?\"] { + if let Some(rest) = text.strip_prefix(prefix) { + return Ok(std::path::PathBuf::from(rest)); + } + } + } - Ok(target) + Ok(target) } #[tracing::instrument( @@ -786,28 +782,28 @@ pub async fn read_link(path: impl AsRef) -> PolyIOResult) -> PolyIOResult<()> { - let path = path.as_ref(); + let path = path.as_ref(); - #[cfg(windows)] - let res = tokio::fs::remove_dir(path).await; + #[cfg(windows)] + let res = tokio::fs::remove_dir(path).await; - #[cfg(not(windows))] - let res = tokio::fs::remove_file(path).await; + #[cfg(not(windows))] + let res = tokio::fs::remove_file(path).await; - res.map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + res.map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } #[tracing::instrument(level = "debug")] pub async fn tempdir() -> PolyIOResult { - Ok(TempDir::new().await?) + Ok(TempDir::new().await?) } #[tracing::instrument(level = "debug")] pub async fn tempfile() -> PolyIOResult { - Ok(TempFile::new().await?) + Ok(TempFile::new().await?) } /// Sanitises every component and normalises separators to `/` @@ -817,12 +813,12 @@ pub async fn tempfile() -> PolyIOResult { fields(path = %path.as_ref().display()) )] pub fn sanitize_path(path: impl AsRef) -> PathBuf { - path.as_ref() - .to_string_lossy() - .replace('\\', "/") - .split('/') - .map(sanitize_filename::sanitize) - .collect() + path.as_ref() + .to_string_lossy() + .replace('\\', "/") + .split('/') + .map(sanitize_filename::sanitize) + .collect() } #[tracing::instrument( @@ -831,312 +827,327 @@ pub fn sanitize_path(path: impl AsRef) -> PathBuf { fields(path = %path.as_ref().display()) )] pub async fn stat(path: impl AsRef) -> PolyIOResult { - tokio::fs::metadata(path).await.map_err(IOError::from) + tokio::fs::metadata(path).await.map_err(IOError::from) } #[cfg(test)] mod tests { - use super::*; - - #[test] - fn write_buffer_fits_the_file() { - assert_eq!(write_buffer_size(Some(10_217)), MIN_WRITE_BUFFER); - assert_eq!(write_buffer_size(Some(1)), MIN_WRITE_BUFFER); - assert_eq!(write_buffer_size(Some(64 * 1024)), 64 * 1024); - assert_eq!(write_buffer_size(Some(25_000_000)), MAX_WRITE_BUFFER); - assert_eq!(write_buffer_size(Some(u64::MAX)), MAX_WRITE_BUFFER); - } - - #[test] - fn write_buffer_falls_back_without_a_length() { - assert_eq!(write_buffer_size(None), DEFAULT_WRITE_BUFFER); - assert_eq!(write_buffer_size(Some(0)), DEFAULT_WRITE_BUFFER); - } - - #[cfg(unix)] - #[tokio::test] - async fn a_link_target_is_stored_resolved() { - let root = scratch("resolved-target"); - let real = root.join("var").join("home"); - std::fs::create_dir_all(&real).unwrap(); - std::os::unix::fs::symlink(&real, root.join("home")).unwrap(); - - let pack = real.join("pack.zip"); - std::fs::write(&pack, b"pack").unwrap(); - - let through_link = root.join("home").join("pack.zip"); - let link = root.join("resourcepacks.zip"); - - symlink_file(&through_link, &link).await.unwrap(); - - assert_eq!( - read_link(&link).await.unwrap(), - crate::canonicalize(&pack).unwrap(), - "the stored target must be the resolved path, not the spelling we were given" - ); - - std::fs::remove_dir_all(&root).ok(); - } - - #[cfg(unix)] - #[tokio::test] - async fn a_missing_target_is_stored_as_given() { - let root = scratch("unresolved-target"); - let missing = root.join("not-here.zip"); - let link = root.join("link.zip"); - - symlink_file(&missing, &link).await.unwrap(); - - assert_eq!(read_link(&link).await.unwrap(), missing); - - std::fs::remove_dir_all(&root).ok(); - } - - fn scratch(tag: &str) -> PathBuf { - static N: AtomicU64 = AtomicU64::new(0); - let dir = std::env::temp_dir().join(format!( - "polyio-{tag}-{}-{}", - std::process::id(), - N.fetch_add(1, Ordering::Relaxed) - )); - std::fs::create_dir_all(&dir).unwrap(); - dir - } - - #[tokio::test] - async fn write_atomic_creates_missing_parents() { - let dir = scratch("atomic-parents"); - let target = dir.join("a").join("b").join("settings.json"); - - write_atomic(&target, b"{}").await.unwrap(); - - assert_eq!(std::fs::read(&target).unwrap(), b"{}"); - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn write_atomic_replaces_and_leaves_no_scratch_files() { - let dir = scratch("atomic-replace"); - let target = dir.join("settings.json"); - - write_atomic(&target, b"old").await.unwrap(); - write_atomic(&target, b"new-and-longer").await.unwrap(); - - assert_eq!(std::fs::read(&target).unwrap(), b"new-and-longer"); - - let leftovers: Vec<_> = std::fs::read_dir(&dir) - .unwrap() - .flatten() - .map(|e| e.file_name().to_string_lossy().to_string()) - .filter(|n| n != "settings.json") - .collect(); - assert!(leftovers.is_empty(), "left scratch files behind: {leftovers:?}"); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - /// Ages a file past [`STALE_TEMP_AGE`] so the sweep treats it as abandoned - fn backdate(path: &Path) { - let stale = std::time::SystemTime::now() - (STALE_TEMP_AGE + Duration::from_secs(60)); - let file = std::fs::File::options().write(true).open(path).unwrap(); - file.set_times(std::fs::FileTimes::new().set_modified(stale)) - .unwrap(); - } - - #[test] - fn temp_sibling_names_round_trip() { - let name = temp_sibling(Path::new("/java/zulu21.zip")); - let name = name.file_name().unwrap().to_str().unwrap(); - - assert_eq!(temp_sibling_pid(name), Some(std::process::id())); - - // Files polyio did not write - assert_eq!(temp_sibling_pid("zulu21.zip"), None); - assert_eq!(temp_sibling_pid(".vimrc.tmp"), None); - assert_eq!(temp_sibling_pid(".cache.notapid.7.tmp"), None); - assert_eq!(temp_sibling_pid(".cache.4242.notacounter.tmp"), None); - } - - #[tokio::test] - async fn sweep_removes_only_abandoned_scratch_files() { - let dir = scratch("sweep"); - - let abandoned = dir.join(format!(".zulu21.zip.{}.0.tmp", std::process::id() + 1)); - std::fs::write(&abandoned, b"half a runtime").unwrap(); - backdate(&abandoned); - - // Same shape but young enough to still have a writer behind it - let in_flight = dir.join(format!(".zulu17.zip.{}.0.tmp", std::process::id() + 2)); - std::fs::write(&in_flight, b"downloading").unwrap(); - - // Ours, however old the launcher has been up - let ours = temp_sibling(&dir.join("zulu8.zip")); - std::fs::write(&ours, b"mine").unwrap(); - backdate(&ours); - - // Not a scratch file at all - let keep = dir.join("zulu21.zip"); - std::fs::write(&keep, b"a real archive").unwrap(); - backdate(&keep); - - let freed = sweep_temp_files(&dir).await.unwrap(); - - assert_eq!(freed, "half a runtime".len() as u64); - assert!(!abandoned.exists()); - assert!(in_flight.exists()); - assert!(ours.exists()); - assert!(keep.exists()); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn sweep_ignores_a_missing_directory() { - let dir = scratch("sweep-missing"); - std::fs::remove_dir_all(&dir).unwrap(); - - assert_eq!(sweep_temp_files(&dir).await.unwrap(), 0); - } - - fn failing_stream( - chunks: Vec<&'static [u8]>, - ) -> impl futures_lite::Stream> + Unpin + Send { - let items = chunks - .into_iter() - .map(|c| Ok(bytes::Bytes::from_static(c))) - .chain(std::iter::once(Err(IOError::IOError( - std::io::Error::from(std::io::ErrorKind::ConnectionReset), - )))); - - Box::pin(futures_lite::stream::iter(items)) - } - - #[tokio::test] - async fn write_stream_publishes_only_a_complete_file() { - let dir = scratch("stream-ok"); - let target = dir.join("object.bin"); - - let chunks = vec![ - Ok(bytes::Bytes::from_static(b"hello ")), - Ok(bytes::Bytes::from_static(b"world")), - ]; - let stream = Box::pin(futures_lite::stream::iter(chunks)); - - write_stream::<_, IOError>(&target, stream, Some(11)) - .await - .unwrap(); - - assert_eq!(std::fs::read(&target).unwrap(), b"hello world"); - - let leftovers: Vec<_> = std::fs::read_dir(&dir) - .unwrap() - .flatten() - .map(|e| e.file_name().to_string_lossy().to_string()) - .filter(|n| n != "object.bin") - .collect(); - assert!(leftovers.is_empty(), "left scratch files behind: {leftovers:?}"); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn write_stream_leaves_nothing_when_the_stream_dies() { - let dir = scratch("stream-drop"); - let target = dir.join("object.bin"); - - write_stream::<_, IOError>(&target, failing_stream(vec![b"partial"]), Some(64)) - .await - .expect_err("a dropped stream must fail"); - - assert!(!target.exists(), "left a truncated file at the destination"); - - let leftovers: Vec<_> = std::fs::read_dir(&dir) - .unwrap() - .flatten() - .map(|e| e.file_name().to_string_lossy().to_string()) - .collect(); - assert!(leftovers.is_empty(), "left scratch files behind: {leftovers:?}"); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn write_stream_keeps_the_old_file_when_a_rewrite_fails() { - let dir = scratch("stream-keep"); - let target = dir.join("object.bin"); - std::fs::write(&target, b"known-good").unwrap(); - - write_stream::<_, IOError>(&target, failing_stream(vec![b"junk"]), Some(64)) - .await - .expect_err("a dropped stream must fail"); - - assert_eq!(std::fs::read(&target).unwrap(), b"known-good"); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn ensure_under_accepts_a_path_inside_a_root() { - let dir = scratch("under-inside"); - let nested = dir.join("clusters").join("logs"); - std::fs::create_dir_all(&nested).unwrap(); - let file = nested.join("latest.log"); - std::fs::write(&file, b"").unwrap(); - - let resolved = ensure_under(&file, [&dir]).unwrap(); - assert!(resolved.is_some()); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn ensure_under_rejects_a_traversal_out_of_every_root() { - let dir = scratch("under-escape"); - let root = dir.join("clusters"); - let outside = dir.join("secrets"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::create_dir_all(&outside).unwrap(); - let file = outside.join("auth.json"); - std::fs::write(&file, b"").unwrap(); - - let sneaky = root.join("..").join("secrets").join("auth.json"); - assert_eq!(ensure_under(&sneaky, [&root]).unwrap(), None); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[test] - fn ensure_under_skips_roots_that_do_not_exist() { - let dir = scratch("under-missing-root"); - let file = dir.join("a.log"); - std::fs::write(&file, b"").unwrap(); - - let missing = dir.join("not-created-yet"); - let resolved = ensure_under(&file, [&missing, &dir]).unwrap(); - assert!(resolved.is_some(), "a missing root must not shadow a real one"); - - std::fs::remove_dir_all(&dir).unwrap(); - } - - #[tokio::test] - async fn copy_dir_excludes_only_at_the_top_level() { - let dir = scratch("copy-dir"); - let src = dir.join("src"); - let dst = dir.join("dst"); - std::fs::create_dir_all(src.join("mods")).unwrap(); - std::fs::create_dir_all(src.join("keep").join("mods")).unwrap(); - std::fs::write(src.join("mods").join("top.jar"), b"top").unwrap(); - std::fs::write(src.join("keep").join("mods").join("nested.jar"), b"nested").unwrap(); - std::fs::write(src.join("options.txt"), b"opts").unwrap(); - - copy_dir(&src, &dst, &["mods"]).await.unwrap(); - - assert!(!dst.join("mods").exists(), "top-level `mods` should be excluded"); - assert!(dst.join("options.txt").exists()); - assert!( - dst.join("keep").join("mods").join("nested.jar").exists(), - "exclusion must not apply below the top level" - ); - - std::fs::remove_dir_all(&dir).unwrap(); - } + use super::*; + + #[test] + fn write_buffer_fits_the_file() { + assert_eq!(write_buffer_size(Some(10_217)), MIN_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(1)), MIN_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(64 * 1024)), 64 * 1024); + assert_eq!(write_buffer_size(Some(25_000_000)), MAX_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(u64::MAX)), MAX_WRITE_BUFFER); + } + + #[test] + fn write_buffer_falls_back_without_a_length() { + assert_eq!(write_buffer_size(None), DEFAULT_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(0)), DEFAULT_WRITE_BUFFER); + } + + #[cfg(unix)] + #[tokio::test] + async fn a_link_target_is_stored_resolved() { + let root = scratch("resolved-target"); + let real = root.join("var").join("home"); + std::fs::create_dir_all(&real).unwrap(); + std::os::unix::fs::symlink(&real, root.join("home")).unwrap(); + + let pack = real.join("pack.zip"); + std::fs::write(&pack, b"pack").unwrap(); + + let through_link = root.join("home").join("pack.zip"); + let link = root.join("resourcepacks.zip"); + + symlink_file(&through_link, &link).await.unwrap(); + + assert_eq!( + read_link(&link).await.unwrap(), + crate::canonicalize(&pack).unwrap(), + "the stored target must be the resolved path, not the spelling we were given" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[cfg(unix)] + #[tokio::test] + async fn a_missing_target_is_stored_as_given() { + let root = scratch("unresolved-target"); + let missing = root.join("not-here.zip"); + let link = root.join("link.zip"); + + symlink_file(&missing, &link).await.unwrap(); + + assert_eq!(read_link(&link).await.unwrap(), missing); + + std::fs::remove_dir_all(&root).ok(); + } + + fn scratch(tag: &str) -> PathBuf { + static N: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "polyio-{tag}-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn write_atomic_creates_missing_parents() { + let dir = scratch("atomic-parents"); + let target = dir.join("a").join("b").join("settings.json"); + + write_atomic(&target, b"{}").await.unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"{}"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn write_atomic_replaces_and_leaves_no_scratch_files() { + let dir = scratch("atomic-replace"); + let target = dir.join("settings.json"); + + write_atomic(&target, b"old").await.unwrap(); + write_atomic(&target, b"new-and-longer").await.unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"new-and-longer"); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n != "settings.json") + .collect(); + assert!( + leftovers.is_empty(), + "left scratch files behind: {leftovers:?}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// Ages a file past [`STALE_TEMP_AGE`] so the sweep treats it as abandoned + fn backdate(path: &Path) { + let stale = std::time::SystemTime::now() - (STALE_TEMP_AGE + Duration::from_secs(60)); + let file = std::fs::File::options().write(true).open(path).unwrap(); + file.set_times(std::fs::FileTimes::new().set_modified(stale)) + .unwrap(); + } + + #[test] + fn temp_sibling_names_round_trip() { + let name = temp_sibling(Path::new("/java/zulu21.zip")); + let name = name.file_name().unwrap().to_str().unwrap(); + + assert_eq!(temp_sibling_pid(name), Some(std::process::id())); + + // Files polyio did not write + assert_eq!(temp_sibling_pid("zulu21.zip"), None); + assert_eq!(temp_sibling_pid(".vimrc.tmp"), None); + assert_eq!(temp_sibling_pid(".cache.notapid.7.tmp"), None); + assert_eq!(temp_sibling_pid(".cache.4242.notacounter.tmp"), None); + } + + #[tokio::test] + async fn sweep_removes_only_abandoned_scratch_files() { + let dir = scratch("sweep"); + + let abandoned = dir.join(format!(".zulu21.zip.{}.0.tmp", std::process::id() + 1)); + std::fs::write(&abandoned, b"half a runtime").unwrap(); + backdate(&abandoned); + + // Same shape but young enough to still have a writer behind it + let in_flight = dir.join(format!(".zulu17.zip.{}.0.tmp", std::process::id() + 2)); + std::fs::write(&in_flight, b"downloading").unwrap(); + + // Ours, however old the launcher has been up + let ours = temp_sibling(&dir.join("zulu8.zip")); + std::fs::write(&ours, b"mine").unwrap(); + backdate(&ours); + + // Not a scratch file at all + let keep = dir.join("zulu21.zip"); + std::fs::write(&keep, b"a real archive").unwrap(); + backdate(&keep); + + let freed = sweep_temp_files(&dir).await.unwrap(); + + assert_eq!(freed, "half a runtime".len() as u64); + assert!(!abandoned.exists()); + assert!(in_flight.exists()); + assert!(ours.exists()); + assert!(keep.exists()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn sweep_ignores_a_missing_directory() { + let dir = scratch("sweep-missing"); + std::fs::remove_dir_all(&dir).unwrap(); + + assert_eq!(sweep_temp_files(&dir).await.unwrap(), 0); + } + + fn failing_stream( + chunks: Vec<&'static [u8]>, + ) -> impl futures_lite::Stream> + Unpin + Send { + let items = chunks + .into_iter() + .map(|c| Ok(bytes::Bytes::from_static(c))) + .chain(std::iter::once(Err(IOError::IOError( + std::io::Error::from(std::io::ErrorKind::ConnectionReset), + )))); + + Box::pin(futures_lite::stream::iter(items)) + } + + #[tokio::test] + async fn write_stream_publishes_only_a_complete_file() { + let dir = scratch("stream-ok"); + let target = dir.join("object.bin"); + + let chunks = vec![ + Ok(bytes::Bytes::from_static(b"hello ")), + Ok(bytes::Bytes::from_static(b"world")), + ]; + let stream = Box::pin(futures_lite::stream::iter(chunks)); + + write_stream::<_, IOError>(&target, stream, Some(11)) + .await + .unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"hello world"); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n != "object.bin") + .collect(); + assert!( + leftovers.is_empty(), + "left scratch files behind: {leftovers:?}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn write_stream_leaves_nothing_when_the_stream_dies() { + let dir = scratch("stream-drop"); + let target = dir.join("object.bin"); + + write_stream::<_, IOError>(&target, failing_stream(vec![b"partial"]), Some(64)) + .await + .expect_err("a dropped stream must fail"); + + assert!(!target.exists(), "left a truncated file at the destination"); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + assert!( + leftovers.is_empty(), + "left scratch files behind: {leftovers:?}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn write_stream_keeps_the_old_file_when_a_rewrite_fails() { + let dir = scratch("stream-keep"); + let target = dir.join("object.bin"); + std::fs::write(&target, b"known-good").unwrap(); + + write_stream::<_, IOError>(&target, failing_stream(vec![b"junk"]), Some(64)) + .await + .expect_err("a dropped stream must fail"); + + assert_eq!(std::fs::read(&target).unwrap(), b"known-good"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_accepts_a_path_inside_a_root() { + let dir = scratch("under-inside"); + let nested = dir.join("clusters").join("logs"); + std::fs::create_dir_all(&nested).unwrap(); + let file = nested.join("latest.log"); + std::fs::write(&file, b"").unwrap(); + + let resolved = ensure_under(&file, [&dir]).unwrap(); + assert!(resolved.is_some()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_rejects_a_traversal_out_of_every_root() { + let dir = scratch("under-escape"); + let root = dir.join("clusters"); + let outside = dir.join("secrets"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let file = outside.join("auth.json"); + std::fs::write(&file, b"").unwrap(); + + let sneaky = root.join("..").join("secrets").join("auth.json"); + assert_eq!(ensure_under(&sneaky, [&root]).unwrap(), None); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_skips_roots_that_do_not_exist() { + let dir = scratch("under-missing-root"); + let file = dir.join("a.log"); + std::fs::write(&file, b"").unwrap(); + + let missing = dir.join("not-created-yet"); + let resolved = ensure_under(&file, [&missing, &dir]).unwrap(); + assert!( + resolved.is_some(), + "a missing root must not shadow a real one" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn copy_dir_excludes_only_at_the_top_level() { + let dir = scratch("copy-dir"); + let src = dir.join("src"); + let dst = dir.join("dst"); + std::fs::create_dir_all(src.join("mods")).unwrap(); + std::fs::create_dir_all(src.join("keep").join("mods")).unwrap(); + std::fs::write(src.join("mods").join("top.jar"), b"top").unwrap(); + std::fs::write(src.join("keep").join("mods").join("nested.jar"), b"nested").unwrap(); + std::fs::write(src.join("options.txt"), b"opts").unwrap(); + + copy_dir(&src, &dst, &["mods"]).await.unwrap(); + + assert!( + !dst.join("mods").exists(), + "top-level `mods` should be excluded" + ); + assert!(dst.join("options.txt").exists()); + assert!( + dst.join("keep").join("mods").join("nested.jar").exists(), + "exclusion must not apply below the top level" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/packages/polyio/src/hash.rs b/packages/polyio/src/hash.rs index 3f50f8c3..e596d554 100644 --- a/packages/polyio/src/hash.rs +++ b/packages/polyio/src/hash.rs @@ -12,246 +12,241 @@ const MAX_HASH_BUFFER: usize = 256 * 1024; pub struct Sha1Stream(sha1::Sha1); impl Sha1Stream { - #[must_use] - pub fn new() -> Self { - Self(sha1::Sha1::new()) - } + #[must_use] + pub fn new() -> Self { + Self(sha1::Sha1::new()) + } - pub fn update(&mut self, data: &[u8]) { - Digest::update(&mut self.0, data); - } + pub fn update(&mut self, data: &[u8]) { + Digest::update(&mut self.0, data); + } - #[must_use] - pub fn finish(self) -> String { - to_hex(&self.0.finalize()) - } + #[must_use] + pub fn finish(self) -> String { + to_hex(&self.0.finalize()) + } } impl Default for Sha1Stream { - fn default() -> Self { - Self::new() - } + fn default() -> Self { + Self::new() + } } /// Runs on the blocking pool one dispatch for the whole file keeping the /// digest off the async workers driving concurrent downloads pub async fn sha1_file(path: impl AsRef) -> PolyIOResult { - let path = path.as_ref().to_path_buf(); - tokio::task::spawn_blocking(move || sha1_file_sync(&path)) - .await - .map_err(std::io::Error::other)? + let path = path.as_ref().to_path_buf(); + tokio::task::spawn_blocking(move || sha1_file_sync(&path)) + .await + .map_err(std::io::Error::other)? } pub fn sha1_file_sync(path: &Path) -> PolyIOResult { - let mut file = std::fs::File::open(path).map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - })?; - let mut hasher = Sha1Stream::new(); + let mut file = std::fs::File::open(path).map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; + let mut hasher = Sha1Stream::new(); - let size = file - .metadata() - .map(|meta| meta.len()) - .unwrap_or(MAX_HASH_BUFFER as u64); - let capacity = (size.max(1).min(MAX_HASH_BUFFER as u64)) as usize; - let mut buffer = vec![0u8; capacity]; + let size = file + .metadata() + .map(|meta| meta.len()) + .unwrap_or(MAX_HASH_BUFFER as u64); + let capacity = (size.max(1).min(MAX_HASH_BUFFER as u64)) as usize; + let mut buffer = vec![0u8; capacity]; - loop { - let n = file.read(&mut buffer).map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - })?; - if n == 0 { - break; - } - hasher.update(&buffer[..n]); - } + loop { + let n = file.read(&mut buffer).map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } - Ok(hasher.finish()) + Ok(hasher.finish()) } #[must_use] pub fn sha1_bytes(data: &[u8]) -> String { - let mut hasher = Sha1Stream::new(); - hasher.update(data); - hasher.finish() + let mut hasher = Sha1Stream::new(); + hasher.update(data); + hasher.finish() } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ChecksumAlgorithm { - Sha1, - Sha256, + Sha1, + Sha256, } impl ChecksumAlgorithm { - /// Hex length which is how a bare manifest string with no algorithm field - /// gets identified - #[must_use] - pub const fn hex_len(self) -> usize { - match self { - Self::Sha1 => 40, - Self::Sha256 => 64, - } - } + /// Hex length which is how a bare manifest string with no algorithm field + /// gets identified + #[must_use] + pub const fn hex_len(self) -> usize { + match self { + Self::Sha1 => 40, + Self::Sha256 => 64, + } + } - #[must_use] - pub const fn name(self) -> &'static str { - match self { - Self::Sha1 => "SHA-1", - Self::Sha256 => "SHA-256", - } - } + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Sha1 => "SHA-1", + Self::Sha256 => "SHA-256", + } + } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Checksum { - pub algorithm: ChecksumAlgorithm, - /// Always normalized (trimmed lowercase) - pub hex: String, + pub algorithm: ChecksumAlgorithm, + /// Always normalized (trimmed lowercase) + pub hex: String, } impl Checksum { - #[must_use] - pub fn new(algorithm: ChecksumAlgorithm, hex: impl AsRef) -> Self { - Self { - algorithm, - hex: normalize_hash(hex.as_ref()), - } - } + #[must_use] + pub fn new(algorithm: ChecksumAlgorithm, hex: impl AsRef) -> Self { + Self { + algorithm, + hex: normalize_hash(hex.as_ref()), + } + } - #[must_use] - pub fn sha1(hex: impl AsRef) -> Self { - Self::new(ChecksumAlgorithm::Sha1, hex) - } + #[must_use] + pub fn sha1(hex: impl AsRef) -> Self { + Self::new(ChecksumAlgorithm::Sha1, hex) + } - #[must_use] - pub fn sha256(hex: impl AsRef) -> Self { - Self::new(ChecksumAlgorithm::Sha256, hex) - } + #[must_use] + pub fn sha256(hex: impl AsRef) -> Self { + Self::new(ChecksumAlgorithm::Sha256, hex) + } - /// Whether the hash is the right shape for its algorithm - /// Callers drop malformed checksums and download unverified - #[must_use] - pub fn is_well_formed(&self) -> bool { - self.hex.len() == self.algorithm.hex_len() - && self.hex.bytes().all(|b| b.is_ascii_hexdigit()) - } + /// Whether the hash is the right shape for its algorithm + /// Callers drop malformed checksums and download unverified + #[must_use] + pub fn is_well_formed(&self) -> bool { + self.hex.len() == self.algorithm.hex_len() + && self.hex.bytes().all(|b| b.is_ascii_hexdigit()) + } - #[must_use] - pub fn matches(&self, actual: &str) -> bool { - self.hex == normalize_hash(actual) - } + #[must_use] + pub fn matches(&self, actual: &str) -> bool { + self.hex == normalize_hash(actual) + } } pub enum ChecksumStream { - Sha1(sha1::Sha1), - Sha256(sha2::Sha256), + Sha1(sha1::Sha1), + Sha256(sha2::Sha256), } impl ChecksumStream { - #[must_use] - pub fn new(algorithm: ChecksumAlgorithm) -> Self { - match algorithm { - ChecksumAlgorithm::Sha1 => Self::Sha1(sha1::Sha1::new()), - ChecksumAlgorithm::Sha256 => Self::Sha256(sha2::Sha256::new()), - } - } + #[must_use] + pub fn new(algorithm: ChecksumAlgorithm) -> Self { + match algorithm { + ChecksumAlgorithm::Sha1 => Self::Sha1(sha1::Sha1::new()), + ChecksumAlgorithm::Sha256 => Self::Sha256(sha2::Sha256::new()), + } + } - pub fn update(&mut self, data: &[u8]) { - match self { - Self::Sha1(hasher) => Digest::update(hasher, data), - Self::Sha256(hasher) => Digest::update(hasher, data), - } - } + pub fn update(&mut self, data: &[u8]) { + match self { + Self::Sha1(hasher) => Digest::update(hasher, data), + Self::Sha256(hasher) => Digest::update(hasher, data), + } + } - #[must_use] - pub fn finish(self) -> String { - match self { - Self::Sha1(hasher) => to_hex(&hasher.finalize()), - Self::Sha256(hasher) => to_hex(&hasher.finalize()), - } - } + #[must_use] + pub fn finish(self) -> String { + match self { + Self::Sha1(hasher) => to_hex(&hasher.finalize()), + Self::Sha256(hasher) => to_hex(&hasher.finalize()), + } + } } #[must_use] pub fn normalize_hash(hash: &str) -> String { - hash.trim().to_ascii_lowercase() + hash.trim().to_ascii_lowercase() } #[must_use] pub fn to_hex(data: &[u8]) -> String { - data.iter().fold(String::new(), |mut out, b| { - let _ = write!(out, "{b:02x}"); - out - }) + data.iter().fold(String::new(), |mut out, b| { + let _ = write!(out, "{b:02x}"); + out + }) } #[cfg(test)] mod tests { - use super::*; + use super::*; - #[test] - fn sha1_bytes_matches_known_vector() { - assert_eq!( - sha1_bytes(b"abc"), - "a9993e364706816aba3e25717850c26c9cd0d89d" - ); - assert_eq!( - sha1_bytes(b""), - "da39a3ee5e6b4b0d3255bfef95601890afd80709" - ); - } + #[test] + fn sha1_bytes_matches_known_vector() { + assert_eq!( + sha1_bytes(b"abc"), + "a9993e364706816aba3e25717850c26c9cd0d89d" + ); + assert_eq!(sha1_bytes(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709"); + } - #[test] - fn stream_and_oneshot_agree() { - let mut stream = Sha1Stream::new(); - stream.update(b"ab"); - stream.update(b"c"); - assert_eq!(stream.finish(), sha1_bytes(b"abc")); - } + #[test] + fn stream_and_oneshot_agree() { + let mut stream = Sha1Stream::new(); + stream.update(b"ab"); + stream.update(b"c"); + assert_eq!(stream.finish(), sha1_bytes(b"abc")); + } - #[test] - fn normalize_hash_trims_and_lowercases() { - assert_eq!(normalize_hash(" A9B2 "), "a9b2"); - } + #[test] + fn normalize_hash_trims_and_lowercases() { + assert_eq!(normalize_hash(" A9B2 "), "a9b2"); + } - #[test] - fn checksum_streams_match_their_one_shot_digests() { - let mut sha1 = ChecksumStream::new(ChecksumAlgorithm::Sha1); - sha1.update(b"ab"); - sha1.update(b"c"); - assert_eq!(sha1.finish(), "a9993e364706816aba3e25717850c26c9cd0d89d"); + #[test] + fn checksum_streams_match_their_one_shot_digests() { + let mut sha1 = ChecksumStream::new(ChecksumAlgorithm::Sha1); + sha1.update(b"ab"); + sha1.update(b"c"); + assert_eq!(sha1.finish(), "a9993e364706816aba3e25717850c26c9cd0d89d"); - let mut sha256 = ChecksumStream::new(ChecksumAlgorithm::Sha256); - sha256.update(b"ab"); - sha256.update(b"c"); - assert_eq!( - sha256.finish(), - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" - ); - } + let mut sha256 = ChecksumStream::new(ChecksumAlgorithm::Sha256); + sha256.update(b"ab"); + sha256.update(b"c"); + assert_eq!( + sha256.finish(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } - #[test] - fn checksum_comparison_ignores_case_and_padding() { - let expected = Checksum::sha1(" A9993E364706816ABA3E25717850C26C9CD0D89D "); - assert!(expected.matches("a9993e364706816aba3e25717850c26c9cd0d89d")); - assert!(expected.matches("A9993E364706816ABA3E25717850C26C9CD0D89D")); - assert!(!expected.matches("da39a3ee5e6b4b0d3255bfef95601890afd80709")); - } + #[test] + fn checksum_comparison_ignores_case_and_padding() { + let expected = Checksum::sha1(" A9993E364706816ABA3E25717850C26C9CD0D89D "); + assert!(expected.matches("a9993e364706816aba3e25717850c26c9cd0d89d")); + assert!(expected.matches("A9993E364706816ABA3E25717850C26C9CD0D89D")); + assert!(!expected.matches("da39a3ee5e6b4b0d3255bfef95601890afd80709")); + } - #[test] - fn a_hash_of_the_wrong_shape_is_not_well_formed() { - assert!(!Checksum::sha256("").is_well_formed()); - assert!(!Checksum::sha256("n/a").is_well_formed()); - assert!(!Checksum::sha256("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed()); - assert!(!Checksum::sha1("zzz3e364706816aba3e25717850c26c9cd0d89d!").is_well_formed()); + #[test] + fn a_hash_of_the_wrong_shape_is_not_well_formed() { + assert!(!Checksum::sha256("").is_well_formed()); + assert!(!Checksum::sha256("n/a").is_well_formed()); + assert!(!Checksum::sha256("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed()); + assert!(!Checksum::sha1("zzz3e364706816aba3e25717850c26c9cd0d89d!").is_well_formed()); - assert!(Checksum::sha1("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed()); - assert!( - Checksum::sha256( - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" - ) - .is_well_formed() - ); - } + assert!(Checksum::sha1("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed()); + assert!( + Checksum::sha256("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + .is_well_formed() + ); + } } diff --git a/packages/polyio/src/lib.rs b/packages/polyio/src/lib.rs index f693d8f9..00b33596 100644 --- a/packages/polyio/src/lib.rs +++ b/packages/polyio/src/lib.rs @@ -1,15 +1,15 @@ -mod error; -mod system; -mod file; mod archive; +mod error; +mod file; mod hash; +mod system; #[cfg(feature = "testing")] pub mod testing; -pub(crate) use error::PolyIOResult; -pub use error::IOError; -pub use file::*; -pub use system::*; pub use archive::*; +pub use error::IOError; +pub(crate) use error::PolyIOResult; +pub use file::*; pub use hash::*; +pub use system::*; diff --git a/packages/polyio/src/system.rs b/packages/polyio/src/system.rs index 0d1566f8..61fc8fbd 100644 --- a/packages/polyio/src/system.rs +++ b/packages/polyio/src/system.rs @@ -3,16 +3,15 @@ use std::path::PathBuf; use crate::{IOError, PolyIOResult}; pub fn env_path(name: &str) -> Option { - std::env::var_os(name).map(PathBuf::from) + std::env::var_os(name).map(PathBuf::from) } /// Like [`std::fs::canonicalize`] but on Windows emits the most compatible /// path form instead of UNC pub fn canonicalize(path: impl AsRef) -> PolyIOResult { - let path = path.as_ref(); - dunce::canonicalize(path).map_err(|e| IOError::PathIOError { - source: e, - path: path.to_string_lossy().to_string(), - }) + let path = path.as_ref(); + dunce::canonicalize(path).map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) } - diff --git a/packages/polyio/src/testing.rs b/packages/polyio/src/testing.rs index 6ac19567..ab7aee91 100644 --- a/packages/polyio/src/testing.rs +++ b/packages/polyio/src/testing.rs @@ -6,40 +6,39 @@ use std::sync::atomic::{AtomicU64, Ordering}; pub struct ScratchDir(PathBuf); impl ScratchDir { - /// `tag` only makes a leaked directory identifiable if the destructor is - /// skipped - #[must_use] - pub fn new(tag: &str) -> Self { - static COUNTER: AtomicU64 = AtomicU64::new(0); - let n = COUNTER.fetch_add(1, Ordering::Relaxed); + /// `tag` only makes a leaked directory identifiable if the destructor is + /// skipped + #[must_use] + pub fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); - let path = std::env::temp_dir() - .join(format!("oneclient-{tag}-{}-{n}", std::process::id())); - std::fs::create_dir_all(&path) - .unwrap_or_else(|e| panic!("failed to create scratch dir {}: {e}", path.display())); + let path = std::env::temp_dir().join(format!("oneclient-{tag}-{}-{n}", std::process::id())); + std::fs::create_dir_all(&path) + .unwrap_or_else(|e| panic!("failed to create scratch dir {}: {e}", path.display())); - Self(path) - } + Self(path) + } - #[must_use] - pub fn path(&self) -> &Path { - &self.0 - } + #[must_use] + pub fn path(&self) -> &Path { + &self.0 + } - #[must_use] - pub fn join(&self, name: impl AsRef) -> PathBuf { - self.0.join(name) - } + #[must_use] + pub fn join(&self, name: impl AsRef) -> PathBuf { + self.0.join(name) + } } impl AsRef for ScratchDir { - fn as_ref(&self) -> &Path { - &self.0 - } + fn as_ref(&self) -> &Path { + &self.0 + } } impl Drop for ScratchDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } }