diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..eb8e347c --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +# Linux and Macos by default have 8MB of stack storage. +# Windows has 1MB, it isn't enough, so it's changed to 8MB also. +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "link-arg=/STACK:8388608"] + +[target.aarch64-pc-windows-msvc] +rustflags = ["-C", "link-arg=/STACK:8388608"] diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..fd3b06ea --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +root = true + +[*] +indent_style = tab +indent_size = 4 +end_of_line = lf +trim_trailing_whitespace = true +insert_final_newline = true +charset = utf-8 + +[*.nix] +indent_style = space +indent_size = 2 + +[*.rs] +insert_final_newline = false + +[*.{yaml,yml}] +indent_style = space +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..e6949018 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +Cargo.lock -diff linguist-generated diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..e69de29b diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..6d74574a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,59 @@ +name: Bug report +description: Create a report to help us improve Polyfrost. Include screenshots if possible. +labels: [bug] +body: + - type: markdown + attributes: + value: Before raising an issue, please search for existing issues to avoid creating duplicates. + - type: input + attributes: + label: Original reporter/ticket ID + description: If you are rewording a ticket, put the original reporter(s) and ticket ID(s). If not, leave this blank. + validations: + required: false + - type: dropdown + attributes: + label: Project + description: Select the project this issue is related to. + multiple: true + options: + - Core + - OneClient + - OneLauncher + - type: textarea + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. + validations: + required: false + - type: textarea + attributes: + label: Steps to reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. See error + validations: + required: false + - type: textarea + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. + validations: + required: false + - type: textarea + attributes: + label: Operating System information + description: Information about your environment. + placeholder: | + macOS 13.4.2, git 1.4.0, etc. + validations: + required: false + - type: textarea + attributes: + label: Additional context + description: Add any other context about the problem here. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..c7277fbc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: true +contact_links: + - name: Discord + about: Ask questions or share feature requests on our Discord Server. + url: https://polyfrost.org/discord + - name: Polyfrost Documentation + about: Useful documentation about Polyfrost, OneConfig, and other Polyfrost projects. + url: https://docs.polyfrost.org + - name: Polyfrost Docs^2 + about: Useful documentation about how you can contribute to Polyfrost projects. + url: https://contributing.polyfrost.org diff --git a/.github/ISSUE_TEMPLATE/enhancement.yml b/.github/ISSUE_TEMPLATE/enhancement.yml new file mode 100644 index 00000000..0b15e1a2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/enhancement.yml @@ -0,0 +1,48 @@ +name: Enhancement +description: Suggest an enhancement for an existing feature +labels: [enhancement] +body: + - type: markdown + attributes: + value: Before raising an issue, please search for existing issues and [Discord](https://polyfrost.org/discord) suggestions to avoid creating duplicates. + - type: input + attributes: + label: Original reporter/ticket ID + description: If you are rewording a ticket, put the original reporter(s) and ticket ID(s). If not, leave this blank. + validations: + required: false + - type: dropdown + attributes: + label: Project + description: Select the project this issue is related to. + multiple: true + default: General + options: + - General + - Core + - OneClient + - OneLauncher + - type: textarea + attributes: + label: Is your suggested enhancement related to a problem? Please describe, or link another Issue. + description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + validations: + required: false + - type: textarea + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + validations: + required: false + - type: textarea + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: false + - type: textarea + attributes: + label: Additional context + description: Add any other context or screenshots about the suggested enhancement here. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..344c1d7c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +## Description + + + +## Related Issue(s) + + +Fixes # + +## How to test + + + +## Documentation + + diff --git a/.github/actions/install-cargo-tool/action.yml b/.github/actions/install-cargo-tool/action.yml new file mode 100644 index 00000000..20d591ee --- /dev/null +++ b/.github/actions/install-cargo-tool/action.yml @@ -0,0 +1,47 @@ +name: Install cargo tool +description: > + Install a cargo binary from a prebuilt release instead of compiling it from + source, and skip the work entirely when the Rust cache already restored it. +inputs: + tool: + description: Crate name providing the binary (e.g. cargo-packager) + required: true + version: + description: Exact version to install + required: true +runs: + using: composite + steps: + # Swatinem/rust-cache restores $CARGO_HOME/bin, so on a cache hit the tool is + # already here and `cargo install` would otherwise re-resolve it every run. + - name: Check for an already-installed ${{ inputs.tool }} + id: check + shell: bash + env: + TOOL: ${{ inputs.tool }} + VERSION: ${{ inputs.version }} + run: | + if command -v "$TOOL" > /dev/null 2>&1 \ + && cargo install --list | grep -qF "$TOOL v$VERSION:"; then + echo "Found $TOOL v$VERSION in \$CARGO_HOME/bin" + echo "installed=true" >> "$GITHUB_OUTPUT" + else + echo "installed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Setup cargo-binstall + if: steps.check.outputs.installed == 'false' + uses: cargo-bins/cargo-binstall@e00d2c94cc0067b77737821097a62d91c0301baa # v1.21.1 + + - name: Install ${{ inputs.tool }} + if: steps.check.outputs.installed == 'false' + shell: bash + env: + TOOL: ${{ inputs.tool }} + VERSION: ${{ inputs.version }} + run: | + # Prebuilt download takes seconds; building cargo-packager from source + # takes minutes on every leg of the matrix. Fall back to a source build + # so a missing prebuilt artifact can never break a release. + cargo binstall --no-confirm --force "${TOOL}@${VERSION}" \ + || cargo install "${TOOL}" --version "${VERSION}" --locked --force diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml new file mode 100644 index 00000000..be22f77e --- /dev/null +++ b/.github/actions/setup-rust/action.yml @@ -0,0 +1,43 @@ +name: Setup Rust +description: Install the Rust toolchain (+ optional targets) and restore the build cache. +inputs: + target: + description: Comma-separated extra target triples to install + required: false + default: '' + save-cache: + description: Whether to save the Rust cache + required: false + default: 'false' + restore-cache: + description: Whether to restore the Rust cache + required: false + default: 'true' + cache-namespace: + description: > + Cache namespace. Workflows that compile with different profile settings + MUST use different namespaces - cargo fingerprints every dependency by its + profile, so sharing one would mean each workflow evicts artifacts the other + cannot use. + required: false + default: release-cache +runs: + using: composite + steps: + - name: Install Rust (stable) + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable branch @ 2026-07-16 + with: + toolchain: stable + targets: ${{ inputs.target }} + components: cargo, clippy, rustc, rustfmt + + - name: Cache Rust + if: ${{ inputs.restore-cache == 'true' }} + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: ${{ inputs.target }} + save-if: ${{ inputs.save-cache }} + shared-key: ${{ inputs.cache-namespace }} + # Release builds are long and can fail late (signing, notarization, + # upload). Without this the whole compile is thrown away on those runs. + cache-on-failure: true diff --git a/.github/media/RepoBanner.png b/.github/media/RepoBanner.png new file mode 100644 index 00000000..db9ec1e4 Binary files /dev/null and b/.github/media/RepoBanner.png differ diff --git a/.github/workflows/aur_publish.yml b/.github/workflows/aur_publish.yml new file mode 100644 index 00000000..ba6a52ae --- /dev/null +++ b/.github/workflows/aur_publish.yml @@ -0,0 +1,97 @@ +name: Publish AUR Packages + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Release tag (e.g. oneclient-2.0.1)' + required: true + +jobs: + resolve: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.parse.outputs.version }} + steps: + - name: Parse release tag + id: parse + run: | + TAG="${{ github.event.release.tag_name || inputs.tag }}" + if [[ "$TAG" =~ ^oneclient-(.+)$ ]]; then + echo "version=${BASH_REMATCH[1]}" >> "$GITHUB_OUTPUT" + else + echo "::error::Tag '$TAG' does not match oneclient-*" + exit 1 + fi + + publish-source: + needs: resolve + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Compute source sha256 + id: hash + env: + VERSION: ${{ needs.resolve.outputs.version }} + run: | + URL="https://github.com/${{ github.repository }}/archive/refs/tags/oneclient-${VERSION}.tar.gz" + SHA=$(curl -sL "$URL" | sha256sum | cut -d' ' -f1) + echo "sha256=$SHA" >> "$GITHUB_OUTPUT" + + - name: Prepare PKGBUILD + env: + VERSION: ${{ needs.resolve.outputs.version }} + SHA256: ${{ steps.hash.outputs.sha256 }} + run: | + PKGBUILD="distribution/aur/oneclient/PKGBUILD" + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD" + sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD" + sed -i "s/^sha256sums=.*/sha256sums=('${SHA256}')/" "$PKGBUILD" + + - name: Publish to AUR + uses: KSXGitHub/github-actions-deploy-aur@a97f56a8425a7a7f3b8c58607f769c69b089cadb # v3.0.1 + with: + pkgname: oneclient + pkgbuild: distribution/aur/oneclient/PKGBUILD + commit_username: ${{ secrets.AUR_USERNAME }} + commit_email: ${{ secrets.AUR_EMAIL }} + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: "oneclient ${{ needs.resolve.outputs.version }}-1" + + publish-bin: + needs: resolve + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Compute deb sha256 + id: hash + env: + VERSION: ${{ needs.resolve.outputs.version }} + run: | + URL="https://github.com/${{ github.repository }}/releases/download/oneclient-${VERSION}/OneClient_${VERSION}_linux_x86_64.deb" + SHA=$(curl -sL "$URL" | sha256sum | cut -d' ' -f1) + echo "sha256=$SHA" >> "$GITHUB_OUTPUT" + + - name: Prepare PKGBUILD + env: + VERSION: ${{ needs.resolve.outputs.version }} + SHA256: ${{ steps.hash.outputs.sha256 }} + run: | + PKGBUILD="distribution/aur/oneclient-bin/PKGBUILD" + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" "$PKGBUILD" + sed -i "s/^pkgrel=.*/pkgrel=1/" "$PKGBUILD" + sed -i "s/^sha256sums=.*/sha256sums=('${SHA256}')/" "$PKGBUILD" + + - name: Publish to AUR + uses: KSXGitHub/github-actions-deploy-aur@a97f56a8425a7a7f3b8c58607f769c69b089cadb # v3.0.1 + with: + pkgname: oneclient-bin + pkgbuild: distribution/aur/oneclient-bin/PKGBUILD + commit_username: ${{ secrets.AUR_USERNAME }} + commit_email: ${{ secrets.AUR_EMAIL }} + ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }} + commit_message: "oneclient-bin ${{ needs.resolve.outputs.version }}-1" diff --git a/.github/workflows/oneclient_nightly.yml b/.github/workflows/oneclient_nightly.yml new file mode 100644 index 00000000..c73906df --- /dev/null +++ b/.github/workflows/oneclient_nightly.yml @@ -0,0 +1,281 @@ +name: OneClient Nightly (Unsigned) + + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: oneclient-nightly-${{ github.ref }} + cancel-in-progress: true + +# Nightlies are unsigned test builds, so they do not need the release profile's +# whole-program optimization - and that optimization is exactly what makes them +# slow. `lto = "fat"` + `codegen-units = 1` force a single-threaded re-optimization +# of the entire dependency graph at link time, on every run, which no cache can +# ever skip. Thin LTO keeps nearly all of the runtime benefit at a fraction of the +# link cost, and 16 codegen units lets the whole compile use every runner core. +# +# These are cargo's documented profile overrides, so `target/release` stays the +# output directory and every path below is unaffected. The shipped release binary +# is untouched: oneclient_release.yml still builds the fat-LTO profile from +# Cargo.toml. That difference is why the two workflows must not share a cache +# namespace - see `cache-namespace` on the Setup Rust step. +env: + CARGO_PROFILE_RELEASE_LTO: thin + CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16' + +jobs: + build: + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - platform: macos-latest + target: aarch64-apple-darwin + os_key: darwin + arch: aarch64 + formats: app,dmg + - platform: macos-latest + target: x86_64-apple-darwin + os_key: darwin + arch: x86_64 + formats: app,dmg + macos_deployment_target: '10.14' + - platform: ubuntu-22.04 + target: '' + os_key: linux + arch: x86_64 + formats: deb,appimage + - platform: windows-latest + target: '' + os_key: windows + arch: x86_64 + formats: nsis + + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: (macOS) Pin deployment target + if: matrix.macos_deployment_target != '' + shell: bash + run: echo "MACOSX_DEPLOYMENT_TARGET=${{ matrix.macos_deployment_target }}" >> "$GITHUB_ENV" + + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + target: ${{ matrix.target }} + save-cache: true + restore-cache: true + # Separate namespace: nightly's thin-LTO dependency artifacts have + # different cargo fingerprints than the release workflow's fat-LTO ones, + # so a shared cache would just thrash between the two. + cache-namespace: nightly-cache + + - name: (Linux) Install system dependencies + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake pkg-config clang python3 \ + 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 \ + libfuse2 + + - name: Install cargo-packager + uses: ./.github/actions/install-cargo-tool + with: + tool: cargo-packager + version: 0.11.8 + + - name: Build release binary + shell: bash + run: | + if [ -n "${{ matrix.target }}" ]; then + cargo build --release --bin oneclient_app --target ${{ matrix.target }} + else + cargo build --release --bin oneclient_app + fi + + - name: (macOS) Verify deployment target + if: matrix.macos_deployment_target != '' + shell: bash + run: | + set -euo pipefail + bin="target/${{ matrix.target }}/release/oneclient_app" + minos="$(vtool -show-build "$bin" | awk '/minos/ {print $2; exit}')" + echo "linked minos=$minos, declared minimum=${{ matrix.macos_deployment_target }}" + if [ "$(printf '%s\n%s\n' "$minos" "${{ matrix.macos_deployment_target }}" | sort -V | tail -n1)" != "${{ matrix.macos_deployment_target }}" ]; then + echo "::error::Binary requires macOS $minos but the bundle advertises ${{ matrix.macos_deployment_target }}." + exit 1 + fi + + - name: (macOS) Compile app icon catalog + if: startsWith(matrix.platform, 'macos') + shell: bash + run: | + set -euo pipefail + out="packages/oneclient_app/icons/generated" + mkdir -p "$out" + xcrun actool packages/oneclient_app/icons/OneClient.icon \ + --compile "$out" \ + --app-icon OneClient \ + --output-partial-info-plist "$out/partial.plist" \ + --platform macosx \ + --minimum-deployment-target 26.0 \ + --errors --warnings --notices + rm -f "$out/OneClient.icns" + test -f "$out/Assets.car" || { echo "::error::actool produced no Assets.car"; exit 1; } + + - name: (Windows) Stage app-local VC++ runtime + if: matrix.platform == 'windows-latest' + shell: bash + run: python3 distribution/windows/bundle-crt.py + + - name: (Windows) Verify staged runtime is Microsoft-signed + if: matrix.platform == 'windows-latest' + shell: pwsh + run: | + $dlls = @(Get-ChildItem 'target/crt/*.dll') + if ($dlls.Count -eq 0) { Write-Error 'no CRT DLLs staged'; exit 1 } + foreach ($dll in $dlls) { + $sig = Get-AuthenticodeSignature $dll.FullName + Write-Host "$($dll.Name): $($sig.Status) $($sig.SignerCertificate.Subject)" + if ($sig.Status -ne 'Valid') { + Write-Error "$($dll.Name) signature is $($sig.Status), refusing to ship it" + exit 1 + } + if ($sig.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') { + Write-Error "$($dll.Name) is not signed by Microsoft" + exit 1 + } + } + + - name: Package (unsigned) + shell: bash + env: + CI: 'true' + run: | + ARGS="--release --formats ${{ matrix.formats }}" + if [ -n "${{ matrix.target }}" ]; then + ARGS="$ARGS --target ${{ matrix.target }}" + fi + cargo packager $ARGS --verbose + + - name: Resolve packager output dir + id: outdir + shell: bash + run: | + if [ -n "${{ matrix.target }}" ]; then + echo "dir=target/${{ matrix.target }}/release" >> "$GITHUB_OUTPUT" + else + echo "dir=target/release" >> "$GITHUB_OUTPUT" + fi + + - name: (Linux) Install cargo-generate-rpm + if: matrix.platform == 'ubuntu-22.04' + uses: ./.github/actions/install-cargo-tool + with: + tool: cargo-generate-rpm + version: 0.21.0 + + - name: (Linux) Build RPM + if: matrix.platform == 'ubuntu-22.04' + shell: bash + run: | + set -euo pipefail + cargo generate-rpm -p packages/oneclient_app + cp target/generate-rpm/*.rpm "${{ steps.outdir.outputs.dir }}/" + + - name: (Windows) Smoke-test installer layout + if: matrix.platform == 'windows-latest' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $dest = 'C:\oneclient-smoke' + Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue + $exe = Resolve-Path (Get-ChildItem '${{ steps.outdir.outputs.dir }}/*-setup.exe')[0].FullName + # NSIS: /S is silent, /D= sets the install dir and must be the last arg. + $p = Start-Process -FilePath $exe -ArgumentList '/S', "/D=$dest" -Wait -PassThru + if ($p.ExitCode -ne 0) { Write-Error "installer exited $($p.ExitCode)"; exit 1 } + + Write-Host '--- installed layout ---' + Get-ChildItem -Recurse $dest | ForEach-Object { Write-Host $_.FullName } + + if (-not (Test-Path "$dest\oneclient_app.exe")) { + Write-Error "oneclient_app.exe is not at the root of $dest" + exit 1 + } + foreach ($dll in @(Get-ChildItem 'target/crt/*.dll')) { + if (-not (Test-Path (Join-Path $dest $dll.Name))) { + Write-Error "$($dll.Name) did not land beside oneclient_app.exe - cargo-packager put resources elsewhere, so app-local CRT loading will not work" + exit 1 + } + } + Write-Host "OK: $($(Get-ChildItem 'target/crt/*.dll').Count) CRT DLLs sit beside oneclient_app.exe" + + # Upload debug info (dSYM / .dwp / .pdb produced by the release profile's + # split-debuginfo) to Sentry so nightly crash reports get real file/function/ + # line stack traces instead of raw addresses. Runs on every main push, so + # each unique build's symbols land in Sentry (matched by debug-id). Skips + # silently unless SENTRY_AUTH_TOKEN secret + SENTRY_ORG/SENTRY_PROJECT vars + # are configured. + - name: Upload debug symbols to Sentry + shell: bash + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} + OUT_DIR: ${{ steps.outdir.outputs.dir }} + run: | + set -euo pipefail + if [ -z "${SENTRY_AUTH_TOKEN:-}" ] || [ -z "${SENTRY_ORG:-}" ] || [ -z "${SENTRY_PROJECT:-}" ]; then + echo "Sentry credentials not configured (need SENTRY_AUTH_TOKEN secret + SENTRY_ORG/SENTRY_PROJECT vars); skipping debug-symbol upload." + exit 0 + fi + curl -sL https://sentry.io/get-cli/ | bash + export PATH="/usr/local/bin:$PATH" + SYMS="$(mktemp -d)" + shopt -s nullglob + for f in "$OUT_DIR"/oneclient_app "$OUT_DIR"/oneclient_app.exe \ + "$OUT_DIR"/oneclient_app.pdb "$OUT_DIR"/*.dwp "$OUT_DIR"/*.dSYM; do + [ -e "$f" ] || continue + cp -R "$f" "$SYMS/" + done + sentry-cli debug-files upload --include-sources "$SYMS" + + - name: Collect installers + id: collect + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + mkdir -p dist + # Human-facing installers only; skip the .sig-paired updater bundles + # (no updater manifest is produced for unsigned nightly builds). + 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 + [ -e "$f" ] || continue + cp "$f" dist/ + done + 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 + with: + name: oneclient-nightly-${{ matrix.os_key }}-${{ matrix.arch }} + path: dist/* + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/oneclient_release.yml b/.github/workflows/oneclient_release.yml new file mode 100644 index 00000000..1ad5909d --- /dev/null +++ b/.github/workflows/oneclient_release.yml @@ -0,0 +1,640 @@ +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 +# variable into its cache key, so a workflow-level declaration gave this workflow +# a cache namespace that the nightly workflow could never populate - every +# release built the whole dependency graph from scratch. They are scoped to the +# individual steps that actually need them instead. + +concurrency: + group: oneclient-release + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + sha: ${{ steps.version.outputs.sha }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.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 + id: version + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUMP: ${{ inputs.bump }} + shell: bash + run: | + set -euo pipefail + + read_version() { + sed -n -E '/^\[workspace\.package\]/,/^\[/ s/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/p' Cargo.toml | head -n1 + } + + 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" + + # 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." + exit 1 + fi + + RELEASE_EXISTS=0 + IS_DRAFT="" + if IS_DRAFT="$(gh release view "$TAG" --json isDraft -q .isDraft 2>/dev/null)"; then + 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." + 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 + 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 push origin "HEAD:${GITHUB_REF_NAME}" + fi + + SHA="$(git rev-parse HEAD)" + + if [ "$RELEASE_EXISTS" = "1" ]; then + echo "Reusing existing draft release $TAG" + gh release edit "$TAG" --target "$SHA" + else + gh release create "$TAG" \ + --draft \ + --target "$SHA" \ + --title "OneClient $VERSION" \ + --notes "Automated release for OneClient $VERSION." + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + + # --------------------------------------------------------------------------- + package: + needs: prepare + permissions: + contents: write + # Required so SignPath can download the uploaded build artifact. + actions: read + strategy: + fail-fast: false + matrix: + include: + - platform: macos-latest + target: aarch64-apple-darwin + os_key: darwin + arch: aarch64 + formats: app,dmg + - platform: macos-latest + target: x86_64-apple-darwin + os_key: darwin + arch: x86_64 + formats: app,dmg + macos_deployment_target: '10.14' + - platform: ubuntu-22.04 + target: '' + os_key: linux + arch: x86_64 + formats: deb,appimage + - platform: windows-latest + target: '' + os_key: windows + arch: x86_64 + formats: nsis + + runs-on: ${{ matrix.platform }} + steps: + # Pin to the commit `prepare` resolved - with `bump` that is the version + # commit it just pushed, which github.sha would not point at. + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ needs.prepare.outputs.sha }} + + - name: (macOS) Pin deployment target + if: matrix.macos_deployment_target != '' + shell: bash + run: echo "MACOSX_DEPLOYMENT_TARGET=${{ matrix.macos_deployment_target }}" >> "$GITHUB_ENV" + + - name: Setup Rust + uses: ./.github/actions/setup-rust + with: + target: ${{ matrix.target }} + save-cache: true + restore-cache: true + + - name: (Linux) Install system dependencies + if: matrix.platform == 'ubuntu-22.04' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake pkg-config clang python3 \ + 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 \ + libfuse2 + + - name: Install cargo-packager + uses: ./.github/actions/install-cargo-tool + with: + tool: cargo-packager + version: 0.11.8 + + - name: Import Apple Developer certificate + if: startsWith(matrix.platform, 'macos') + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + echo "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12 + security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain + security set-keychain-settings -t 3600 -u build.keychain + security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain + CERT_ID=$(security find-identity -v -p codesigning build.keychain | awk -F'"' '/Developer ID Application/ {print $2; exit}') + if [ -z "$CERT_ID" ]; then + echo "::error::No Developer ID Application certificate found in keychain." + exit 1 + fi + echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> "$GITHUB_ENV" + # cargo-packager only codesigns when `signing-identity` is present in the + # packager config; it does NOT read APPLE_SIGNING_IDENTITY. Inject it into + # the macOS metadata so signing + notarization actually run. + CERT_ID="$CERT_ID" python3 - <<'PY' + import os, re + p = "packages/oneclient_app/Cargo.toml" + s = open(p).read() + ident = os.environ["CERT_ID"] + hdr = "[package.metadata.packager.macos]" + assert hdr in s, "macos packager section not found" + if "signing-identity" not in s: + s = s.replace(hdr, f'{hdr}\nsigning-identity = "{ident}"', 1) + open(p, "w").write(s) + print(f"signing-identity set to: {ident}") + PY + + - name: Build release binary + shell: bash + run: | + if [ -n "${{ matrix.target }}" ]; then + cargo build --release --bin oneclient_app --target ${{ matrix.target }} + else + cargo build --release --bin oneclient_app + fi + + - name: (macOS) Verify deployment target + if: matrix.macos_deployment_target != '' + shell: bash + run: | + set -euo pipefail + bin="target/${{ matrix.target }}/release/oneclient_app" + minos="$(vtool -show-build "$bin" | awk '/minos/ {print $2; exit}')" + echo "linked minos=$minos, declared minimum=${{ matrix.macos_deployment_target }}" + if [ "$(printf '%s\n%s\n' "$minos" "${{ matrix.macos_deployment_target }}" | sort -V | tail -n1)" != "${{ matrix.macos_deployment_target }}" ]; then + echo "::error::Binary requires macOS $minos but the bundle advertises ${{ matrix.macos_deployment_target }}." + exit 1 + fi + + - name: (Windows) Upload unsigned app binary artifact + if: matrix.platform == 'windows-latest' + id: win_upload_unsigned_bin + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: oneclient-windows-binary-unsigned + path: target/release/oneclient_app.exe + + - name: (Windows) Submit SignPath signing request for app binary + if: matrix.platform == 'windows-latest' + uses: signpath/github-action-submit-signing-request@ced31329c0317e779dad2eec2a7c3bb46ea1343e # v1.3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} + project-slug: onelauncher + signing-policy-slug: release-signing + artifact-configuration-slug: onelauncher-binary + github-artifact-id: ${{ steps.win_upload_unsigned_bin.outputs.artifact-id }} + wait-for-completion: true + output-artifact-directory: signed-bin + + - name: (Windows) Swap in signed app binary + if: matrix.platform == 'windows-latest' + shell: bash + run: | + set -euo pipefail + signed="signed-bin/oneclient_app.exe" + if [ ! -e "$signed" ]; then + echo "::error::SignPath did not return signed-bin/oneclient_app.exe" + ls -R signed-bin || true + exit 1 + fi + cp "$signed" target/release/oneclient_app.exe + + - name: (Windows) Verify app binary is signed + if: matrix.platform == 'windows-latest' + shell: pwsh + run: | + $sig = Get-AuthenticodeSignature 'target/release/oneclient_app.exe' + Write-Host "Authenticode status: $($sig.Status)" + Write-Host "Signer: $($sig.SignerCertificate.Subject)" + if ($sig.Status -eq 'NotSigned') { + Write-Error 'oneclient_app.exe carries no Authenticode signature' + exit 1 + } + + - name: (macOS) Compile app icon catalog + if: startsWith(matrix.platform, 'macos') + shell: bash + run: | + set -euo pipefail + out="packages/oneclient_app/icons/generated" + mkdir -p "$out" + xcrun actool packages/oneclient_app/icons/OneClient.icon \ + --compile "$out" \ + --app-icon OneClient \ + --output-partial-info-plist "$out/partial.plist" \ + --platform macosx \ + --minimum-deployment-target 26.0 \ + --errors --warnings --notices + rm -f "$out/OneClient.icns" + test -f "$out/Assets.car" || { echo "::error::actool produced no Assets.car"; exit 1; } + + - name: (Windows) Stage app-local VC++ runtime + if: matrix.platform == 'windows-latest' + shell: bash + run: python3 distribution/windows/bundle-crt.py + + - name: (Windows) Verify staged runtime is Microsoft-signed + if: matrix.platform == 'windows-latest' + shell: pwsh + run: | + $dlls = @(Get-ChildItem 'target/crt/*.dll') + if ($dlls.Count -eq 0) { Write-Error 'no CRT DLLs staged'; exit 1 } + foreach ($dll in $dlls) { + $sig = Get-AuthenticodeSignature $dll.FullName + Write-Host "$($dll.Name): $($sig.Status) $($sig.SignerCertificate.Subject)" + if ($sig.Status -ne 'Valid') { + Write-Error "$($dll.Name) signature is $($sig.Status), refusing to ship it" + exit 1 + } + if ($sig.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') { + Write-Error "$($dll.Name) is not signed by Microsoft" + exit 1 + } + } + + - name: Package + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + CI: ${{ startsWith(matrix.platform, 'macos') && 'false' || 'true' }} + CARGO_PACKAGER_SIGN_PRIVATE_KEY: ${{ secrets.CARGO_PACKAGER_SIGN_PRIVATE_KEY }} + CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD: ${{ secrets.CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD }} + run: | + ARGS="--release --formats ${{ matrix.formats }}" + if [ -n "${{ matrix.target }}" ]; then + ARGS="$ARGS --target ${{ matrix.target }}" + fi + cargo packager $ARGS --verbose + + - name: Resolve packager output dir + id: outdir + shell: bash + run: | + if [ -n "${{ matrix.target }}" ]; then + echo "dir=target/${{ matrix.target }}/release" >> "$GITHUB_OUTPUT" + else + echo "dir=target/release" >> "$GITHUB_OUTPUT" + fi + + - name: (Linux) Install cargo-generate-rpm + if: matrix.platform == 'ubuntu-22.04' + uses: ./.github/actions/install-cargo-tool + with: + tool: cargo-generate-rpm + version: 0.21.0 + + - name: (Linux) Build RPM + if: matrix.platform == 'ubuntu-22.04' + shell: bash + run: | + set -euo pipefail + cargo generate-rpm -p packages/oneclient_app + cp target/generate-rpm/*.rpm "${{ steps.outdir.outputs.dir }}/" + + # Upload debug info (dSYM / .dwp / .pdb produced by the release profile's + # split-debuginfo) to Sentry so crash reports get real file/function/line + # stack traces instead of raw addresses. Skips silently unless the repo has + # a SENTRY_AUTH_TOKEN secret plus SENTRY_ORG / SENTRY_PROJECT variables. + - name: Upload debug symbols to Sentry + shell: bash + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} + OUT_DIR: ${{ steps.outdir.outputs.dir }} + run: | + set -euo pipefail + if [ -z "${SENTRY_AUTH_TOKEN:-}" ] || [ -z "${SENTRY_ORG:-}" ] || [ -z "${SENTRY_PROJECT:-}" ]; then + echo "Sentry credentials not configured (need SENTRY_AUTH_TOKEN secret + SENTRY_ORG/SENTRY_PROJECT vars); skipping debug-symbol upload." + exit 0 + fi + curl -sL https://sentry.io/get-cli/ | bash + export PATH="/usr/local/bin:$PATH" + SYMS="$(mktemp -d)" + shopt -s nullglob + for f in "$OUT_DIR"/oneclient_app "$OUT_DIR"/oneclient_app.exe \ + "$OUT_DIR"/oneclient_app.pdb "$OUT_DIR"/*.dwp "$OUT_DIR"/*.dSYM; do + [ -e "$f" ] || continue + cp -R "$f" "$SYMS/" + done + sentry-cli debug-files upload --include-sources "$SYMS" + + - name: (Windows) Locate installer + if: matrix.platform == 'windows-latest' + id: win_installer + shell: bash + run: echo "path=$(ls ${{ steps.outdir.outputs.dir }}/*-setup.exe | head -n1)" >> "$GITHUB_OUTPUT" + + - name: (Windows) Smoke-test installer layout + if: matrix.platform == 'windows-latest' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $dest = 'C:\oneclient-smoke' + Remove-Item -Recurse -Force $dest -ErrorAction SilentlyContinue + $exe = Resolve-Path '${{ steps.win_installer.outputs.path }}' + # NSIS: /S is silent, /D= sets the install dir and must be the last arg. + $p = Start-Process -FilePath $exe -ArgumentList '/S', "/D=$dest" -Wait -PassThru + if ($p.ExitCode -ne 0) { Write-Error "installer exited $($p.ExitCode)"; exit 1 } + + Write-Host '--- installed layout ---' + Get-ChildItem -Recurse $dest | ForEach-Object { Write-Host $_.FullName } + + if (-not (Test-Path "$dest\oneclient_app.exe")) { + Write-Error "oneclient_app.exe is not at the root of $dest" + exit 1 + } + foreach ($dll in @(Get-ChildItem 'target/crt/*.dll')) { + if (-not (Test-Path (Join-Path $dest $dll.Name))) { + Write-Error "$($dll.Name) did not land beside oneclient_app.exe - cargo-packager put resources elsewhere, so app-local CRT loading will not work" + exit 1 + } + } + Write-Host "OK: $($(Get-ChildItem 'target/crt/*.dll').Count) CRT DLLs sit beside oneclient_app.exe" + + - name: (Windows) Upload unsigned installer artifact + if: matrix.platform == 'windows-latest' + id: win_upload_unsigned + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: oneclient-windows-installer-unsigned + path: ${{ steps.win_installer.outputs.path }} + + - name: (Windows) Submit SignPath signing request + if: matrix.platform == 'windows-latest' + uses: signpath/github-action-submit-signing-request@ced31329c0317e779dad2eec2a7c3bb46ea1343e # v1.3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} + project-slug: onelauncher + signing-policy-slug: release-signing + artifact-configuration-slug: onelauncher-installer + github-artifact-id: ${{ steps.win_upload_unsigned.outputs.artifact-id }} + wait-for-completion: true + output-artifact-directory: signed + + - name: (Windows) Replace installer with signed one + recompute updater sig + if: matrix.platform == 'windows-latest' + shell: bash + env: + CARGO_PACKAGER_SIGN_PRIVATE_KEY: ${{ secrets.CARGO_PACKAGER_SIGN_PRIVATE_KEY }} + CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD: ${{ secrets.CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD }} + run: | + signed="$(ls signed/*-setup.exe | head -n1)" + # Overwrite the unsigned installer in the packager out dir with the signed exe. + cp "$signed" "${{ steps.win_installer.outputs.path }}" + # Recompute the updater signature over the SIGNED exe. + cargo packager signer sign \ + --private-key "$CARGO_PACKAGER_SIGN_PRIVATE_KEY" \ + --password "$CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD" \ + "${{ steps.win_installer.outputs.path }}" + + - name: Upload installers and build manifest fragment + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.prepare.outputs.tag }} + OUT_DIR: ${{ steps.outdir.outputs.dir }} + OS_KEY: ${{ matrix.os_key }} + ARCH: ${{ matrix.arch }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + shopt -s nullglob + + # Identify the single updater artifact for this platform (the file with a + # sibling .sig): -setup.exe (nsis), *.app.tar.gz (app), *.AppImage (appimage). + # NOTE: nullglob is on, so an unmatched glob expands to nothing. Use arrays + # rather than `ls | head` - the latter falls back to listing the cwd + # when nothing matches, silently picking an unrelated file. + fmt=""; ext=""; matches=() + case "$OS_KEY" in + windows) matches=("$OUT_DIR"/*-setup.exe); fmt="nsis"; ext="exe" ;; + darwin) matches=("$OUT_DIR"/*.app.tar.gz); fmt="app"; ext="app.tar.gz" ;; + linux) matches=("$OUT_DIR"/*.AppImage); fmt="appimage"; ext="AppImage" ;; + esac + updater_file="${matches[0]:-}" + if [ -z "$updater_file" ]; then + echo "::error::No updater artifact found in $OUT_DIR for $OS_KEY" + exit 1 + fi + + # The updater bundle isn't always arch-tagged by cargo-packager, so the two + # macOS legs would collide on the release. Rename the updater artifact + its .sig + # to an arch-unique name before upload, and point the manifest at that name. + unique="OneClient_${VERSION}_${OS_KEY}_${ARCH}.${ext}" + cp "$updater_file" "$OUT_DIR/$unique" + cp "$updater_file.sig" "$OUT_DIR/$unique.sig" + + # cargo-packager names the .deb after the binary (oneclient_app__amd64.deb). + # Rename it to the same OneClient___ scheme as the other assets so + # the AUR oneclient-bin package can point at a stable, predictable URL. + deb_unique="" + deb_matches=("$OUT_DIR"/*.deb) + if [ -e "${deb_matches[0]:-}" ]; then + deb_unique="OneClient_${VERSION}_${OS_KEY}_${ARCH}.deb" + cp "${deb_matches[0]}" "$OUT_DIR/$deb_unique" + fi + + # Same treatment for the .rpm (cargo-generate-rpm names it + # oneclient_app--1.x86_64.rpm). + rpm_unique="" + rpm_matches=("$OUT_DIR"/*.rpm) + if [ -e "${rpm_matches[0]:-}" ]; then + rpm_unique="OneClient_${VERSION}_${OS_KEY}_${ARCH}.rpm" + cp "${rpm_matches[0]}" "$OUT_DIR/$rpm_unique" + fi + + # Upload human-facing installers (dmg/renamed deb/renamed rpm/AppImage/signed exe) + + # the renamed updater bundle and its .sig. Skip the un-renamed updater bundle to avoid clashes. + for f in "$OUT_DIR"/*.dmg ${deb_unique:+"$OUT_DIR/$deb_unique"} ${rpm_unique:+"$OUT_DIR/$rpm_unique"} "$OUT_DIR"/*.AppImage "$OUT_DIR"/*-setup.exe "$OUT_DIR/$unique" "$OUT_DIR/$unique.sig"; do + [ -e "$f" ] || continue + gh release upload "$TAG" "$f" --clobber + done + + base="$unique" + mkdir -p fragments + case "$OS_KEY" in + darwin) KEYS="darwin-${ARCH} macos-${ARCH}" ;; + *) KEYS="${OS_KEY}-${ARCH}" ;; + esac + SIG="$(cat "$updater_file.sig")" \ + URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${base}" \ + FMT="$fmt" \ + KEYS="$KEYS" \ + FRAG_NAME="${OS_KEY}-${ARCH}" \ + python3 - <<'PY' + import json, os + entry = { + "signature": os.environ["SIG"], + "url": os.environ["URL"], + "format": os.environ["FMT"], + } + frag = {key: entry for key in os.environ["KEYS"].split()} + with open(f"fragments/{os.environ['FRAG_NAME']}.json", "w") as f: + json.dump(frag, f) + PY + + - name: Upload manifest fragment + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: manifest-${{ matrix.os_key }}-${{ matrix.arch }} + path: fragments/*.json + + manifest: + needs: [prepare, package] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download all manifest fragments + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: manifest-* + path: fragments + merge-multiple: true + + - name: Assemble latest.json + env: + VERSION: ${{ needs.prepare.outputs.version }} + run: | + python3 - <<'PY' + import json, glob, os, datetime + platforms = {} + for path in glob.glob("fragments/*.json"): + with open(path) as f: + platforms.update(json.load(f)) + manifest = { + "version": os.environ["VERSION"], + "pub_date": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "platforms": platforms, + } + with open("latest.json", "w") as f: + json.dump(manifest, f, indent=2) + print(json.dumps(manifest, indent=2)) + PY + + - name: Upload latest.json to release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.prepare.outputs.tag }} + run: gh release upload "$TAG" latest.json --clobber --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 102b3489..b4999407 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,9 @@ on: workflow_dispatch: inputs: tag: - description: 'Release Tag (e.g. v2.2.3)' + description: 'Release Tag (e.g. v2.3.1)' required: false - default: 'v2.2.3-c2' + default: 'v2.3.1-c1' type: string permissions: @@ -138,7 +138,7 @@ jobs: elif [ -n "${{ inputs.tag }}" ]; then echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT else - echo "tag=v2.2.3-c2" >> $GITHUB_OUTPUT + echo "tag=v2.3.1-c1" >> $GITHUB_OUTPUT fi - name: Clean up previous release if exists @@ -155,7 +155,10 @@ jobs: name: "OneLauncher-Cracked ${{ steps.release_tag.outputs.tag }}" body: | ### What's New in ${{ steps.release_tag.outputs.tag }} - - **Fixed In-Game Multiplayer Skins**: Fixed an issue where custom skins were cloned across all players in multiplayer servers. Custom skins now target only your account's specific character slot while preserving all other players' skins/models, alongside pre-configured CustomSkinLoader support for Ely.by & SkinRestorer. + - **Rebased onto Official Upstream 2.3.1**: Integrated all upstream OneLauncher 2.3.1 updates, fixes, and performance improvements. + - **Offline / Cracked Accounts**: Full offline account support with customizable username and avatar caching. + - **Custom Offline Skins & targeted multiplayer injection**: Custom 3D skin changer, per-account UUID hash targeting preventing multiplayer skin cloning, and automatic CustomSkinLoader config generation. + - **Integrated Auto-Updater**: Native background check and direct GitHub Releases binary downloads. draft: false prerelease: false generate_release_notes: false diff --git a/.gitignore b/.gitignore index a5a928f9..a3d27549 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ /target -/dist -*.swp -*~ .DS_Store -Thumbs.db +/.idea +OneLauncher.iml + +/packages/oneclient_app/icons/generated/ diff --git a/.signpath/oneclient-binary.artifact-configuration.xml b/.signpath/oneclient-binary.artifact-configuration.xml new file mode 100644 index 00000000..fb1cd9ad --- /dev/null +++ b/.signpath/oneclient-binary.artifact-configuration.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/.signpath/oneclient.artifact-configuration.xml b/.signpath/oneclient.artifact-configuration.xml new file mode 100644 index 00000000..f81ffb5d --- /dev/null +++ b/.signpath/oneclient.artifact-configuration.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/.sqlx/query-011448f2ce79e981700ee75c8cd5db5e0fcb793b72671c20a925b73ee6fb9456.json b/.sqlx/query-011448f2ce79e981700ee75c8cd5db5e0fcb793b72671c20a925b73ee6fb9456.json new file mode 100644 index 00000000..7031ab6c --- /dev/null +++ b/.sqlx/query-011448f2ce79e981700ee75c8cd5db5e0fcb793b72671c20a925b73ee6fb9456.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE cluster_artifacts SET seen_status = ? WHERE seen_status <> ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "011448f2ce79e981700ee75c8cd5db5e0fcb793b72671c20a925b73ee6fb9456" +} diff --git a/packages/oneclient_db/.sqlx/query-b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af.json b/.sqlx/query-071d02aba0d2eb073b8ca1e6488087101255147c064612fad99500d3c993f61f.json similarity index 73% rename from packages/oneclient_db/.sqlx/query-b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af.json rename to .sqlx/query-071d02aba0d2eb073b8ca1e6488087101255147c064612fad99500d3c993f61f.json index c7ab1753..7ec4b478 100644 --- a/packages/oneclient_db/.sqlx/query-b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af.json +++ b/.sqlx/query-071d02aba0d2eb073b8ca1e6488087101255147c064612fad99500d3c993f61f.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n\t\tSELECT cluster_id, hash, cluster_file_name, enabled\n\t\tFROM cluster_artifacts\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\t", + "query": "\n\t\tSELECT cluster_id, hash, cluster_file_name, enabled, seen_status\n\t\tFROM cluster_artifacts\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\t", "describe": { "columns": [ { @@ -46,6 +46,17 @@ "name": "enabled" } } + }, + { + "name": "seen_status", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "seen_status" + } + } } ], "parameters": { @@ -55,8 +66,9 @@ false, false, false, + false, false ] }, - "hash": "b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af" + "hash": "071d02aba0d2eb073b8ca1e6488087101255147c064612fad99500d3c993f61f" } diff --git a/packages/oneclient_db/.sqlx/query-0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba.json b/.sqlx/query-0c3651cf175cb900c2466f0e97beca338b95637d43d9d067b974327f7720637b.json similarity index 77% rename from packages/oneclient_db/.sqlx/query-0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba.json rename to .sqlx/query-0c3651cf175cb900c2466f0e97beca338b95637d43d9d067b974327f7720637b.json index 5da3a9d7..3ce34f5c 100644 --- a/packages/oneclient_db/.sqlx/query-0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba.json +++ b/.sqlx/query-0c3651cf175cb900c2466f0e97beca338b95637d43d9d067b974327f7720637b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n FROM java_versions\n ORDER BY major DESC, version DESC\n ", + "query": "\n SELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\", probe_version\n FROM java_versions\n ORDER BY major DESC, version DESC\n ", "describe": { "columns": [ { @@ -68,6 +68,17 @@ "name": "is_jdk" } } + }, + { + "name": "probe_version", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "probe_version" + } + } } ], "parameters": { @@ -79,8 +90,9 @@ false, false, false, + false, false ] }, - "hash": "0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba" + "hash": "0c3651cf175cb900c2466f0e97beca338b95637d43d9d067b974327f7720637b" } diff --git a/.sqlx/query-0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba.json b/.sqlx/query-0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba.json deleted file mode 100644 index 5da3a9d7..00000000 --- a/.sqlx/query-0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n SELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n FROM java_versions\n ORDER BY major DESC, version DESC\n ", - "describe": { - "columns": [ - { - "name": "absolute_path", - "ordinal": 0, - "type_info": "Text", - "origin": { - "Table": { - "table": "java_versions", - "name": "absolute_path" - } - } - }, - { - "name": "major", - "ordinal": 1, - "type_info": "Integer", - "origin": { - "Table": { - "table": "java_versions", - "name": "major" - } - } - }, - { - "name": "version", - "ordinal": 2, - "type_info": "Text", - "origin": { - "Table": { - "table": "java_versions", - "name": "version" - } - } - }, - { - "name": "vendor", - "ordinal": 3, - "type_info": "Text", - "origin": { - "Table": { - "table": "java_versions", - "name": "vendor" - } - } - }, - { - "name": "os_arch", - "ordinal": 4, - "type_info": "Text", - "origin": { - "Table": { - "table": "java_versions", - "name": "os_arch" - } - } - }, - { - "name": "is_jdk: bool", - "ordinal": 5, - "type_info": "Integer", - "origin": { - "Table": { - "table": "java_versions", - "name": "is_jdk" - } - } - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba" -} diff --git a/packages/oneclient_db/.sqlx/query-33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b.json b/.sqlx/query-14ab205c5b8d4c1c435814e4b7a155acbae41a67925b6b179b82dea1758d621a.json similarity index 77% rename from packages/oneclient_db/.sqlx/query-33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b.json rename to .sqlx/query-14ab205c5b8d4c1c435814e4b7a155acbae41a67925b6b179b82dea1758d621a.json index caaad113..e50e191f 100644 --- a/packages/oneclient_db/.sqlx/query-33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b.json +++ b/.sqlx/query-14ab205c5b8d4c1c435814e4b7a155acbae41a67925b6b179b82dea1758d621a.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n\t\tSELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n\t\tFROM java_versions\n\t\tWHERE major = ?\n\t\tORDER BY version DESC\n\t\tLIMIT 1\n\t\t", + "query": "\n\t\tSELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\", probe_version\n\t\tFROM java_versions\n\t\tWHERE major = ?\n\t\tORDER BY version DESC\n\t\tLIMIT 1\n\t\t", "describe": { "columns": [ { @@ -68,6 +68,17 @@ "name": "is_jdk" } } + }, + { + "name": "probe_version", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "probe_version" + } + } } ], "parameters": { @@ -79,8 +90,9 @@ false, false, false, + false, false ] }, - "hash": "33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b" + "hash": "14ab205c5b8d4c1c435814e4b7a155acbae41a67925b6b179b82dea1758d621a" } diff --git a/.sqlx/query-24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51.json b/.sqlx/query-24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51.json deleted file mode 100644 index f01cd4bc..00000000 --- a/.sqlx/query-24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n\t\tINSERT INTO cluster_artifacts (cluster_id, hash, cluster_file_name, enabled)\n\t\tVALUES (?, ?, ?, 1)\n\t\tON CONFLICT(cluster_id, hash) DO UPDATE SET\n\t\t\tcluster_file_name = excluded.cluster_file_name\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled\n\t\t", - "describe": { - "columns": [ - { - "name": "cluster_id", - "ordinal": 0, - "type_info": "Integer", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "cluster_id" - } - } - }, - { - "name": "hash", - "ordinal": 1, - "type_info": "Text", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "hash" - } - } - }, - { - "name": "cluster_file_name", - "ordinal": 2, - "type_info": "Text", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "cluster_file_name" - } - } - }, - { - "name": "enabled", - "ordinal": 3, - "type_info": "Integer", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "enabled" - } - } - } - ], - "parameters": { - "Right": 3 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51" -} diff --git a/packages/oneclient_db/.sqlx/query-24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51.json b/.sqlx/query-25259e9d6eda7f5113e0d88f00b24b28b98de1eda40a1e25d48bbcf212af42b5.json similarity index 78% rename from packages/oneclient_db/.sqlx/query-24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51.json rename to .sqlx/query-25259e9d6eda7f5113e0d88f00b24b28b98de1eda40a1e25d48bbcf212af42b5.json index f01cd4bc..0fdea98a 100644 --- a/packages/oneclient_db/.sqlx/query-24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51.json +++ b/.sqlx/query-25259e9d6eda7f5113e0d88f00b24b28b98de1eda40a1e25d48bbcf212af42b5.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n\t\tINSERT INTO cluster_artifacts (cluster_id, hash, cluster_file_name, enabled)\n\t\tVALUES (?, ?, ?, 1)\n\t\tON CONFLICT(cluster_id, hash) DO UPDATE SET\n\t\t\tcluster_file_name = excluded.cluster_file_name\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled\n\t\t", + "query": "\n\t\tINSERT INTO cluster_artifacts (cluster_id, hash, cluster_file_name, enabled)\n\t\tVALUES (?, ?, ?, 1)\n\t\tON CONFLICT(cluster_id, hash) DO UPDATE SET\n\t\t\tcluster_file_name = excluded.cluster_file_name\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled, seen_status\n\t\t", "describe": { "columns": [ { @@ -46,6 +46,17 @@ "name": "enabled" } } + }, + { + "name": "seen_status", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "seen_status" + } + } } ], "parameters": { @@ -55,8 +66,9 @@ false, false, false, + false, false ] }, - "hash": "24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51" + "hash": "25259e9d6eda7f5113e0d88f00b24b28b98de1eda40a1e25d48bbcf212af42b5" } diff --git a/.sqlx/query-bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca.json b/.sqlx/query-25f4eb2e9040bb8f1c9a38ca41a773225099f150c3fc8a41d31fd40c09711d0c.json similarity index 67% rename from .sqlx/query-bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca.json rename to .sqlx/query-25f4eb2e9040bb8f1c9a38ca41a773225099f150c3fc8a41d31fd40c09711d0c.json index b79fa719..11ce2107 100644 --- a/.sqlx/query-bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca.json +++ b/.sqlx/query-25f4eb2e9040bb8f1c9a38ca41a773225099f150c3fc8a41d31fd40c09711d0c.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n\t\tSELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled\n\t\tFROM cluster_artifacts ca\n\t\tJOIN provider_releases pr ON pr.hash = ca.hash\n\t\tWHERE ca.cluster_id = ?\n\t\t\tAND pr.provider = ?\n\t\t\tAND pr.project_id = ?\n\t\t\tAND ca.hash <> ?\n\t\t", + "query": "\n\t\tSELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled, ca.seen_status\n\t\tFROM cluster_artifacts ca\n\t\tJOIN provider_releases pr ON pr.hash = ca.hash\n\t\tWHERE ca.cluster_id = ?\n\t\t\tAND pr.provider = ?\n\t\t\tAND pr.project_id = ?\n\t\t\tAND ca.hash <> ?\n\t\t", "describe": { "columns": [ { @@ -46,6 +46,17 @@ "name": "enabled" } } + }, + { + "name": "seen_status", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "seen_status" + } + } } ], "parameters": { @@ -55,8 +66,9 @@ false, false, false, + false, false ] }, - "hash": "bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca" + "hash": "25f4eb2e9040bb8f1c9a38ca41a773225099f150c3fc8a41d31fd40c09711d0c" } diff --git a/.sqlx/query-f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2.json b/.sqlx/query-331bee3da454f39a8a2cc8ed4e5338b743a7cd033fdef023c4b3b93827248be1.json similarity index 78% rename from .sqlx/query-f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2.json rename to .sqlx/query-331bee3da454f39a8a2cc8ed4e5338b743a7cd033fdef023c4b3b93827248be1.json index 27530fbf..23c27b31 100644 --- a/.sqlx/query-f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2.json +++ b/.sqlx/query-331bee3da454f39a8a2cc8ed4e5338b743a7cd033fdef023c4b3b93827248be1.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n FROM java_versions\n WHERE absolute_path = ?\n ", + "query": "\n SELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\", probe_version\n FROM java_versions\n WHERE absolute_path = ?\n ", "describe": { "columns": [ { @@ -68,6 +68,17 @@ "name": "is_jdk" } } + }, + { + "name": "probe_version", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "probe_version" + } + } } ], "parameters": { @@ -79,8 +90,9 @@ false, false, false, + false, false ] }, - "hash": "f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2" + "hash": "331bee3da454f39a8a2cc8ed4e5338b743a7cd033fdef023c4b3b93827248be1" } diff --git a/.sqlx/query-33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b.json b/.sqlx/query-33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b.json deleted file mode 100644 index caaad113..00000000 --- a/.sqlx/query-33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n\t\tSELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n\t\tFROM java_versions\n\t\tWHERE major = ?\n\t\tORDER BY version DESC\n\t\tLIMIT 1\n\t\t", - "describe": { - "columns": [ - { - "name": "absolute_path", - "ordinal": 0, - "type_info": "Text", - "origin": { - "Table": { - "table": "java_versions", - "name": "absolute_path" - } - } - }, - { - "name": "major", - "ordinal": 1, - "type_info": "Integer", - "origin": { - "Table": { - "table": "java_versions", - "name": "major" - } - } - }, - { - "name": "version", - "ordinal": 2, - "type_info": "Text", - "origin": { - "Table": { - "table": "java_versions", - "name": "version" - } - } - }, - { - "name": "vendor", - "ordinal": 3, - "type_info": "Text", - "origin": { - "Table": { - "table": "java_versions", - "name": "vendor" - } - } - }, - { - "name": "os_arch", - "ordinal": 4, - "type_info": "Text", - "origin": { - "Table": { - "table": "java_versions", - "name": "os_arch" - } - } - }, - { - "name": "is_jdk: bool", - "ordinal": 5, - "type_info": "Integer", - "origin": { - "Table": { - "table": "java_versions", - "name": "is_jdk" - } - } - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b" -} diff --git a/.sqlx/query-3b87531a1231c467072ec4d1f9afd3c93114777031250c7f9e74f6b9eef4ab9f.json b/.sqlx/query-3b87531a1231c467072ec4d1f9afd3c93114777031250c7f9e74f6b9eef4ab9f.json new file mode 100644 index 00000000..e2886846 --- /dev/null +++ b/.sqlx/query-3b87531a1231c467072ec4d1f9afd3c93114777031250c7f9e74f6b9eef4ab9f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO cluster_optional_mods\n (cluster_id, bundle_name, package_id, bundle_version_id, seen_status)\n VALUES (?, ?, ?, ?, ?)\n ON CONFLICT(cluster_id, package_id) DO UPDATE SET\n bundle_name = excluded.bundle_name,\n bundle_version_id = excluded.bundle_version_id\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "3b87531a1231c467072ec4d1f9afd3c93114777031250c7f9e74f6b9eef4ab9f" +} diff --git a/packages/oneclient_db/.sqlx/query-0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813.json b/.sqlx/query-5b3dd7940a9bd6f0067fac7b5f21e83f8d9eeceda6b1c53c5ce5b735a8fe2a71.json similarity index 54% rename from packages/oneclient_db/.sqlx/query-0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813.json rename to .sqlx/query-5b3dd7940a9bd6f0067fac7b5f21e83f8d9eeceda6b1c53c5ce5b735a8fe2a71.json index 8adcc90a..73de2a51 100644 --- a/packages/oneclient_db/.sqlx/query-0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813.json +++ b/.sqlx/query-5b3dd7940a9bd6f0067fac7b5f21e83f8d9eeceda6b1c53c5ce5b735a8fe2a71.json @@ -1,93 +1,82 @@ { "db_name": "SQLite", - "query": "\n SELECT\n cluster_id, hash, cluster_file_name, enabled,\n bundle_name, bundle_version_id, package_id, installed_at\n FROM cluster_artifacts\n WHERE cluster_id = ? AND bundle_name IS NOT NULL\n ", + "query": "\n SELECT id, cluster_id, bundle_name, package_id, bundle_version_id,\n seen_status, queued_at\n FROM cluster_optional_mods\n WHERE cluster_id = ?\n ORDER BY queued_at, id\n ", "describe": { "columns": [ { - "name": "cluster_id", + "name": "id", "ordinal": 0, "type_info": "Integer", "origin": { "Table": { - "table": "cluster_artifacts", + "table": "cluster_optional_mods", + "name": "id" + } + } + }, + { + "name": "cluster_id", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_optional_mods", "name": "cluster_id" } } }, { - "name": "hash", - "ordinal": 1, - "type_info": "Text", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "hash" - } - } - }, - { - "name": "cluster_file_name", + "name": "bundle_name", "ordinal": 2, "type_info": "Text", "origin": { "Table": { - "table": "cluster_artifacts", - "name": "cluster_file_name" - } - } - }, - { - "name": "enabled", - "ordinal": 3, - "type_info": "Integer", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "enabled" - } - } - }, - { - "name": "bundle_name", - "ordinal": 4, - "type_info": "Text", - "origin": { - "Table": { - "table": "cluster_artifacts", + "table": "cluster_optional_mods", "name": "bundle_name" } } }, - { - "name": "bundle_version_id", - "ordinal": 5, - "type_info": "Text", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "bundle_version_id" - } - } - }, { "name": "package_id", - "ordinal": 6, + "ordinal": 3, "type_info": "Text", "origin": { "Table": { - "table": "cluster_artifacts", + "table": "cluster_optional_mods", "name": "package_id" } } }, { - "name": "installed_at", - "ordinal": 7, + "name": "bundle_version_id", + "ordinal": 4, "type_info": "Text", "origin": { "Table": { - "table": "cluster_artifacts", - "name": "installed_at" + "table": "cluster_optional_mods", + "name": "bundle_version_id" + } + } + }, + { + "name": "seen_status", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_optional_mods", + "name": "seen_status" + } + } + }, + { + "name": "queued_at", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_optional_mods", + "name": "queued_at" } } } @@ -100,11 +89,10 @@ false, false, false, - true, - true, - true, - true + false, + false, + false ] }, - "hash": "0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813" + "hash": "5b3dd7940a9bd6f0067fac7b5f21e83f8d9eeceda6b1c53c5ce5b735a8fe2a71" } diff --git a/.sqlx/query-b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af.json b/.sqlx/query-b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af.json deleted file mode 100644 index c7ab1753..00000000 --- a/.sqlx/query-b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n\t\tSELECT cluster_id, hash, cluster_file_name, enabled\n\t\tFROM cluster_artifacts\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\t", - "describe": { - "columns": [ - { - "name": "cluster_id", - "ordinal": 0, - "type_info": "Integer", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "cluster_id" - } - } - }, - { - "name": "hash", - "ordinal": 1, - "type_info": "Text", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "hash" - } - } - }, - { - "name": "cluster_file_name", - "ordinal": 2, - "type_info": "Text", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "cluster_file_name" - } - } - }, - { - "name": "enabled", - "ordinal": 3, - "type_info": "Integer", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "enabled" - } - } - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af" -} diff --git a/.sqlx/query-4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f.json b/.sqlx/query-b68194fc40d9fdd508ab6cc354fff662ba4b7ca17c1c4be3e51e30fcb3d0cb16.json similarity index 75% rename from .sqlx/query-4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f.json rename to .sqlx/query-b68194fc40d9fdd508ab6cc354fff662ba4b7ca17c1c4be3e51e30fcb3d0cb16.json index 5eb036f5..e229a717 100644 --- a/.sqlx/query-4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f.json +++ b/.sqlx/query-b68194fc40d9fdd508ab6cc354fff662ba4b7ca17c1c4be3e51e30fcb3d0cb16.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n\t\tUPDATE cluster_artifacts\n\t\tSET cluster_file_name = ?, enabled = ?\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled\n\t\t", + "query": "\n\t\tUPDATE cluster_artifacts\n\t\tSET cluster_file_name = ?, enabled = ?\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled, seen_status\n\t\t", "describe": { "columns": [ { @@ -46,6 +46,17 @@ "name": "enabled" } } + }, + { + "name": "seen_status", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "seen_status" + } + } } ], "parameters": { @@ -55,8 +66,9 @@ false, false, false, + false, false ] }, - "hash": "4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f" + "hash": "b68194fc40d9fdd508ab6cc354fff662ba4b7ca17c1c4be3e51e30fcb3d0cb16" } diff --git a/.sqlx/query-cd9609d707fa11bc4c246cd69c879159d23af897bdcb0bd9f04df73a7805a713.json b/.sqlx/query-cd9609d707fa11bc4c246cd69c879159d23af897bdcb0bd9f04df73a7805a713.json new file mode 100644 index 00000000..d265164b --- /dev/null +++ b/.sqlx/query-cd9609d707fa11bc4c246cd69c879159d23af897bdcb0bd9f04df73a7805a713.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE cluster_artifacts\n\t\tSET seen_status = ?\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "cd9609d707fa11bc4c246cd69c879159d23af897bdcb0bd9f04df73a7805a713" +} diff --git a/.sqlx/query-d1fc4c93f507d459e590cda5b5830a1e26e3047139db08a9f6b90c84185402a6.json b/.sqlx/query-d1fc4c93f507d459e590cda5b5830a1e26e3047139db08a9f6b90c84185402a6.json new file mode 100644 index 00000000..5ed10086 --- /dev/null +++ b/.sqlx/query-d1fc4c93f507d459e590cda5b5830a1e26e3047139db08a9f6b90c84185402a6.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tca.hash AS \"hash!: String\",\n\t\t\tca.cluster_file_name AS \"cluster_file_name!: String\",\n\t\t\tca.enabled AS \"enabled!: i64\",\n\t\t\tca.seen_status AS \"seen_status!: i64\",\n\t\t\ta.content_type AS \"content_type!: i64\",\n\t\t\ta.file_name AS \"file_name!: String\",\n\t\t\tpr.provider AS \"provider?: i64\",\n\t\t\tpr.project_id AS \"project_id?: String\",\n\t\t\tpr.version_id AS \"version_id?: String\",\n\t\t\tpr.display_name AS \"display_name?: String\",\n\t\t\tpr.display_version AS \"display_version?: String\",\n\t\t\tpr.published_at AS \"published_at?: String\"\n\t\tFROM cluster_artifacts ca\n\t\tJOIN artifacts a ON a.hash = ca.hash\n\t\tLEFT JOIN provider_releases pr ON pr.rowid = (\n\t\t\tSELECT rowid\n\t\t\tFROM provider_releases\n\t\t\tWHERE hash = ca.hash\n\t\t\tORDER BY published_at DESC, provider, project_id, version_id\n\t\t\tLIMIT 1\n\t\t)\n\t\tWHERE ca.cluster_id = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "hash!: String", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "hash" + } + } + }, + { + "name": "cluster_file_name!: String", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_file_name" + } + } + }, + { + "name": "enabled!: i64", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "enabled" + } + } + }, + { + "name": "seen_status!: i64", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "seen_status" + } + } + }, + { + "name": "content_type!: i64", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "artifacts", + "name": "content_type" + } + } + }, + { + "name": "file_name!: String", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "artifacts", + "name": "file_name" + } + } + }, + { + "name": "provider?: i64", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "provider_releases", + "name": "provider" + } + } + }, + { + "name": "project_id?: String", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "project_id" + } + } + }, + { + "name": "version_id?: String", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "version_id" + } + } + }, + { + "name": "display_name?: String", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "display_name" + } + } + }, + { + "name": "display_version?: String", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "display_version" + } + } + }, + { + "name": "published_at?: String", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "published_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "d1fc4c93f507d459e590cda5b5830a1e26e3047139db08a9f6b90c84185402a6" +} diff --git a/.sqlx/query-e03d85ee97b3a632d5946019865d9efb5d665e521519d05b16a4b66b066987ca.json b/.sqlx/query-e03d85ee97b3a632d5946019865d9efb5d665e521519d05b16a4b66b066987ca.json new file mode 100644 index 00000000..9e6af624 --- /dev/null +++ b/.sqlx/query-e03d85ee97b3a632d5946019865d9efb5d665e521519d05b16a4b66b066987ca.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE cluster_optional_mods SET seen_status = ?\n WHERE cluster_id = ? AND package_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "e03d85ee97b3a632d5946019865d9efb5d665e521519d05b16a4b66b066987ca" +} diff --git a/.sqlx/query-e2ee6fbe534dd47ce658d547c2bfc3a4e86dfce86da3a4ffe35d25d1fa412d13.json b/.sqlx/query-e2ee6fbe534dd47ce658d547c2bfc3a4e86dfce86da3a4ffe35d25d1fa412d13.json new file mode 100644 index 00000000..5ecd038a --- /dev/null +++ b/.sqlx/query-e2ee6fbe534dd47ce658d547c2bfc3a4e86dfce86da3a4ffe35d25d1fa412d13.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM cluster_optional_mods\n WHERE cluster_id = ? AND package_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "e2ee6fbe534dd47ce658d547c2bfc3a4e86dfce86da3a4ffe35d25d1fa412d13" +} diff --git a/.sqlx/query-e372eec993f6e82238fe95b1c74dbb377ee08e44c7e8c3b5eaa6f3b8b229fddf.json b/.sqlx/query-e372eec993f6e82238fe95b1c74dbb377ee08e44c7e8c3b5eaa6f3b8b229fddf.json deleted file mode 100644 index 0a86cb15..00000000 --- a/.sqlx/query-e372eec993f6e82238fe95b1c74dbb377ee08e44c7e8c3b5eaa6f3b8b229fddf.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n\t\tSELECT cluster_id, hash, cluster_file_name, enabled\n\t\tFROM cluster_artifacts\n\t\tWHERE cluster_id = ?\n\t\t", - "describe": { - "columns": [ - { - "name": "cluster_id", - "ordinal": 0, - "type_info": "Integer", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "cluster_id" - } - } - }, - { - "name": "hash", - "ordinal": 1, - "type_info": "Text", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "hash" - } - } - }, - { - "name": "cluster_file_name", - "ordinal": 2, - "type_info": "Text", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "cluster_file_name" - } - } - }, - { - "name": "enabled", - "ordinal": 3, - "type_info": "Integer", - "origin": { - "Table": { - "table": "cluster_artifacts", - "name": "enabled" - } - } - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "e372eec993f6e82238fe95b1c74dbb377ee08e44c7e8c3b5eaa6f3b8b229fddf" -} diff --git a/.sqlx/query-010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc.json b/.sqlx/query-f5dbcf7798afb495efe228a4ba7d1ddd9904cc25f34bef3c16f29cfb141158f0.json similarity index 66% rename from .sqlx/query-010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc.json rename to .sqlx/query-f5dbcf7798afb495efe228a4ba7d1ddd9904cc25f34bef3c16f29cfb141158f0.json index 33514fa6..044b3eb0 100644 --- a/.sqlx/query-010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc.json +++ b/.sqlx/query-f5dbcf7798afb495efe228a4ba7d1ddd9904cc25f34bef3c16f29cfb141158f0.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n\t\tINSERT INTO java_versions (absolute_path, major, version, vendor, os_arch, is_jdk)\n\t\tVALUES (?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(absolute_path) DO UPDATE SET\n\t\t\tmajor = excluded.major,\n\t\t\tversion = excluded.version,\n\t\t\tvendor = excluded.vendor,\n\t\t\tos_arch = excluded.os_arch,\n\t\t\tis_jdk = excluded.is_jdk\n\t\tRETURNING absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n\t\t", + "query": "\n\t\tINSERT INTO java_versions (absolute_path, major, version, vendor, os_arch, is_jdk, probe_version)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(absolute_path) DO UPDATE SET\n\t\t\tmajor = excluded.major,\n\t\t\tversion = excluded.version,\n\t\t\tvendor = excluded.vendor,\n\t\t\tos_arch = excluded.os_arch,\n\t\t\tis_jdk = excluded.is_jdk,\n\t\t\tprobe_version = excluded.probe_version\n\t\tRETURNING absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\", probe_version\n\t\t", "describe": { "columns": [ { @@ -68,10 +68,21 @@ "name": "is_jdk" } } + }, + { + "name": "probe_version", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "probe_version" + } + } } ], "parameters": { - "Right": 6 + "Right": 7 }, "nullable": [ false, @@ -79,8 +90,9 @@ false, false, false, + false, false ] }, - "hash": "010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc" + "hash": "f5dbcf7798afb495efe228a4ba7d1ddd9904cc25f34bef3c16f29cfb141158f0" } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d0b3af4..a5b328e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ To find an issue that interests you, you can browse through our [existing issues #### Making Changes Locally This project is a pure Rust [`Cargo`] workspace (edition 2024). Make sure you have a -recent Rust toolchain (`rustc` **1.85+**) installed before proceeding — the easiest way +recent Rust toolchain (`rustc` **1.85+**) installed before proceeding - the easiest way is via [rustup]. To make changes locally, follow these steps: diff --git a/Cargo.lock b/Cargo.lock index 9af1bb6c..61b19605 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1133,6 +1133,42 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cargo-packager-updater" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec09acab5c2227aba2e592d431708305bdeb6d507703f6cd8983fb57b6c5ef7" +dependencies = [ + "base64 0.22.1", + "cargo-packager-utils", + "ctor 0.2.9", + "dirs", + "dunce", + "flate2", + "http 1.5.0", + "log", + "minisign-verify", + "percent-encoding", + "reqwest 0.12.28", + "semver", + "serde", + "serde_json", + "tar", + "tempfile", + "thiserror 1.0.69", + "time", + "url", +] + +[[package]] +name = "cargo-packager-utils" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b43458dd2ee3cdab3f5b105acd80791383b730380c929018701313d7d299d4e8" +dependencies = [ + "ctor 0.2.9", +] + [[package]] name = "cc" version = "1.4.0" @@ -1501,6 +1537,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "ctor" version = "0.10.1" @@ -1610,7 +1656,28 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -1621,7 +1688,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2027,8 +2094,7 @@ dependencies = [ [[package]] name = "freya" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0902ffd592df176d3088ad001788f9a9438c21c40b4cbdec4dc3ef8bd16247" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "cfg-if", "freya-android", @@ -2051,8 +2117,7 @@ dependencies = [ [[package]] name = "freya-android" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe3befea9d845539f4428f232ec6b1661a54e974a6e7b852f82fef90d994ccba" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "freya-components", "freya-core", @@ -2065,10 +2130,8 @@ dependencies = [ [[package]] name = "freya-animation" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f9429ff1e0a793068ebdd4be84c03e722fdfb3d20c8bf3a63a7a875ba0a6de" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ - "async-io", "easer", "freya-core", ] @@ -2076,8 +2139,7 @@ dependencies = [ [[package]] name = "freya-clipboard" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8b2076b0a2d040ef2153cdd624dba28a6f4fabd64ca6182b128f642e2423b32" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "copypasta", "freya-core", @@ -2086,14 +2148,11 @@ dependencies = [ [[package]] name = "freya-components" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd20c885e7e29f43e0279affea98ae3e1392c9d9f07a09b0ce0fc65d46fc09dc" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "accesskit", "anyhow", - "async-io", "async-lock", - "blocking", "bytes 1.11.1", "cfg-if", "euclid", @@ -2103,22 +2162,24 @@ dependencies = [ "freya-engine", "freya-router", "freya-sdk", - "open", + "futures-channel", "paste", "reqwest 0.12.28", "thiserror 2.0.18", "torin", "tracing", + "url", ] [[package]] name = "freya-core" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee644e4c5b4686b4cf59463347df7b64d9eb057c4a251e4528e90a1ecbf707d" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "accesskit", + "async-io", "bitflags 2.13.1", + "blocking", "bytes 1.11.1", "cursor-icon", "freya-engine", @@ -2143,8 +2204,7 @@ dependencies = [ [[package]] name = "freya-edit" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0e20601f2d927591cace6bdaf731397908105a572eeaa934b387471bc6cd3b1" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "bitflags 2.13.1", "freya-clipboard", @@ -2158,8 +2218,7 @@ dependencies = [ [[package]] name = "freya-engine" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95c5ba0dae520d78222758cffdcb335ba9cac6f846721b544fe216d894866b8c" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "freya-skia-safe", ] @@ -2167,8 +2226,7 @@ dependencies = [ [[package]] name = "freya-markdown" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94eb6ca3e8f9f18654ff14589c94155d7fa60c697dcbbbf0f02cb0a9e72e538c" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "freya-components", "freya-core", @@ -2179,8 +2237,7 @@ dependencies = [ [[package]] name = "freya-performance-plugin" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2c69c000dd001e2df79a8b9ad0977cc0f835c494d2e17a2848b80495c293fce" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "freya-core", "freya-engine", @@ -2190,10 +2247,8 @@ dependencies = [ [[package]] name = "freya-query" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9db86c4a0dea3ce04df0defa4ce62dad8e7c32bad05090e3644ff6d2f2e0655e" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ - "async-io", "freya-core", "futures-util", ] @@ -2201,8 +2256,7 @@ dependencies = [ [[package]] name = "freya-radio" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a2458e07d312a2365951a216a8fa14a604159f8ae7702a7ccb2ffbc3fccf0a" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "freya-core", ] @@ -2210,8 +2264,7 @@ dependencies = [ [[package]] name = "freya-router" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26a8c7b3b3c6432de8d90c01821f5206ab6d6c2c8417fe7b873d7a6586e7580" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "freya-core", "freya-router-macro", @@ -2223,8 +2276,7 @@ dependencies = [ [[package]] name = "freya-router-macro" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "105a54bccebe377d4ce12066813737a0f86e1cc587cdc98c4df92fe48c800be4" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "proc-macro2", "quote", @@ -2235,18 +2287,16 @@ dependencies = [ [[package]] name = "freya-sdk" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe2c1e80db9c6c782536bf1e8f8e4859ed43359e7c0775c619be8826e16dd18" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ - "async-io", "freya-core", ] [[package]] name = "freya-skia-bindings" -version = "0.99.1" +version = "0.100.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e65f9ff56a7d08451c839386a86842f065552c561041d0cdcc213390a2361a2" +checksum = "2a59be930a7bfe544a7f16fcc6d7326dfabff3ffe65c3321932f4e61234f4941" dependencies = [ "bindgen", "cc", @@ -2261,9 +2311,9 @@ dependencies = [ [[package]] name = "freya-skia-safe" -version = "0.99.1" +version = "0.100.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567c808eff01a4ad7d9d3dc42ce0fb4335dce76a2398d0cd18f1357bf2450c81" +checksum = "7975576c62caa0b8bccb6f4cfd16c61f57b27fffa15540f37deba9bd246bf009" dependencies = [ "base64 0.23.1", "bitflags 2.13.1", @@ -2283,11 +2333,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "freya-testing" +version = "0.5.0-rc.4" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" +dependencies = [ + "accesskit", + "freya-clipboard", + "freya-components", + "freya-core", + "freya-engine", + "futures-channel", + "ragnarok", + "torin", +] + [[package]] name = "freya-winit" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f3e4172e15aa5bd524db527733fec0ba9ac3e4191388064c9ff0d4f5a23e091" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "accesskit", "accesskit_winit", @@ -2313,6 +2377,7 @@ dependencies = [ "objc2-core-foundation", "objc2-metal 0.3.2", "objc2-quartz-core 0.3.2", + "open", "ragnarok", "raw-window-handle", "rfd", @@ -2320,6 +2385,7 @@ dependencies = [ "softbuffer", "torin", "tracing", + "windows-sys 0.60.2", "winit", ] @@ -2881,6 +2947,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -3605,12 +3672,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "md5" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" - [[package]] name = "memchr" version = "2.8.3" @@ -3666,6 +3727,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4397,14 +4464,19 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oneclient_app" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "anyhow", "arboard", "bytes 1.11.1", + "cargo-packager-updater", "chrono", + "directories", + "dunce", "freya", + "freya-core", "freya-skia-safe", + "freya-testing", "futures-util", "image", "mimalloc", @@ -4422,7 +4494,9 @@ dependencies = [ "oneclient_net", "oneclient_polyplus", "open", + "percent-encoding", "polyio", + "pulldown-cmark", "regex", "reqwest 0.13.3", "rfd", @@ -4434,12 +4508,13 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid 1.23.1", + "windows 0.62.2", "winresource", ] [[package]] name = "oneclient_auth" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "base64 0.22.1", "chrono", @@ -4463,7 +4538,7 @@ dependencies = [ [[package]] name = "oneclient_cluster" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "bytes 1.11.1", "chrono", @@ -4486,18 +4561,20 @@ dependencies = [ [[package]] name = "oneclient_common" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "directories", "interfrost", "serde", "strum", + "sysinfo", "thiserror 2.0.18", + "url", ] [[package]] name = "oneclient_content" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "async-trait", "async_zip", @@ -4510,12 +4587,14 @@ dependencies = [ "oneclient_events", "oneclient_net", "polyio", + "pulldown-cmark", "reqwest 0.13.3", "serde", "serde_json", "sqlx", "thiserror 2.0.18", "tokio", + "toml", "tracing", "url", "uuid 1.23.1", @@ -4523,7 +4602,7 @@ dependencies = [ [[package]] name = "oneclient_core" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "async-compression", "async-stream", @@ -4545,7 +4624,6 @@ dependencies = [ "indicatif", "interfrost", "md-5 0.10.6", - "md5", "oneclient_auth", "oneclient_cluster", "oneclient_common", @@ -4588,7 +4666,7 @@ dependencies = [ [[package]] name = "oneclient_db" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "async-tempfile", "chrono", @@ -4603,7 +4681,7 @@ dependencies = [ [[package]] name = "oneclient_discord" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "chrono", "discord-rich-presence", @@ -4615,7 +4693,7 @@ dependencies = [ [[package]] name = "oneclient_events" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "thiserror 2.0.18", "tokio", @@ -4625,7 +4703,7 @@ dependencies = [ [[package]] name = "oneclient_java" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "astral-tokio-tar", "async-tempfile", @@ -4647,7 +4725,7 @@ dependencies = [ [[package]] name = "oneclient_macro" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "proc-macro2", "quote", @@ -4656,7 +4734,7 @@ dependencies = [ [[package]] name = "oneclient_mc" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "base64 0.22.1", "chrono", @@ -4678,7 +4756,7 @@ dependencies = [ [[package]] name = "oneclient_net" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "arc-swap", "async-trait", @@ -4700,7 +4778,7 @@ dependencies = [ [[package]] name = "oneclient_polyplus" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "async-tungstenite", "bytes 1.11.1", @@ -4832,8 +4910,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "pathgraph" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2f33d657119e71a8b28da5c8d52ade41672d873c292ae5bf4833b8fa658c86a" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" [[package]] name = "percent-encoding" @@ -4978,7 +5055,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "polyio" -version = "2.2.3-c2" +version = "2.3.1-c1" dependencies = [ "astral-tokio-tar", "async-compression", @@ -5260,8 +5337,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "ragnarok" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5bbbde00035bdece3362a320eb982f23a57f82bf5f480a74f6aec4aaa01203" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "euclid", "hotpath", @@ -5455,6 +5531,17 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -5525,6 +5612,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -5532,12 +5620,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", "webpki-roots", ] @@ -5584,7 +5674,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -6885,7 +6975,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" dependencies = [ "as-raw-xcb-connection", - "ctor", + "ctor 0.10.1", "libloading", "pkg-config", "tracing", @@ -7048,8 +7138,7 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "torin" version = "0.5.0-rc.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4fb54c965056694c8616b9e0a6dfa151207c3599cd0c18f93acdb7172949bee" +source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59" dependencies = [ "euclid", "itertools 0.14.0", @@ -7411,7 +7500,6 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.3", "js-sys", - "md-5 0.10.6", "serde_core", "wasm-bindgen", ] @@ -7534,6 +7622,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -7940,6 +8041,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -7976,6 +8086,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -8018,6 +8143,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -8030,6 +8161,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -8042,6 +8179,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -8066,6 +8209,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -8078,6 +8227,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -8090,6 +8245,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -8102,6 +8263,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 75b2d058..30a932f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,13 +9,13 @@ authors = [ "Polyfrost", "Polyfrost Contributors", ] -documentation = "https://github.com/Polyfrost/OneLauncher" +documentation = "https://github.com/oiupoyt/OneLauncher-Cracked" edition = "2024" homepage = "https://polyfrost.org/projects/oneclient" license = "GPL-3.0-only" readme = "README.md" -repository = "https://github.com/Polyfrost/OneLauncher" -version = "2.2.3-c2" +repository = "https://github.com/oiupoyt/OneLauncher-Cracked" +version = "2.3.1-c1" [workspace.dependencies] # core @@ -115,6 +115,7 @@ indicatif = {version = "=0.18.4", features = ["tokio"]} junction = {version = "=2.0.0"} notify = {version = "=8.2.0"} 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"} rust-embed = {version = "=8.12.0", features = ["include-exclude", "compression", "debug-embed"]} @@ -123,7 +124,9 @@ 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"} +percent-encoding = {version = "=2.3.2"} url = {version = "=2.5.8", features = ["serde"]} uuid = {version = "=1.23.1", features = ["serde", "v4"]} @@ -136,17 +139,12 @@ syn = {version = "=2.0.117"} thiserror = {version = "=2.0.18"} # ui -freya = {version = "=0.5.0-rc.4", features = ["engine", "winit", "router", "remote-asset", "sdk", "markdown", "query", "radio", "titlebar"]} -freya-skia-safe = "=0.99.1" # required for the SKSL player renderer +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 image = {version = "=0.25.10", default-features = false, features = ["png", "jpeg"]} -[profile.dev] -debug = 1 - -[profile.dev.package."*"] -opt-level = 3 -debug = false - [profile.release] codegen-units = 1 debug = "line-tables-only" @@ -161,3 +159,11 @@ inherits = "release" debug = true strip = false + +[profile.dev] +debug = 1 + +[profile.dev.package."*"] +opt-level = 3 +debug = false + diff --git a/LICENSE b/LICENSE index f288702d..0946f652 100644 --- a/LICENSE +++ b/LICENSE @@ -636,8 +636,7 @@ the "copyright" line and a pointer to where the full notice is found. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. + the Free Software Foundation, version 3 of the License. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml index ea10677f..332d3406 100644 --- a/packages/oneclient_app/Cargo.toml +++ b/packages/oneclient_app/Cargo.toml @@ -42,11 +42,15 @@ notify.workspace = true anyhow.workspace = true regex.workspace = true +directories.workspace = true +dunce.workspace = true +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" @@ -57,10 +61,21 @@ image.workspace = true freya-skia-safe.workspace = true freya.workspace = true +freya-core.workspace = true +pulldown-cmark.workspace = true + +[dev-dependencies] +freya-testing.workspace = true [target.'cfg(target_os = "macos")'.dependencies] mimalloc = "=0.1.52" +[target.'cfg(windows)'.dependencies] +windows = { version = "=0.62.2", features = [ + "Win32_Foundation", + "Win32_System_Registry", +] } + [target.'cfg(windows)'.build-dependencies] winresource = "=0.1.31" @@ -72,6 +87,7 @@ publisher = "Polyfrost Inc." copyright = "© 2026 Polyfrost Inc." homepage = "https://polyfrost.org/projects/oneclient" long-description = "Next-generation open source Minecraft launcher" +formats = ["nsis", "app", "dmg", "deb", "appimage"] binaries = [{ path = "oneclient_app", main = true }] icons = [ "icons/32x32.png", @@ -143,7 +159,7 @@ desktop-template = "distribution/linux/org.polyfrost.oneclient.template" auto-req = "builtin" assets = [ { source = "target/release/oneclient_app", dest = "/usr/bin/oneclient_app", mode = "755" }, - { source = "distribution/linux/oneclient_app.desktop", dest = "/usr/share/applications/oneclient_app.desktop", mode = "644" }, + { source = "target/release/.cargo-packager/deb/*/data/usr/share/applications/oneclient_app.desktop", dest = "/usr/share/applications/", mode = "644" }, { source = "icons/32x32.png", dest = "/usr/share/icons/hicolor/32x32/apps/oneclient_app.png", mode = "644" }, { source = "icons/64x64.png", dest = "/usr/share/icons/hicolor/64x64/apps/oneclient_app.png", mode = "644" }, { source = "icons/128x128.png", dest = "/usr/share/icons/hicolor/128x128/apps/oneclient_app.png", mode = "644" }, diff --git a/packages/oneclient_app/distribution/Info.plist b/packages/oneclient_app/distribution/Info.plist index 52b863f3..444a8145 100644 --- a/packages/oneclient_app/distribution/Info.plist +++ b/packages/oneclient_app/distribution/Info.plist @@ -4,18 +4,6 @@ CFBundleIconName OneClient - - CFBundleURLTypes - - - CFBundleURLName - OneClient - CFBundleURLSchemes - - oneclient - - - NSCameraUsageDescription A Minecraft mod is requesting access to your camera. diff --git a/packages/oneclient_app/src/cli.rs b/packages/oneclient_app/src/cli.rs new file mode 100644 index 00000000..d7a41ec3 --- /dev/null +++ b/packages/oneclient_app/src/cli.rs @@ -0,0 +1,84 @@ +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Cli { + pub launch: Option, +} + +impl Cli { + fn from_args(args: impl IntoIterator) -> Self { + let mut cli = Self::default(); + let mut args = args.into_iter(); + + while let Some(arg) = args.next() { + if let Some(value) = arg.strip_prefix("--launch=") { + cli.launch = non_empty(value.to_string()); + } else if arg == "--launch" { + cli.launch = args.next().and_then(non_empty); + } else if let Some(folder) = crate::protocol::parse_launch_url(&arg) { + cli.launch = Some(folder); + } + } + + cli + } +} + +fn non_empty(value: String) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +#[must_use] +pub fn parse() -> Cli { + Cli::from_args(std::env::args().skip(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Cli { + Cli::from_args(args.iter().map(|s| (*s).to_string())) + } + + #[test] + fn no_arguments_is_an_ordinary_start() { + assert_eq!(parse(&[]).launch, None); + } + + #[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")); + } + + #[test] + fn a_blank_folder_is_no_request_at_all() { + assert_eq!(parse(&["--launch", " "]).launch, None); + assert_eq!(parse(&["--launch="]).launch, None); + assert_eq!(parse(&["--launch"]).launch, None); + } + + #[test] + fn spaces_survive_the_round_trip() { + assert_eq!( + parse(&["--launch", "My Pack (1.8.9)"]).launch.as_deref(), + Some("My Pack (1.8.9)"), + ); + } + + #[test] + fn unknown_flags_are_ignored_not_fatal() { + assert_eq!(parse(&["--verbose", "--launch", "x", "leftover"]).launch.as_deref(), Some("x")); + } + + #[test] + fn a_launch_url_is_accepted_as_a_bare_argument() { + let url = crate::protocol::launch_url("26.1.2 Fabric"); + assert_eq!(parse(&[&url]).launch.as_deref(), Some("26.1.2 Fabric")); + } + + #[test] + fn a_url_that_is_not_ours_is_not_a_launch() { + assert_eq!(parse(&["https://polyfrost.org"]).launch, None); + } +} diff --git a/packages/oneclient_app/src/components/active_cluster_panel.rs b/packages/oneclient_app/src/components/active_cluster_panel.rs index 14cdf34b..7e5cccaa 100644 --- a/packages/oneclient_app/src/components/active_cluster_panel.rs +++ b/packages/oneclient_app/src/components/active_cluster_panel.rs @@ -5,10 +5,7 @@ use freya::router::RouterContext; use crate::components::{Button, Icon, IconType}; use oneclient_common::parse_mc_version; -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; diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs index 8699a010..c8163a43 100644 --- a/packages/oneclient_app/src/components/button.rs +++ b/packages/oneclient_app/src/components/button.rs @@ -327,7 +327,7 @@ impl Component for Button { }) .cursor(cursor_icon) .map(on_press.clone(), |rect, handler| { - rect.on_all_press(move |event: Event| { + rect.on_press(move |event: Event| { handler.call(event); }) }); @@ -446,7 +446,7 @@ pub fn open_folder_button(folder: std::path::PathBuf) -> Button { .icon() .on_press(move |_| { std::fs::create_dir_all(&folder).ok(); - crate::platform::open_url(&folder.to_string_lossy()); + crate::platform::open_path(&folder.to_string_lossy()); }) .child(Icon::new(IconType::Folder).size(16.)) } diff --git a/packages/oneclient_app/src/components/charts.rs b/packages/oneclient_app/src/components/charts.rs index b254b010..8e94c22b 100644 --- a/packages/oneclient_app/src/components/charts.rs +++ b/packages/oneclient_app/src/components/charts.rs @@ -363,7 +363,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/checkbox.rs b/packages/oneclient_app/src/components/checkbox.rs new file mode 100644 index 00000000..d2216074 --- /dev/null +++ b/packages/oneclient_app/src/components/checkbox.rs @@ -0,0 +1,133 @@ +use freya::{ + animation::{AnimColor, AnimNum, AnimatedValue, Ease, Function, use_animation_transition}, + prelude::*, +}; + +use crate::{ + components::{Icon, IconType}, + theme::colors, + ui, +}; + +const BOX_SIZE: f32 = 18.; +const MARK_SIZE: f32 = 12.; +const TIME: u64 = 180; + +pub fn checkbox_labeled(value: State, text: impl Into>) -> impl IntoElement { + let mut v = value; + Check { + value, + label: Some(text.into()), + on_press: (move |()| v.toggle()).into(), + disabled: false, + } +} + +#[derive(PartialEq)] +struct Check { + value: State, + label: Option>, + on_press: EventHandler<()>, + disabled: bool, +} + +impl Component for Check { + fn render(&self) -> impl IntoElement { + let value = self.value; + let on_press = self.on_press.clone(); + let checked = *value.read(); + let disabled = self.disabled; + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let box_border = if focus().is_focused() && !disabled { + colors::fg_primary() + } else if checked && !disabled { + colors::brand() + } else { + colors::component_border() + }; + + let background = use_animation_transition(value, |_, checked| { + if checked { + AnimColor::new(colors::component_bg(), colors::brand()) + } else { + AnimColor::new(colors::brand(), colors::component_bg()) + } + .time(TIME) + .function(Function::Expo) + .ease(Ease::Out) + }); + + let mark = use_animation_transition(value, |_, checked| { + let scale = AnimNum::new(0.5, 1.) + .time(TIME) + .function(Function::Expo) + .ease(Ease::Out); + let opacity = AnimNum::new(0., 1.) + .time(TIME) + .function(Function::Expo) + .ease(Ease::Out); + + if checked { + (scale, opacity) + } else { + (scale.into_reversed(), opacity.into_reversed()) + } + }); + + let (mark_scale, mark_opacity) = { + let mark = mark.read(); + (mark.0.value(), mark.1.value()) + }; + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .a11y_id(a11y_id) + .a11y_focusable(!disabled) + .a11y_role(AccessibilityRole::CheckBox) + .maybe(!disabled, |el| { + el.cursor(CursorIcon::Pointer).on_press(move |_| { + on_press.call(()); + }) + }) + .child( + rect() + .width(Size::px(BOX_SIZE)) + .height(Size::px(BOX_SIZE)) + .corner_radius(CornerRadius::new_all(5.)) + .main_align(Alignment::Center) + .cross_align(Alignment::Center) + .background(if disabled { + colors::component_bg_disabled() + } else { + background.read().value() + }) + .border(ui::border_all_color(1., box_border)) + .maybe_child((checked || mark_opacity > 0.).then(|| { + rect().opacity(mark_opacity).scale(mark_scale).child( + Icon::new(IconType::Check) + .size(MARK_SIZE) + .color(if disabled { + colors::fg_secondary() + } else { + Color::WHITE + }), + ) + })), + ) + .maybe_child(self.label.as_ref().map(|text| { + label() + .text(text.to_string()) + .font_size(12.) + .color(if disabled { + colors::fg_secondary() + } else { + colors::fg_primary() + }) + })) + } +} diff --git a/packages/oneclient_app/src/components/cluster_update_popup.rs b/packages/oneclient_app/src/components/cluster_update_popup.rs index 1e7e794a..24623a54 100644 --- a/packages/oneclient_app/src/components/cluster_update_popup.rs +++ b/packages/oneclient_app/src/components/cluster_update_popup.rs @@ -4,9 +4,7 @@ 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; @@ -23,13 +21,15 @@ enum UpdateTab { Updates, Additions, Removals, + Optional, } impl UpdateTab { - const ALL: [UpdateTab; 3] = [ + const ALL: [UpdateTab; 4] = [ UpdateTab::Updates, UpdateTab::Additions, UpdateTab::Removals, + UpdateTab::Optional, ]; fn label(self) -> &'static str { @@ -37,6 +37,7 @@ impl UpdateTab { UpdateTab::Updates => "Updated", UpdateTab::Additions => "Added", UpdateTab::Removals => "Removed", + UpdateTab::Optional => "Optional", } } @@ -45,6 +46,7 @@ impl UpdateTab { UpdateTab::Updates => "Nothing was updated.", UpdateTab::Additions => "Nothing was added.", UpdateTab::Removals => "Nothing was removed.", + UpdateTab::Optional => "Nothing optional was offered.", } } @@ -53,6 +55,7 @@ impl UpdateTab { UpdateTab::Updates => IconType::RefreshCw01, UpdateTab::Additions => IconType::Plus, UpdateTab::Removals => IconType::Trash01, + UpdateTab::Optional => IconType::Plus, } } @@ -61,6 +64,7 @@ impl UpdateTab { UpdateTab::Updates => colors::brand(), UpdateTab::Additions => colors::success(), UpdateTab::Removals => colors::danger(), + UpdateTab::Optional => colors::brand(), } } @@ -69,6 +73,7 @@ impl UpdateTab { UpdateTab::Updates => &summary.updated, UpdateTab::Additions => &summary.added, UpdateTab::Removals => &summary.removed, + UpdateTab::Optional => &summary.optional, } } } @@ -94,7 +99,13 @@ impl Component for ClusterUpdatePopup { let all_items: Vec<&ClusterUpdateItem> = summaries .iter() .flatten() - .flat_map(|s| s.updated.iter().chain(&s.added).chain(&s.removed)) + .flat_map(|s| { + s.updated + .iter() + .chain(&s.added) + .chain(&s.removed) + .chain(&s.optional) + }) .collect(); let mut meta = MetaMap::new(); for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { @@ -344,7 +355,11 @@ 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/context_menu.rs b/packages/oneclient_app/src/components/context_menu.rs index a2d1a3eb..7f0edbe8 100644 --- a/packages/oneclient_app/src/components/context_menu.rs +++ b/packages/oneclient_app/src/components/context_menu.rs @@ -1,6 +1,6 @@ use freya::prelude::*; -use crate::components::{Icon, IconType, OverlayPopup}; +use crate::components::{Icon, IconType, OVERLAY_BASE_LEVEL, OverlayPopup}; use crate::theme::colors; const MENU_BG: Color = Color::from_rgb(25, 32, 38); @@ -8,6 +8,11 @@ const MENU_BORDER: Color = Color::from_argb(26, 255, 255, 255); const MENU_FG: Color = Color::from_rgb(155, 161, 166); const MENU_DANGER: Color = Color::from_rgb(242, 84, 90); +/// Panel padding and border on both axes, which the measured list sits inside of +const PANEL_INSET: f32 = 14.; +/// Gap the clamped menu keeps from the window edges +const EDGE_MARGIN: f32 = 8.; + enum Entry { Action { icon: IconType, @@ -18,11 +23,25 @@ enum Entry { Separator, } +fn separator(item_width: Option) -> Rect { + let mut sep = rect() + .height(Size::px(1.)) + .margin(Gaps::new_symmetric(4., 0.)) + .background(MENU_BORDER); + if let Some(w) = item_width { + sep = sep.width(Size::px(w)); + } + sep +} + pub struct ContextMenu { x: f32, y: f32, + upwards: bool, + title: Option, entries: Vec, on_close: EventHandler<()>, + overlay_level: u8, } impl ContextMenu { @@ -30,16 +49,34 @@ impl ContextMenu { Self { x, y, + upwards: false, + title: None, entries: Vec::new(), on_close: (|()| {}).into(), + overlay_level: OVERLAY_BASE_LEVEL, } } + pub fn title(mut self, title: impl Into) -> Self { + self.title = Some(title.into()); + self + } + + pub fn open_upwards(mut self) -> Self { + self.upwards = true; + self + } + pub fn on_close(mut self, on_close: impl Into>) -> Self { self.on_close = on_close.into(); self } + pub fn overlay_level(mut self, level: u8) -> Self { + self.overlay_level = level; + self + } + pub fn action( mut self, icon: IconType, @@ -87,26 +124,32 @@ impl Component for ContextMenu { fn render(&self) -> impl IntoElement { let on_close = self.on_close.clone(); - let mut width = use_state(|| 0f32); + let mut size = use_state(Size2D::zero); - let item_width = { - let w = *width.read(); - (w > 0.).then_some(w) - }; + let measured = *size.read(); + let item_width = (measured.width > 0.).then_some(measured.width); let mut list = rect().vertical().spacing(4.); + + if let Some(title) = &self.title { + list = list + .child( + // Asymmetric on purpose + rect().padding(Gaps::new(6., 8., 4., 8.)).child( + label() + .text(title.clone()) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .child(separator(item_width)); + } + for entry in &self.entries { list = match entry { - Entry::Separator => { - let mut sep = rect() - .height(Size::px(1.)) - .margin(Gaps::new_symmetric(4., 0.)) - .background(MENU_BORDER); - if let Some(w) = item_width { - sep = sep.width(Size::px(w)); - } - list.child(sep) - } + Entry::Separator => list.child(separator(item_width)), Entry::Action { icon, label, @@ -127,12 +170,28 @@ impl Component for ContextMenu { } let list = list.on_sized(move |e: Event| { - let measured = e.data().area.width(); - if (measured - *width.peek()).abs() > 0.5 { - width.set(measured); + let area = e.data().area.size; + let prev = *size.peek(); + if (area.width - prev.width).abs() > 0.5 || (area.height - prev.height).abs() > 0.5 { + size.set(area); } }); + let placed = measured.width > 0.; + let menu_width = measured.width + PANEL_INSET; + let menu_height = measured.height + PANEL_INSET; + + let (x, y) = if placed { + let top = if self.upwards && self.y - menu_height >= 0. { + self.y - menu_height + } else { + self.y + }; + clamp_to_window(self.x, top, menu_width, menu_height) + } else { + (self.x, self.y) + }; + let panel = rect() .vertical() .padding(Gaps::new_all(6.)) @@ -144,16 +203,38 @@ impl Component for ContextMenu { bottom: 1., left: 1., })) + .opacity(if placed { 1. } else { 0. }) .child(list); OverlayPopup::new() .backdrop(false) - .position(Position::new_global().top(self.y).left(self.x)) + .overlay_level(self.overlay_level) + .position(Position::new_global().top(y).left(x)) .on_close(move |_| on_close.call(())) .child(panel.into_element()) } } +/// `root_size` is physical while the press position is logical, so it has to be +/// scaled down before the two are compared +fn clamp_to_window(x: f32, y: f32, menu_width: f32, menu_height: f32) -> (f32, f32) { + let platform = Platform::get(); + let scale = *platform.scale_factor.peek() as f32; + if scale <= 0. { + return (x, y); + } + + let window = *platform.root_size.peek(); + let clamp = |pos: f32, len: f32, limit: f32| { + pos.clamp(EDGE_MARGIN, (limit - len - EDGE_MARGIN).max(EDGE_MARGIN)) + }; + + ( + clamp(x, menu_width, window.width / scale), + clamp(y, menu_height, window.height / scale), + ) +} + #[derive(PartialEq)] struct ContextMenuRow { icon: IconType, diff --git a/packages/oneclient_app/src/components/dropdown.rs b/packages/oneclient_app/src/components/dropdown.rs index 5ebecd2c..4099d385 100644 --- a/packages/oneclient_app/src/components/dropdown.rs +++ b/packages/oneclient_app/src/components/dropdown.rs @@ -1,6 +1,7 @@ use freya::prelude::*; use crate::components::{Icon, IconType, ScrollArea}; +use crate::hooks::use_overlay_claim_when; use crate::theme::colors; use crate::ui::border_all_color; @@ -70,6 +71,8 @@ impl Component for Dropdown { let on_select = self.on_select.clone(); let is_open = open(); + use_overlay_claim_when(is_open); + if !is_open && list_size().is_some() { let _ = list_size.take(); } @@ -108,6 +111,11 @@ impl Component for Dropdown { rect() .width(self.width.clone()) + .on_global_key_down(move |e: Event| { + if e.key == Key::Named(NamedKey::Escape) { + open.set_if_modified(false); + } + }) .child( rect() .width(Size::fill()) @@ -130,7 +138,7 @@ impl Component for Dropdown { .on_sized(move |e: Event| { button_area.set_if_modified(Some(e.area)); }) - .on_all_press(move |e: Event| { + .on_press(move |e: Event| { e.prevent_default(); e.stop_propagation(); open.toggle(); @@ -237,7 +245,7 @@ impl Component for DropdownOption { }) .on_pointer_enter(move |_| hovering.set(true)) .on_pointer_leave(move |_| hovering.set(false)) - .on_all_press(self.on_press.clone()) + .on_press(self.on_press.clone()) .child( label() .text(self.text.clone()) diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs index 62f9a1cc..f533c1e7 100644 --- a/packages/oneclient_app/src/components/dynamic_art.rs +++ b/packages/oneclient_app/src/components/dynamic_art.rs @@ -1,9 +1,9 @@ use bytes::Bytes; use freya::prelude::*; -use oneclient_common::domain::GameLoader; -use oneclient_common::{VersionKey, parse_mc_version}; 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 crate::AppAssets; use crate::hooks::{loaded_image, use_cached_image, use_version_metadata}; diff --git a/packages/oneclient_app/src/components/file_drop.rs b/packages/oneclient_app/src/components/file_drop.rs index a363dc2e..e10dc522 100644 --- a/packages/oneclient_app/src/components/file_drop.rs +++ b/packages/oneclient_app/src/components/file_drop.rs @@ -7,8 +7,8 @@ use std::path::{Path, PathBuf}; use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation}; use freya::prelude::*; use freya::router::use_route; -use oneclient_content::packages::ContentType; use oneclient_core::clusters::Cluster; +use oneclient_content::packages::ContentType; use oneclient_db::models::ClusterId; use crate::Route; @@ -330,9 +330,7 @@ fn prompt_body( let import_list = resolved.clone(); let import = move |_| { - for (path, content_type) in &import_list { - dispatch.import_local_file(cluster_id, *content_type, path.clone()); - } + dispatch.import_local_files(cluster_id, import_list.clone()); pending.set(Vec::new()); }; diff --git a/packages/oneclient_app/src/components/generic_prompt.rs b/packages/oneclient_app/src/components/generic_prompt.rs index fa5667d0..e1468bf0 100644 --- a/packages/oneclient_app/src/components/generic_prompt.rs +++ b/packages/oneclient_app/src/components/generic_prompt.rs @@ -76,7 +76,9 @@ 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/grid_columns.rs b/packages/oneclient_app/src/components/grid_columns.rs new file mode 100644 index 00000000..a5a1b3c1 --- /dev/null +++ b/packages/oneclient_app/src/components/grid_columns.rs @@ -0,0 +1,27 @@ +use freya::prelude::*; + +use super::Dropdown; + +pub const GRID_COLUMNS_MIN: u8 = 1; +pub const GRID_COLUMNS_MAX: u8 = 5; + +pub fn resolved_columns(columns: u8) -> usize { + columns.clamp(GRID_COLUMNS_MIN, GRID_COLUMNS_MAX) as usize +} + +pub fn grid_columns_picker(mut columns: State, height: f32) -> impl IntoElement { + let options: Vec = (GRID_COLUMNS_MIN..=GRID_COLUMNS_MAX) + .map(|n| n.to_string()) + .collect(); + let selected = resolved_columns(*columns.read()).to_string(); + + Dropdown::new(selected, options) + .width(Size::px(60.)) + .height(Size::px(height)) + .on_select(move |idx: usize| columns.set(GRID_COLUMNS_MIN + idx as u8)) +} + +pub fn cell_width(total: f32, cols: usize, gap: f32) -> f32 { + let cols = cols.max(1); + ((total - (cols - 1) as f32 * gap) / cols as f32).max(0.) +} diff --git a/packages/oneclient_app/src/components/java_prompt.rs b/packages/oneclient_app/src/components/java_prompt.rs index 995e0edd..1a180caf 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_events::Answer; use oneclient_java::{JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JavaVendor}; +use oneclient_events::Answer; use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup}; use crate::hooks::{use_dispatch, use_notifications_snapshot}; @@ -65,8 +65,9 @@ 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()), + ); } }); }; @@ -123,7 +124,9 @@ 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/local_image.rs b/packages/oneclient_app/src/components/local_image.rs index 8d39fa8a..0909fb4c 100644 --- a/packages/oneclient_app/src/components/local_image.rs +++ b/packages/oneclient_app/src/components/local_image.rs @@ -122,7 +122,7 @@ impl Component for LocalImage { } } -fn decode(bytes: &Bytes) -> Option { +pub(super) fn decode(bytes: &Bytes) -> Option { let data = unsafe { SkData::new_bytes(bytes) }; let img = SkImage::from_encoded(data)?; Some(ImageHandle::new(img, bytes.clone())) diff --git a/packages/oneclient_app/src/components/markdown/image.rs b/packages/oneclient_app/src/components/markdown/image.rs new file mode 100644 index 00000000..d1f1c0ae --- /dev/null +++ b/packages/oneclient_app/src/components/markdown/image.rs @@ -0,0 +1,328 @@ +use std::any::Any; +use std::borrow::Cow; +use std::rc::Rc; + +use freya::elements::image::ImageHandle; +use freya::engine::prelude::{ClipOp, Paint, SkRect}; +use freya::prelude::*; +use freya_core::element::{ClipContext, ElementExt, LayoutContext}; +use freya_core::tree::DiffModifies; + +use super::super::local_image::decode; +use crate::hooks::{loaded_image, use_cached_image}; +use crate::theme::colors; + +const MAX_EDGE: u32 = 1600; + +#[derive(PartialEq)] +pub struct MarkdownImage { + url: String, + alt: String, +} + +impl MarkdownImage { + pub fn new(url: impl Into, alt: impl Into) -> Self { + Self { + url: url.into(), + alt: alt.into(), + } + } +} + +impl Component for MarkdownImage { + fn render(&self) -> impl IntoElement { + 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 ptr = bytes.as_ptr() as usize; + + if let Some((cached_ptr, holder)) = cache.read().clone() + && cached_ptr == ptr + { + return Some(holder); + } + + let holder = decode(&bytes)?; + cache.set(Some((ptr, holder.clone()))); + + Some(holder) + }); + + match holder { + Some(holder) => scaled_image(holder) + .a11y_role(AccessibilityRole::Image) + .a11y_alt(self.alt.clone()) + .into_element(), + None if self.alt.is_empty() => rect().into_element(), + None => label() + .text(self.alt.clone()) + .color(colors::fg_secondary()) + .into_element(), + } + } +} + +#[derive(Clone)] +pub struct ScaledImage { + key: DiffKey, + element: ScaledImageElement, +} + +#[derive(Clone, PartialEq)] +pub struct ScaledImageElement { + accessibility: AccessibilityData, + layout: LayoutData, + image_handle: ImageHandle, + sampling_mode: SamplingMode, + corner_radius: CornerRadius, +} + +pub fn scaled_image(image_handle: ImageHandle) -> ScaledImage { + ScaledImage { + key: DiffKey::None, + element: ScaledImageElement { + accessibility: AccessibilityData::default(), + layout: LayoutData::default(), + image_handle, + sampling_mode: SamplingMode::default(), + corner_radius: CornerRadius::default(), + }, + } +} + +impl ScaledImage { + pub fn sampling_mode(mut self, sampling_mode: SamplingMode) -> Self { + self.element.sampling_mode = sampling_mode; + self + } + + pub fn corner_radius(mut self, corner_radius: CornerRadius) -> Self { + self.element.corner_radius = corner_radius; + self + } +} + +impl KeyExt for ScaledImage { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl LayoutExt for ScaledImage { + fn get_layout(&mut self) -> &mut LayoutData { + &mut self.element.layout + } +} + +impl AccessibilityExt for ScaledImage { + fn get_accessibility_data(&mut self) -> &mut AccessibilityData { + &mut self.element.accessibility + } +} + +impl MaybeExt for ScaledImage {} + +impl From for Element { + fn from(value: ScaledImage) -> Self { + Element::Element { + key: value.key, + element: Rc::new(value.element), + elements: Vec::new(), + } + } +} + +impl ElementExt for ScaledImageElement { + fn changed(&self, other: &Rc) -> bool { + let Some(other) = (other.as_ref() as &dyn Any).downcast_ref::() else { + return false; + }; + self != other + } + + fn diff(&self, other: &Rc) -> DiffModifies { + let Some(other) = (other.as_ref() as &dyn Any).downcast_ref::() else { + return DiffModifies::all(); + }; + + let mut diff = DiffModifies::empty(); + + if self.accessibility != other.accessibility { + diff.insert(DiffModifies::ACCESSIBILITY); + } + + if self.layout != other.layout { + diff.insert(DiffModifies::LAYOUT); + } + + if self.image_handle != other.image_handle { + diff.insert(DiffModifies::STYLE); + + if self.image_handle.image.dimensions() != other.image_handle.image.dimensions() { + diff.insert(DiffModifies::LAYOUT); + } + } + + if self.sampling_mode != other.sampling_mode || self.corner_radius != other.corner_radius { + diff.insert(DiffModifies::STYLE); + } + + diff + } + + fn layout(&'_ self) -> Cow<'_, LayoutData> { + Cow::Borrowed(&self.layout) + } + + fn accessibility(&'_ self) -> Cow<'_, AccessibilityData> { + Cow::Borrowed(&self.accessibility) + } + + fn style(&'_ self) -> Cow<'_, StyleState> { + Cow::Owned(StyleState { + corner_radius: self.corner_radius, + ..StyleState::default() + }) + } + + fn should_hook_measurement(&self) -> bool { + true + } + + 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 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. { + ratio + } else { + 1. + }; + + let size = Size2D::new(natural.width * ratio, natural.height * ratio); + + Some((size, Rc::new(size))) + } + + fn clip(&self, context: ClipContext) { + let rrect = self.render_rect(context.visible_area, context.scale_factor as f32); + context.canvas.clip_rrect(rrect, ClipOp::Intersect, true); + } + + fn render(&self, context: RenderContext) { + let Some(size) = context + .layout_node + .data + .as_ref() + .and_then(|data| data.downcast_ref::()) + else { + return; + }; + + let area = context.layout_node.visible_area(); + let rect = SkRect::new( + area.min_x(), + area.min_y(), + area.min_x() + size.width, + area.min_y() + size.height, + ); + + context.canvas.save(); + let clip_rrect = self.render_rect(&area, context.scale_factor as f32); + context + .canvas + .clip_rrect(clip_rrect, ClipOp::Intersect, true); + + 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.restore(); + } +} + +#[cfg(test)] +mod tests { + use freya::prelude::Bytes; + use freya::engine::prelude::AlphaType; + use freya_testing::prelude::*; + + use super::*; + + fn handle(width: u32, height: u32) -> ImageHandle { + let pixels = Bytes::from(vec![200u8; (width * height * 4) as usize]); + ImageHandle::from_rgba(width, height, pixels, AlphaType::Opaque).expect("rgba handle") + } + + fn measured(panel: (f32, f32), image: (u32, u32)) -> Size2D { + let app = move || { + rect() + .width(Size::px(panel.0)) + .height(Size::px(panel.1)) + .child(scaled_image(handle(image.0, image.1))) + }; + + let test = launch_test(app); + test.find(|node, element| { + (element as &dyn Any) + .downcast_ref::() + .map(|_| node.layout()) + }) + .expect("image node") + .area + .size + } + + #[test] + fn scales_an_image_inlined_in_a_paragraph() { + let app = || { + rect().width(Size::px(300.)).height(Size::px(400.)).child( + paragraph() + .span(Span::new("before ")) + .child(scaled_image(handle(600, 200))) + .span(Span::new(" after")), + ) + }; + + let test = launch_test(app); + let size = test + .find(|node, element| { + (element as &dyn Any) + .downcast_ref::() + .map(|_| node.layout()) + }) + .expect("image node") + .area + .size; + + assert_eq!(size, Size2D::new(300., 100.)); + } + + #[test] + fn scales_a_wide_image_down_to_the_panel() { + assert_eq!(measured((300., 400.), (600, 200)), Size2D::new(300., 100.)); + } + + #[test] + fn leaves_a_small_image_at_its_natural_size() { + assert_eq!(measured((300., 400.), (88, 31)), Size2D::new(88., 31.)); + } + + #[test] + fn short_panel_does_not_shrink_the_width() { + assert_eq!(measured((300., 60.), (600, 200)), Size2D::new(300., 100.)); + } +} diff --git a/packages/oneclient_app/src/components/markdown/mod.rs b/packages/oneclient_app/src/components/markdown/mod.rs new file mode 100644 index 00000000..b281dba7 --- /dev/null +++ b/packages/oneclient_app/src/components/markdown/mod.rs @@ -0,0 +1,118 @@ +use std::borrow::Cow; + +use freya::prelude::*; +use pulldown_cmark::HeadingLevel; + +mod image; +mod parse; +mod render; + +#[derive(Clone, PartialEq)] +pub struct MarkdownStyle { + pub color: Color, + pub color_link: Color, + pub color_code: Color, + pub background_code: Color, + pub background_blockquote: Color, + pub border_blockquote: Color, + pub background_divider: Color, + pub headings: [f32; 6], + pub paragraph_size: f32, + pub code_font_size: f32, + pub table_font_size: f32, + pub code_font_family: Cow<'static, str>, +} + +impl Default for MarkdownStyle { + fn default() -> Self { + Self { + color: Color::WHITE, + color_link: Color::from_rgb(90, 160, 255), + color_code: Color::WHITE, + background_code: Color::from_rgb(32, 32, 32), + background_blockquote: Color::from_rgb(32, 32, 32), + border_blockquote: Color::from_rgb(80, 80, 80), + background_divider: Color::from_rgb(64, 64, 64), + headings: [32., 28., 24., 20., 18., 16.], + paragraph_size: 16., + code_font_size: 14., + table_font_size: 14., + code_font_family: Cow::Borrowed("Jetbrains Mono"), + } + } +} + +impl MarkdownStyle { + fn heading_size(&self, level: HeadingLevel) -> f32 { + self.headings[match level { + HeadingLevel::H1 => 0, + HeadingLevel::H2 => 1, + HeadingLevel::H3 => 2, + HeadingLevel::H4 => 3, + HeadingLevel::H5 => 4, + HeadingLevel::H6 => 5, + }] + } +} + +#[derive(PartialEq)] +pub struct Markdown { + content: String, + style: MarkdownStyle, + layout: LayoutData, + key: DiffKey, +} + +impl Markdown { + pub fn new(content: impl Into) -> Self { + Self { + content: content.into(), + style: MarkdownStyle::default(), + layout: LayoutData::default(), + key: DiffKey::None, + } + } + + pub fn style(mut self, style: MarkdownStyle) -> Self { + self.style = style; + self + } +} + +impl KeyExt for Markdown { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl LayoutExt for Markdown { + fn get_layout(&mut self) -> &mut LayoutData { + &mut self.layout + } +} + +impl ContainerExt for Markdown {} + +impl Component for Markdown { + fn render(&self) -> impl IntoElement { + let content = use_reactive(&self.content); + let blocks = use_memo(move || parse::parse(&content.read())); + let blocks = blocks.read(); + + rect() + .vertical() + .layout(self.layout.clone()) + .spacing(12.) + .children( + blocks + .iter() + .enumerate() + .map(|(idx, block)| render::render_block(block, idx, &self.style)), + ) + } + + fn render_key(&self) -> DiffKey { + 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 new file mode 100644 index 00000000..13a87f99 --- /dev/null +++ b/packages/oneclient_app/src/components/markdown/parse.rs @@ -0,0 +1,589 @@ +use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; + +#[derive(Clone, PartialEq)] +pub enum Block { + Heading { + level: HeadingLevel, + spans: Vec, + }, + Paragraph { + content: Vec, + }, + Code { + code: String, + }, + List(List), + Image { + url: String, + alt: String, + }, + Link { + url: String, + title: Option, + content: Vec, + }, + Blockquote { + content: Vec, + }, + Table { + headers: Vec>, + rows: Vec>>, + }, + Rule, +} + +#[derive(Clone, PartialEq)] +pub struct List { + pub start: Option, + pub items: Vec, +} + +#[derive(Clone, PartialEq)] +pub struct ListItem { + pub content: Vec, + pub nested_lists: Vec, +} + +#[derive(Clone, PartialEq)] +pub enum Inline { + Span(TextSpan), + Image { + url: String, + alt: String, + }, + Link { + url: String, + title: Option, + content: Vec, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextSpan { + pub text: String, + pub bold: bool, + pub italic: bool, + pub code: bool, +} + +impl TextSpan { + fn new(text: impl Into) -> Self { + Self { + text: text.into(), + bold: false, + italic: false, + code: false, + } + } +} + +pub fn parse(content: &str) -> Vec { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + + Walker::default().walk(Parser::new_ext(content, options)) +} + +#[derive(Default)] +struct Walker { + blocks: Vec, + + spans: Vec, + content: Vec, + + lists: Vec, + items: Vec, + + heading: Option, + in_paragraph: bool, + + in_code_block: bool, + code: String, + + in_blockquote: bool, + blockquote: Vec, + + in_table_cell: bool, + headers: Vec>, + rows: Vec>>, + row: Vec>, + cell: Vec, + + in_link: bool, + link_url: Option, + link_title: Option, + link_content: Vec, + + in_image: bool, + image_url: String, + image_title: String, + image_alt: String, + + bold: bool, + italic: bool, +} + +impl Walker { + fn walk(mut self, parser: Parser<'_>) -> Vec { + for event in parser { + match event { + Event::Start(tag) => self.start(tag), + Event::End(tag) => self.end(tag), + Event::Text(text) => self.text(&text), + Event::Code(code) => self.code(&code), + Event::SoftBreak => self.break_of(" "), + Event::HardBreak => self.break_of("\n"), + Event::InlineHtml(_) => {} + Event::Html(html) => self.html_block(&html), + Event::Rule => self.blocks.push(Block::Rule), + _ => {} + } + } + + self.blocks + } + + fn start(&mut self, tag: Tag<'_>) { + match tag { + Tag::Heading { level, .. } => { + self.heading = Some(level); + self.spans.clear(); + } + Tag::Paragraph => { + if !self.in_blockquote && self.items.is_empty() { + self.in_paragraph = true; + self.spans.clear(); + self.content.clear(); + } + } + Tag::CodeBlock(_) => { + self.in_code_block = true; + self.code.clear(); + } + Tag::List(start) => self.lists.push(List { + start, + items: Vec::new(), + }), + Tag::Item => self.items.push(ListItem { + content: Vec::new(), + nested_lists: Vec::new(), + }), + Tag::Strong => self.bold = true, + Tag::Emphasis => self.italic = true, + Tag::BlockQuote(_) => { + self.in_blockquote = true; + self.blockquote.clear(); + } + Tag::Image { + dest_url, title, .. + } => { + self.in_image = true; + self.image_url = dest_url.to_string(); + self.image_title = title.to_string(); + self.image_alt.clear(); + } + Tag::Link { + dest_url, title, .. + } => { + self.in_link = true; + self.link_url = Some(dest_url.to_string()); + self.link_title = Some(title.to_string()); + self.link_content.clear(); + } + Tag::Table(_) => { + self.headers.clear(); + self.rows.clear(); + self.row.clear(); + } + Tag::TableRow => self.row.clear(), + Tag::TableCell => { + self.in_table_cell = true; + self.cell.clear(); + } + _ => {} + } + } + + fn end(&mut self, tag: TagEnd) { + match tag { + TagEnd::Heading(_) => { + if let Some(level) = self.heading.take() { + let spans = std::mem::take(&mut self.spans); + self.blocks.push(Block::Heading { level, spans }); + } + } + TagEnd::Paragraph => { + let spans: Vec = self.spans.drain(..).map(Inline::Span).collect(); + if self.in_blockquote { + self.blockquote.extend(spans); + } else if let Some(item) = self.items.last_mut() { + item.content.extend(spans); + } else if self.in_paragraph { + self.in_paragraph = false; + self.content.extend(spans); + let content = std::mem::take(&mut self.content); + self.blocks.push(Block::Paragraph { content }); + } + } + TagEnd::CodeBlock => { + self.in_code_block = false; + let code = std::mem::take(&mut self.code); + self.blocks.push(Block::Code { + code: code.trim_end().to_string(), + }); + } + TagEnd::List(_) => { + if let Some(list) = self.lists.pop() { + match self.items.last_mut() { + Some(item) => item.nested_lists.push(list), + None => self.blocks.push(Block::List(list)), + } + } + } + TagEnd::Item => { + if let (Some(item), Some(list)) = (self.items.pop(), self.lists.last_mut()) { + list.items.push(item); + } + } + TagEnd::Strong => self.bold = false, + TagEnd::Emphasis => self.italic = false, + TagEnd::BlockQuote(_) => { + self.in_blockquote = false; + let content = std::mem::take(&mut self.blockquote); + self.blocks.push(Block::Blockquote { content }); + } + TagEnd::Table => self.blocks.push(Block::Table { + headers: std::mem::take(&mut self.headers), + rows: std::mem::take(&mut self.rows), + }), + TagEnd::TableHead => self.headers = std::mem::take(&mut self.row), + TagEnd::TableRow => { + let row = std::mem::take(&mut self.row); + self.rows.push(row); + } + TagEnd::TableCell => { + self.in_table_cell = false; + let cell = std::mem::take(&mut self.cell); + self.row.push(cell); + } + TagEnd::Image => { + self.in_image = false; + let url = std::mem::take(&mut self.image_url); + let alt = if self.image_alt.is_empty() { + std::mem::take(&mut self.image_title) + } else { + std::mem::take(&mut self.image_alt) + }; + self.place(Inline::Image { url, alt }, |url, alt| Block::Image { url, alt }); + } + TagEnd::Link => { + self.in_link = false; + let Some(url) = self.link_url.take() else { + return; + }; + let title = self.link_title.take(); + let content = std::mem::take(&mut self.link_content); + + if self.in_table_cell { + for item in content { + flatten(item, &mut self.cell); + } + return; + } + + let inline = Inline::Link { + url: url.clone(), + title: title.clone(), + content: content.clone(), + }; + if self.in_blockquote { + self.blockquote.push(inline); + } else if let Some(item) = self.items.last_mut() { + item.content.push(inline); + } else if self.in_paragraph { + self.content.extend(self.spans.drain(..).map(Inline::Span)); + self.content.push(inline); + } else if self.heading.is_some() { + for item in content { + flatten(item, &mut self.spans); + } + } else { + self.blocks.push(Block::Link { + url, + title, + content, + }); + } + } + _ => {} + } + } + + fn place(&mut self, inline: Inline, as_block: impl FnOnce(String, String) -> Block) { + if self.in_table_cell { + flatten(inline, &mut self.cell); + } else if self.in_link { + self.link_content.push(inline); + } else if self.in_blockquote { + self.blockquote.push(inline); + } else if let Some(item) = self.items.last_mut() { + item.content.push(inline); + } else if self.in_paragraph { + self.content.extend(self.spans.drain(..).map(Inline::Span)); + self.content.push(inline); + } else if self.heading.is_some() { + flatten(inline, &mut self.spans); + } else if let Inline::Image { url, alt } = inline { + self.blocks.push(as_block(url, alt)); + } + } + + fn text(&mut self, text: &str) { + if self.in_code_block { + self.code.push_str(text); + return; + } + if self.in_image { + self.image_alt.push_str(text); + return; + } + + self.push_span(TextSpan { + text: text.to_string(), + bold: self.bold, + italic: self.italic, + code: false, + }); + } + + fn code(&mut self, code: &str) { + if self.in_image { + self.image_alt.push_str(code); + return; + } + + self.push_span(TextSpan { + text: code.to_string(), + bold: self.bold, + italic: self.italic, + code: true, + }); + } + + fn break_of(&mut self, text: &str) { + if self.in_image { + self.image_alt.push(' '); + return; + } + + self.push_span(TextSpan::new(text)); + } + + fn html_block(&mut self, html: &str) { + let text = strip_tags(html); + if text.trim().is_empty() { + return; + } + + self.blocks.push(Block::Paragraph { + content: vec![Inline::Span(TextSpan::new(text))], + }); + } + + fn push_span(&mut self, span: TextSpan) { + if self.in_table_cell { + self.cell.push(span); + } else if self.in_link { + self.link_content.push(Inline::Span(span)); + } else if self.in_blockquote && !self.in_paragraph { + self.blockquote.push(Inline::Span(span)); + } else if let Some(item) = self.items.last_mut() + && !self.in_paragraph + { + item.content.push(Inline::Span(span)); + } else { + self.spans.push(span); + } + } +} + +/// Headings and table cells hold plain spans, so links and images collapse to their text. +fn flatten(inline: Inline, out: &mut Vec) { + match inline { + Inline::Span(span) => out.push(span), + Inline::Image { alt, .. } => { + if !alt.is_empty() { + out.push(TextSpan::new(alt)); + } + } + Inline::Link { content, .. } => { + for item in content { + flatten(item, out); + } + } + } +} + +fn strip_tags(html: &str) -> String { + let mut out = String::with_capacity(html.len()); + let mut rest = html; + + while let Some(open) = rest.find('<') { + out.push_str(&rest[..open]); + match rest[open..].find('>') { + Some(close) => rest = &rest[open + close + 1..], + None => { + out.push_str(&rest[open..]); + return out; + } + } + } + out.push_str(rest); + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn blocks(markdown: &str) -> Vec { + parse(markdown) + } + + fn paragraph_text(block: &Block) -> String { + let Block::Paragraph { content } = block else { + panic!("expected a paragraph"); + }; + content + .iter() + .map(|inline| match inline { + Inline::Span(span) => span.text.clone(), + Inline::Image { alt, .. } => format!("[img:{alt}]"), + Inline::Link { content, .. } => content + .iter() + .map(|inline| match inline { + Inline::Span(span) => span.text.clone(), + Inline::Image { alt, .. } => format!("[img:{alt}]"), + Inline::Link { .. } => String::new(), + }) + .collect(), + }) + .collect() + } + + #[test] + fn hard_break_is_a_line_break_and_soft_break_is_a_space() { + assert_eq!(paragraph_text(&blocks("one \ntwo")[0]), "one\ntwo"); + assert_eq!(paragraph_text(&blocks("one\ntwo")[0]), "one two"); + } + + #[test] + fn code_block_keeps_its_lines() { + let Block::Code { code } = &blocks("```rust\nlet a = 1;\nlet b = 2;\n```")[0] else { + panic!("expected a code block"); + }; + assert_eq!(code, "let a = 1;\nlet b = 2;"); + } + + #[test] + fn inline_html_keeps_the_text_it_wrapped() { + 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"); + } + + #[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"); + } + + #[test] + fn an_image_inside_a_link_stays_in_the_link() { + assert_eq!( + paragraph_text(&blocks("text [![badge](b.svg)](https://example.com)")[0]), + "text [img:badge]" + ); + } + + #[test] + fn nested_lists_keep_their_nesting() { + let Block::List(list) = &blocks("- one\n - inner\n- two")[0] else { + panic!("expected a list"); + }; + assert_eq!(list.items.len(), 2); + assert_eq!(list.items[0].nested_lists.len(), 1); + assert_eq!(list.items[0].nested_lists[0].items.len(), 1); + } + + #[test] + fn ordered_lists_keep_their_start() { + let Block::List(list) = &blocks("3. three\n4. four")[0] else { + panic!("expected a list"); + }; + assert_eq!(list.start, Some(3)); + } + + #[test] + fn tables_split_head_from_body() { + let Block::Table { headers, rows } = &blocks("| a | b |\n| - | - |\n| 1 | 2 |\n| 3 | 4 |")[0] + else { + panic!("expected a table"); + }; + assert_eq!(headers.len(), 2); + assert_eq!(rows.len(), 2); + assert_eq!(rows[1][1][0].text, "4"); + } + + #[test] + fn emphasis_and_code_carry_their_style() { + let Block::Paragraph { content } = &blocks("**b** *i* `c`")[0] else { + panic!("expected a paragraph"); + }; + let spans: Vec<&TextSpan> = content + .iter() + .filter_map(|inline| match inline { + Inline::Span(span) => Some(span), + _ => None, + }) + .collect(); + assert!(spans.iter().any(|s| s.text == "b" && s.bold)); + assert!(spans.iter().any(|s| s.text == "i" && s.italic)); + assert!(spans.iter().any(|s| s.text == "c" && s.code)); + } + + #[test] + fn a_linked_heading_keeps_its_text_and_leaks_no_link_block() { + let parsed = blocks("## [Docs](https://example.com)"); + assert_eq!(parsed.len(), 1); + let Block::Heading { spans, .. } = &parsed[0] else { + panic!("expected a heading"); + }; + assert_eq!(spans[0].text, "Docs"); + } + + #[test] + fn a_badge_table_keeps_its_badges_inside_the_table() { + let parsed = blocks( + "| a | b |\n| - | - |\n| [![CF](cf.svg)](https://curseforge.com) | [![MR](mr.svg)](https://modrinth.com) |\n", + ); + assert_eq!(parsed.len(), 1); + let Block::Table { rows, .. } = &parsed[0] else { + panic!("expected a table"); + }; + assert_eq!(rows[0][0][0].text, "CF"); + assert_eq!(rows[0][1][0].text, "MR"); + } + + #[test] + fn headings_blockquotes_and_rules_are_their_own_blocks() { + let parsed = blocks("# Title\n\n> quoted\n\n---\n"); + assert!(matches!(parsed[0], Block::Heading { .. })); + assert!(matches!(parsed[1], Block::Blockquote { .. })); + assert!(matches!(parsed[2], Block::Rule)); + } +} diff --git a/packages/oneclient_app/src/components/markdown/render.rs b/packages/oneclient_app/src/components/markdown/render.rs new file mode 100644 index 00000000..bc968ce1 --- /dev/null +++ b/packages/oneclient_app/src/components/markdown/render.rs @@ -0,0 +1,275 @@ +use freya::prelude::*; + +use super::MarkdownStyle; +use super::image::MarkdownImage; +use super::parse::{Block, Inline, List, TextSpan}; +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::Paragraph { content } => render_content(content, style.paragraph_size, style) + .key(key) + .into(), + Block::Code { code } => rect() + .key(key) + .width(Size::fill()) + .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), + ) + .into(), + Block::List(list) => render_list(list, style).key(key).into(), + Block::Image { url, alt } => rect() + .key(key) + .child(MarkdownImage::new(url.clone(), alt.clone())) + .into(), + Block::Link { + url, + title, + content, + } => MarkdownLink::new(url.clone(), title.clone(), content.clone(), style.clone()) + .key(key) + .into(), + Block::Blockquote { content } => rect() + .key(key) + .width(Size::fill()) + .padding(Gaps::new(12., 12., 12., 16.)) + .border( + Border::new() + .width(4.) + .fill(style.border_blockquote) + .alignment(BorderAlignment::Inner), + ) + .background(style.background_blockquote) + .child(render_content(content, style.paragraph_size, style).font_slant(FontSlant::Italic)) + .into(), + Block::Rule => rect() + .key(key) + .width(Size::fill()) + .height(Size::px(1.)) + .background(style.background_divider) + .into(), + Block::Table { headers, rows } => render_table(headers, rows, style).key(key).into(), + } +} + +fn styled_span(span: &TextSpan, text_color: Color, code_color: Color) -> Span<'static> { + let mut styled = Span::new(span.text.clone()); + if span.bold { + styled = styled.font_weight(FontWeight::BOLD); + } + if span.italic { + styled = styled.font_slant(FontSlant::Italic); + } + if span.code { + styled.font_family("monospace").color(code_color) + } else { + styled.color(text_color) + } +} + +fn render_spans( + spans: &[TextSpan], + base_font_size: f32, + text_color: Color, + code_color: Color, +) -> Paragraph { + paragraph().font_size(base_font_size).spans_iter( + spans + .iter() + .map(|span| styled_span(span, text_color, code_color)), + ) +} + +fn render_content(content: &[Inline], base_font_size: f32, style: &MarkdownStyle) -> Paragraph { + let mut result = paragraph().font_size(base_font_size); + + for item in content { + result = match item { + Inline::Span(span) => result.span(styled_span(span, style.color, style.color_code)), + Inline::Image { url, alt } => { + result.child(MarkdownImage::new(url.clone(), alt.clone())) + } + Inline::Link { + url, + title, + content, + } => result.child(MarkdownLink::new( + url.clone(), + title.clone(), + content.clone(), + style.clone(), + )), + }; + } + + result +} + +fn render_list(list: &List, style: &MarkdownStyle) -> Rect { + rect() + .vertical() + .spacing(4.) + .padding(Gaps::new(0., 0., 0., 20.)) + .children(list.items.iter().enumerate().map(|(item_idx, item)| { + rect() + .key(item_idx) + .horizontal() + .cross_align(Alignment::Start) + .spacing(8.) + .child( + label() + .text(match list.start { + Some(start) => format!("{}.", start + item_idx as u64), + None => "•".to_string(), + }) + .font_size(style.paragraph_size) + .color(style.color), + ) + .child( + rect() + .vertical() + .spacing(4.) + .child(render_content(&item.content, style.paragraph_size, style)) + .children( + item.nested_lists + .iter() + .map(|nested| render_list(nested, style)), + ), + ) + })) +} + +fn render_table( + headers: &[Vec], + rows: &[Vec>], + style: &MarkdownStyle, +) -> Rect { + rect() + .vertical() + .width(Size::fill()) + .corner_radius(CornerRadius::new_all(8.)) + .border( + Border::new() + .width(1.) + .fill(style.background_divider) + .alignment(BorderAlignment::Inner), + ) + .child(table_row(headers, style, true)) + .children(rows.iter().enumerate().flat_map(|(row_idx, row)| { + [ + rect() + .key(format!("divider-{row_idx}")) + .width(Size::fill()) + .height(Size::px(1.)) + .background(style.background_divider) + .into_element(), + table_row(row, style, false).key(row_idx).into_element(), + ] + })) +} + +fn table_row(cells: &[Vec], style: &MarkdownStyle, header: bool) -> Rect { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .maybe(header, |row| row.background(style.background_code)) + .children(cells.iter().enumerate().map(|(col_idx, spans)| { + rect() + .key(col_idx) + .width(Size::flex(1.)) + .padding(Gaps::new_all(8.)) + .child( + render_spans(spans, style.table_font_size, style.color, style.color_code) + .maybe(header, |cell| cell.font_weight(FontWeight::BOLD)), + ) + })) +} + +#[derive(PartialEq)] +struct MarkdownLink { + url: String, + title: Option, + content: Vec, + style: MarkdownStyle, + key: DiffKey, +} + +impl KeyExt for MarkdownLink { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl MarkdownLink { + fn new( + url: String, + title: Option, + content: Vec, + style: MarkdownStyle, + ) -> Self { + Self { + url, + title, + content, + style, + key: DiffKey::None, + } + } +} + +impl Component for MarkdownLink { + fn render(&self) -> impl IntoElement { + let pending = try_consume_root_context::().map(|state| state.0); + let mut hovering = use_state(|| false); + + let url = self.url.clone(); + let alt = self.title.clone().filter(|title| !title.is_empty()); + + 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::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, + }), + }; + } + + rect() + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_press(move |_| match pending { + Some(mut pending) => pending.set(Some(url.clone())), + None => tracing::warn!("no link confirmation available, not opening {url}"), + }) + .maybe(hovering(), |element| { + element.text_decoration(TextDecoration::Underline) + }) + .a11y_role(AccessibilityRole::Link) + .map(alt, |element, alt| element.a11y_alt(alt)) + .child(text) + } +} diff --git a/packages/oneclient_app/src/components/memory_field.rs b/packages/oneclient_app/src/components/memory_field.rs index 76176220..5827501f 100644 --- a/packages/oneclient_app/src/components/memory_field.rs +++ b/packages/oneclient_app/src/components/memory_field.rs @@ -39,7 +39,7 @@ pub fn memory_field(mut memory: State) -> impl IntoElement { .child( TextInput::new(memory) .width(Size::px(90.)) - .placeholder("4096") + .placeholder(oneclient_common::default_mem_max().to_string()) .on_validate(validate_memory) .trailing( label() diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 0b0eff07..75dd453b 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -3,19 +3,23 @@ mod active_cluster_panel; mod avatar; mod button; mod charts; +mod checkbox; mod cluster_landscape_art; mod cluster_update_popup; mod context_menu; +mod optional_mods_popup; mod dropdown; mod dynamic_art; mod file_drop; -mod generic_prompt; +mod grid_columns; mod icons; mod java_install_manager; +mod generic_prompt; mod java_prompt; mod link_confirm; mod local_image; mod log_viewer; +mod markdown; mod memory_field; mod microsoft_login; mod navbar; @@ -37,41 +41,46 @@ mod text_input; mod toasts; mod toggle; mod update_prompt; -pub mod upload_mclogs; mod version_card; +pub mod upload_mclogs; pub use account_switcher::AccountSwitcher; pub use active_cluster_panel::ActiveClusterPanel; pub use avatar::Avatar; pub use button::{Button, ButtonSize, ButtonVariant, 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 context_menu::ContextMenu; +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 generic_prompt::GenericPromptOverlay; +pub use grid_columns::{cell_width, grid_columns_picker, resolved_columns}; pub use icons::{Icon, IconTint, IconType}; pub use java_install_manager::JavaInstallManager; +pub use generic_prompt::GenericPromptOverlay; pub use java_prompt::JavaPromptOverlay; pub use link_confirm::ConfirmLinkOverlay; pub use local_image::LocalImage; +pub use markdown::{Markdown, MarkdownStyle}; pub use log_viewer::LogViewer; pub use memory_field::memory_field; -pub(crate) use microsoft_login::login_dialog; pub use microsoft_login::use_microsoft_login; +pub(crate) use microsoft_login::login_dialog; pub(crate) use navbar::window_controls; pub use navbar::{AppNavbar, OnboardingNavbar}; pub use notifications::NotificationCenter; -pub use overlay_popup::OverlayPopup; +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_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 screenshot_viewer::ScreenshotViewer; +pub use screenshot_viewer::{ScreenshotViewer, screenshot_context_menu}; pub(crate) use scrollview::corrected_scroll; pub use scrollview::{ScrollArea, ScrollAreaCtx}; pub use segmented_control::{Segment, SegmentedControl}; diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index 2d13b785..d5dfcd04 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -8,9 +8,11 @@ use crate::{ Route, components::{Avatar, Icon, IconType}, hooks::{ - try_default_account, use_current_account, use_dispatch, use_notifications_snapshot, + settled_or_loading, try_default_account, use_active_cluster_id, use_clusters, + use_current_account, use_dispatch, use_notifications_snapshot, }, theme, + utils::sort_clusters_for_home, }; const NAVBAR_INTRO_MS: u64 = 460; @@ -45,8 +47,8 @@ impl Component for Navbar { .padding(Gaps::new_symmetric(0.0, 40.0)) .offset_y(slide) .opacity(eased) - .child(NavbarLeft) - .child(NavbarCenter) + .child(navbar_left()) + .child(navbar_center()) .child(NavbarRight), ) .child( @@ -59,83 +61,75 @@ impl Component for Navbar { } } -#[derive(PartialEq)] -struct NavbarLeft; - -impl Component for NavbarLeft { - fn render(&self) -> impl IntoElement { - let logo_bytes = crate::AppAssets::get_bytes("logo.svg").unwrap_or_default(); - - rect() - .horizontal() - .width(Size::flex(1.0)) - .cross_align(Alignment::Center) - .child( - SvgViewer::new(("logo.svg", logo_bytes)) - .show_loader(false) - .height(Size::px(44.)) - .width(Size::px(214.)) - .color(theme::colors::fg_primary()), - ) - } +fn navbar_left() -> impl IntoElement { + rect() + .horizontal() + .width(Size::flex(1.0)) + .cross_align(Alignment::Center) + .child(navbar_logo()) } -#[derive(PartialEq)] -struct NavbarCenter; +fn navbar_logo() -> impl IntoElement { + let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default()); -impl Component for NavbarCenter { - fn render(&self) -> impl IntoElement { - let route = use_route::(); - let browse_target = browse_target(); - - rect() - .horizontal() - .width(Size::flex(1.0)) - .main_align(Alignment::Center) - .cross_align(Alignment::Center) - .spacing(36.) - .child(NavLink { - active: route == Route::Home {}, - target: NavTarget::Route(Route::Home {}), - nav_label: "Home", - }) - .child(NavLink { - active: route == Route::Clusters {}, - 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, - Route::Browser { - pick_cluster: true, - .. - } - ), - target: NavTarget::Route(browse_target), - nav_label: "Browse", - }) - .child(NavLink { - active: route == Route::Stats {}, - target: NavTarget::Route(Route::Stats {}), - nav_label: "Stats", - }) - } + SvgViewer::new(("logo.svg", bytes.read().cloned())) + .show_loader(false) + .height(Size::px(44.)) + .width(Size::px(214.)) + .color(theme::colors::fg_primary()) } -/// Falls back to active cluster then most recently played then Versions when none exist +fn navbar_center() -> impl IntoElement { + let route = use_route::(); + let browse_target = browse_target(); + + rect() + .horizontal() + .width(Size::flex(1.0)) + .main_align(Alignment::Center) + .cross_align(Alignment::Center) + .spacing(36.) + .child(NavLink { + active: route == Route::Home {}, + target: NavTarget::Route(Route::Home {}), + nav_label: "Home", + }) + .child(NavLink { + active: route == Route::Clusters {}, + 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, + Route::Browser { + pick_cluster: true, + .. + } + ), + target: NavTarget::Route(browse_target), + nav_label: "Browse", + }) + .child(NavLink { + active: route == Route::Stats {}, + target: NavTarget::Route(Route::Stats {}), + nav_label: "Stats", + }) +} + +/// Falls back active cluster then most recently played then Versions when none exist fn browse_target() -> Route { - let clusters = crate::hooks::settled_or_loading(&crate::hooks::use_clusters()).unwrap_or_default(); - let active = *crate::hooks::use_active_cluster_id().read(); + let clusters = settled_or_loading(&use_clusters()).unwrap_or_default(); + let active = *use_active_cluster_id().read(); let cluster_id = active .filter(|id| clusters.iter().any(|cluster| cluster.id == *id)) - .or_else(|| crate::utils::sort_clusters_for_home(clusters).first().map(|c| c.id)); + .or_else(|| sort_clusters_for_home(clusters).first().map(|c| c.id)); match cluster_id { Some(cluster_id) => Route::Browser { @@ -196,7 +190,7 @@ impl Component for NavLink { .a11y_id(a11y_id) .a11y_focusable(true) .a11y_role(AccessibilityRole::Button) - .on_all_press(move |e: Event| { + .on_press(move |e: Event| { e.prevent_default(); match &target { NavTarget::Route(route) => { diff --git a/packages/oneclient_app/src/components/navbar/mod.rs b/packages/oneclient_app/src/components/navbar/mod.rs index 150c3832..55b87014 100644 --- a/packages/oneclient_app/src/components/navbar/mod.rs +++ b/packages/oneclient_app/src/components/navbar/mod.rs @@ -18,20 +18,20 @@ pub(super) fn navbar_button() -> Button { pub fn window_controls() -> impl IntoElement { let minimize = |_| { - Platform::get().with_window(None, |win| { + Platform::get().with_window(Platform::window_id(), |win| { win.set_minimized(true); }); }; let maximize = |_| { - Platform::get().with_window(None, |win| { + Platform::get().with_window(Platform::window_id(), |win| { win.set_maximized(!win.is_maximized()); }); }; let close = |_| { let platform = Platform::get(); - Platform::get().with_window(None, move |window| { + Platform::get().with_window(Platform::window_id(), move |window| { platform.close_window(window.id()); }); }; diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs index 2924ce7f..a960a129 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_duration_hms, format_size}, }; @@ -212,9 +212,7 @@ 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 { @@ -527,3 +525,4 @@ 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 new file mode 100644 index 00000000..53ad7e73 --- /dev/null +++ b/packages/oneclient_app/src/components/optional_mods_popup.rs @@ -0,0 +1,383 @@ +use std::collections::{HashMap, HashSet}; + +use freya::prelude::*; +use oneclient_content::packages::{CachedPackageMeta, ProviderId}; +use oneclient_db::models::SeenStatus; + +use crate::components::{ + Button, CARD_GRID_H, GRID_GAP, GRID_MIN_W, Icon, IconType, OverlayPopup, PackageEntry, + grid_card, package_icon, +}; +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::theme::colors; +use crate::ui::border_all_color; + +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.; + +type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; + +type Picked = HashSet<(i64, String)>; + +#[derive(PartialEq)] +pub struct OptionalModsPopup; + +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 groups = snapshot.optional_mods.clone(); + + let mut meta = MetaMap::new(); + for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { + let ids: Vec = groups + .iter() + .flatten() + .flat_map(|group| &group.mods) + .filter(|item| item.provider == provider) + .filter_map(|item| item.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(groups) = groups.filter(|groups| !groups.is_empty()) else { + return rect().into_element(); + }; + + 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())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog(&groups, &meta, picked, dispatch)), + ) + .into_element() + } +} + +fn package_key(item: &ClusterUpdateItem) -> String { + item.offer + .as_ref() + .map(|(_, package_id)| package_id.clone()) + .unwrap_or_else(|| item.fallback.clone()) +} + +fn entry_from_item( + item: &ClusterUpdateItem, + meta: &MetaMap, + package_id: String, + selected: bool, +) -> PackageEntry { + let cached = item + .project_id + .as_ref() + .and_then(|project_id| meta.get(&(item.provider, project_id.clone()))); + + PackageEntry { + package_id, + bundle_name: item.offer.as_ref().map(|(bundle, _)| bundle.clone()), + provider: item.provider, + name: cached + .map(|cached| cached.name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| item.fallback.clone()), + file_name: item.fallback.clone(), + author: cached + .map(|cached| cached.author.clone()) + .unwrap_or_default(), + description: cached + .map(|cached| cached.summary.clone()) + .unwrap_or_default(), + icon_url: cached.and_then(|cached| cached.icon_url.clone()), + size: 0, + categories: Vec::new(), + enabled: selected, + installed: false, + hash: None, + manifest_default: false, + hidden: false, + update_available: false, + // Offered, not installed, so there is no recency badge to show + seen_status: SeenStatus::default(), + } +} + +fn dialog( + groups: &[OptionalModsGroup], + meta: &MetaMap, + picked: State, + dispatch: crate::Actions, +) -> impl IntoElement { + 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(content(groups, meta, picked, dispatch)) +} + +fn content( + groups: &[OptionalModsGroup], + meta: &MetaMap, + picked: State, + dispatch: crate::Actions, +) -> impl IntoElement { + let total: usize = groups.iter().map(|group| group.mods.len()).sum(); + let offers: Vec<(i64, OptionalModRef)> = + groups.iter().flat_map(|group| group.offers()).collect(); + + let chosen = picked.read().clone(); + let selected = + |cluster_id: i64, package_id: &str| chosen.contains(&(cluster_id, package_id.to_string())); + let (enable, skip): (Vec<_>, Vec<_>) = offers + .iter() + .cloned() + .partition(|(cluster_id, (_, package_id))| selected(*cluster_id, package_id)); + + 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.", + 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.", + groups.len() + ), + }; + + let cancel = dispatch.clone(); + let declined = offers.clone(); + + let enable_text = if enable.is_empty() { + "Enable".to_string() + } else { + format!("Enable {}", enable.len()) + }; + + 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("Optional mods available") + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.5) + .max_lines(3) + .color(colors::fg_secondary()), + ), + ) + .child(mod_list(groups, meta, picked)) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| cancel.decline_optional_mods(declined.clone())) + .text("Cancel"), + ) + .maybe_child((!offers.is_empty()).then(|| { + let apply = dispatch.clone(); + Button::new() + .primary() + .disabled(enable.is_empty()) + .on_press(move |_| { + 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() + })), + ) +} + +fn grid_columns() -> usize { + let content_w = DIALOG_W - DIALOG_PAD * 2.; + (((content_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize).clamp(1, 3) +} + +fn mod_list( + groups: &[OptionalModsGroup], + meta: &MetaMap, + picked: State, +) -> impl IntoElement { + let show_headers = groups.len() > 1; + let cols = grid_columns(); + + let mut list = rect().vertical().width(Size::fill()).spacing(GRID_GAP); + + for (group_index, group) in groups.iter().enumerate() { + if show_headers { + list = list.child(cluster_header(&group.cluster_name, group_index == 0)); + } + + for (row_index, row_items) in group.mods.chunks(cols).enumerate() { + list = list.child(grid_row( + group.cluster_id, + row_items, + meta, + picked, + cols, + format!("{group_index}-{row_index}"), + )); + } + } + + ScrollView::new() + .width(Size::fill()) + .height(Size::Inner) + .max_height(Size::px(LIST_MAX_H)) + .child(list) +} + +fn grid_row( + cluster_id: i64, + items: &[ClusterUpdateItem], + meta: &MetaMap, + picked: State, + cols: usize, + key: String, +) -> impl IntoElement { + let mut row = rect() + .key(key) + .horizontal() + .width(Size::fill()) + .height(Size::px(CARD_GRID_H)) + .spacing(GRID_GAP) + .content(Content::Flex); + + for slot in 0..cols { + let cell = rect().width(Size::flex(1.0)).height(Size::px(CARD_GRID_H)); + row = row.child(match items.get(slot) { + Some(item) => { + let package_id = package_key(item); + cell.key(package_id.clone()) + .child(mod_card(cluster_id, item, meta, picked, package_id)) + } + None => cell, + }); + } + + row +} + +fn mod_card( + cluster_id: i64, + item: &ClusterUpdateItem, + meta: &MetaMap, + mut picked: State, + package_id: String, +) -> impl IntoElement { + let key = (cluster_id, package_id.clone()); + let selected = picked.read().contains(&key); + + let on_toggle: EventHandler<()> = (move |()| { + let mut next = picked.read().clone(); + if !next.remove(&key) { + next.insert(key.clone()); + } + picked.set(next); + }) + .into(); + + OptionalModCard { + entry: entry_from_item(item, meta, package_id, selected), + cluster_id, + on_toggle, + } +} + +#[derive(PartialEq)] +struct OptionalModCard { + entry: PackageEntry, + cluster_id: i64, + on_toggle: EventHandler<()>, +} + +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); + + grid_card( + &self.entry, + "mod", + self.cluster_id, + icon, + self.on_toggle.clone(), + false, + ) + } +} + +fn cluster_header(name: &str, first: bool) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(6., 8.)) + .margin(Gaps::new(if first { 0. } else { 8. }, 0., 3., 0.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .child( + label() + .text(name.to_string()) + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_primary()), + ) +} diff --git a/packages/oneclient_app/src/components/overlay_popup.rs b/packages/oneclient_app/src/components/overlay_popup.rs index 3b2f776c..4f22759e 100644 --- a/packages/oneclient_app/src/components/overlay_popup.rs +++ b/packages/oneclient_app/src/components/overlay_popup.rs @@ -1,16 +1,39 @@ +use std::cell::RefCell; + use freya::{ animation::{AnimNum, Ease, OnCreation, use_animation}, prelude::*, }; +use crate::hooks::use_overlay_claim; + const SCRIM_ALPHA: f32 = 90.; +/// Overlay level of a top level popup. Its scrim sits two levels below it. +pub const OVERLAY_BASE_LEVEL: u8 = 12; + +/// Freya maps a level to `(level.max(1) as i16) * (i16::MAX / 16)`, which +/// saturates above 16 so every higher level collides with 16. +pub const OVERLAY_MAX_LEVEL: u8 = 16; + +thread_local! { + static OPEN_LEVELS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// True while no open overlay sits above `level`. Freya dispatches +/// `on_global_key_down` to every listener, so a handler that should only answer +/// for the topmost overlay has to gate itself on this. +pub fn overlay_is_topmost(level: u8) -> bool { + OPEN_LEVELS.with_borrow(|open| open.iter().all(|&other| other <= level)) +} + #[derive(PartialEq)] pub struct OverlayPopup { children: Vec, on_close: Option>, position: Position, backdrop: bool, + overlay_level: u8, key: DiffKey, } @@ -27,6 +50,7 @@ impl OverlayPopup { on_close: None, position: Position::new_global().top(0.).left(0.), backdrop: true, + overlay_level: OVERLAY_BASE_LEVEL, key: DiffKey::None, } } @@ -36,6 +60,15 @@ impl OverlayPopup { self } + pub fn overlay_level(mut self, level: u8) -> Self { + debug_assert!( + (1..=OVERLAY_MAX_LEVEL).contains(&level), + "overlay levels above {OVERLAY_MAX_LEVEL} saturate onto {OVERLAY_MAX_LEVEL}" + ); + self.overlay_level = level; + self + } + pub fn backdrop(mut self, backdrop: bool) -> Self { self.backdrop = backdrop; self @@ -61,9 +94,21 @@ impl KeyExt for OverlayPopup { impl Component for OverlayPopup { fn render(&self) -> impl IntoElement { + use_overlay_claim(); + let a11y_id = use_a11y(); let scrim_close = self.on_close.clone(); let key_close = self.on_close.clone(); + let level = self.overlay_level; + + use_hook(move || OPEN_LEVELS.with_borrow_mut(|open| open.push(level))); + use_drop(move || { + OPEN_LEVELS.with_borrow_mut(|open| { + if let Some(pos) = open.iter().rposition(|&other| other == level) { + open.remove(pos); + } + }); + }); let fade = use_animation(|conf| { conf.on_creation(OnCreation::Run); @@ -83,7 +128,7 @@ impl Component for OverlayPopup { .position(Position::new_global().top(0.).left(0.)) .width(Size::window_percent(100.)) .height(Size::window_percent(100.)) - .layer(Layer::OverlayLevel(10)) + .layer(Layer::OverlayLevel(self.overlay_level.saturating_sub(2))) .background(Color::from_argb(alpha, 0, 0, 0)) .on_press(move |_| { if let Some(on_close) = scrim_close.as_ref() { @@ -98,9 +143,10 @@ impl Component for OverlayPopup { .a11y_focusable(true) .a11y_auto_focus(true) .a11y_role(AccessibilityRole::Dialog) - .layer(Layer::OverlayLevel(12)) + .layer(Layer::OverlayLevel(self.overlay_level)) .on_global_key_down(move |e: Event| { if e.key == Key::Named(NamedKey::Escape) + && overlay_is_topmost(level) && let Some(on_close) = key_close.as_ref() { on_close.call(()); diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs index 00b316d4..1a733baa 100644 --- a/packages/oneclient_app/src/components/package_row.rs +++ b/packages/oneclient_app/src/components/package_row.rs @@ -1,6 +1,7 @@ use freya::prelude::*; 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}; @@ -12,6 +13,9 @@ 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.; #[derive(Clone, Copy, PartialEq)] pub enum CardLayout { @@ -48,6 +52,8 @@ pub struct PackageEntry { pub hidden: bool, /// Only set for browser-installed content bundle packages use the bundle update flow pub update_available: bool, + /// Recency badge state cleared once the user views the list + pub seen_status: SeenStatus, } impl PackageEntry { @@ -63,6 +69,14 @@ impl PackageEntry { pub fn is_outdated(&self) -> bool { self.update_available && self.is_remote() && !self.in_bundle() } + + pub fn recency_badge(&self) -> Option { + match self.seen_status { + SeenStatus::New => Some(new_badge()), + SeenStatus::Updated => Some(updated_badge()), + SeenStatus::Seen => None, + } + } } #[derive(PartialEq)] @@ -111,11 +125,7 @@ impl Component for PackageRow { CardLayout::Grid => 52., }; let icon_query = use_cached_image(item.icon_url.clone(), 256); - let icon = if item.is_remote() { - remote_icon(&item.icon_url, &icon_query, icon_size) - } else { - local_icon(icon_size) - }; + let icon = package_icon(&item, &icon_query, icon_size); let on_toggle: EventHandler<()> = { let hash = item.hash.clone(); @@ -125,9 +135,10 @@ impl Component for PackageRow { let manifest_default = item.manifest_default; (move |()| { if let Some(h) = &hash { - cluster.mutate(ClusterAction::ToggleArtifact { + cluster.mutate(ClusterAction::SetArtifactEnabled { cluster_id, hash: h.clone(), + enabled: !enabled_now, }); } else if let Some(bundle) = &bundle_name { cluster.mutate(ClusterAction::SetBundlePackageEnabled { @@ -166,7 +177,7 @@ impl Component for PackageRow { remove_hover, ) } - CardLayout::Grid => grid_card(&item, package_type, cluster_id, icon, on_toggle), + CardLayout::Grid => grid_card(&item, package_type, cluster_id, icon, on_toggle, true), } } } @@ -199,20 +210,17 @@ fn list_card( .into_element() } -fn grid_card( +pub(crate) fn grid_card( item: &PackageEntry, package_type: &'static str, cluster_id: i64, icon: impl IntoElement, on_toggle: EventHandler<()>, + navigable: bool, // if true, it takes the user to the mod page ) -> Element { let remote = item.is_remote(); let enabled = item.enabled; - let title = if remote { - item.name.clone() - } else { - item.file_name.clone() - }; + let title = item.name.clone(); let (bg, border, content_alpha) = if enabled { (colors::brand().with_a(38), colors::brand(), 255) @@ -220,7 +228,7 @@ fn grid_card( (CARD_BG, colors::component_border(), 140) }; - let badge = if remote { + let badge = if remote && navigable { let provider = item.provider; let package_id = item.package_id.clone(); let package_type_owned = package_type.to_string(); @@ -236,6 +244,8 @@ fn grid_card( }) .child(provider_badge(item.provider)) .into_element() + } else if remote { + provider_badge(item.provider) } else { local_badge() }; @@ -274,10 +284,13 @@ fn grid_card( .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.is_outdated().then(outdated_badge)) + .maybe_child(item.recency_badge()), ), ) .into_element(); @@ -329,11 +342,7 @@ fn package_info( let provider = item.provider; let package_id = item.package_id.clone(); let package_type = package_type.to_string(); - let title = if remote { - item.name.clone() - } else { - item.file_name.clone() - }; + let title = item.name.clone(); rect() .horizontal() @@ -374,7 +383,8 @@ fn package_info( } else { local_badge() }) - .maybe_child(item.is_outdated().then(outdated_badge)), + .maybe_child(item.is_outdated().then(outdated_badge)) + .maybe_child(item.recency_badge()), ) .maybe(!item.author.is_empty(), |el| { el.child( @@ -398,11 +408,12 @@ fn package_info( .into_element() } -fn remote_icon( - icon_url: &Option, +pub(crate) fn package_icon( + item: &PackageEntry, icon_query: &freya::query::UseQuery, size: f32, ) -> Element { + let icon_url = &item.icon_url; let loaded = loaded_image(icon_url.as_deref(), icon_query); match loaded { @@ -413,14 +424,11 @@ fn remote_icon( .corner_radius(CornerRadius::new_all(8.)) .into_element(), - None => icon_box(IconType::DotsGrid, size), + None if icon_url.is_some() => icon_box(IconType::DotsGrid, size), + None => icon_box(IconType::HelpCircle, size), } } -fn local_icon(size: f32) -> Element { - icon_box(IconType::HelpCircle, size) -} - fn icon_box(icon: IconType, size: f32) -> Element { rect() .center() @@ -465,6 +473,28 @@ fn outdated_badge() -> Element { ) } +fn new_badge() -> Element { + accent_badge( + Icon::new(IconType::Plus) + .size(12.) + .color(colors::success()) + .into_element(), + "New".to_string(), + colors::success(), + ) +} + +fn updated_badge() -> Element { + accent_badge( + Icon::new(IconType::RefreshCcw02) + .size(12.) + .color(colors::success()) + .into_element(), + "Updated".to_string(), + colors::success(), + ) +} + fn local_badge() -> Element { badge( Icon::new(IconType::File02) diff --git a/packages/oneclient_app/src/components/package_update_popup.rs b/packages/oneclient_app/src/components/package_update_popup.rs index ec39435a..ce6b2e21 100644 --- a/packages/oneclient_app/src/components/package_update_popup.rs +++ b/packages/oneclient_app/src/components/package_update_popup.rs @@ -3,11 +3,12 @@ use std::collections::HashMap; use freya::prelude::*; +use oneclient_common::{PackageUpdateMode, Patch}; use oneclient_content::packages::{CachedPackageMeta, ProviderId}; -use oneclient_core::BrowserPackageUpdate; +use oneclient_core::{BrowserPackageUpdate, ProfileUpdate}; use oneclient_db::models::ClusterId; -use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; +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, }; @@ -63,6 +64,7 @@ impl Component for PackageUpdatePopup { // Absent entries default to Update so groups arriving mid-modal need no reconciliation let choices = use_state(HashMap::::new); + let mut dont_show_again = use_state(|| false); let groups = snapshot.package_updates.clone(); @@ -83,6 +85,8 @@ impl Component for PackageUpdatePopup { } let Some(groups) = groups.filter(|groups| !groups.is_empty()) else { + // The popup stays mounted, so the tick must not survive into the next prompt + dont_show_again.set_if_modified(false); return rect().into_element(); }; @@ -95,7 +99,7 @@ impl Component for PackageUpdatePopup { .width(Size::window_percent(100.)) .height(Size::window_percent(100.)) .center() - .child(dialog(&groups, &meta, dispatch, choices)), + .child(dialog(&groups, &meta, dispatch, choices, dont_show_again)), ) .into_element() } @@ -113,6 +117,7 @@ fn dialog( meta: &MetaMap, dispatch: crate::Actions, choices: State>, + dont_show_again: State, ) -> impl IntoElement { rect() .vertical() @@ -130,7 +135,7 @@ fn dialog( 0., Color::from_argb(150, 0, 0, 0), ))) - .child(content(groups, meta, dispatch, choices)) + .child(content(groups, meta, dispatch, choices, dont_show_again)) } fn content( @@ -138,10 +143,13 @@ fn content( meta: &MetaMap, dispatch: crate::Actions, choices: State>, + dont_show_again: State, ) -> impl IntoElement { let dismiss = dispatch.clone(); let proceed_dispatch = dispatch.clone(); + let dsa_dispatch = dispatch.clone(); let total: usize = groups.iter().map(|group| group.packages.len()).sum(); + let clusters: Vec = groups.iter().map(|group| group.cluster_id).collect(); let subtitle = match groups { [only] => format!( @@ -194,35 +202,73 @@ fn content( .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) - .main_align(Alignment::End) + .main_align(Alignment::SpaceBetween) .spacing(8.) .child( - Button::new() - .ghost() - .on_press(move |_| dismiss.close_package_updates()) - .text("Cancel"), + rect() + .direction(Direction::Horizontal) + .main_align(Alignment::Start) + .cross_align(Alignment::Center) + .spacing(6.) + .child( + checkbox_labeled(dont_show_again, "Don't show again?") + ) ) .child( - Button::new() - .primary() - .on_press(move |_| { - let answers = choices.read(); - for update in &all { - match answers.get(&row_key(update)).copied().unwrap_or_default() { - RowChoice::Update => { - proceed_dispatch.apply_package_update(update.clone()); + rect() + .width(Size::fill()) + .direction(Direction::Horizontal) + .cross_align(Alignment::Center) + .main_align(Alignment::End) + .child( + Button::new() + .ghost() + .on_press(move |_| dismiss.close_package_updates()) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + if *dont_show_again.peek() { + // A cluster override outranks the global one, so the prompt + // is only silenced by writing the cluster that raised it + for cluster_id in &clusters { + dsa_dispatch.update_cluster_profile( + *cluster_id, + ProfileUpdate { + browser_update_mode: Patch::Set( + PackageUpdateMode::Skip, + ), + ..Default::default() + }, + ); + } } - RowChoice::Skip => {} - RowChoice::SkipVersion => proceed_dispatch.skip_package_update( - update.cluster_id, - update.hash.clone(), - ), - } - } - proceed_dispatch.close_package_updates(); - }) - .child(Icon::new(IconType::DownloadCloud02).size(15.)) - .text("Proceed"), + + let answers = choices.read(); + for update in &all { + match answers + .get(&row_key(update)) + .copied() + .unwrap_or_default() + { + RowChoice::Update => { + proceed_dispatch.apply_package_update(update.clone()); + } + RowChoice::Skip => {} + RowChoice::SkipVersion => proceed_dispatch + .skip_package_update( + update.cluster_id, + update.hash.clone(), + ), + } + } + proceed_dispatch.close_package_updates(); + }) + .child(Icon::new(IconType::DownloadCloud02).size(15.)) + .text("Proceed"), + ) ), ) } diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs index b30fcbb2..191b1e72 100644 --- a/packages/oneclient_app/src/components/recents_row.rs +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -3,7 +3,7 @@ use freya::prelude::*; use freya::router::RouterContext; use oneclient_core::clusters::Cluster; -use crate::components::{ART_PREVIEW_EDGE, DynamicArt, Icon, IconType}; +use crate::components::{ART_PREVIEW_EDGE, ContextMenu, DynamicArt, Icon, IconType}; use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters}; use crate::routes::Route; use crate::theme; @@ -35,8 +35,10 @@ 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 @@ -65,7 +67,9 @@ impl Component for RecentsRow { index, items, progress, + key: DiffKey::None, } + .key(cluster.id) .into_element() })) .child(OtherVersionsTile { @@ -92,11 +96,58 @@ fn stagger_eased(progress: f32, index: usize, items: usize) -> f32 { 1.0 - (1.0 - local).powi(3) } +fn cluster_menu_entries(cluster_id: i64) -> [(IconType, &'static str, Route); 7] { + [ + ( + IconType::InfoCircle, + "Overview", + Route::ClusterOverview { cluster_id }, + ), + ( + IconType::Terminal, + "Logs", + Route::ClusterLogs { cluster_id }, + ), + ( + IconType::Eye, + "Screenshots", + Route::ClusterScreenshots { cluster_id }, + ), + ( + IconType::CodeSnippet02, + "Mods", + Route::ClusterMods { cluster_id }, + ), + ( + IconType::PaintPour, + "Shaders", + Route::ClusterShaders { cluster_id }, + ), + ( + IconType::Colors, + "Textures", + Route::ClusterTextures { cluster_id }, + ), + ( + IconType::Settings01, + "Settings", + Route::ClusterSettings { cluster_id }, + ), + ] +} + struct ClusterCard { cluster: Cluster, index: usize, items: usize, progress: f32, + key: DiffKey, +} + +impl KeyExt for ClusterCard { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } } impl PartialEq for ClusterCard { @@ -113,6 +164,7 @@ impl Component for ClusterCard { let mut active_id = use_active_cluster_id(); let active = *active_id.read() == Some(self.cluster.id); let mut hovering = use_state(|| false); + let mut menu = use_state(|| None::<(f32, f32)>); let a11y_id = use_a11y(); let focus = use_focus(a11y_id); @@ -128,6 +180,19 @@ impl Component for ClusterCard { *active_id.write() = Some(cluster_id); }; + let menu_overlay = (*menu.read()).map(|(x, y)| { + let mut context = ContextMenu::new(x, y) + .open_upwards() + .title(title.clone()) + .on_close(move |_| menu.set(None)); + for (icon, label, route) in cluster_menu_entries(cluster_id) { + context = context.action(icon, label, move |()| { + let _ = RouterContext::get().push(route.clone()); + }); + } + context.into_element() + }); + rect() .key(self.cluster.id) .width(Size::flex(1.0)) @@ -171,7 +236,15 @@ impl Component for ClusterCard { .y(0.) .color(Color::from_af32rgb(0.3, 0, 0, 0)), ) - .on_all_press(on_press) + .on_press(on_press) + .on_secondary_down(move |e: Event| { + if let PressEventData::Mouse(m) = e.data() { + menu.set(Some(( + m.global_location.x as f32, + m.global_location.y as f32, + ))); + } + }) .child( rect() .width(Size::fill()) @@ -182,10 +255,7 @@ impl Component for ClusterCard { .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)), ) .child( rect() @@ -224,6 +294,11 @@ impl Component for ClusterCard { ), ), ) + .maybe_child(menu_overlay) + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) } } @@ -282,7 +357,7 @@ impl Component for OtherVersionsTile { .y(0.) .color(Color::from_af32rgb(0.3, 0, 0, 0)), ) - .on_all_press(|_| { + .on_press(|_| { let _ = RouterContext::get().push(Route::Clusters {}); }) .child( diff --git a/packages/oneclient_app/src/components/screenshot_viewer.rs b/packages/oneclient_app/src/components/screenshot_viewer.rs index 2826d06d..3a9ee50c 100644 --- a/packages/oneclient_app/src/components/screenshot_viewer.rs +++ b/packages/oneclient_app/src/components/screenshot_viewer.rs @@ -1,8 +1,15 @@ +use std::path::PathBuf; + use freya::prelude::*; use oneclient_core::ScreenshotInfo; -use crate::components::{Button, Icon, IconType, LocalImage, OverlayPopup}; -use crate::hooks::{ScreenshotAction, use_dispatch, use_screenshot_action}; +use crate::components::{ + Button, ContextMenu, Icon, IconType, LocalImage, OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, + OverlayPopup, overlay_is_topmost, +}; +use crate::hooks::{ + Actions, ScreenshotAction, UseScreenshotAction, use_dispatch, use_screenshot_action, +}; use crate::theme::colors; use crate::ui::fmt_date; use crate::utils::{format_res, format_size}; @@ -37,6 +44,7 @@ impl Component for ScreenshotViewer { let start = self.start.min(len.saturating_sub(1)); move || start }); + let mut menu = use_state(|| None::<(f32, f32)>); if len == 0 { return rect().into_element(); @@ -50,6 +58,7 @@ impl Component for ScreenshotViewer { let close = self.on_close.clone(); let scrim_close = self.on_close.clone(); let delete_close = self.on_close.clone(); + let menu_delete_close = self.on_close.clone(); let has_prev = idx > 0; let has_next = idx + 1 < len; @@ -61,6 +70,23 @@ impl Component for ScreenshotViewer { let copy_dispatch = dispatch.clone(); let delete_path = info.path.clone(); + let menu_overlay = (*menu.read()).map(|(x, y)| { + let menu_close = menu_delete_close.clone(); + + // Nested inside this viewer's popup, so it has to be raised above it + screenshot_context_menu( + x, + y, + info.path.clone(), + action, + dispatch.clone(), + move |()| menu_close.call(()), + ) + .overlay_level(OVERLAY_MAX_LEVEL) + .on_close(move |_| menu.set(None)) + .into_element() + }); + OverlayPopup::new() .on_close(move |_| scrim_close.call(())) .child( @@ -68,10 +94,15 @@ impl Component for ScreenshotViewer { .width(Size::window_percent(100.)) .height(Size::window_percent(100.)) .center() - .on_global_key_down(move |e: Event| match &e.key { - Key::Named(NamedKey::ArrowLeft) if idx > 0 => index.set(idx - 1), - Key::Named(NamedKey::ArrowRight) if idx + 1 < len => index.set(idx + 1), - _ => {} + .on_global_key_down(move |e: Event| { + if !overlay_is_topmost(OVERLAY_BASE_LEVEL) { + return; + } + match &e.key { + Key::Named(NamedKey::ArrowLeft) if idx > 0 => index.set(idx - 1), + Key::Named(NamedKey::ArrowRight) if idx + 1 < len => index.set(idx + 1), + _ => {} + } }) .child( rect() @@ -100,6 +131,14 @@ impl Component for ScreenshotViewer { rect() .width(Size::flex(1.0)) .height(Size::fill()) + .on_secondary_down(move |e: Event| { + if let PressEventData::Mouse(m) = e.data() { + menu.set(Some(( + m.global_location.x as f32, + m.global_location.y as f32, + ))); + } + }) .child(preview), ) .child(chevron_btn( @@ -123,7 +162,7 @@ impl Component for ScreenshotViewer { .secondary() .on_press(move |_| { if let Some(dir) = open_path.parent() { - crate::platform::open_url( + crate::platform::open_path( &dir.to_string_lossy(), ); } @@ -163,10 +202,50 @@ impl Component for ScreenshotViewer { ), ), ) + .maybe_child(menu_overlay) .into_element() } } +/// The screenshot menu shown both by the grid and by this viewer, so the two +/// cannot drift apart +pub fn screenshot_context_menu( + x: f32, + y: f32, + path: PathBuf, + action: UseScreenshotAction, + dispatch: Actions, + on_deleted: impl Into>, +) -> 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) + .action(IconType::Folder, "Open in folder", move |()| { + if let Some(dir) = open_path.parent() { + crate::platform::open_path(&dir.to_string_lossy()); + } + }) + .action(IconType::Copy01, "Copy", move |()| { + crate::platform::copy_image_to_clipboard(copy_path.clone()); + dispatch + .notify("Copied to clipboard") + .body("Screenshot copied to your clipboard.") + .info() + .icon(IconType::ClipboardCheck) + .send(); + }) + .separator() + .danger_action(IconType::Trash01, "Delete", move |()| { + action.mutate(ScreenshotAction::Delete { + path: delete_path.clone(), + }); + on_deleted.call(()); + }) +} + fn chevron_btn( icon: IconType, enabled: bool, diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs index 6d6377cc..46978be6 100644 --- a/packages/oneclient_app/src/components/scrollview.rs +++ b/packages/oneclient_app/src/components/scrollview.rs @@ -10,11 +10,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 { diff --git a/packages/oneclient_app/src/components/segmented_control.rs b/packages/oneclient_app/src/components/segmented_control.rs index 917beb31..32b08452 100644 --- a/packages/oneclient_app/src/components/segmented_control.rs +++ b/packages/oneclient_app/src/components/segmented_control.rs @@ -185,7 +185,7 @@ impl Component for SegmentButton { }) .maybe(!disabled, |el| { el.cursor(CursorIcon::Pointer) - .on_all_press(move |_| *selected.write() = value) + .on_press(move |_| *selected.write() = value) }) .maybe_child(self.icon.map(|icon| { let mut ic = Icon::new(icon); diff --git a/packages/oneclient_app/src/components/tab_bar.rs b/packages/oneclient_app/src/components/tab_bar.rs index 636748fe..c56cb394 100644 --- a/packages/oneclient_app/src/components/tab_bar.rs +++ b/packages/oneclient_app/src/components/tab_bar.rs @@ -187,7 +187,7 @@ impl Component for TabButton { .cursor(CursorIcon::Pointer); if let Some(handler) = self.on_press.clone() { - el = el.on_all_press(move |e| handler.call(e)); + el = el.on_press(move |e| handler.call(e)); } el.child( diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs index b7d98eeb..a3cbaa91 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, progress_track}, hooks::{use_dispatch, use_notifications_snapshot}, motion::use_animations_enabled, notifications::{InboxEntry, NotificationActionKind}, theme::colors, - ui::divider, }; const TOAST_W: f32 = 300.; @@ -337,7 +337,12 @@ 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/toggle.rs b/packages/oneclient_app/src/components/toggle.rs index 30adb1d7..6055819b 100644 --- a/packages/oneclient_app/src/components/toggle.rs +++ b/packages/oneclient_app/src/components/toggle.rs @@ -106,7 +106,7 @@ impl Component for Switch { .a11y_focusable(!disabled) .a11y_role(AccessibilityRole::Button) .maybe(!disabled, |el| { - el.cursor(CursorIcon::Pointer).on_all_press(move |_| { + el.cursor(CursorIcon::Pointer).on_press(move |_| { on_press.call(()); }) }) diff --git a/packages/oneclient_app/src/components/upload_mclogs.rs b/packages/oneclient_app/src/components/upload_mclogs.rs index ac5d2ffa..81ead70a 100644 --- a/packages/oneclient_app/src/components/upload_mclogs.rs +++ b/packages/oneclient_app/src/components/upload_mclogs.rs @@ -14,7 +14,10 @@ pub struct UploadToMclogs { impl UploadToMclogs { pub fn new(has_log: bool, confirm: State>) -> Self { - Self { has_log, confirm } + Self { + has_log, + confirm + } } } diff --git a/packages/oneclient_app/src/components/version_card.rs b/packages/oneclient_app/src/components/version_card.rs index 8695627a..b8c806e7 100644 --- a/packages/oneclient_app/src/components/version_card.rs +++ b/packages/oneclient_app/src/components/version_card.rs @@ -79,7 +79,7 @@ impl Component for VersionCard { .a11y_id(a11y_id) .a11y_focusable(true) .a11y_role(AccessibilityRole::Button) - .on_all_press(move |e| on_press.call(e)) + .on_press(move |e| on_press.call(e)) .on_pointer_enter(move |_| { *hovering.write() = true; }) diff --git a/packages/oneclient_app/src/constants.rs b/packages/oneclient_app/src/constants.rs index a8aa2e0c..6fa12a2a 100644 --- a/packages/oneclient_app/src/constants.rs +++ b/packages/oneclient_app/src/constants.rs @@ -4,4 +4,4 @@ pub const WINDOW_TITLE: &str = "OneClient"; pub const GITHUB_REPO: &str = "oiupoyt/OneLauncher-Cracked"; pub const GITHUB_API_LATEST_RELEASE: &str = "https://api.github.com/repos/oiupoyt/OneLauncher-Cracked/releases/latest"; -pub const RELEASES_URL: &str = "https://github.com/oiupoyt/OneLauncher-Cracked/releases/latest"; +pub const RELEASES_URL: &str = "https://github.com/oiupoyt/OneLauncher-Cracked/releases/latest"; \ No newline at end of file diff --git a/packages/oneclient_app/src/events.rs b/packages/oneclient_app/src/events.rs index fe924da8..43dbddde 100644 --- a/packages/oneclient_app/src/events.rs +++ b/packages/oneclient_app/src/events.rs @@ -171,10 +171,7 @@ 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 { @@ -249,9 +246,7 @@ 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)); } @@ -313,7 +308,7 @@ pub async fn start_launcher( oneclient_polyplus::start(std::sync::Arc::clone(&state.auth)); oneclient_core::run_startup_tasks(&state); - let data_dir = oneclient_common::paths::launcher_dir() + let data_dir = oneclient_common::paths::data_dir() .map(|p| p.display().to_string()) .unwrap_or_default(); @@ -330,6 +325,7 @@ pub async fn start_launcher( error: None, snapshots: 0, data_dir, + needs_location: false, }; } { @@ -344,11 +340,14 @@ 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:#}"); - let data_dir = oneclient_common::paths::launcher_dir() + let data_dir = oneclient_common::paths::data_dir() .map(|p| p.display().to_string()) .unwrap_or_default(); @@ -362,9 +361,7 @@ pub fn report_startup_failure(station: &RadioStation, err: 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 new file mode 100644 index 00000000..16ee825f --- /dev/null +++ b/packages/oneclient_app/src/file_content.rs @@ -0,0 +1,248 @@ +#![allow(dead_code)] + +use std::path::Path; + +pub fn single_line(value: &str) -> String { + let cleaned: String = value + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + cleaned.split_whitespace().collect::>().join(" ") +} + +pub fn desktop_exec_arg(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + // The value-level unescape runs before the Exec quoting one, so the + // backslash the shell layer wants has to survive doubled + '"' | '$' | '`' => { + out.push_str(r"\\"); + out.push(c); + } + '\\' => out.push_str(r"\\\\"), + '%' => out.push_str("%%"), + _ => out.push(c), + } + } + out.push('"'); + out +} + +pub fn desktop_entry(name: &str, exe: &Path, folder: &str, icon: &str) -> String { + let name = single_line(name); + let exec = format!( + "{} --launch {}", + desktop_exec_arg(&exe.to_string_lossy()), + desktop_exec_arg(folder), + ); + + format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Version=1.0\n\ + Name={name}\n\ + Comment=Launch {name} with OneClient\n\ + Exec={exec}\n\ + Icon={icon}\n\ + Terminal=false\n\ + Categories=Game;\n\ + StartupNotify=true\n" + ) +} + +pub fn url_handler_entry(exe: &Path, scheme: &str, icon: &str) -> String { + let exec = desktop_exec_arg(&exe.to_string_lossy()); + + format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name=OneClient\n\ + Exec={exec} %u\n\ + Icon={icon}\n\ + Terminal=false\n\ + NoDisplay=true\n\ + MimeType=x-scheme-handler/{scheme};\n" + ) +} + +pub fn sh_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', r"'\''")) +} + +pub fn shell_script(exe: &Path, folder: &str) -> String { + format!( + "#!/bin/sh\nexec {} --launch {}\n", + sh_quote(&exe.to_string_lossy()), + sh_quote(folder), + ) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +pub fn bundle_slug(folder: &str) -> String { + let slug: String = folder + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let trimmed = slug.trim_matches('-').to_string(); + if trimmed.is_empty() { + "cluster".to_string() + } else { + trimmed + } +} + +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!( + "\n\ + \n\ + \n\ + \n\ + \tCFBundleName\n\t{display}\n\ + \tCFBundleDisplayName\n\t{display}\n\ + \tCFBundleIdentifier\n\torg.polyfrost.OneClient.shortcut.{slug}\n\ + \tCFBundleExecutable\n\t{executable}\n\ + {icon_entry}\ + \tCFBundlePackageType\n\tAPPL\n\ + \tCFBundleInfoDictionaryVersion\n\t6.0\n\ + \tCFBundleShortVersionString\n\t1.0\n\ + \tLSUIElement\n\t\n\ + \n\ + \n", + executable = xml_escape(executable), + slug = bundle_slug(folder), + ) +} + +pub fn url_shortcut(url: &str, icon: &Path) -> String { + format!( + "[InternetShortcut]\r\nURL={url}\r\nIconFile={}\r\nIconIndex=0\r\n", + icon.display(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn a_newline_in_a_cluster_name_cannot_forge_a_second_key() { + let entry = desktop_entry( + "Pack\nExec=/bin/sh", + &PathBuf::from("/usr/bin/oneclient_app"), + "pack", + "oneclient_app", + ); + assert!(!entry.lines().any(|line| line.starts_with("Exec=/bin/sh"))); + assert!(entry.contains(r#"Exec="/usr/bin/oneclient_app" --launch "pack""#)); + assert!(entry.contains("Name=Pack Exec=/bin/sh")); + } + + #[test] + fn a_space_in_the_install_path_stays_one_argument() { + let entry = desktop_entry( + "Pack", + &PathBuf::from("/opt/One Client/oneclient_app"), + "my pack", + "oneclient_app", + ); + assert!(entry.contains(r#"Exec="/opt/One Client/oneclient_app" --launch "my pack""#)); + } + + #[test] + fn the_url_handler_quotes_an_install_path_with_a_space() { + let entry = url_handler_entry( + &PathBuf::from("/opt/One Client/oneclient_app"), + "oneclient", + "oneclient_app", + ); + assert!(entry.contains(r#"Exec="/opt/One Client/oneclient_app" %u"#)); + assert!(entry.contains("MimeType=x-scheme-handler/oneclient;")); + } + + #[test] + fn the_url_handler_keeps_the_field_code_outside_the_quoted_path() { + let entry = url_handler_entry( + &PathBuf::from("/usr/bin/oneclient_app"), + "oneclient", + "oneclient_app", + ); + assert!(entry.contains(r#"Exec="/usr/bin/oneclient_app" %u"#)); + assert!(!entry.contains("%%u")); + } + + #[test] + fn percent_is_doubled_for_the_desktop_spec() { + assert_eq!(desktop_exec_arg("100%"), r#""100%%""#); + } + + #[test] + fn desktop_reserved_characters_are_escaped() { + assert_eq!(desktop_exec_arg("a$b`c\"d"), r#""a\\$b\\`c\\"d""#); + } + + #[test] + fn an_install_path_with_a_backslash_survives_both_unescape_passes() { + let entry = desktop_entry( + "Pack", + &PathBuf::from(r"/opt/one\client/$app"), + "pack", + "oneclient_app", + ); + assert!(entry.contains(r#"Exec="/opt/one\\\\client/\\$app" --launch "pack""#)); + } + + #[test] + fn the_mac_wrapper_survives_a_quote_in_the_path() { + let script = shell_script(&PathBuf::from("/Users/o'brien/OneClient"), "pack"); + assert!(script.contains(r"'/Users/o'\''brien/OneClient'")); + assert!(script.starts_with("#!/bin/sh\n")); + } + + #[test] + fn the_plist_escapes_a_name_that_looks_like_markup() { + let plist = info_plist("A & ", "launch", "pack", Some("icon")); + assert!(plist.contains("A & <B>")); + assert!(!plist.contains("")); + } + + #[test] + fn the_plist_omits_the_icon_key_when_there_is_no_icon() { + let plist = info_plist("Pack", "launch", "pack", None); + assert!(!plist.contains("CFBundleIconFile")); + } + + #[test] + fn a_bundle_id_keeps_only_what_it_is_allowed() { + assert_eq!(bundle_slug("My Pack (1.8.9)"), "My-Pack--1-8-9"); + assert_eq!(bundle_slug("---"), "cluster"); + } + + #[test] + fn the_url_shortcut_is_a_single_ini_section() { + let file = url_shortcut( + "oneclient://launch/My%20Pack", + &PathBuf::from(r"C:\Program Files\OneClient\oneclient_app.exe"), + ); + + assert!(file.starts_with("[InternetShortcut]\r\n")); + assert!(file.contains("\r\nURL=oneclient://launch/My%20Pack\r\n")); + assert!(file.contains(r"IconFile=C:\Program Files\OneClient\oneclient_app.exe")); + assert!(file.ends_with("IconIndex=0\r\n")); + } +} diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs index 2afc0127..73855c58 100644 --- a/packages/oneclient_app/src/hooks/actions.rs +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -3,8 +3,9 @@ //! Always `spawn_forever` never `spawn` Freya's `spawn` cancels the task when //! the calling component unmounts this work is app-scoped not component-scoped +use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use freya::prelude::spawn_forever; @@ -13,6 +14,8 @@ use oneclient_cluster::{ ClusterStage, ClusterUpdate, GameSettingsProfile, PackageUpdateMode, ProfileUpdate, }; use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_content::packages::{LiveSync, LocalImportReport}; +use oneclient_core::relocate::RelocationPlan; use oneclient_core::settings::LauncherSettings; use oneclient_core::settings::store::{save_global_profile, save_settings_and_apply}; use oneclient_db::models::ClusterId; @@ -20,11 +23,12 @@ use oneclient_events::{Answer, Level}; use tokio::sync::mpsc; use crate::components::IconType; -use crate::notifications::{ - ClusterUpdateSummary, NotificationAction, NotificationSpec, PackageUpdateGroup, PendingPrompt, -}; -use crate::state::{AppChannel, AppState, AsyncStatus}; use crate::{invalidate_java_queries, launcher}; +use crate::notifications::{ + ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, OptionalModsGroup, + PackageUpdateGroup, PendingPrompt, +}; +use crate::state::{AppChannel, AppState, AsyncStatus, RelocationState}; /// Over-disabling is the cheaper mistake a dead button beats a second game const LAUNCH_HOLD: Duration = Duration::from_secs(2); @@ -32,6 +36,28 @@ const LAUNCH_HOLD: Duration = Duration::from_secs(2); /// Mirrors `oneclient_net`'s reachability probe not reqwest's per-request /// timeouts which are minutes long This sits between Play and the game const UPDATE_CHECK_BUDGET: Duration = Duration::from_secs(8); +const BUNDLE_CHECK_INTERVAL: Duration = Duration::from_secs(15 * 60); +const BUNDLE_SYNC_BUDGET: Duration = Duration::from_secs(30); +/// The downloads need one too the net stack alone retries for minutes and the +/// launch button is already claimed by then +const BUNDLE_APPLY_BUDGET: Duration = Duration::from_secs(60); + +#[derive(Debug, Default)] +struct BundleCheckLog { + checked_at: HashMap, +} + +impl BundleCheckLog { + fn due(&self, cluster_id: ClusterId, now: Instant) -> bool { + self.checked_at + .get(&cluster_id) + .is_none_or(|last| now.duration_since(*last) >= BUNDLE_CHECK_INTERVAL) + } + + fn record(&mut self, cluster_id: ClusterId, now: Instant) { + self.checked_at.insert(cluster_id, now); + } +} #[derive(Debug, Clone, PartialEq)] enum LaunchUpdatePlan { @@ -75,6 +101,7 @@ pub struct Actions { station: RadioStation, pump: mpsc::UnboundedSender, events: oneclient_events::EventBus, + bundle_checks: Arc>, } impl Actions { @@ -99,6 +126,7 @@ impl Actions { station, pump, events, + bundle_checks: Arc::new(Mutex::new(BundleCheckLog::default())), } } @@ -106,12 +134,24 @@ impl Actions { let _ = self.pump.send(signal); } + fn bundle_check_due(&self, cluster_id: ClusterId) -> bool { + self.bundle_checks + .lock() + .unwrap() + .due(cluster_id, Instant::now()) + } + + fn record_bundle_checks(&self, cluster_ids: impl IntoIterator) { + let now = Instant::now(); + let mut log = self.bundle_checks.lock().unwrap(); + for cluster_id in cluster_ids { + log.record(cluster_id, now); + } + } + 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); @@ -135,8 +175,10 @@ 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); @@ -168,10 +210,7 @@ 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(); @@ -248,9 +287,37 @@ impl Actions { } pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) { - if let Some(updated) = self.mutate_settings(|settings| { + let mut was_declined = false; + + let Some(updated) = self.mutate_settings(|settings| { + was_declined = settings.declined_tos; settings.accepted_tos_version = terms_version; settings.accepted_privacy_version = privacy_version; + settings.declined_tos = false; + }) else { + return; + }; + + self.persist(updated); + + if was_declined { + self.notify("Restart to finish") + .body( + "Thanks. OneClient reconnects to Polyfrost services the next time you start \ + it.", + ) + .icon(IconType::RefreshCw01) + .send(); + } + } + + pub fn decline_tos(&self) { + oneclient_common::consent::decline(); + + if let Some(updated) = self.mutate_settings(|settings| { + settings.declined_tos = true; + settings.accepted_tos_version = 0; + settings.accepted_privacy_version = 0; }) { self.persist(updated); } @@ -456,12 +523,14 @@ impl Actions { 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() + } } }); } @@ -533,11 +602,7 @@ impl Actions { } 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) { @@ -563,6 +628,173 @@ impl Actions { self.with_engine(|state| state.notifications.close_cluster_update()); } + pub fn open_optional_mods( + &self, + groups: Vec, + 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 decline_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) { + self.close_optional_mods(); + self.record_skipped_optional_mods(mods); + } + + pub fn record_skipped_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) { + if mods.is_empty() { + return; + } + + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let content = state.services.content(); + + for (cluster_id, package_ids) in group_by_cluster(&mods) { + if let Err(err) = + oneclient_core::skip_optional_mods(cluster_id, &package_ids, &content).await + { + tracing::warn!( + cluster_id, + error = %err, + "failed to record skipped optional mods, they will come back as new" + ); + } + } + }); + } + + /// Holds the launch until the mods are actually on disk Minecraft reads + /// `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(); + return; + } + + // Off screen straight away, but the launch keeps waiting until the + // install below is done, so the pause needs a progress notification of + // its own the same way `apply_updates_for_launch` gives one + self.with_engine(|state| state.notifications.hide_optional_mods()); + let actions = self.clone(); + + spawn_forever(async move { + let Ok(state) = launcher::state() else { + actions.close_optional_mods(); + 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 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" + ); + } + } + } + + 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), + ) + .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"); + } + } + + session.finish(); + super::invalidate_cluster_queries().await; + + if installed > 0 { + events + .notify("Mods added") + .body(format!( + "{installed} mod{} added", + if installed == 1 { "" } else { "s" } + )) + .send(); + } + if failed > 0 { + events + .notify("Some mods were not added") + .body(format!( + "{failed} mod{} could not be installed", + if failed == 1 { "" } else { "s" } + )) + .error() + .send(); + } + + actions.close_optional_mods(); + }); + } + pub fn close_package_updates(&self) { self.with_engine(|state| state.notifications.close_package_updates()); } @@ -631,8 +863,6 @@ impl Actions { .progress(id, "Downloading assets", current, total); } - /// The pending flag is raised synchronously before the first `await` the - /// claim itself is the guard against a double-click spawning two games pub fn launch_cluster(&self, cluster_id: ClusterId) { let claimed = self .station @@ -678,28 +908,44 @@ impl Actions { .error = None; } - pub fn import_local_file( + 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, - content_type: ContentType, - path: PathBuf, + files: Vec<(PathBuf, ContentType)>, ) { + if files.is_empty() { + return; + } + spawn_forever(async move { let Ok(state) = launcher::state() else { return }; let events = state.services.events.clone(); - match oneclient_content::packages::PackageStore::import_local_file( - &path, - content_type, + match oneclient_content::packages::PackageStore::import_local_files( + &files, cluster_id, &state.services.content(), ) .await { - Ok(row) => { - events - .notify("Imported") - .body(format!("Added {}", row.file_name)) - .send(); + Ok(report) => { + let mut deferred = false; + for row in &report.imported { + let live = oneclient_content::packages::PackageStore::sync_live_content( + cluster_id, + row, + &state.services.content(), + ) + .await + .unwrap_or(LiveSync::Skipped); + + deferred |= live == LiveSync::Deferred; + } + + notify_import(&events, &report, deferred && state.games.is_active(cluster_id)); super::invalidate_cluster_queries().await; } Err(err) => events @@ -760,6 +1006,7 @@ impl Actions { name, &install.dependencies, &install.missing_dependencies, + install.live_deferred, ), level: Level::Info, icon: Some(IconType::Download01), @@ -931,17 +1178,82 @@ impl Actions { } }; + actions.record_bundle_checks([cluster_id]); + super::invalidate_cluster_queries().await; if let Some(spec) = crate::install::cluster_update_notification(cluster_id, &result, &state.services) .await { + // The offers are queued by the sync itself and raised at launch + // this path only reports what changed actions.push_notification(spec); } }); } - /// `syncing_bundles` gates every launch button so the readiness check must + /// App-scoped on purpose: the copy has to outlive the settings page, which + /// unmounts as soon as the router swaps to the move screen + pub fn relocate(&self, plan: RelocationPlan) { + let state = match launcher::state() { + Ok(state) => state, + Err(err) => { + tracing::error!("move skipped, launcher not ready: {err:#}"); + self.write_relocation(RelocationState { + plan: Some(plan), + result: Some(Err(format!("OneClient is not ready yet: {err}"))), + ..RelocationState::default() + }); + return; + } + }; + + self.write_relocation(RelocationState { + plan: Some(plan.clone()), + ..RelocationState::default() + }); + + 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 mut guard = station.write_channel(AppChannel::Relocation); + guard.relocation.copied = copied; + guard.relocation.total = total; + }) + .await; + + if let Err(message) = &result { + tracing::error!("moving the data folder failed: {message}"); + } + + let moved = result.is_ok(); + station + .write_channel(AppChannel::Relocation) + .relocation + .result = Some(result); + + if moved { + super::invalidate_leftovers_queries().await; + } + }); + } + + /// Leaves the move screen, which only the user can do and only once the + /// copy has settled + pub fn end_relocation(&self) { + self.write_relocation(RelocationState::default()); + } + + fn write_relocation(&self, relocation: RelocationState) { + self.station + .clone() + .write_channel(AppChannel::Relocation) + .relocation = relocation; + } + + /// `syncing_bundles` gates every launch button so the readiness check must /// happen before the flag is raised not inside the task pub fn sync_bundles(&self) { let state = match launcher::state() { @@ -960,9 +1272,14 @@ 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:#}"); - } + + let synced = match state.bundles.sync(&state.services.content()).await { + Ok(_) => true, + Err(err) => { + tracing::error!("bundle catalog sync failed: {err:#}"); + false + } + }; if let Err(err) = oneclient_core::clusters::apply_remote_migrations(&state).await { tracing::error!("cluster migrations failed: {err:#}"); } @@ -984,6 +1301,12 @@ impl Actions { .await; let session_id = session.detach(); + if synced + && let Ok(clusters) = state.clusters.list().await + { + actions.record_bundle_checks(clusters.iter().map(|cluster| cluster.id)); + } + actions .station .clone() @@ -992,8 +1315,7 @@ impl Actions { .syncing_bundles = false; super::invalidate_cluster_queries().await; - let spec = - crate::install::combined_cluster_update_spec(&changed, &state.services).await; + let spec = crate::install::combined_cluster_update_spec(&changed, &state.services).await; actions.with_engine(|app| { app.notifications .finish_grouped_as_actions(&mut app.inbox, session_id, spec); @@ -1001,6 +1323,115 @@ impl Actions { }); } + async fn resolve_bundle_updates_before_launch( + &self, + state: &Arc, + cluster_id: ClusterId, + ) { + if !self.bundle_check_due(cluster_id) { + tracing::debug!(cluster_id, "bundle update check skipped, checked recently"); + return; + } + + let content = state.services.content(); + + if let Ok(false) = oneclient_core::cluster_has_bundle_content(cluster_id, &content).await { + tracing::debug!(cluster_id, "bundle update check skipped, no bundle content"); + return; + } + + let mode = self.cluster_update_mode(state, cluster_id).await; + + self.station + .clone() + .write_channel(AppChannel::Launcher) + .launcher + .syncing_bundles = true; + + let synced = match tokio::time::timeout(BUNDLE_SYNC_BUDGET, state.bundles.sync(&content)) + .await + { + Ok(Ok(_)) => true, + Ok(Err(err)) => { + tracing::warn!( + cluster_id, + error = %err, + "bundle catalog sync failed, launching against the cached catalog" + ); + false + } + Err(_elapsed) => { + tracing::debug!( + cluster_id, + "bundle catalog sync exceeded its launch budget" + ); + false + } + }; + + self.station + .clone() + .write_channel(AppChannel::Launcher) + .launcher + .syncing_bundles = false; + + // No prompt for bundles the package manager's markers carry them instead + if !matches!(mode, PackageUpdateMode::Automatic) { + tracing::debug!(cluster_id, "bundle updates left for the user to apply"); + if synced { + self.record_bundle_checks([cluster_id]); + } + return; + } + + let applied = match tokio::time::timeout( + BUNDLE_APPLY_BUDGET, + oneclient_core::apply_bundle_updates(cluster_id, state.bundles.as_ref(), &content), + ) + .await + { + Ok(applied) => applied, + Err(_elapsed) => { + tracing::warn!( + cluster_id, + "bundle updates exceeded their launch budget, launching anyway" + ); + return; + } + }; + + let result = match applied { + Ok(result) => result, + Err(err) => { + tracing::warn!( + cluster_id, + error = %err, + "bundle update failed, launching anyway" + ); + return; + } + }; + + if synced { + self.record_bundle_checks([cluster_id]); + } + + if result.updates_applied.is_empty() + && result.additions_applied.is_empty() + && result.removals_applied.is_empty() + { + return; + } + + super::invalidate_cluster_queries().await; + + if let Some(spec) = + crate::install::cluster_update_notification(cluster_id, &result, &state.services).await + { + self.push_notification(spec); + } + } + /// The check runs and the cache is written whatever the mode says keeping /// the package manager's "Update available" markers honest /// @@ -1142,17 +1573,54 @@ impl Actions { } } + async fn resolve_optional_mods_before_launch( + &self, + state: &Arc, + cluster_id: ClusterId, + ) { + let pending = match oneclient_core::pending_optional_mods( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + { + Ok(pending) => pending, + Err(err) => { + tracing::warn!( + cluster_id, + error = %err, + "could not read queued optional mods, launching anyway" + ); + return; + } + }; + + // `pending_optional_mods` already drops everything the player turned + // down, so an empty list here means there is nothing left to ask + let Some(group) = + crate::install::pending_optional_group(cluster_id, &pending, &state.services).await + else { + return; + }; + + let (done, wait) = tokio::sync::oneshot::channel(); + self.with_engine(move |state| { + state.notifications.open_optional_mods(vec![group], Some(done)); + state.center_open = false; + }); + + let _ = wait.await; + } + async fn prompt_package_updates(&self, group: PackageUpdateGroup) { 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; }); - // A replaced or torn-down modal is equally a reason to stop waiting let _ = wait.await; } @@ -1245,8 +1713,19 @@ impl Actions { } } -/// Failures are reported as game events so the caller only has to know the -/// attempt is over Everything before `launch_cluster` is pre-launch work +fn group_by_cluster(mods: &[(ClusterId, OptionalModRef)]) -> Vec<(ClusterId, Vec)> { + let mut grouped: Vec<(ClusterId, Vec)> = Vec::new(); + + for (cluster_id, (_bundle_name, package_id)) in mods { + match grouped.iter_mut().find(|(id, _)| id == cluster_id) { + Some((_, package_ids)) => package_ids.push(package_id.clone()), + None => grouped.push((*cluster_id, vec![package_id.clone()])), + } + } + + grouped +} + async fn launch(actions: &Actions, cluster_id: ClusterId) { let Ok(state) = launcher::state() else { return }; let events = state.services.events.clone(); @@ -1271,10 +1750,18 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) { // Before the game process never after Minecraft reads its mods once at // startup + actions + .resolve_bundle_updates_before_launch(&state, cluster_id) + .await; actions .resolve_package_updates_before_launch(&state, cluster_id) .await; + // After the updates so the player never faces two modals at once + 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 @@ -1331,6 +1818,39 @@ async fn repair_and_relaunch( } } +fn notify_import(events: &oneclient_events::EventBus, report: &LocalImportReport, deferred: bool) { + let failed = report.failed.len(); + + let Some(mut body) = (match report.imported.as_slice() { + [] => None, + [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()); + + events.notify("Import failed").body(reason).error().send(); + return; + }; + + if failed > 0 { + body.push_str(&format!(", {failed} could not be read")); + } + + if deferred { + body.push_str(". Minecraft is running, so it will be there at the next launch"); + } + + if failed == 0 { + events.notify("Imported").body(body).send(); + return; + } + + events.notify("Imported").body(body).error().send(); +} + #[must_use = "the notification is not raised until `.send()` is called"] pub struct NotificationBuilder { actions: Actions, @@ -1425,6 +1945,41 @@ mod tests { ); } + #[test] + fn a_cluster_that_was_never_checked_is_due() { + assert!(BundleCheckLog::default().due(1, Instant::now())); + } + + #[test] + fn a_relaunch_inside_the_window_reuses_the_last_check() { + let mut log = BundleCheckLog::default(); + let now = Instant::now(); + log.record(1, now); + + assert!(!log.due(1, now)); + assert!(!log.due(1, now + BUNDLE_CHECK_INTERVAL / 2)); + } + + #[test] + fn the_window_reopens_once_the_interval_has_passed() { + let mut log = BundleCheckLog::default(); + let now = Instant::now(); + log.record(1, now); + + assert!(log.due(1, now + BUNDLE_CHECK_INTERVAL)); + assert!(log.due(1, now + BUNDLE_CHECK_INTERVAL * 2)); + } + + #[test] + fn clusters_are_throttled_independently() { + let mut log = BundleCheckLog::default(); + let now = Instant::now(); + log.record(1, now); + + assert!(!log.due(1, now)); + assert!(log.due(2, now)); + } + #[test] fn nothing_pending_never_opens_a_modal() { for mode in PackageUpdateMode::ALL.iter().copied() { diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs index cf617d3b..46b5cba7 100644 --- a/packages/oneclient_app/src/hooks/active_cluster.rs +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -8,7 +8,7 @@ use oneclient_db::models::ClusterId; pub struct ActiveClusterState(pub State>); pub fn use_provide_active_cluster(active: ActiveClusterState) { - use_provide_root_context(move || active.clone()); + use_hook(move || provide_root_context(active)); } pub fn use_active_cluster_id() -> State> { @@ -31,11 +31,24 @@ pub fn use_splash() -> SplashState { consume_root_context::() } +/// The settings value the window was built from, carried in from startup because +/// the settings channel is still on its defaults during the first frames +#[derive(Clone, Copy)] +pub struct StartMaximizedState(pub bool); + +pub fn use_provide_start_maximized(state: StartMaximizedState) { + use_provide_root_context(move || state); +} + +pub fn use_start_maximized() -> bool { + consume_root_context::().0 +} + #[derive(Clone)] pub struct BrowserCompatState(pub State); pub fn use_provide_browser_compat(state: BrowserCompatState) { - use_provide_root_context(move || state.clone()); + use_hook(move || provide_root_context(state)); } pub fn use_browser_compat() -> State { @@ -53,7 +66,7 @@ pub fn use_link_confirm() -> State> { consume_root_context::().0 } -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone)] pub struct BrowserUiState { pub query: String, pub provider: ProviderId, @@ -76,7 +89,7 @@ impl Default for BrowserUiState { pub struct BrowserStateStore(pub State>); pub fn use_provide_browser_state(store: BrowserStateStore) { - use_provide_root_context(move || store.clone()); + use_hook(move || provide_root_context(store)); } pub fn use_browser_state_store() -> State> { @@ -98,12 +111,67 @@ pub struct OnboardingSelectionState { /// `None` = don't import pub import_folder: State>, pub import_dedicated: State, + pub picks_location: State, } pub fn use_provide_onboarding_selection(state: OnboardingSelectionState) { - use_provide_root_context(move || state.clone()); + use_hook(move || provide_root_context(state)); } pub fn use_onboarding_selection() -> OnboardingSelectionState { consume_root_context::() } + +#[cfg(test)] +mod tests { + use freya_testing::TestingRunner; + + use super::*; + + /// Stands in for a route layout: it owns the state and publishes it as a root + /// context, so unmounting it drops the value the context still points at + #[derive(PartialEq)] + struct Shell; + + impl Component for Shell { + fn render(&self) -> impl IntoElement { + let active = use_state(|| None::); + use_provide_active_cluster(ActiveClusterState(active)); + rect().child(Reader) + } + } + + #[derive(PartialEq)] + struct Reader; + + impl Component for Reader { + fn render(&self) -> impl IntoElement { + let active = *use_active_cluster_id().read(); + label().text(format!("{active:?}")) + } + } + + fn app() -> impl IntoElement { + let mounted = use_consume::>(); + rect().maybe_child(mounted().then(|| Shell.into_element())) + } + + #[test] + fn a_remounted_shell_replaces_the_dropped_root_state() { + let (mut test, mut mounted) = TestingRunner::new( + app, + Size2D::new(300., 300.), + |runner| runner.provide_root_context(|| State::create(true)), + 1., + ); + test.sync_and_update(); + + // Leaving the shell drops the state the root context holds + test.run_in(|| mounted.set(false)); + test.sync_and_update(); + + // Coming back must not hand the reader that dropped state + test.run_in(|| mounted.set(true)); + test.sync_and_update(); + } +} diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs index cb9a6609..f0c5e986 100644 --- a/packages/oneclient_app/src/hooks/mod.rs +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -1,18 +1,25 @@ -mod actions; mod active_cluster; mod debounce; +mod actions; +mod overlay_claims; mod queries; +mod shortcut_actions; mod view_state; pub use debounce::use_debounced; +pub use overlay_claims::{ + OverlayClaims, use_overlay_claim, use_overlay_claim_when, use_overlay_claims, + use_provide_overlay_claims, +}; pub use view_state::{PersistedView, use_view_state}; pub use active_cluster::{ ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserUiState, LinkConfirmState, - OnboardingSelectionState, SplashState, use_active_cluster_id, use_browser_compat, - use_browser_state_store, use_link_confirm, use_onboarding_selection, + OnboardingSelectionState, SplashState, StartMaximizedState, use_active_cluster_id, + use_browser_compat, use_browser_state_store, use_link_confirm, use_onboarding_selection, use_provide_active_cluster, use_provide_browser_compat, use_provide_browser_state, - use_provide_link_confirm, use_provide_onboarding_selection, use_provide_splash, use_splash, + 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}; @@ -22,42 +29,50 @@ pub use queries::{ 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, + 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, invalidate_logs_queries, - invalidate_profile_queries, invalidate_screenshots_queries, invalidate_storage_queries, + invalidate_cluster_queries, invalidate_java_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, + 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, stale_hashes, terms_document, terms_error, terms_is_loading, try_account, + settled_or_loading, 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, - try_storage_report, use_account, use_accounts, use_add_microsoft_account, - use_add_offline_account, 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, 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_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_updates, use_package_versions, 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_storage_action, use_storage_report, - use_terms, use_upload_log, use_version_metadata, use_versions, version_list, versions_metadata, - versions_total, + 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, + 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_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_version_metadata, use_versions, version_list, versions_metadata, versions_total, }; +pub(crate) use queries::invalidate_auth_queries; + use crate::notifications::NotificationSnapshot; use crate::state::{ - AppChannel, GameState, InstallState, LauncherInit, LoginProgress, SettingsState, + AppChannel, GameState, InstallState, LauncherInit, LoginProgress, RelocationState, + SettingsState, }; use freya::prelude::*; use freya::radio::use_radio; @@ -77,6 +92,10 @@ pub fn use_launcher() -> LauncherInit { use_radio(AppChannel::Launcher).read().launcher.clone() } +pub fn use_relocation() -> RelocationState { + use_radio(AppChannel::Relocation).read().relocation.clone() +} + pub fn use_settings_snapshot() -> SettingsState { use_radio(AppChannel::Settings).read().settings.clone() } @@ -107,6 +126,13 @@ pub fn use_installs_snapshot() -> InstallState { use_radio(AppChannel::Installs).read().installs.clone() } +pub fn use_pending_launch() -> Option { + use_radio(AppChannel::PendingLaunch) + .read() + .pending_launch + .clone() +} + pub fn use_microsoft_login_status() -> Option { use_radio(AppChannel::MicrosoftLogin) .read() @@ -114,4 +140,3 @@ pub fn use_microsoft_login_status() -> Option { .clone() } -pub(crate) use queries::invalidate_auth_queries; diff --git a/packages/oneclient_app/src/hooks/overlay_claims.rs b/packages/oneclient_app/src/hooks/overlay_claims.rs new file mode 100644 index 00000000..3d226f91 --- /dev/null +++ b/packages/oneclient_app/src/hooks/overlay_claims.rs @@ -0,0 +1,64 @@ +use std::cell::Cell; +use std::rc::Rc; + +use freya::prelude::*; + +#[derive(Clone, Default)] +pub struct OverlayClaims(Rc>); + +impl OverlayClaims { + /// Whether anything on screen currently owns Escape and mouse-back. + pub fn any(&self) -> bool { + self.0.get() > 0 + } + + fn acquire(&self) { + self.0.set(self.0.get() + 1); + } + + fn release(&self) { + let held = self.0.get(); + debug_assert!(held > 0, "overlay claim released without a matching acquire"); + self.0.set(held.saturating_sub(1)); + } +} + +pub fn use_provide_overlay_claims() { + use_provide_root_context(OverlayClaims::default); +} + +/// Falls back to a detached counter outside `RootLayout` so a claim raised there +/// degrades to "nothing claimed" instead of taking the app down. +pub fn use_overlay_claims() -> OverlayClaims { + try_consume_root_context::().unwrap_or_default() +} + +pub fn use_overlay_claim() { + let claims = use_hook(use_overlay_claims); + let released = claims.clone(); + + use_hook(move || claims.acquire()); + use_drop(move || released.release()); +} + +pub fn use_overlay_claim_when(active: bool) { + let claims = use_hook(use_overlay_claims); + let held = use_hook(|| Rc::new(Cell::new(false))); + + if held.get() != active { + held.set(active); + if active { + claims.acquire(); + } else { + claims.release(); + } + } + + let released = claims; + let held_at_drop = held; + use_drop(move || { + if held_at_drop.get() { + released.release(); + } + }); +} diff --git a/packages/oneclient_app/src/hooks/queries/auth.rs b/packages/oneclient_app/src/hooks/queries/auth.rs index 4380b280..527feba8 100644 --- a/packages/oneclient_app/src/hooks/queries/auth.rs +++ b/packages/oneclient_app/src/hooks/queries/auth.rs @@ -1,11 +1,10 @@ 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_auth::{AccountKind, MicrosoftLoginSession, MinecraftAccount}; use oneclient_core::LauncherError; +use oneclient_auth::{AccountKind, MicrosoftLoginSession, MinecraftAccount}; use uuid::Uuid; static HANDLED_LOGIN_CODE: Mutex> = Mutex::new(None); @@ -133,10 +132,7 @@ 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?) } } @@ -149,10 +145,7 @@ 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) { @@ -177,10 +170,7 @@ 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(()) } } @@ -220,10 +210,7 @@ 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) { @@ -247,10 +234,7 @@ 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) { @@ -274,10 +258,7 @@ 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) { @@ -301,10 +282,7 @@ 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) { @@ -323,10 +301,7 @@ 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) { @@ -389,6 +364,18 @@ pub fn mutation_is_running(mutation: &UseMutation) -> mutation.read().state().is_loading() } +pub fn mutation_ok(mutation: &UseMutation) -> Option +where + M: MutationCapability, + M::Ok: Clone, +{ + let reader = mutation.read(); + match &*reader.state() { + MutationStateData::Settled { res: Ok(ok), .. } => Some(ok.clone()), + _ => None, + } +} + pub fn mutation_error(mutation: &UseMutation) -> Option where M: MutationCapability, diff --git a/packages/oneclient_app/src/hooks/queries/bundles.rs b/packages/oneclient_app/src/hooks/queries/bundles.rs index 7e22aa88..f24b88cf 100644 --- a/packages/oneclient_app/src/hooks/queries/bundles.rs +++ b/packages/oneclient_app/src/hooks/queries/bundles.rs @@ -33,11 +33,7 @@ impl QueryCapability for OnboardingBundlesQuery { for cluster in clusters { let archives = state .bundles - .archives_for( - &state.services.content(), - &cluster.mc_version, - cluster.mc_loader, - ) + .archives_for(&state.services.content(), &cluster.mc_version, cluster.mc_loader) .await .unwrap_or_default(); out.push(ClusterBundles { cluster, archives }); @@ -74,12 +70,14 @@ impl QueryCapability for BundlesWithStatusQuery { async fn run(&self, keys: &Self::Keys) -> Result { let _ = keys; let state = crate::launcher::state()?; - Ok(get_bundles_with_update_status( - self.cluster_id, - state.bundles.as_ref(), - &state.services.content(), + Ok( + get_bundles_with_update_status( + self.cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await?, ) - .await?) } } @@ -114,8 +112,7 @@ 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)) diff --git a/packages/oneclient_app/src/hooks/queries/changelog.rs b/packages/oneclient_app/src/hooks/queries/changelog.rs index 3f4d0eab..55d29431 100644 --- a/packages/oneclient_app/src/hooks/queries/changelog.rs +++ b/packages/oneclient_app/src/hooks/queries/changelog.rs @@ -1,5 +1,7 @@ use freya::query::{Query, QueryCapability, UseQuery, use_query}; -use oneclient_core::{ChangelogGroup, LauncherError, fetch_changelog, parse_changelog}; +use oneclient_core::{ + ChangelogGroup, LauncherError, fetch_changelog, parse_changelog, +}; #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct ChangelogKeys { diff --git a/packages/oneclient_app/src/hooks/queries/cluster_content.rs b/packages/oneclient_app/src/hooks/queries/cluster_content.rs index 196d57f5..33f62445 100644 --- a/packages/oneclient_app/src/hooks/queries/cluster_content.rs +++ b/packages/oneclient_app/src/hooks/queries/cluster_content.rs @@ -22,8 +22,7 @@ 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) diff --git a/packages/oneclient_app/src/hooks/queries/image.rs b/packages/oneclient_app/src/hooks/queries/image.rs index e35c94e2..85d2e506 100644 --- a/packages/oneclient_app/src/hooks/queries/image.rs +++ b/packages/oneclient_app/src/hooks/queries/image.rs @@ -37,10 +37,7 @@ 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 b65ec305..5c26f125 100644 --- a/packages/oneclient_app/src/hooks/queries/java.rs +++ b/packages/oneclient_app/src/hooks/queries/java.rs @@ -14,7 +14,9 @@ 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()) } } @@ -45,9 +47,7 @@ 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 c1718ffe..53d1a4b1 100644 --- a/packages/oneclient_app/src/hooks/queries/logs.rs +++ b/packages/oneclient_app/src/hooks/queries/logs.rs @@ -1,8 +1,8 @@ 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, diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs index 0a27de45..e65694c6 100644 --- a/packages/oneclient_app/src/hooks/queries/mod.rs +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -40,11 +40,11 @@ pub use auth::{ CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount, UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error, - mutation_is_pending, mutation_is_running, 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, + 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, @@ -52,8 +52,7 @@ pub use bundles::{ use_onboarding_bundles, }; pub use changelog::{ - changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, - use_changelog, + changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, use_changelog, }; pub use cluster_content::{cluster_content_items, use_cluster_content}; pub use clusters::{use_cluster, use_clusters}; @@ -68,6 +67,13 @@ 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, invalidate_profile_queries, use_cluster_mutation, @@ -77,7 +83,8 @@ 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, version_list, versions_total, + use_package_versions_when, + version_list, versions_total, }; pub use player_profile::use_player_profile; pub use screenshots::{ @@ -93,10 +100,6 @@ pub use skin::{ use_player_skin, }; pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading}; -pub use storage::{ - StorageAction, StorageActionMutation, StorageReportQuery, UseStorageAction, - invalidate_storage_queries, try_storage_report, 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}; diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs index 89798f29..e6cdda32 100644 --- a/packages/oneclient_app/src/hooks/queries/mutations.rs +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -1,7 +1,5 @@ -use std::path::PathBuf; - use freya::query::{Mutation, MutationCapability, QueriesStorage, UseMutation, use_mutation}; -use oneclient_content::packages::{ContentType, PackageStore}; +use oneclient_content::packages::LiveSync; use oneclient_db::models::ClusterId; use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery}; @@ -25,36 +23,12 @@ 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("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; tracing::debug!( target: "oneclient_app::perf", ms = started.elapsed().as_millis() as u64, @@ -68,6 +42,17 @@ pub async fn invalidate_cluster_content_queries() { QueriesStorage::::invalidate_all().await; } +/// Everything [`invalidate_cluster_queries`] does bar the cluster list and the +/// cached package updates neither of which an enabled flag can move +/// 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; +} + pub async fn invalidate_profile_queries() { QueriesStorage::::invalidate_all().await; QueriesStorage::::invalidate_all().await; @@ -81,9 +66,10 @@ pub struct ClusterMutation; #[derive(Clone, PartialEq, Eq, Hash)] pub enum ClusterAction { - ToggleArtifact { + SetArtifactEnabled { cluster_id: ClusterId, hash: String, + enabled: bool, }, RemoveArtifact { cluster_id: ClusterId, @@ -102,11 +88,6 @@ pub enum ClusterAction { /// writes `Enabled` / `Disabled` manifest_default: bool, }, - ImportLocalFile { - cluster_id: ClusterId, - content_type: ContentType, - path: PathBuf, - }, SetDedicatedDir { cluster_id: ClusterId, dedicated: bool, @@ -127,19 +108,26 @@ impl MutationCapability for ClusterMutation { let services = &state.services; let content = &state.services.content(); let result = match keys { - ClusterAction::ToggleArtifact { cluster_id, hash } => { - // Applied to the game folder at next launch never mid-session - // Minecraft reads its mods once at startup - oneclient_core::toggle_artifact_enabled(*cluster_id, hash, content) - .await - .map(|_| ()) - } + ClusterAction::SetArtifactEnabled { + cluster_id, + hash, + enabled, + } => oneclient_core::set_artifact_enabled_to(*cluster_id, hash, *enabled, content) + .await + .map(|live| { + if live == LiveSync::Deferred && state.games.is_active(*cluster_id) { + services + .events + .notify("Saved for the next launch") + .body("Minecraft is running, but this could not be added to the open game.") + .send(); + } + }), ClusterAction::RemoveArtifact { cluster_id, hash } => { oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, true, content).await } ClusterAction::RemoveBundlePackageFromDisk { cluster_id, hash } => { - oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, false, content) - .await + oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, false, content).await } ClusterAction::SetBundlePackageEnabled { cluster_id, @@ -158,29 +146,22 @@ impl MutationCapability for ClusterMutation { ) .await } - ClusterAction::ImportLocalFile { - cluster_id, - content_type, - path, - } => PackageStore::import_local_file(path, *content_type, *cluster_id, content) - .await - .map(|row| { - services - .events - .notify("Imported") - .body(format!("Added {}", row.file_name)) - .send(); - }), ClusterAction::SetDedicatedDir { cluster_id, dedicated, - } => state - .clusters - .set_dedicated_dir(*cluster_id, *dedicated, state.games.is_active(*cluster_id)) - .await + } => { + state + .clusters + .set_dedicated_dir( + *cluster_id, + *dedicated, + state.games.is_active(*cluster_id), + ) + .await .map_err(|err| oneclient_content::ContentError::InvalidData { reason: err.to_string(), - }), + }) + } ClusterAction::VerifyFiles { cluster_id } => { // Reports its own outcome not the generic failure toast a // verify that finds nothing wrong is still a useful result @@ -210,19 +191,17 @@ impl MutationCapability for ClusterMutation { result.map_err(|e| e.to_string()) } - async fn on_settled(&self, _keys: &ClusterAction, result: &Result<(), String>) { + async fn on_settled(&self, keys: &ClusterAction, result: &Result<(), String>) { 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 { .. }) { + invalidate_enabled_flag_queries().await; + } else { + invalidate_cluster_queries().await; } - invalidate_cluster_queries().await; } } diff --git a/packages/oneclient_app/src/hooks/queries/screenshots.rs b/packages/oneclient_app/src/hooks/queries/screenshots.rs index 29625df6..c7d2fa1e 100644 --- a/packages/oneclient_app/src/hooks/queries/screenshots.rs +++ b/packages/oneclient_app/src/hooks/queries/screenshots.rs @@ -6,8 +6,8 @@ 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 oneclient_core::{LauncherError, ScreenshotInfo}; @@ -119,14 +119,15 @@ async fn watch_folder( 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 { + 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; diff --git a/packages/oneclient_app/src/hooks/queries/settings_profiles.rs b/packages/oneclient_app/src/hooks/queries/settings_profiles.rs index 6977b829..329339e2 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,15 +110,11 @@ 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 4db9e814..658294dd 100644 --- a/packages/oneclient_app/src/hooks/queries/storage.rs +++ b/packages/oneclient_app/src/hooks/queries/storage.rs @@ -5,6 +5,7 @@ use freya::query::{ use_mutation, use_query, }; use oneclient_core::LauncherError; +use oneclient_core::relocate::{Leftovers, discard_leftovers, leftovers}; use oneclient_core::storage::{StorageReport, storage_report}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -62,7 +63,7 @@ impl MutationCapability for StorageActionMutation { .map(|_| ()) } StorageAction::CleanLegacyClusterContent => { - oneclient_core::storage::clean_legacy_cluster_content(&state) + oneclient_core::storage::clean_cluster_leftovers(&state) .await .map(|_| ()) } @@ -82,3 +83,62 @@ pub type UseStorageAction = UseMutation; pub fn use_storage_action() -> UseStorageAction { use_mutation(Mutation::new(StorageActionMutation)) } + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LeftoversKeys; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LeftoversQuery; + +impl QueryCapability for LeftoversQuery { + type Ok = Option; + type Err = LauncherError; + type Keys = LeftoversKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + Ok(leftovers(&state).await) + } +} + +pub fn use_leftovers() -> UseQuery { + use_query(Query::new(LeftoversKeys, LeftoversQuery).stale_time(STORAGE_STALE_TIME)) +} + +pub fn try_leftovers(query: &UseQuery) -> Option { + super::state::settled_or_loading(query).flatten() +} + +pub async fn invalidate_leftovers_queries() { + QueriesStorage::::invalidate_all().await; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct DiscardLeftoversKeys; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct DiscardLeftoversMutation; + +impl MutationCapability for DiscardLeftoversMutation { + type Ok = u64; + type Err = String; + type Keys = DiscardLeftoversKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state().map_err(|err| err.to_string())?; + discard_leftovers(&state).await + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_leftovers_queries().await; + invalidate_storage_queries().await; + } + } +} + +pub type UseDiscardLeftovers = UseMutation; + +pub fn use_discard_leftovers() -> UseDiscardLeftovers { + use_mutation(Mutation::new(DiscardLeftoversMutation)) +} diff --git a/packages/oneclient_app/src/hooks/queries/version_metadata.rs b/packages/oneclient_app/src/hooks/queries/version_metadata.rs index e2f5646c..fb90e3d9 100644 --- a/packages/oneclient_app/src/hooks/queries/version_metadata.rs +++ b/packages/oneclient_app/src/hooks/queries/version_metadata.rs @@ -1,6 +1,6 @@ use oneclient_common::VersionKey; -use oneclient_common::domain::GameLoader; use oneclient_core::VersionMetadata; +use oneclient_common::domain::GameLoader; use super::use_versions; diff --git a/packages/oneclient_app/src/hooks/queries/versions.rs b/packages/oneclient_app/src/hooks/queries/versions.rs index d4ff81cf..be744e63 100644 --- a/packages/oneclient_app/src/hooks/queries/versions.rs +++ b/packages/oneclient_app/src/hooks/queries/versions.rs @@ -15,18 +15,12 @@ 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; + 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) + Ok(state.versions.metadata(&state.services.requester.config().meta_url_base).await) } } diff --git a/packages/oneclient_app/src/hooks/shortcut_actions.rs b/packages/oneclient_app/src/hooks/shortcut_actions.rs new file mode 100644 index 00000000..ca0e3094 --- /dev/null +++ b/packages/oneclient_app/src/hooks/shortcut_actions.rs @@ -0,0 +1,108 @@ +use freya::prelude::spawn_forever; +use oneclient_db::models::ClusterId; + +use super::actions::Actions; +use crate::components::IconType; +use crate::launcher; +use crate::shortcut::{self, ShortcutRequest}; +use crate::state::{AppChannel, LaunchBlock}; + +impl Actions { + pub fn request_launch_by_folder(&self, folder: String) { + self.station() + .write_channel(AppChannel::PendingLaunch) + .pending_launch = Some(folder); + } + + pub fn take_pending_launch(&self) -> Option { + let waiting = self.station().peek().pending_launch.is_some(); + if !waiting { + return None; + } + + self.station() + .write_channel(AppChannel::PendingLaunch) + .pending_launch + .take() + } + + #[must_use] + pub fn launch_block(&self, cluster_id: 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) + } + + pub fn report_missing_shortcut_target(&self, folder: &str) { + self.notify("Shortcut is out of date") + .body(format!( + "No version folder named \"{folder}\" is installed any more. The shortcut can be recreated from the version's page." + )) + .error() + .icon(IconType::LinkExternal01) + .send(); + } + + pub fn create_cluster_shortcut(&self, cluster_id: ClusterId) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + + let cluster = match state.clusters.get(cluster_id).await { + Ok(cluster) => cluster, + Err(err) => { + actions.report_shortcut_failure(&format!("{err:#}")); + return; + } + }; + + let Some(dir) = pick_shortcut_dir().await else { + return; + }; + + let request = ShortcutRequest { + cluster_name: cluster.name.clone(), + folder_name: cluster.folder_name.clone(), + dir: dir.clone(), + }; + + match tokio::task::spawn_blocking(move || shortcut::create(&request)).await { + Ok(Ok(path)) => { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| cluster.name.clone()); + + actions + .notify("Shortcut created") + .body(format!("Saved as {name} in {}", dir.display())) + .icon(IconType::LinkExternal01) + .send(); + } + Ok(Err(err)) => actions.report_shortcut_failure(&format!("{err:#}")), + Err(err) => actions.report_shortcut_failure(&err.to_string()), + } + }); + } + + fn report_shortcut_failure(&self, reason: &str) { + tracing::error!("could not create a cluster shortcut: {reason}"); + self.notify("Couldn't create the shortcut") + .body(reason.to_string()) + .error() + .send(); + } +} + +async fn pick_shortcut_dir() -> Option { + let mut dialog = rfd::AsyncFileDialog::new().set_title("Where should the shortcut go?"); + if let Some(desktop) = shortcut::default_dir() { + dialog = dialog.set_directory(desktop); + } + + dialog + .pick_folder() + .await + .map(|handle| handle.path().to_path_buf()) +} diff --git a/packages/oneclient_app/src/hooks/view_state.rs b/packages/oneclient_app/src/hooks/view_state.rs index 64696a34..7379d707 100644 --- a/packages/oneclient_app/src/hooks/view_state.rs +++ b/packages/oneclient_app/src/hooks/view_state.rs @@ -7,6 +7,7 @@ use super::{use_dispatch, use_settings_snapshot}; pub struct PersistedView { pub layout: State, pub sort: State>, + pub columns: State, } pub fn use_view_state(key: &str) -> PersistedView { @@ -17,14 +18,17 @@ pub fn use_view_state(key: &str) -> PersistedView { let initial = settings.view_state(&key); let init_layout = initial.layout; let init_sort = initial.sort; + let init_columns = initial.columns; let layout = use_state(move || init_layout); let sort = use_state(move || init_sort.clone()); + let columns = use_state(move || init_columns); let mut first = use_state(|| true); use_side_effect(move || { let next = ViewState { layout: *layout.read(), sort: sort.read().clone(), + columns: *columns.read(), }; if *first.peek() { first.set(false); @@ -38,5 +42,9 @@ pub fn use_view_state(key: &str) -> PersistedView { dispatch.set_settings(updated); }); - PersistedView { layout, sort } + PersistedView { + layout, + sort, + columns, + } } diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs index 62961989..acc7f4b1 100644 --- a/packages/oneclient_app/src/install.rs +++ b/packages/oneclient_app/src/install.rs @@ -8,7 +8,7 @@ use oneclient_events::Level; use crate::components::IconType; use crate::notifications::{ ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind, - NotificationSpec, PackageUpdateGroup, + NotificationSpec, OptionalModsGroup, PackageUpdateGroup, }; /// Falls back to a placeholder a missing row must not lose the whole message @@ -48,15 +48,26 @@ pub fn item_from_bundle_file(file: &oneclient_core::BundleFile) -> ClusterUpdate provider: *provider, project_id: Some(project_id.clone()), fallback: file.display_name(), + offer: None, + status: None, }, oneclient_core::BundleFileKind::External(_) => ClusterUpdateItem { provider: oneclient_content::packages::ProviderId::Local, project_id: None, fallback: file.display_name(), + offer: None, + status: None, }, } } +fn item_from_optional(package: &oneclient_core::BundleOptionalPackage) -> ClusterUpdateItem { + ClusterUpdateItem { + offer: Some((package.bundle_name.clone(), package.package_id.clone())), + ..item_from_bundle_file(&package.file) + } +} + async fn cluster_update_summary( cluster_id: i64, result: &oneclient_core::ApplyBundleUpdatesResult, @@ -84,10 +95,17 @@ async fn cluster_update_summary( .display_name .clone() .unwrap_or_else(|| r.package_id.clone()), + offer: None, + status: None, }) .collect(); + let optional: Vec = result + .optional_available + .iter() + .map(item_from_optional) + .collect(); - if updated.is_empty() && added.is_empty() && removed.is_empty() { + if updated.is_empty() && added.is_empty() && removed.is_empty() && optional.is_empty() { return None; } @@ -103,6 +121,7 @@ async fn cluster_update_summary( updated, added, removed, + optional, }) } @@ -113,14 +132,31 @@ pub async fn cluster_update_notification( ) -> Option { let summary = cluster_update_summary(cluster_id, result, services).await?; let total = summary.total(); - let body = format!( - "{total} package{} changed in {}", - if total == 1 { "" } else { "s" }, - summary.cluster_name - ); + // A bundle can offer an opt-in mod without changing anything else + // "0 packages changed" would be both wrong and alarming + let (title, body) = if total == 0 { + let offers = summary.optional.len(); + ( + "Optional mods available", + format!( + "{offers} optional mod{} in {}", + if offers == 1 { "" } else { "s" }, + summary.cluster_name + ), + ) + } else { + ( + "Cluster updated", + format!( + "{total} package{} changed in {}", + if total == 1 { "" } else { "s" }, + summary.cluster_name + ), + ) + }; Some(NotificationSpec { - title: "Cluster updated".to_string(), + title: title.to_string(), body, level: Level::Info, icon: Some(IconType::DownloadCloud02), @@ -152,14 +188,29 @@ pub async fn combined_cluster_update_spec( } let cluster_count = summaries.len(); - let body = format!( - "{total_changes} package{} updated across {cluster_count} cluster{}", - if total_changes == 1 { "" } else { "s" }, - if cluster_count == 1 { "" } else { "s" } - ); + let (title, body) = if total_changes == 0 { + let offers: usize = summaries.iter().map(|s| s.optional.len()).sum(); + ( + "Optional mods available", + format!( + "{offers} optional mod{} across {cluster_count} cluster{}", + if offers == 1 { "" } else { "s" }, + if cluster_count == 1 { "" } else { "s" } + ), + ) + } else { + ( + "Mods updated", + format!( + "{total_changes} package{} updated across {cluster_count} cluster{}", + if total_changes == 1 { "" } else { "s" }, + if cluster_count == 1 { "" } else { "s" } + ), + ) + }; Some(NotificationSpec { - title: "Mods updated".to_string(), + title: title.to_string(), body, level: Level::Info, icon: Some(IconType::DownloadCloud02), @@ -171,18 +222,47 @@ pub async fn combined_cluster_update_spec( }) } -/// The session is detached not finished so the caller can reuse its notification -/// as the "Installed" / "Install failed" result -/// `session_id` is `None` if no download ran +pub async fn pending_optional_group( + cluster_id: i64, + pending: &[oneclient_core::PendingOptionalMod], + services: &oneclient_core::LauncherServices, +) -> Option { + if pending.is_empty() { + return None; + } + + let mods: Vec = pending + .iter() + .map(|entry| ClusterUpdateItem { + offer: Some((entry.bundle_name.clone(), entry.package_id.clone())), + status: Some(entry.status), + ..item_from_bundle_file(&entry.file) + }) + .collect(); + + Some(OptionalModsGroup { + cluster_id, + cluster_name: cluster_display_name(cluster_id, services).await, + mods, + }) +} + pub struct PackageInstall { pub session_id: Option, pub result: anyhow::Result, pub dependencies: Vec, - /// Unresolved or failed dependencies the package still installs pub missing_dependencies: Vec, + /// The game is open but could not take the package, so the toast must not + /// claim it is there now + pub live_deferred: bool, } -pub fn install_body(name: &str, dependencies: &[String], missing: &[String]) -> String { +pub fn install_body( + name: &str, + dependencies: &[String], + missing: &[String], + live_deferred: bool, +) -> String { let mut body = format!("Added {name}"); if !dependencies.is_empty() { @@ -198,6 +278,10 @@ pub fn install_body(name: &str, dependencies: &[String], missing: &[String]) -> body.push_str(&format!(" Could not add: {}.", missing.join(", "))); } + if live_deferred { + body.push_str(" Minecraft is running, so it will be there at the next launch."); + } + body } @@ -208,6 +292,7 @@ impl PackageInstall { result: Err(err), dependencies: Vec::new(), missing_dependencies: Vec::new(), + live_deferred: false, } } } @@ -301,7 +386,10 @@ pub async fn install_package( child.finish(); match result { - Ok(_) => installed_dependencies.push(dependency.project.name.clone()), + Ok((artifact, _)) => { + mark_new(cluster_id, &artifact.hash, state).await; + installed_dependencies.push(dependency.project.name.clone()); + } Err(err) => { tracing::warn!( dependency = %dependency.project.name, @@ -333,10 +421,28 @@ pub async fn install_package( child.finish(); + let live_deferred = matches!( + result, + Ok((_, oneclient_content::packages::LiveSync::Deferred)) + ) && state.games.is_active(cluster_id); + + if let Ok((artifact, _)) = &result { + mark_new(cluster_id, &artifact.hash, state).await; + } + 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 mark_new(cluster_id: i64, hash: &str, state: &LauncherState) { + if let Err(err) = + PackageStore::mark_artifact_new(cluster_id, hash, &state.services.content()).await + { + tracing::warn!(cluster_id, hash, %err, "failed to badge package as new"); } } diff --git a/packages/oneclient_app/src/ipc.rs b/packages/oneclient_app/src/ipc.rs new file mode 100644 index 00000000..015d8f39 --- /dev/null +++ b/packages/oneclient_app/src/ipc.rs @@ -0,0 +1,320 @@ +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; + +use crate::cli::Cli; + +const IO_TIMEOUT: Duration = Duration::from_secs(3); +const CLAIM_ATTEMPTS: usize = 3; +const REPLY_OK: &str = "OK"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IpcCommand { + Launch(String), + Focus, +} + +pub enum Claim { + Primary(Listener), + Forwarded, + Solo(String), +} + +impl IpcCommand { + fn encode(&self) -> String { + match self { + Self::Launch(folder) => format!("LAUNCH {folder}"), + Self::Focus => "FOCUS".to_string(), + } + } + + fn decode(line: &str) -> Option { + let line = line.trim_end_matches(['\r', '\n']); + if let Some(folder) = line.strip_prefix("LAUNCH ") { + let folder = folder.trim(); + return (!folder.is_empty()).then(|| Self::Launch(folder.to_string())); + } + (line == "FOCUS").then_some(Self::Focus) + } +} + +#[must_use] +pub fn request_for(cli: &Cli) -> IpcCommand { + match &cli.launch { + Some(folder) => IpcCommand::Launch(folder.clone()), + None => IpcCommand::Focus, + } +} + +pub async fn claim(cli: &Cli) -> Claim { + let request = request_for(cli); + + for _ in 0..CLAIM_ATTEMPTS { + if forward(&request).await { + return Claim::Forwarded; + } + + match imp::bind().await { + Ok(listener) => return Claim::Primary(listener), + Err(BindError::Taken) => continue, + Err(BindError::Io(err)) => return Claim::Solo(err.to_string()), + } + } + + Claim::Solo(format!("the endpoint changed hands {CLAIM_ATTEMPTS} times")) +} + +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)); + + while let Some(command) = incoming.recv().await { + on_command(command); + } +} + +enum BindError { + /// Only Windows reports a held endpoint; the unix path clears a stale + /// socket and rebinds instead of surfacing this + #[cfg_attr(not(windows), allow(dead_code))] + Taken, + Io(std::io::Error), +} + +async fn handle(stream: S, commands: &tokio::sync::mpsc::UnboundedSender) +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let mut stream = BufReader::new(stream); + let mut line = String::new(); + + let read = tokio::time::timeout(IO_TIMEOUT, stream.read_line(&mut line)).await; + if !matches!(read, Ok(Ok(n)) if n > 0) { + return; + } + + let reply = match IpcCommand::decode(&line) { + Some(command) => match commands.send(command) { + Ok(()) => format!("{REPLY_OK}\n"), + Err(_) => "ERR the launcher is shutting down\n".to_string(), + }, + None => "ERR unknown request\n".to_string(), + }; + + let _ = tokio::time::timeout(IO_TIMEOUT, stream.write_all(reply.as_bytes())).await; + let _ = stream.flush().await; +} + +async fn exchange(stream: S, request: &str) -> bool +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let mut stream = BufReader::new(stream); + + let wrote = tokio::time::timeout(IO_TIMEOUT, async { + stream.write_all(request.as_bytes()).await?; + stream.write_all(b"\n").await?; + stream.flush().await + }) + .await; + if !matches!(wrote, Ok(Ok(()))) { + return false; + } + + let mut reply = String::new(); + let read = tokio::time::timeout(IO_TIMEOUT, stream.read_line(&mut reply)).await; + matches!(read, Ok(Ok(n)) if n > 0) && reply.trim_end_matches(['\r', '\n']) == REPLY_OK +} + +#[cfg(windows)] +mod imp { + use super::{BindError, IpcCommand, exchange, handle}; + use std::time::Duration; + use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeServer, ServerOptions}; + use tokio::sync::mpsc::UnboundedSender; + + #[cfg(not(debug_assertions))] + const ENDPOINT: &str = r"\\.\pipe\org.polyfrost.OneClient.ipc"; + #[cfg(debug_assertions)] + const ENDPOINT: &str = r"\\.\pipe\org.polyfrost.OneClient-dev.ipc"; + + const ERROR_ACCESS_DENIED: i32 = 5; + const ERROR_PIPE_BUSY: i32 = 231; + + const BUSY_RETRIES: usize = 5; + const BUSY_BACKOFF: Duration = Duration::from_millis(60); + + pub struct Listener { + server: NamedPipeServer, + } + + pub async fn bind() -> Result { + 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)), + } + } + + pub async fn send(request: &str) -> bool { + for _ in 0..BUSY_RETRIES { + match ClientOptions::new().open(ENDPOINT) { + Ok(client) => return exchange(client, request).await, + Err(err) if err.raw_os_error() == Some(ERROR_PIPE_BUSY) => { + tokio::time::sleep(BUSY_BACKOFF).await; + } + Err(_) => return false, + } + } + false + } + + pub async fn serve(listener: Listener, commands: UnboundedSender) { + let mut server = listener.server; + + loop { + if server.connect().await.is_err() { + return; + } + + let next = match ServerOptions::new().create(ENDPOINT) { + Ok(next) => next, + Err(_) => return, + }; + let connected = std::mem::replace(&mut server, next); + + let commands = commands.clone(); + tokio::spawn(async move { handle(connected, &commands).await }); + } + } +} + +#[cfg(unix)] +mod imp { + use super::{BindError, IpcCommand, exchange, handle}; + use std::os::unix::fs::PermissionsExt; + use std::path::{Path, PathBuf}; + use tokio::net::{UnixListener, UnixStream}; + use tokio::sync::mpsc::UnboundedSender; + + fn endpoint() -> Option { + oneclient_common::paths::config_dir() + .ok() + .map(|dir| dir.join("ipc.sock")) + } + + pub struct Listener { + listener: UnixListener, + path: PathBuf, + } + + impl Drop for Listener { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } + } + + pub async fn bind() -> Result { + let Some(path) = endpoint() else { + return Err(BindError::Io(std::io::Error::other("no data directory"))); + }; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(BindError::Io)?; + } + + match bound(&path) { + Ok(listener) => Ok(listener), + Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => { + let _ = std::fs::remove_file(&path); + bound(&path).map_err(BindError::Io) + } + Err(err) => Err(BindError::Io(err)), + } + } + + /// The launcher directory is not private, so the endpoint itself has to + /// keep other local users from sending a launch of their choosing + fn bound(path: &Path) -> std::io::Result { + let listener = UnixListener::bind(path)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + + Ok(Listener { listener, path: path.to_path_buf() }) + } + + pub async fn send(request: &str) -> bool { + let Some(path) = endpoint() else { + return false; + }; + let Ok(stream) = UnixStream::connect(&path).await else { + return false; + }; + exchange(stream, request).await + } + + pub async fn serve(listener: Listener, commands: UnboundedSender) { + loop { + match listener.listener.accept().await { + Ok((stream, _)) => { + let commands = commands.clone(); + tokio::spawn(async move { handle(stream, &commands).await }); + } + Err(_) => return, + } + } + } +} + +pub use imp::Listener; + +#[cfg(test)] +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)); + } + + #[test] + fn a_folder_with_spaces_is_not_split() { + let command = IpcCommand::Launch("My Pack (1.8.9)".into()); + assert_eq!(IpcCommand::decode(&command.encode()), Some(command)); + } + + #[test] + fn line_endings_are_stripped() { + assert_eq!( + IpcCommand::decode("LAUNCH pack\r\n"), + Some(IpcCommand::Launch("pack".into())), + ); + } + + #[test] + fn junk_decodes_to_nothing() { + assert_eq!(IpcCommand::decode(""), None); + assert_eq!(IpcCommand::decode("LAUNCH"), None); + assert_eq!(IpcCommand::decode("LAUNCH "), None); + assert_eq!(IpcCommand::decode("QUIT"), 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()) }), + IpcCommand::Launch("pack".into()), + ); + } +} diff --git a/packages/oneclient_app/src/launcher.rs b/packages/oneclient_app/src/launcher.rs index 8b7bcb4e..a8c7db22 100644 --- a/packages/oneclient_app/src/launcher.rs +++ b/packages/oneclient_app/src/launcher.rs @@ -18,5 +18,8 @@ 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) } diff --git a/packages/oneclient_app/src/layout/animated_outlet.rs b/packages/oneclient_app/src/layout/animated_outlet.rs index 628ad21a..859f97b4 100644 --- a/packages/oneclient_app/src/layout/animated_outlet.rs +++ b/packages/oneclient_app/src/layout/animated_outlet.rs @@ -2,8 +2,9 @@ use freya::animation::*; use freya::prelude::*; use freya::router::*; -use super::app_shell::{appshell_overlay, back_button, hides_overlay}; +use super::app_shell::{appshell_overlay, back_button, hides_overlay, navigate_back}; use crate::Route; +use crate::hooks::use_overlay_claims; use crate::theme; use crate::ui::entrance_motion_layer; @@ -46,6 +47,41 @@ fn is_cluster_route(route: &Route) -> bool { ) } +/// Checks if it should go back to home or somewhere else +fn escape_exits_section(route: &Route) -> bool { + is_cluster_route(route) || is_sidebar_route(route) +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum EscapeAction { + /// The root screen has nothing above it so Escape must not walk history + Ignore, + ExitSection, + Back, +} + +fn escape_action(route: &Route) -> EscapeAction { + if matches!(route, Route::Home {}) { + EscapeAction::Ignore + } else if escape_exits_section(route) { + EscapeAction::ExitSection + } else { + EscapeAction::Back + } +} + +/// Escape belongs to whatever the user is typing in before it belongs to the +/// router so a search field does not throw the page away mid-word +fn is_text_entry(role: AccessibilityRole) -> bool { + matches!( + role, + AccessibilityRole::TextInput + | AccessibilityRole::MultilineTextInput + | AccessibilityRole::SearchInput + | AccessibilityRole::PasswordInput + ) +} + fn enter_kind(from: &Route, to: &Route) -> Enter { if (is_sidebar_route(from) && is_sidebar_route(to)) || (is_cluster_route(from) && is_cluster_route(to)) @@ -66,6 +102,9 @@ impl Component for AnimatedAppOutlet { let mut router = use_animated_router::(); let route = use_route::(); + let at_home = matches!(&route, Route::Home {}); + let escape = escape_action(&route); + let mut origin_route = use_state(|| route.clone()); let mut last_route = use_state(|| route.clone()); @@ -79,6 +118,48 @@ impl Component for AnimatedAppOutlet { let back_title = origin_route.read().title(); + let platform = Platform::get(); + let focused_node = platform.focused_accessibility_node; + let focused_id = platform.focused_accessibility_id; + + let key_claims = use_overlay_claims(); + let pointer_claims = key_claims.clone(); + + let on_global_key = move |e: Event| { + if e.key != Key::Named(NamedKey::Escape) || key_claims.any() { + return; + } + + if is_text_entry(focused_node.peek().role()) { + focused_id.peek().request_unfocus(); + return; + } + + match escape { + EscapeAction::Ignore => {} + EscapeAction::ExitSection => { + let _ = RouterContext::get().push(Route::Home {}); + } + EscapeAction::Back => navigate_back(at_home), + } + }; + + let on_global_pointer = move |e: Event| { + if pointer_claims.any() { + return; + } + match e.button() { + Some(MouseButton::Back) => navigate_back(at_home), + Some(MouseButton::Forward) => { + let router = RouterContext::get(); + if router.can_go_forward() { + router.go_forward(); + } + } + _ => {} + } + }; + let anim = use_animation(|_conf| { AnimNum::new(0., 1.) .time(430) @@ -103,13 +184,14 @@ impl Component for AnimatedAppOutlet { use_side_effect_with_deps(&anim_finished, move |&finished| { if finished { - if matches!(&*router.peek(), AnimatedRouterContext::FromTo(_, _)) { - router.write().settle(); - } Platform::get().send(UserEvent::RequestRedraw); } }); + if anim_finished && matches!(&*router.peek(), AnimatedRouterContext::FromTo(_, _)) { + router.write().settle(); + } + let kind = match &*router.read() { AnimatedRouterContext::FromTo(from, to) => enter_kind(from, to), AnimatedRouterContext::In(_) => Enter::None, @@ -169,7 +251,28 @@ impl Component for AnimatedAppOutlet { .width(Size::fill()) .height(Size::fill()) .overflow(Overflow::Clip) + .on_global_key_down(on_global_key) + .on_global_pointer_press(on_global_pointer) .maybe_child(overlay) .child(column) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escape_action_per_route() { + assert_eq!(escape_action(&Route::Home {}), EscapeAction::Ignore); + assert_eq!( + escape_action(&Route::ClusterSettings { cluster_id: 1 }), + EscapeAction::ExitSection + ); + assert_eq!( + escape_action(&Route::SettingsJava {}), + EscapeAction::ExitSection + ); + assert_eq!(escape_action(&Route::Clusters {}), EscapeAction::Back); + } +} diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs index d278b057..f5df1122 100644 --- a/packages/oneclient_app/src/layout/app_shell.rs +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -73,7 +73,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); + drop_pending.write().extend_from_slice(&e.file_paths); }) .child(AppNavbar) .child(AppHomeBackground) @@ -275,6 +275,30 @@ pub struct HomeArtPrefetch; impl Component for HomeArtPrefetch { fn render(&self) -> impl IntoElement { + // Before the launcher is ready every query errors and a settled error is not + // retried until a new subscriber mounts + if !use_launcher().ready { + return rect().into_element(); + } + HomeArtWarm.into_element() + } +} + +#[derive(PartialEq, Clone, Copy)] +struct HomeArtWarm; + +impl Component for HomeArtWarm { + fn render(&self) -> impl IntoElement { + let clusters_query = use_clusters(); + let art = { + let reader = clusters_query.read(); + let state = reader.state(); + // No active cluster this early so this picks the same one `AppHomeBackground` will + let clusters = state.ok().map_or(&[][..], Vec::as_slice); + home_art(home_cluster(clusters, None)) + }; + let _ = art.use_bytes(); + rect().into_element() } } @@ -576,12 +600,22 @@ pub(crate) fn gradient_overlay_radial() -> impl IntoElement { )) } +pub(crate) fn navigate_back(at_home: bool) { + let router = RouterContext::get(); + if router.can_go_back() { + router.go_back(); + } else if !at_home { + let _ = router.push(Route::Home {}); + } +} + pub(crate) fn back_button(destination: &str) -> impl IntoElement { Button::new() .ghost() .small() .on_press(|_| { - RouterContext::get().go_back(); + // Only rendered off home so the history-empty fallback is reachable + navigate_back(false); }) .margin(Gaps::new(0., 0., 0., 32.)) .child(Icon::new(IconType::ArrowLeft).size(12.)) diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index 5edbd453..a5458e7e 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -4,9 +4,7 @@ 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; @@ -109,9 +107,7 @@ impl Component for ClusterShell { // 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()), @@ -203,13 +199,14 @@ impl Component for ClusterContentOutlet { use_side_effect_with_deps(&anim_finished, move |&finished| { if finished { - if matches!(&*router.peek(), AnimatedRouterContext::FromTo(_, _)) { - router.write().settle(); - } Platform::get().send(UserEvent::RequestRedraw); } }); + if anim_finished && matches!(&*router.peek(), AnimatedRouterContext::FromTo(_, _)) { + router.write().settle(); + } + const CONTENT_DX: f32 = 44.; let p = if anim_finished { 1.0 diff --git a/packages/oneclient_app/src/layout/mod.rs b/packages/oneclient_app/src/layout/mod.rs index ac882c8b..7f2cb598 100644 --- a/packages/oneclient_app/src/layout/mod.rs +++ b/packages/oneclient_app/src/layout/mod.rs @@ -2,6 +2,7 @@ mod animated_outlet; mod app_shell; mod cluster_shell; mod onboarding_shell; +mod pending_launch; mod root_layout; mod settings_shell; @@ -10,5 +11,6 @@ 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 onboarding_shell::OnboardingShell; +pub use pending_launch::PendingLaunchDriver; pub use root_layout::RootLayout; pub use settings_shell::SettingsShell; diff --git a/packages/oneclient_app/src/layout/onboarding_shell.rs b/packages/oneclient_app/src/layout/onboarding_shell.rs index 0f6c489a..ed701bd2 100644 --- a/packages/oneclient_app/src/layout/onboarding_shell.rs +++ b/packages/oneclient_app/src/layout/onboarding_shell.rs @@ -6,8 +6,8 @@ use freya::router::{Outlet, use_route}; use crate::Route; use crate::components::OnboardingNavbar; use crate::hooks::{ - OnboardingSelectionState, has_migration_data, onboarding_bundles_items, use_migration, - use_onboarding_bundles, use_provide_onboarding_selection, + OnboardingSelectionState, has_migration_data, onboarding_bundles_items, use_launcher, + use_migration, use_onboarding_bundles, use_provide_onboarding_selection, use_settings_snapshot, }; use crate::theme::colors; use crate::view::onboarding::{ @@ -28,6 +28,10 @@ impl Component for OnboardingShell { let setup_started = use_state(|| false); let import_folder = use_state(|| None::); let import_dedicated = use_state(|| false); + let picks_location = use_state({ + let fresh = use_launcher().needs_location; + move || fresh + }); use_provide_onboarding_selection(OnboardingSelectionState { selected, user_touched, @@ -38,13 +42,18 @@ impl Component for OnboardingShell { setup_started, import_folder, import_dedicated, + picks_location, }); let migration_query = use_migration(); let detected_migration = has_migration_data(&migration_query); let route = use_route::(); - let step_index = onboarding_step_index(&route, detected_migration); + let choosing_location = *picks_location.read(); + let step_index = onboarding_step_index(&route, detected_migration, choosing_location); + + let onboarded = use_settings_snapshot().settings.seen_onboarding; + let reconsent = onboarded && matches!(&route, Route::OnboardingTerms {}); let bundles = use_onboarding_bundles(); @@ -85,10 +94,13 @@ impl Component for OnboardingShell { .height(Size::fill()) .content(Content::Flex) .layer(Layer::Relative(30)) - .child(progress_bar( - step_index, - onboarding_total(detected_migration), - )) + .maybe_child((!reconsent).then(|| { + progress_bar( + step_index, + onboarding_total(detected_migration, choosing_location), + ) + .into_element() + })) .child(OnboardingNavbar) .child( rect() diff --git a/packages/oneclient_app/src/layout/pending_launch.rs b/packages/oneclient_app/src/layout/pending_launch.rs new file mode 100644 index 00000000..db28ab66 --- /dev/null +++ b/packages/oneclient_app/src/layout/pending_launch.rs @@ -0,0 +1,133 @@ +use freya::prelude::*; +use freya::router::*; +use oneclient_cluster::Cluster; + +use crate::Actions; +use crate::components::IconType; +use crate::hooks::{use_dispatch, use_launcher, use_pending_launch, use_settings_snapshot}; +use crate::launcher; +use crate::routes::Route; +use crate::state::LaunchBlock; + +#[derive(PartialEq)] +pub struct PendingLaunchDriver; + +impl Component for PendingLaunchDriver { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let waiting = use_pending_launch().is_some(); + let ready = use_launcher().ready; + let onboarded = use_settings_snapshot().settings.seen_onboarding; + + let router = RouterContext::get(); + + let armed = waiting && ready && onboarded; + use_side_effect_with_deps(&armed, move |&armed| { + if !armed { + return; + } + + let Some(folder) = dispatch.take_pending_launch() else { + return; + }; + + let actions = dispatch.clone(); + spawn(async move { launch_shortcut(actions, router, folder).await }); + }); + + rect().into_element() + } +} + +async fn launch_shortcut(actions: Actions, router: RouterContext, folder: String) { + let Ok(state) = launcher::state() else { return }; + + let found = match state.clusters.find_by_folder_name(&folder).await { + Ok(found) => found, + Err(err) => { + tracing::error!(folder, "shortcut lookup failed: {err:#}"); + actions + .notify("Couldn't open that version") + .body(format!("{err:#}")) + .error() + .send(); + return; + } + }; + + let Some(cluster) = found else { + tracing::warn!(folder, "shortcut names a cluster that no longer exists"); + actions.report_missing_shortcut_target(&folder); + return; + }; + + let _ = router.replace(Route::ClusterOverview { + cluster_id: cluster.id, + }); + + let Some(block) = actions.launch_block(cluster.id) else { + actions.launch_cluster(cluster.id); + return; + }; + + let blocker = blocker_name(&cluster, block).await; + report_already_open(&actions, &cluster, block, blocker.as_deref()); +} + +async fn blocker_name(cluster: &Cluster, block: LaunchBlock) -> Option { + if block.cluster_id() == cluster.id { + return None; + } + + let Ok(state) = launcher::state() else { + return Some("Another version".to_string()); + }; + + Some( + state + .clusters + .get(block.cluster_id()) + .await + .map_or_else(|_| "Another version".to_string(), |other| other.name), + ) +} + +fn report_already_open( + actions: &Actions, + cluster: &Cluster, + block: LaunchBlock, + blocker: Option<&str>, +) { + let running = matches!(block, LaunchBlock::Running(_)); + + tracing::info!( + cluster_id = cluster.id, + blocking = block.cluster_id(), + running, + "refusing a shortcut launch, a game is already open" + ); + + let title = if running { + "Minecraft is already running" + } else { + "Minecraft is already starting" + }; + + let body = match (blocker, running) { + (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) + } + (Some(other), false) => format!( + "{other} is on its way up. Wait for it before launching {}.", + cluster.name + ), + }; + + actions + .notify(title) + .body(body) + .icon(IconType::Rocket02) + .send(); +} diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 4ba02c60..b891bad3 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -2,11 +2,14 @@ use freya::prelude::*; use freya::router::*; use crate::components::{ - AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, - NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, UpdatePromptOverlay, + AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, + NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, + UpdatePromptOverlay, }; -use crate::hooks::{SplashState, use_provide_splash}; -use crate::layout::HomeArtPrefetch; +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::motion::AnimationClockDriver; use crate::routes::Route; use crate::theme; @@ -20,6 +23,7 @@ impl Component for RootLayout { let active = use_state(|| false); let home_ready = use_state(|| false); use_provide_splash(SplashState { active, home_ready }); + use_provide_overlay_claims(); // macOS rounds the window natively so Freya must not round on top of it #[cfg(target_os = "macos")] @@ -30,7 +34,10 @@ impl Component for RootLayout { #[cfg(not(target_os = "macos"))] let corner = { let root_size = Platform::get().root_size; - let mut maximized = use_state(|| false); + // 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 |_| { @@ -40,7 +47,9 @@ impl Component for RootLayout { ctx.windows.get(&id).map(|w| w.window().is_maximized()) }) .await; - if let Ok(Some(is_max)) = is_max.map(|opt| opt.filter(|&m| *maximized.peek() != m)) { + if let Ok(Some(is_max)) = is_max + && *maximized.peek() != is_max + { maximized.set(is_max); } }); @@ -70,10 +79,12 @@ impl Component for RootLayout { // Must stay last it renders whatever the overlays above did not claim .child(GenericPromptOverlay) .child(ClusterUpdatePopup) + .child(OptionalModsPopup) .child(PackageUpdatePopup) .child(StatusBar) .child(SplashCurtain) .child(AnimationClockDriver) .child(HomeArtPrefetch) + .child(PendingLaunchDriver) } } diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs index 0700c104..c1d44c5a 100644 --- a/packages/oneclient_app/src/layout/settings_shell.rs +++ b/packages/oneclient_app/src/layout/settings_shell.rs @@ -65,8 +65,11 @@ const SEARCH_INDEX: &[SearchItem] = &[ id: "launcher.folder", icon: IconType::Folder, title: "Launcher Folder", - description: "Open the launcher data directory.", - keywords: &["data dir", "directory", "folder", "open"], + description: "Open the launcher data directory, or move it to another drive.", + keywords: &[ + "data dir", "directory", "folder", "open", "move", "change", "relocate", "location", + "drive", "disk", + ], route: Route::SettingsLauncher {}, }, SearchItem { @@ -794,7 +797,7 @@ impl Component for SidebarItem { .on_pointer_enter(move |_| *hovering.write() = true) .on_pointer_leave(move |_| *hovering.write() = false) .map(route, |el, route| { - el.on_all_press(move |_| { + el.on_press(move |_| { let _ = RouterContext::get().replace(route.clone()); }) }); diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs index f5b016d1..67413770 100644 --- a/packages/oneclient_app/src/lib.rs +++ b/packages/oneclient_app/src/lib.rs @@ -4,20 +4,25 @@ #![recursion_limit = "256"] mod assets; +pub mod cli; mod components; -pub mod events; pub mod hooks; +pub mod events; +pub(crate) mod file_content; mod install; +pub mod ipc; mod launcher; +pub mod state; +mod transfer; mod layout; mod motion; mod notifications; pub mod platform; +pub mod protocol; pub mod recovery; mod routes; -pub mod state; +pub mod shortcut; pub mod theme; -mod transfer; mod ui; pub mod updater; pub(crate) mod utils; @@ -26,8 +31,8 @@ mod view; pub mod constants; pub use assets::AppAssets; -pub use components::ConfirmLinkOverlay; 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 d555468d..ceb56952 100644 --- a/packages/oneclient_app/src/main.rs +++ b/packages/oneclient_app/src/main.rs @@ -6,26 +6,44 @@ use freya::prelude::*; use freya::radio::use_init_radio_station; -use oneclient_app::state::{AppChannel, AppState}; +use oneclient_app::ipc::{self, Claim}; +use oneclient_app::state::{AppChannel, AppState, LauncherInit}; use oneclient_app::{ - Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, constants, events, router, theme, - use_provide_actions, use_provide_link_confirm, + Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, StartMaximizedState, cli, constants, + events, platform, router, theme, use_provide_actions, use_provide_link_confirm, + use_provide_start_maximized, }; +use std::cell::Cell; use tokio::runtime::Builder; -struct OneClientApp; +struct OneClientApp { + needs_location: bool, + start_maximized: bool, + boot_launch: Cell>, + ipc: Cell>, +} impl App for OneClientApp { fn render(&self) -> impl IntoElement { + let needs_location = self.needs_location; + // Radio state is `!Send` so every writer runs on the UI thread `spawn_forever` - let station = use_init_radio_station::(AppState::default); + let station = use_init_radio_station::(move || AppState { + launcher: LauncherInit { + needs_location, + ..LauncherInit::default() + }, + ..AppState::default() + }); + + 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(); let (events_bus, events_rx) = oneclient_events::EventBus::channel(); let actions = Actions::new(station, signals_tx, events_bus.clone()); - // Started first so nothing emitted during startup waits on a consumer spawn_forever( EventPump { events: events_rx, @@ -35,19 +53,36 @@ impl App for OneClientApp { .run(), ); - let startup = actions.clone(); - let rescue_bus = events_bus.clone(); - spawn_forever(async move { - match events::start_launcher(station, events_bus).await { - // Must follow startup `sync_bundles` needs the launcher handle and firing - // it early leaves `syncing_bundles` stuck disabling every launch button - Ok(()) => startup.sync_bundles(), - Err(err) => { - events::report_startup_failure(&station, &err); - oneclient_app::updater::spawn_update_check(false, rescue_bus); + if !needs_location { + let startup = actions.clone(); + let rescue_bus = events_bus.clone(); + spawn_forever(async move { + match events::start_launcher(station, events_bus).await { + // Must follow startup `sync_bundles` needs the launcher handle and firing + // it early leaves `syncing_bundles` stuck disabling every launch button + Ok(()) => startup.sync_bundles(), + Err(err) => { + events::report_startup_failure(&station, &err); + oneclient_app::updater::spawn_update_check(false, rescue_bus); + } } - } - }); + }); + } + + if let Some(folder) = boot_launch { + 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 }); @@ -57,6 +92,8 @@ impl App for OneClientApp { let link_confirm = use_state(|| None::); use_provide_link_confirm(LinkConfirmState(link_confirm)); + use_provide_start_maximized(StartMaximizedState(self.start_maximized)); + rect() .width(Size::fill()) .height(Size::fill()) @@ -66,6 +103,8 @@ impl App for OneClientApp { } fn main() { + let cli = cli::parse(); + let mut builder = Builder::new_multi_thread(); builder.enable_all().max_blocking_threads(64); @@ -76,8 +115,43 @@ fn main() { let rt = builder.build().unwrap(); let _tokio_guard = rt.enter(); + // 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()) + .unwrap_or(false); + let has_database = oneclient_common::paths::database_file() + .map(|path| path.exists()) + .unwrap_or(false); + let was_damaged = oneclient_common::paths::damaged_settings_file() + .map(|path| path.exists()) + .unwrap_or(false); + let needs_location = never_set_up && !has_database && !was_damaged; + + let mut unprotected = None; + let ipc = match rt.block_on(ipc::claim(&cli)) { + Claim::Forwarded => return, + Claim::Primary(listener) => Some(listener), + Claim::Solo(reason) => { + unprotected = Some(reason); + None + } + }; + let settings = rt.block_on(oneclient_core::settings::store::load_settings(None)); + oneclient_common::consent::init(settings.declined_tos); + + let abandoned_move = settings + .data_dir + .clone() + .filter(|dir| oneclient_core::relocate::incomplete(dir)); + + if let Some(dir) = settings.data_dir.clone() + && abandoned_move.is_none() + { + oneclient_common::paths::set_data_dir(dir); + } + if settings.log_debug { oneclient_core::logger::init_debug() } else { @@ -85,12 +159,41 @@ fn main() { } .expect("Failed to initialize logger"); + if let Some(reason) = unprotected { + tracing::warn!("no single-instance endpoint, a second launcher can start: {reason}"); + } + + match oneclient_app::shortcut::launcher_exe() + .and_then(|exe| oneclient_app::protocol::register(&exe)) + { + Ok(()) => {} + Err(err) => tracing::warn!( + "could not register the {}:// handler: {err:#}", + oneclient_app::protocol::SCHEME + ), + } + + if let Some(dir) = abandoned_move { + tracing::error!( + path = %dir.display(), + "a move of the game data folder never finished; staying on the old folder" + ); + } + let _sentry_guard = oneclient_core::reporting::init(settings.crash_reporting); + #[cfg(target_os = "macos")] oneclient_app::platform::macos::loop_memory_collector(); - let window_config = WindowConfig::new_app(OneClientApp) + let start_maximized = settings.start_maximized; + + 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!( @@ -99,23 +202,36 @@ fn main() { .with_size(1200., 800.) .with_min_size(800., 600.) .with_transparency(true) - .with_background(Color::TRANSPARENT); + .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 + } + }); #[cfg(target_os = "macos")] let window_config = window_config .with_decorations(true) - .with_window_attributes(|attrs, _| { + .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_has_shadow(true) + .with_maximized(start_maximized) }); #[cfg(not(target_os = "macos"))] - let window_config = window_config.with_decorations(false); + let window_config = window_config + .with_decorations(false) + .with_window_attributes(move |attrs, _| attrs.with_maximized(start_maximized)); let mut launch_config = LaunchConfig::new() .with_window(window_config) diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs index 386d927f..5bc1d3fe 100644 --- a/packages/oneclient_app/src/notifications.rs +++ b/packages/oneclient_app/src/notifications.rs @@ -1,12 +1,12 @@ use std::collections::HashMap; use std::time::{Duration, Instant}; -use oneclient_content::packages::ProviderId; -use oneclient_core::BrowserPackageUpdate; -use oneclient_db::models::ClusterId; use oneclient_events::{ Answer, Choice, Event, GroupedProgressEvent, Level, Notification, ProgressEvent, TaskCategory, }; +use oneclient_content::packages::ProviderId; +use oneclient_core::BrowserPackageUpdate; +use oneclient_db::models::{ClusterId, OptionalModStatus}; use tokio::sync::oneshot; use uuid::Uuid; @@ -36,12 +36,16 @@ pub struct PackageUpdateGroup { pub packages: Vec, } +pub type OptionalModRef = (String, String); + #[derive(Clone, Debug, PartialEq)] pub struct ClusterUpdateItem { pub provider: ProviderId, pub project_id: Option, /// Used when the meta cache has no entry (file name / package id) pub fallback: String, + pub offer: Option, + pub status: Option, } impl ClusterUpdateItem { @@ -50,16 +54,45 @@ impl ClusterUpdateItem { provider: ProviderId::Local, project_id: None, fallback: name.into(), + offer: None, + status: None, } } } +#[derive(Clone, Debug, PartialEq)] +pub struct OptionalModsGroup { + pub cluster_id: ClusterId, + pub cluster_name: String, + pub mods: Vec, +} + +impl OptionalModsGroup { + pub fn from_summary(summary: &ClusterUpdateSummary) -> Option { + if summary.optional.is_empty() { + return None; + } + Some(Self { + cluster_id: summary.cluster_id, + cluster_name: summary.cluster_name.clone(), + mods: summary.optional.clone(), + }) + } + + pub fn offers(&self) -> impl Iterator + '_ { + self.mods + .iter() + .filter_map(move |item| item.offer.clone().map(|reference| (self.cluster_id, reference))) + } +} + #[derive(Clone, Debug, PartialEq)] pub struct ClusterUpdateSummary { pub cluster_id: ClusterId, pub cluster_name: String, pub updated: Vec, pub added: Vec, + pub optional: Vec, pub removed: Vec, } @@ -167,6 +200,7 @@ pub struct NotificationSnapshot { pub center_open: bool, pub pending_prompt: Option, pub cluster_update: Option>, + pub optional_mods: Option>, pub package_updates: Option>, pub active_toast_entry_ids: Vec, } @@ -186,11 +220,13 @@ pub struct NotificationState { grouped_tasks: HashMap, pending_timers: Vec, cluster_update: Option>, + optional_mods: Option>, package_updates: 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>, } const CATEGORY_ORDER: [TaskCategory; 7] = [ @@ -238,11 +274,8 @@ 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(); @@ -297,6 +330,7 @@ impl NotificationState { center_open, pending_prompt, cluster_update: self.cluster_update.clone(), + optional_mods: self.optional_mods.clone(), package_updates: self.package_updates.clone(), active_toast_entry_ids: self.active_toasts.iter().map(|t| t.entry_id).collect(), } @@ -318,8 +352,39 @@ impl NotificationState { self.cluster_update = None; } - /// `done` is the launch's continuation it fires immediately when there is nothing - /// to show so a launch never waits on a modal that was never drawn + pub fn open_optional_mods( + &mut self, + groups: Vec, + done: Option>, + ) { + let groups: Vec = groups + .into_iter() + .filter(|group| !group.mods.is_empty()) + .collect(); + + if groups.is_empty() { + if let Some(done) = done { + let _ = done.send(()); + } + return; + } + + self.finish_optional_mods(); + self.optional_mods = Some(groups); + self.optional_mods_done = done; + } + + pub fn hide_optional_mods(&mut self) { + self.optional_mods = None; + } + + pub fn finish_optional_mods(&mut self) { + self.optional_mods = None; + if let Some(done) = self.optional_mods_done.take() { + let _ = done.send(()); + } + } + pub fn open_package_updates( &mut self, groups: Vec, @@ -668,7 +733,8 @@ impl NotificationState { self.update_inbox_entry(inbox, entry_id, title, body, None, false); 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); } } @@ -943,10 +1009,7 @@ 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()); } @@ -971,10 +1034,7 @@ 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()); } @@ -987,10 +1047,7 @@ 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"); } diff --git a/packages/oneclient_app/src/platform.rs b/packages/oneclient_app/src/platform.rs index d6cc40a1..f36267dc 100644 --- a/packages/oneclient_app/src/platform.rs +++ b/packages/oneclient_app/src/platform.rs @@ -1,6 +1,41 @@ +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| { + win.set_minimized(false); + win.focus_window(); + }); +} + pub fn open_url(url: &str) { - if let Err(err) = open::that_detached(url) { - tracing::warn!("failed to open url {url}: {err}"); + if !has_allowed_scheme(url) { + tracing::warn!("refused to open url with a disallowed scheme: {url}"); + return; + } + + open_target(url); +} + +/// For paths the launcher itself produced, which carry no url scheme. +pub fn open_path(path: &str) { + open_target(path); +} + +fn has_allowed_scheme(url: &str) -> bool { + let Some((scheme, _)) = url.split_once(':') else { + return false; + }; + + ALLOWED_URL_SCHEMES + .iter() + .any(|allowed| scheme.eq_ignore_ascii_case(allowed)) +} + +fn open_target(target: &str) { + if let Err(err) = open::that_detached(target) { + tracing::warn!("failed to open {target}: {err}"); } } diff --git a/packages/oneclient_app/src/protocol/linux.rs b/packages/oneclient_app/src/protocol/linux.rs new file mode 100644 index 00000000..61971fe5 --- /dev/null +++ b/packages/oneclient_app/src/protocol/linux.rs @@ -0,0 +1,50 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use super::SCHEME; +use crate::file_content::url_handler_entry; + +const ICON: &str = "oneclient_app"; + +fn handler_name() -> String { + format!("{SCHEME}-url-handler.desktop") +} + +fn applications_dir() -> Option { + directories::BaseDirs::new().map(|dirs| dirs.data_dir().join("applications")) +} + +pub fn register(exe: &Path) -> Result<()> { + let dir = applications_dir().context("no user data directory")?; + let path = dir.join(handler_name()); + let entry = url_handler_entry(exe, SCHEME, ICON); + + if std::fs::read_to_string(&path).is_ok_and(|existing| existing == entry) { + return Ok(()); + } + + std::fs::create_dir_all(&dir)?; + std::fs::write(&path, entry)?; + announce(&dir); + + tracing::info!(scheme = SCHEME, "registered the url scheme"); + Ok(()) +} + +fn announce(dir: &Path) { + let _ = std::process::Command::new("update-desktop-database") + .arg(dir) + .output(); + let _ = std::process::Command::new("xdg-mime") + .args([ + "default", + &handler_name(), + &format!("x-scheme-handler/{SCHEME}"), + ]) + .output(); +} + +pub fn is_registered() -> bool { + applications_dir().is_some_and(|dir| dir.join(handler_name()).is_file()) +} diff --git a/packages/oneclient_app/src/protocol/macos.rs b/packages/oneclient_app/src/protocol/macos.rs new file mode 100644 index 00000000..a4e2a6c4 --- /dev/null +++ b/packages/oneclient_app/src/protocol/macos.rs @@ -0,0 +1,11 @@ +use std::path::Path; + +use anyhow::Result; + +pub fn register(_exe: &Path) -> Result<()> { + Ok(()) +} + +pub fn is_registered() -> bool { + false +} diff --git a/packages/oneclient_app/src/protocol/mod.rs b/packages/oneclient_app/src/protocol/mod.rs new file mode 100644 index 00000000..a95d0698 --- /dev/null +++ b/packages/oneclient_app/src/protocol/mod.rs @@ -0,0 +1,128 @@ +#[cfg(all(unix, not(target_os = "macos")))] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(windows)] +mod registry; +#[cfg(windows)] +mod windows; + +#[cfg(all(unix, not(target_os = "macos")))] +use linux as imp; +#[cfg(target_os = "macos")] +use macos as imp; +#[cfg(windows)] +use windows as imp; + +use std::path::Path; + +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; + +#[cfg(debug_assertions)] +pub const SCHEME: &str = "oneclient-dev"; +#[cfg(not(debug_assertions))] +pub const SCHEME: &str = "oneclient"; + +const LAUNCH_HOST: &str = "launch"; + +const FOLDER: &AsciiSet = &NON_ALPHANUMERIC + .remove(b'-') + .remove(b'.') + .remove(b'_') + .remove(b'~'); + +#[must_use] +pub fn launch_url(folder: &str) -> String { + format!( + "{SCHEME}://{LAUNCH_HOST}/{}", + utf8_percent_encode(folder, FOLDER) + ) +} + +#[must_use] +pub fn parse_launch_url(raw: &str) -> Option { + let (scheme, rest) = raw.trim().split_once("://")?; + if !scheme.eq_ignore_ascii_case(SCHEME) { + return None; + } + + let (host, path) = rest.trim_end_matches('/').split_once('/')?; + if !host.eq_ignore_ascii_case(LAUNCH_HOST) { + return None; + } + + let folder = percent_decode_str(path).decode_utf8().ok()?; + let folder = folder.trim(); + (!folder.is_empty()).then(|| folder.to_string()) +} + +pub fn register(exe: &Path) -> anyhow::Result<()> { + imp::register(exe) +} + +#[must_use] +pub fn is_registered() -> bool { + imp::is_registered() +} + +#[cfg(test)] +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")); + } + + #[test] + fn punctuation_in_a_folder_name_round_trips() { + for folder in ["My Pack (1.8.9)", "a/b", "100%", "zażółć gęślą jaźń"] { + assert_eq!( + parse_launch_url(&launch_url(folder)).as_deref(), + Some(folder), + "failed for {folder}", + ); + } + } + + #[test] + fn a_trailing_slash_is_tolerated() { + assert_eq!( + parse_launch_url(&format!("{SCHEME}://launch/pack/")).as_deref(), + Some("pack"), + ); + } + + #[test] + fn the_scheme_and_host_are_matched_case_insensitively() { + assert_eq!( + parse_launch_url(&format!("{}://LAUNCH/pack", SCHEME.to_uppercase())).as_deref(), + Some("pack"), + ); + } + + #[test] + fn a_foreign_url_is_not_ours() { + assert_eq!(parse_launch_url("https://polyfrost.org/pack"), None); + assert_eq!(parse_launch_url("file:///etc/passwd"), None); + assert_eq!(parse_launch_url(""), None); + } + + #[test] + fn an_unknown_host_is_refused() { + assert_eq!(parse_launch_url(&format!("{SCHEME}://install/pack")), None); + } + + #[test] + fn an_empty_folder_is_not_a_request() { + assert_eq!(parse_launch_url(&format!("{SCHEME}://launch/")), None); + assert_eq!(parse_launch_url(&format!("{SCHEME}://launch/%20")), None); + assert_eq!(parse_launch_url(&format!("{SCHEME}://launch")), None); + } +} diff --git a/packages/oneclient_app/src/protocol/registry.rs b/packages/oneclient_app/src/protocol/registry.rs new file mode 100644 index 00000000..a5920116 --- /dev/null +++ b/packages/oneclient_app/src/protocol/registry.rs @@ -0,0 +1,106 @@ +use anyhow::{Result, bail}; +use windows::Win32::Foundation::{ERROR_SUCCESS, WIN32_ERROR}; +use windows::Win32::System::Registry::{ + HKEY, HKEY_CURRENT_USER, KEY_READ, KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ, RegCloseKey, + RegCreateKeyExW, RegOpenKeyExW, RegQueryValueExW, RegSetValueExW, +}; +use windows::core::{HSTRING, PCWSTR}; + +fn check(status: WIN32_ERROR, what: &str) -> Result<()> { + if status == ERROR_SUCCESS { + return Ok(()); + } + bail!("{what} failed with Windows error {}", status.0) +} + +fn close(key: HKEY) { + unsafe { + let _ = RegCloseKey(key); + }; +} + +fn name_ptr(name: Option<&HSTRING>) -> PCWSTR { + name.map_or_else(PCWSTR::null, |name| PCWSTR(name.as_ptr())) +} + +pub fn write_string(subkey: &str, name: Option<&str>, value: &str) -> Result<()> { + let mut key = HKEY::default(); + let status = unsafe { + RegCreateKeyExW( + HKEY_CURRENT_USER, + &HSTRING::from(subkey), + None, + PCWSTR::null(), + REG_OPTION_NON_VOLATILE, + KEY_WRITE, + None, + &raw mut key, + None, + ) + }; + check(status, "creating the registry key")?; + + let wide: Vec = value.encode_utf16().chain(std::iter::once(0)).collect(); + 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)) }; + close(key); + + check(status, "writing the registry value") +} + +pub fn read_string(subkey: &str, name: Option<&str>) -> Option { + let mut key = HKEY::default(); + let status = unsafe { + RegOpenKeyExW( + HKEY_CURRENT_USER, + &HSTRING::from(subkey), + None, + KEY_READ, + &raw mut key, + ) + }; + if status != ERROR_SUCCESS { + return None; + } + + let name = name.map(HSTRING::from); + let name = name_ptr(name.as_ref()); + + let mut size = 0u32; + let status = unsafe { RegQueryValueExW(key, name, None, None, None, Some(&raw mut size)) }; + if status != ERROR_SUCCESS || size == 0 { + close(key); + return None; + } + + let mut buffer = vec![0u8; size as usize]; + let status = unsafe { + RegQueryValueExW( + key, + name, + None, + None, + Some(buffer.as_mut_ptr()), + Some(&raw mut size), + ) + }; + close(key); + if status != ERROR_SUCCESS { + return None; + } + + buffer.truncate(size as usize); + let wide: Vec = buffer + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(); + + Some( + String::from_utf16_lossy(&wide) + .trim_end_matches('\0') + .to_string(), + ) +} diff --git a/packages/oneclient_app/src/protocol/windows.rs b/packages/oneclient_app/src/protocol/windows.rs new file mode 100644 index 00000000..382d6eab --- /dev/null +++ b/packages/oneclient_app/src/protocol/windows.rs @@ -0,0 +1,36 @@ +use std::path::Path; + +use anyhow::Result; + +use super::SCHEME; +use super::registry::{read_string, write_string}; + +fn root_key() -> String { + format!(r"Software\Classes\{SCHEME}") +} + +fn command_key() -> String { + format!(r"{}\shell\open\command", root_key()) +} + +pub fn register(exe: &Path) -> Result<()> { + let exe = exe.display().to_string(); + let command = format!("\"{exe}\" \"%1\""); + + if read_string(&command_key(), None).as_deref() == Some(command.as_str()) { + return Ok(()); + } + + let root = root_key(); + write_string(&root, None, &format!("URL:{SCHEME} Protocol"))?; + write_string(&root, Some("URL Protocol"), "")?; + write_string(&format!(r"{root}\DefaultIcon"), None, &format!("{exe},0"))?; + write_string(&command_key(), None, &command)?; + + tracing::info!(scheme = SCHEME, "registered the url scheme"); + Ok(()) +} + +pub fn is_registered() -> bool { + read_string(&command_key(), None).is_some_and(|value| !value.is_empty()) +} diff --git a/packages/oneclient_app/src/recovery.rs b/packages/oneclient_app/src/recovery.rs index 9478d4e9..a5fc246c 100644 --- a/packages/oneclient_app/src/recovery.rs +++ b/packages/oneclient_app/src/recovery.rs @@ -15,11 +15,11 @@ pub fn snapshots() -> Vec { } pub fn open_data_folder() { - let Ok(dir) = oneclient_common::paths::launcher_dir() else { + let Ok(dir) = oneclient_common::paths::data_dir() else { return; }; - crate::platform::open_url(&dir.to_string_lossy()); + crate::platform::open_path(&dir.to_string_lossy()); } pub fn restore_latest(actions: &Actions) { @@ -49,9 +49,7 @@ 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 de586dc8..e27f4011 100644 --- a/packages/oneclient_app/src/routes.rs +++ b/packages/oneclient_app/src/routes.rs @@ -8,7 +8,7 @@ use crate::layout::RootLayout; use crate::layout::SettingsShell; use crate::view::{ - NotFound, Startup, + NotFound, Relocating, Startup, app::{ AccountSkins, Clusters, Debug, Home, Stats, browser::{Browser, BrowserPackage}, @@ -24,7 +24,8 @@ use crate::view::{ }, onboarding::{ OnboardingAccount, OnboardingBundles, OnboardingDownloading, OnboardingLanguage, - OnboardingMigration, OnboardingPreferences, OnboardingTerms, OnboardingWelcome, + OnboardingLocation, OnboardingMigration, OnboardingPreferences, OnboardingTerms, + OnboardingWelcome, }, }; @@ -34,10 +35,14 @@ pub enum Route { #[layout(RootLayout)] #[route("/")] Startup {}, + #[route("/moving")] + Relocating {}, #[layout(OnboardingShell)] #[route("/onboarding")] OnboardingWelcome {}, + #[route("/onboarding/location")] + OnboardingLocation {}, #[route("/onboarding/terms")] OnboardingTerms {}, #[route("/onboarding/migration")] @@ -124,7 +129,9 @@ impl Route { pub fn title(&self) -> String { match self { Route::Startup { .. } => "Startup".to_string(), + Route::Relocating { .. } => "Moving".to_string(), Route::OnboardingWelcome { .. } => "Welcome".to_string(), + Route::OnboardingLocation { .. } => "Folder".to_string(), Route::OnboardingTerms { .. } => "Terms & Privacy".to_string(), Route::OnboardingMigration { .. } => "Migration".to_string(), Route::OnboardingLanguage { .. } => "Language".to_string(), diff --git a/packages/oneclient_app/src/shortcut/linux.rs b/packages/oneclient_app/src/shortcut/linux.rs new file mode 100644 index 00000000..7e0789b2 --- /dev/null +++ b/packages/oneclient_app/src/shortcut/linux.rs @@ -0,0 +1,30 @@ +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use anyhow::Result; + +use super::ShortcutRequest; +use crate::file_content::desktop_entry; + +pub const EXTENSION: &str = "desktop"; + +const ICON: &str = "oneclient_app"; + +pub fn write(request: &ShortcutRequest, exe: &Path, path: &Path) -> Result<()> { + let entry = desktop_entry(&request.cluster_name, exe, &request.folder_name, ICON); + std::fs::write(path, entry)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?; + trust(path); + + Ok(()) +} + +/// GNOME shows a desktop file without this attribute as plain text rather than +/// launching it +fn trust(path: &Path) { + let _ = std::process::Command::new("gio") + .arg("set") + .arg(path) + .args(["metadata::trusted", "true"]) + .output(); +} diff --git a/packages/oneclient_app/src/shortcut/macos.rs b/packages/oneclient_app/src/shortcut/macos.rs new file mode 100644 index 00000000..c022126b --- /dev/null +++ b/packages/oneclient_app/src/shortcut/macos.rs @@ -0,0 +1,88 @@ +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use super::ShortcutRequest; +use crate::file_content::{info_plist, shell_script}; + +pub const EXTENSION: &str = "app"; + +const EXECUTABLE: &str = "launch"; + +pub fn write(request: &ShortcutRequest, exe: &Path, path: &Path) -> Result<()> { + let result = build(request, exe, path); + if result.is_err() { + let _ = std::fs::remove_dir_all(path); + } + result +} + +fn build(request: &ShortcutRequest, exe: &Path, path: &Path) -> Result<()> { + let contents = path.join("Contents"); + let macos = contents.join("MacOS"); + let resources = contents.join("Resources"); + std::fs::create_dir_all(&macos)?; + std::fs::create_dir_all(&resources)?; + + let script = macos.join(EXECUTABLE); + std::fs::write(&script, shell_script(exe, &request.folder_name))?; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755))?; + + let icon = copy_icon(exe, &resources); + std::fs::write( + contents.join("Info.plist"), + info_plist( + &request.cluster_name, + EXECUTABLE, + &request.folder_name, + icon.as_deref(), + ), + )?; + + Ok(()) +} + +fn copy_icon(exe: &Path, resources: &Path) -> Option { + let source = bundle_root(exe)? + .join("Contents") + .join("Resources") + .join("icon.icns"); + + std::fs::copy(&source, resources.join("icon.icns")).ok()?; + Some("icon".to_string()) +} + +fn bundle_root(exe: &Path) -> Option { + let macos = exe.parent()?; + let contents = macos.parent()?; + let app = contents.parent()?; + + (macos.file_name()? == "MacOS" + && contents.file_name()? == "Contents" + && app.extension()? == "app") + .then(|| app.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_installed_binary_finds_its_bundle() { + let exe = PathBuf::from("/Applications/OneClient.app/Contents/MacOS/oneclient_app"); + assert_eq!( + bundle_root(&exe), + Some(PathBuf::from("/Applications/OneClient.app")), + ); + } + + #[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("/tmp/target/debug/oneclient_app")), + None, + ); + } +} diff --git a/packages/oneclient_app/src/shortcut/mod.rs b/packages/oneclient_app/src/shortcut/mod.rs new file mode 100644 index 00000000..cbbb1b0e --- /dev/null +++ b/packages/oneclient_app/src/shortcut/mod.rs @@ -0,0 +1,187 @@ +#[cfg(all(unix, not(target_os = "macos")))] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(windows)] +mod windows; + +#[cfg(all(unix, not(target_os = "macos")))] +use linux as imp; +#[cfg(target_os = "macos")] +use macos as imp; +#[cfg(windows)] +use windows as imp; + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; + +const MAX_STEM: usize = 96; +const FALLBACK_STEM: &str = "OneClient"; +const MAX_COLLISIONS: usize = 20; +const FORBIDDEN: &[char] = &['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + +const RESERVED: &[&str] = &[ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", +]; + +pub struct ShortcutRequest { + pub cluster_name: String, + pub folder_name: String, + pub dir: PathBuf, +} + +pub fn create(request: &ShortcutRequest) -> Result { + let exe = launcher_exe()?; + + 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)?; + imp::write(request, &exe, &path) + .with_context(|| format!("couldn't write {}", path.display()))?; + + tracing::info!(path = %path.display(), folder = request.folder_name, "wrote cluster shortcut"); + Ok(path) +} + +#[must_use] +pub fn default_dir() -> Option { + directories::UserDirs::new() + .and_then(|dirs| dirs.desktop_dir().map(Path::to_path_buf)) + .filter(|dir| dir.is_dir()) +} + +pub fn launcher_exe() -> Result { + #[cfg(all(unix, not(target_os = "macos")))] + if let Some(appimage) = std::env::var_os("APPIMAGE") { + let path = PathBuf::from(appimage); + if path.is_file() { + return Ok(path); + } + } + + let exe = std::env::current_exe().context("couldn't locate the OneClient executable")?; + Ok(dunce::canonicalize(&exe).unwrap_or(exe)) +} + +fn file_stem(name: &str) -> String { + let replaced: String = name + .chars() + .map(|c| { + if FORBIDDEN.contains(&c) || c.is_control() { + '-' + } else { + c + } + }) + .collect(); + + let trimmed = replaced.trim().trim_end_matches(['.', ' ']).trim(); + if trimmed.is_empty() { + return FALLBACK_STEM.to_string(); + } + + let truncated: String = trimmed.chars().take(MAX_STEM).collect(); + let truncated = truncated.trim_end().trim_end_matches('.').to_string(); + if truncated.is_empty() { + return FALLBACK_STEM.to_string(); + } + + if RESERVED.iter().any(|r| r.eq_ignore_ascii_case(&truncated)) { + return format!("{truncated}-shortcut"); + } + + truncated +} + +fn unique_path(dir: &Path, stem: &str, extension: &str) -> Result { + for attempt in 1..=MAX_COLLISIONS { + let name = if attempt == 1 { + format!("{stem}.{extension}") + } else { + format!("{stem} ({attempt}).{extension}") + }; + + let candidate = dir.join(name); + if !candidate.exists() { + return Ok(candidate); + } + } + + bail!("there are already {MAX_COLLISIONS} shortcuts named \"{stem}\" in that folder") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_ordinary_name_is_left_alone() { + assert_eq!(file_stem("Fabric 1.20"), "Fabric 1.20"); + } + + #[test] + fn path_separators_cannot_escape_the_chosen_folder() { + assert_eq!(file_stem("../../etc/passwd"), "..-..-etc-passwd"); + assert_eq!(file_stem(r"a\b"), "a-b"); + } + + #[test] + fn a_name_that_is_all_illegal_falls_back() { + assert_eq!(file_stem(" "), FALLBACK_STEM); + assert_eq!(file_stem(""), FALLBACK_STEM); + assert_eq!(file_stem("..."), FALLBACK_STEM); + } + + #[test] + fn reserved_device_names_are_pushed_out_of_the_way() { + assert_eq!(file_stem("NUL"), "NUL-shortcut"); + assert_eq!(file_stem("com1"), "com1-shortcut"); + assert_eq!(file_stem("CONSOLE"), "CONSOLE"); + } + + #[test] + fn a_trailing_dot_is_dropped_before_windows_drops_it_silently() { + assert_eq!(file_stem("Pack."), "Pack"); + assert_eq!(file_stem("Pack "), "Pack"); + } + + #[test] + fn a_very_long_name_is_cut_to_a_usable_length() { + let stem = file_stem(&"ą".repeat(400)); + assert_eq!(stem.chars().count(), MAX_STEM); + } + + #[test] + fn a_control_character_cannot_reach_the_file_name() { + assert_eq!(file_stem("Pack\nEvil"), "Pack-Evil"); + } + + #[test] + fn the_first_shortcut_gets_the_plain_name() { + let dir = std::env::temp_dir().join("oneclient-shortcut-plain"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let path = unique_path(&dir, "Pack", "url").unwrap(); + assert_eq!(path.file_name().unwrap(), "Pack.url"); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_second_shortcut_does_not_replace_the_first() { + let dir = std::env::temp_dir().join("oneclient-shortcut-collide"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Pack.url"), b"existing").unwrap(); + + let path = unique_path(&dir, "Pack", "url").unwrap(); + assert_eq!(path.file_name().unwrap(), "Pack (2).url"); + assert_eq!(std::fs::read(dir.join("Pack.url")).unwrap(), b"existing"); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/packages/oneclient_app/src/shortcut/windows.rs b/packages/oneclient_app/src/shortcut/windows.rs new file mode 100644 index 00000000..26c5b4d3 --- /dev/null +++ b/packages/oneclient_app/src/shortcut/windows.rs @@ -0,0 +1,23 @@ +use std::path::Path; + +use anyhow::{Result, bail}; + +use super::ShortcutRequest; +use crate::file_content::url_shortcut; +use crate::protocol; + +pub const EXTENSION: &str = "url"; + +pub fn write(request: &ShortcutRequest, exe: &Path, path: &Path) -> Result<()> { + if !protocol::is_registered() { + bail!( + "the {} :// handler is not registered on this machine", + protocol::SCHEME + ); + } + + let url = protocol::launch_url(&request.folder_name); + std::fs::write(path, url_shortcut(&url, exe))?; + + Ok(()) +} diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs index eae4ac29..82c3654e 100644 --- a/packages/oneclient_app/src/state.rs +++ b/packages/oneclient_app/src/state.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use freya::radio::RadioChannel; use oneclient_common::domain::ProviderId; +use oneclient_core::relocate::{RelocationOutcome, RelocationPlan}; use oneclient_core::settings::LauncherSettings; use oneclient_events::LaunchStage; @@ -21,6 +22,8 @@ pub enum AppChannel { AccountSwitcher, MicrosoftLogin, Installs, + Relocation, + PendingLaunch, } impl RadioChannel for AppChannel {} @@ -40,6 +43,22 @@ pub struct AppState { pub account_switcher_open: bool, pub microsoft_login: Option, pub installs: InstallState, + pub relocation: RelocationState, + pub pending_launch: Option, +} + +/// A move of the data folder owns the whole window while it runs: the router +/// swaps to [`crate::routes::Route::Relocating`] so nothing else can be touched +/// until the copy settles +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RelocationState { + /// What is being moved where; cleared when the user leaves the move screen + pub plan: Option, + /// Bytes written so far and the total to write, reported by the copy itself + pub copied: u64, + pub total: u64, + /// Set once the copy settles, which turns the screen into its result + pub result: Option>, } /// In-flight installs so the button that started one stays disabled until it lands @@ -69,12 +88,11 @@ impl InstallState { pub struct LauncherInit { pub ready: bool, pub fetching: bool, - /// Launch is disabled until this clears pub syncing_bundles: bool, pub error: Option, pub data_dir: String, - /// Restorable database snapshots, for the startup failure screen pub snapshots: usize, + pub needs_location: bool, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -103,6 +121,21 @@ pub struct LoginProgress { pub total: u64, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LaunchBlock { + Starting(i64), + Running(i64), +} + +impl LaunchBlock { + #[must_use] + pub fn cluster_id(self) -> i64 { + match self { + Self::Starting(id) | Self::Running(id) => id, + } + } +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct GameState { pub stages: HashMap, @@ -121,13 +154,41 @@ impl GameState { /// Returns false if a launch is already in flight the re-entrancy guard pub fn begin_launch(&mut self, cluster_id: i64) -> bool { - if self.is_active(cluster_id) || self.is_launch_pending(cluster_id) { + if self.block_for(cluster_id).is_some() { return false; } self.pending.insert(cluster_id); true } + fn block_for(&self, cluster_id: i64) -> Option { + if self.is_running(cluster_id) { + Some(LaunchBlock::Running(cluster_id)) + } else if self.is_active(cluster_id) || self.is_launch_pending(cluster_id) { + Some(LaunchBlock::Starting(cluster_id)) + } else { + None + } + } + + #[must_use] + pub fn launch_block(&self, cluster_id: i64, parallel: bool) -> 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) + .find_map(|id| self.block_for(id)) + } + pub fn finish_launch(&mut self, cluster_id: i64) { self.pending.remove(&cluster_id); } @@ -189,6 +250,43 @@ mod tests { assert!(game.begin_launch(1)); } + #[test] + fn a_shortcut_is_told_which_cluster_is_in_the_way() { + 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); + } + + #[test] + fn a_game_that_is_still_coming_up_still_blocks() { + 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))); + } + + #[test] + fn a_claim_with_no_stage_yet_blocks_too() { + 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))); + } + + #[test] + fn an_exited_game_is_out_of_the_way() { + 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); + } + #[test] fn the_button_disables_on_the_claim_alone() { let mut game = GameState::default(); diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs index 0e075fe3..4b3a9a6b 100644 --- a/packages/oneclient_app/src/ui.rs +++ b/packages/oneclient_app/src/ui.rs @@ -24,6 +24,58 @@ pub fn border_all_color(width: f32, color: Color) -> Border { }) } +/// A tinted strip of explanation under a row; `accent` washes the background +pub fn note(message: String, accent: Color) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .spacing(10.) + .padding(Gaps::new_symmetric(12., 14.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(accent.with_a(30)) + .child( + label() + .text(message) + .font_size(12.) + .max_lines(5) + .width(Size::flex(1.0)) + .color(theme::colors::fg_primary()), + ) + .into_element() +} + +/// A captioned, read-only path in a boxed field +pub fn path_block(caption: &'static str, path: &std::path::Path) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .child( + label() + .text(caption) + .font_size(11.) + .color(theme::colors::fg_secondary()), + ) + .child( + rect() + .width(Size::fill()) + .padding(Gaps::new_all(10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(theme::colors::component_bg()) + .border(border_all_color(1., theme::colors::component_border())) + .child( + label() + .text(path.display().to_string()) + .font_size(12.) + .max_lines(3) + .width(Size::fill()) + .color(theme::colors::fg_primary()), + ), + ) + .into_element() +} + pub fn fmt_date(ts: chrono::DateTime) -> String { ts.format("%Y-%m-%d %H:%M").to_string() } @@ -62,15 +114,6 @@ pub fn centered_note(text: &str) -> Element { .into_element() } -/// Returns 1 until the grid has measured itself so the first frame still renders -pub fn grid_columns_for_width(width: f32, max_col: f32, gap: f32) -> usize { - if width <= 0. { - return 1; - } - (((width + gap) / (max_col + gap)).ceil() as usize).max(1) -} - -/// Reports its measured size back through `width` for [`grid_columns_for_width`] /// 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/utils.rs b/packages/oneclient_app/src/utils.rs index ecea19aa..ada19be8 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -2,10 +2,12 @@ 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::clusters::Cluster; -use sysinfo::{MemoryRefreshKind, RefreshKind, System}; + +use oneclient_common::MEMORY_HEADROOM_GB; +pub use oneclient_common::total_ram_mb; pub type ClusterGroups = BTreeMap>; @@ -205,21 +207,9 @@ 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_HEADROOM_GB: u32 = 2; -const MEMORY_PRESETS_GB: [u32; 10] = [2, 4, 6, 8, 12, 16, 24, 32, 48, 64]; - -pub fn total_ram_mb() -> u32 { - static TOTAL_RAM_MB: std::sync::OnceLock = std::sync::OnceLock::new(); - - *TOTAL_RAM_MB.get_or_init(|| { - let system = System::new_with_specifics( - RefreshKind::nothing().with_memory(MemoryRefreshKind::nothing().with_ram()), - ); - (system.total_memory() / 1024 / 1024).min(u32::MAX as u64) as u32 - }) -} +const MEMORY_PRESETS_GB: [u32; 11] = [2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64]; pub fn memory_presets_mb() -> Vec { presets_for_total_gb((total_ram_mb() as f32 / 1024.).round() as u32) @@ -451,17 +441,17 @@ mod tests { fn presets_stop_two_gigabytes_short_of_the_machine() { assert_eq!( presets_for_total_gb(16), - vec![2048, 4096, 6144, 8192, 12288, 14336] + vec![2048, 3072, 4096, 6144, 8192, 12288, 14336] ); assert_eq!( presets_for_total_gb(32), - vec![2048, 4096, 6144, 8192, 12288, 16384, 24576, 30720] + vec![2048, 3072, 4096, 6144, 8192, 12288, 16384, 24576, 30720] ); } #[test] fn a_ceiling_landing_on_a_round_step_is_not_repeated() { - assert_eq!(presets_for_total_gb(8), vec![2048, 4096, 6144]); + assert_eq!(presets_for_total_gb(8), vec![2048, 3072, 4096, 6144]); } #[test] 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 b05f09bd..f75869c6 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -31,9 +31,10 @@ pub(super) fn grid_row( cluster_id: i64, package_type: &str, installed: &InstalledMap, + metrics: GridMetrics, ) -> impl IntoElement { let package_type = package_type.to_string(); - let fill = GRID_COLUMNS - row.len(); + let fill = metrics.cols.saturating_sub(row.len()); let installed: Vec> = row .iter() .map(|item| installed_for(installed, item)) @@ -42,14 +43,14 @@ pub(super) fn grid_row( rect() .horizontal() .width(Size::fill()) - .height(Size::px(CARD_H)) + .height(Size::px(metrics.card_h)) .spacing(GRID_SPACING) .content(Content::Flex) .children( row.into_iter() .zip(installed) .map(move |(item, installed)| { - PackageCard::new(item, cluster_id, package_type.clone(), installed) + PackageCard::new(item, cluster_id, package_type.clone(), installed, metrics) .into_element() }), ) @@ -76,6 +77,7 @@ struct PackageCard { cluster_id: i64, package_type: String, installed: Option, + metrics: GridMetrics, } impl PackageCard { @@ -84,12 +86,14 @@ impl PackageCard { cluster_id: i64, package_type: String, installed: Option, + metrics: GridMetrics, ) -> Self { Self { item, cluster_id, package_type, installed, + metrics, } } } @@ -109,7 +113,7 @@ impl Component for PackageCard { rect() .vertical() .width(Size::flex(1.0)) - .height(Size::px(CARD_H)) + .height(Size::px(self.metrics.card_h)) .corner_radius(CornerRadius::new_all(10.)) .background(CARD_BG) .border(border_all_color( @@ -125,13 +129,13 @@ impl Component for PackageCard { .a11y_focusable(true) .a11y_role(AccessibilityRole::Button) .cursor(CursorIcon::Pointer) - .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) + .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.)) .overflow(Overflow::Clip) - .child(PackageBanner::new(icon_url, BANNER_H)) + .child(PackageBanner::new(icon_url, self.metrics.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() @@ -258,7 +262,7 @@ impl Component for ListRow { .a11y_focusable(true) .a11y_role(AccessibilityRole::Button) .cursor(CursorIcon::Pointer) - .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) + .on_press(move |_| open_package(cluster_id, &package_type, provider, &id)) .child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.)) .child( rect() @@ -303,7 +307,6 @@ impl Component for ListRow { ), ) .child(downloads_row(item.downloads)) - // Kept once installed rather than dropped a row that loses its button leaves its neighbours misaligned .child(InstallButton::new( &self.item, self.cluster_id, @@ -313,7 +316,6 @@ impl Component for ListRow { } } -/// Same version pick and dispatch as the install button on the package page #[derive(PartialEq)] struct InstallButton { provider: ProviderId, @@ -372,7 +374,7 @@ impl Component for InstallButton { rect() // The card behind is one big press target stop here so the package page doesn't open on top of the install - .on_all_press(|e: Event| e.stop_propagation()) + .on_press(|e: Event| e.stop_propagation()) .child( Button::new() .primary() 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 7f8c8bd4..53b30f17 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -16,6 +16,7 @@ use oneclient_core::settings::ViewLayout; use crate::components::{ Dropdown, Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput, + cell_width, grid_columns_picker, resolved_columns, }; use crate::hooks::use_cluster; use crate::hooks::{ @@ -43,15 +44,24 @@ 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 GRID_COLUMNS: usize = 3; const SCROLLBAR_GUTTER: f32 = 18.; const CARD_H: f32 = 240.; const BANNER_H: f32 = 100.; +const CARD_TEXT_H: f32 = CARD_H - BANNER_H; +const BANNER_RATIO: f32 = 0.35; +const BANNER_MAX_H: f32 = 200.; const LIST_ROW_H: f32 = 78.; const GRID_SPACING: f32 = 12.; const LIST_SPACING: f32 = 8.; const SEARCH_DEBOUNCE_MS: u64 = 250; +#[derive(Clone, Copy, PartialEq)] +pub(super) struct GridMetrics { + pub cols: usize, + pub card_h: f32, + pub banner_h: f32, +} + fn type_title(package_type: &str) -> &'static str { match package_type { "shader" => "Shaders", @@ -84,7 +94,9 @@ impl Component for Browser { let query = use_state(|| saved.query.clone()); let provider = use_state(|| saved.provider); - let view_mode = use_view_state(&format!("browser.{package_type}")).layout; + let view = use_view_state(&format!("browser.{package_type}")); + let view_mode = view.layout; + let grid_columns = view.columns; let compatible_only = use_browser_compat(); let selected_categories = use_state(|| saved.categories.clone()); let page = use_state(|| saved.page); @@ -92,22 +104,14 @@ impl Component for Browser { { let mut store = store; let key = state_key.clone(); - let deps = ( - query.read().clone(), - *provider.read(), - selected_categories.read().clone(), - *page.read(), - ); - use_side_effect_with_deps(&deps, move |(q, prov, cats, p)| { + use_side_effect(move || { let snapshot = BrowserUiState { - query: q.clone(), - provider: *prov, - categories: cats.clone(), - page: *p, + query: query.read().clone(), + provider: *provider.read(), + categories: selected_categories.read().clone(), + page: *page.read(), }; - if store.peek().get(&key) != Some(&snapshot) { - store.write().insert(key.clone(), snapshot); - } + store.write().insert(key.clone(), snapshot); }); } @@ -186,6 +190,16 @@ impl Component for Browser { let mode = *view_mode.read(); + let mut grid_width = use_state(|| 0f32); + let cols = resolved_columns(*grid_columns.read()); + let card_w = cell_width( + (*grid_width.read() - SCROLLBAR_GUTTER).max(0.), + cols, + GRID_SPACING, + ); + let banner_h = (card_w * BANNER_RATIO).clamp(BANNER_H, BANNER_MAX_H); + let card_h = banner_h + CARD_TEXT_H; + let fade_dep = (current_page, packages.is_empty(), pending, mode); let fade = use_animation_with_dependencies(&fade_dep, |conf, _| { conf.on_creation(OnCreation::Run); @@ -206,10 +220,21 @@ impl Component for Browser { match mode { ViewLayout::Grid => { let rows: Vec> = - packages.chunks(GRID_COLUMNS).map(|c| c.to_vec()).collect(); + packages.chunks(cols).map(|c| c.to_vec()).collect(); - sa.lazy(rows.len(), CARD_H, GRID_SPACING, move |i| { - grid_row(rows[i].clone(), cluster_id, &pkg, &installed).into_element() + sa.lazy(rows.len(), card_h, GRID_SPACING, move |i| { + grid_row( + rows[i].clone(), + cluster_id, + &pkg, + &installed, + GridMetrics { + cols, + card_h, + banner_h, + }, + ) + .into_element() }) } ViewLayout::List => sa.lazy(packages.len(), LIST_ROW_H, LIST_SPACING, move |i| { @@ -219,9 +244,14 @@ impl Component for Browser { } else if pending { match mode { ViewLayout::Grid => { - let rows = BROWSE_PAGE_SIZE.div_ceil(GRID_COLUMNS); - sa.lazy(rows, CARD_H, GRID_SPACING, |_| { - skeleton_grid_row().into_element() + let rows = BROWSE_PAGE_SIZE.div_ceil(cols); + sa.lazy(rows, card_h, GRID_SPACING, move |_| { + skeleton_grid_row(GridMetrics { + cols, + card_h, + banner_h, + }) + .into_element() }) } ViewLayout::List => sa.lazy(BROWSE_PAGE_SIZE, LIST_ROW_H, LIST_SPACING, |_| { @@ -248,7 +278,7 @@ impl Component for Browser { package_type: package_type.clone(), }), )) - .child(controls(provider, query, view_mode)) + .child(controls(provider, query, view_mode, grid_columns)) .child( rect() .horizontal() @@ -273,7 +303,13 @@ impl Component for Browser { .width(Size::fill()) .height(Size::flex(1.0)) .child(results) - .opacity(fade_opacity), + .opacity(fade_opacity) + .on_sized(move |event: Event| { + let w = event.data().area.width(); + if (w - *grid_width.peek()).abs() > 0.5 { + *grid_width.write() = w; + } + }), ) .maybe_child(pages.map(|pages| { Pagination::new(page, pages) @@ -387,6 +423,7 @@ fn controls( provider: State, query: State, view_mode: State, + grid_columns: State, ) -> impl IntoElement { rect() .horizontal() @@ -395,6 +432,10 @@ fn controls( .spacing(12.) .content(Content::Flex) .child(rect().width(Size::flex(1.0))) + .maybe_child( + (*view_mode.read() == ViewLayout::Grid) + .then(|| grid_columns_picker(grid_columns, 30.).into_element()), + ) .child( SegmentedControl::new(view_mode) .equal_width(30.) 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 66b7dfdb..ad814631 100644 --- a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs @@ -100,7 +100,7 @@ impl Component for CategoryRow { .cursor(CursorIcon::Pointer) .on_pointer_enter(move |_| hovering.set(true)) .on_pointer_leave(move |_| hovering.set(false)) - .on_all_press(move |_| on_toggle.call(())) + .on_press(move |_| on_toggle.call(())) .child( label() .text(capitalize(&self.name)) 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 30f6d2aa..ee6dca57 100644 --- a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs +++ b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs @@ -27,7 +27,9 @@ fn use_skeleton_pulse() -> f32 { } #[derive(PartialEq)] -struct SkeletonCard; +struct SkeletonCard { + metrics: GridMetrics, +} impl Component for SkeletonCard { fn render(&self) -> impl IntoElement { @@ -35,13 +37,13 @@ impl Component for SkeletonCard { rect() .vertical() .width(Size::flex(1.0)) - .height(Size::px(CARD_H)) + .height(Size::px(self.metrics.card_h)) .corner_radius(CornerRadius::new_all(10.)) .background(CARD_BG) .border(border_all_color(1., colors::component_border())) .overflow(Overflow::Clip) .opacity(pulse) - .child(skeleton_block(Size::fill(), BANNER_H)) + .child(skeleton_block(Size::fill(), self.metrics.banner_h)) .child( rect() .vertical() @@ -88,13 +90,13 @@ impl Component for SkeletonListRow { } } -pub(super) fn skeleton_grid_row() -> impl IntoElement { +pub(super) fn skeleton_grid_row(metrics: GridMetrics) -> impl IntoElement { rect() .horizontal() .width(Size::fill()) - .height(Size::px(CARD_H)) + .height(Size::px(metrics.card_h)) .spacing(GRID_SPACING) .content(Content::Flex) - .children((0..GRID_COLUMNS).map(|_| SkeletonCard.into_element())) + .children((0..metrics.cols).map(move |_| SkeletonCard { metrics }.into_element())) .into_element() } diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs index 2bf654f7..fde529ae 100644 --- a/packages/oneclient_app/src/view/app/browser/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -175,7 +175,13 @@ 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( @@ -391,6 +397,7 @@ mod tests { display_version: None, provider: Some(ProviderId::Modrinth), published_at: None, + seen_status: oneclient_core::SeenStatus::Seen, } } @@ -438,10 +445,7 @@ 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()), @@ -461,11 +465,7 @@ 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) 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 a8b66d45..8eae22da 100644 --- a/packages/oneclient_app/src/view/app/browser/package/gallery.rs +++ b/packages/oneclient_app/src/view/app/browser/package/gallery.rs @@ -4,15 +4,18 @@ use super::*; use oneclient_content::packages::types::GalleryImage; -use crate::components::{Button, Icon, IconType, OverlayPopup}; -use crate::hooks::{loaded_image, query_is_busy, use_cached_image}; +use crate::components::{ + Button, Icon, IconType, OverlayPopup, cell_width, grid_columns_picker, resolved_columns, +}; +use crate::hooks::{loaded_image, query_is_busy, use_cached_image, use_view_state}; use crate::theme::colors; -use crate::ui::{border_all_color, flow_grid, grid_columns_for_width}; +use crate::ui::{border_all_color, flow_grid}; -/// How wide a tile is allowed to get before the grid takes another column const MAX_COL_W: f32 = 400.; const GRID_GAP: f32 = 16.; const TILE_PREVIEW_H: f32 = 168.; +const TILE_PREVIEW_RATIO: f32 = TILE_PREVIEW_H / MAX_COL_W; +const TILE_PREVIEW_MAX_H: f32 = 420.; /// Downscale ceilings for the image cache full-size tiles made the grid slow to fill const TILE_EDGE: u32 = 640; @@ -32,6 +35,11 @@ impl Component for Gallery { let grid_width = use_state(|| 0f32); let mut viewing = use_state(|| None::); + let grid_columns = use_view_state("browser.gallery").columns; + let cols = resolved_columns(*grid_columns.read()); + let tile_w = cell_width(*grid_width.read(), cols, GRID_GAP); + let preview_h = (tile_w * TILE_PREVIEW_RATIO).clamp(TILE_PREVIEW_H, TILE_PREVIEW_MAX_H); + let tiles: Vec = self .images .iter() @@ -40,6 +48,7 @@ impl Component for Gallery { let key = image.url.clone(); GalleryTile { image: image.clone(), + preview_h, on_open: (move |()| viewing.set(Some(idx))).into(), key: DiffKey::None, } @@ -48,11 +57,21 @@ impl Component for Gallery { }) .collect(); - let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP); let images = self.images.clone(); rect() + .vertical() .width(Size::fill()) + .spacing(10.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .child(rect().width(Size::flex(1.0))) + .child(grid_columns_picker(grid_columns, 30.)), + ) .child(flow_grid(tiles, cols, grid_width, GRID_GAP)) .maybe_child((*viewing.read()).map(|start| { GalleryViewer { @@ -68,6 +87,7 @@ impl Component for Gallery { #[derive(PartialEq)] struct GalleryTile { image: GalleryImage, + preview_h: f32, on_open: EventHandler<()>, key: DiffKey, } @@ -88,7 +108,7 @@ impl Component for GalleryTile { let preview = rect() .width(Size::fill()) - .height(Size::px(TILE_PREVIEW_H)) + .height(Size::px(self.preview_h)) .center() .overflow(Overflow::Clip) .background(colors::component_bg()) @@ -104,7 +124,6 @@ impl Component for GalleryTile { rect() .vertical() .width(Size::flex(1.0)) - .max_width(Size::px(MAX_COL_W)) .corner_radius(CornerRadius::new_all(10.)) .overflow(Overflow::Clip) .background(PANEL_BG) 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 31770866..62212e47 100644 --- a/packages/oneclient_app/src/view/app/browser/package/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -2,7 +2,6 @@ use freya::prelude::*; use oneclient_content::packages::{ContentType, ProviderId}; use crate::components::ScrollArea; -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, @@ -11,6 +10,7 @@ use crate::hooks::{ }; 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, 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 829aca06..7ae602be 100644 --- a/packages/oneclient_app/src/view/app/browser/package/panels.rs +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -1,10 +1,11 @@ use super::*; use oneclient_content::packages::ProviderId; +use oneclient_content::packages::markdown::normalize_markdown; use oneclient_content::packages::types::{PackageBody, ProjectDetail, ReleaseType, VersionSummary}; use crate::Actions; -use crate::components::{Button, Icon, IconType, Segment, SegmentedControl}; +use crate::components::{Button, Icon, IconType, Markdown, MarkdownStyle, Segment, SegmentedControl}; use crate::hooks::VERSIONS_PAGE_SIZE; use crate::theme::colors; use crate::ui::border_all_color; @@ -38,11 +39,34 @@ pub(super) fn tabs(active_tab: State, has_gallery: bool) -> impl IntoElem pub(super) fn about_panel(project: &ProjectDetail) -> impl IntoElement { let body = match &project.body { PackageBody::Raw(md) => md.clone(), - PackageBody::Url(url) => format!("{}\n\n[View online]({url})", project.summary), + PackageBody::Url(url) => normalize_markdown(&format!( + "{}\n\n[View online]({})", + project.summary, + escape_link_destination(url) + )), }; MarkdownPanel { body }.into_element() } +fn escape_link_destination(url: &str) -> String { + let mut out = String::with_capacity(url.len()); + + for character in url.chars() { + match character { + '(' => out.push_str("%28"), + ')' => out.push_str("%29"), + '<' => out.push_str("%3C"), + '>' => out.push_str("%3E"), + ' ' => out.push_str("%20"), + '\\' => out.push_str("%5C"), + character if character.is_control() => {} + character => out.push(character), + } + } + + out +} + #[derive(PartialEq)] struct MarkdownPanel { body: String, @@ -57,23 +81,21 @@ impl Component for MarkdownPanel { .background(PANEL_BG) .border(border_all_color(1., colors::component_border())) .child( - MarkdownViewer::new(self.body.clone()) + Markdown::new(self.body.clone()) .width(Size::fill()) - .color(colors::fg_primary()) - .color_link(colors::code_info()) - .background_code(colors::component_bg()) - .color_code(colors::fg_primary()) - .background_blockquote(colors::component_bg()) - .border_blockquote(colors::brand()) - .background_divider(colors::component_border()) - .heading_h1(26.) - .heading_h2(22.) - .heading_h3(18.) - .heading_h4(16.) - .heading_h5(14.) - .heading_h6(13.) - .paragraph_size(13.) - .code_font_size(12.), + .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: [26., 22., 18., 16., 14., 13.], + paragraph_size: 13., + code_font_size: 12., + ..MarkdownStyle::default() + }), ) } } @@ -261,7 +283,14 @@ fn version_row( .map(|installed| activity_badge(installed.enabled).into_element()), ) .child(version_button( - installed, v.name, provider, project_id, version_id, cluster_id, dispatch, installing, + installed, + v.name, + provider, + project_id, + version_id, + cluster_id, + dispatch, + installing, )) } @@ -294,11 +323,7 @@ fn version_button( }; 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() 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 04939b5d..628e8659 100644 --- a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -208,7 +208,7 @@ impl Component for ProviderTag { el.border(border_all_color(1., colors::brand())) }) .cursor(CursorIcon::Pointer) - .on_all_press(move |_| confirm.set(Some(url.clone()))) + .on_press(move |_| confirm.set(Some(url.clone()))) }) } } @@ -302,7 +302,7 @@ impl Component for AuthorRow { .a11y_focusable(true) .a11y_role(AccessibilityRole::Button) .cursor(CursorIcon::Pointer) - .on_all_press(move |_| confirm.set(Some(url.clone()))) + .on_press(move |_| confirm.set(Some(url.clone()))) }) .maybe(interactive && focused, |el| { el.border(border_all_color(1., colors::brand())) @@ -439,7 +439,7 @@ impl Component for LinkRow { el.border(border_all_color(1., colors::brand())) }) .cursor(CursorIcon::Pointer) - .on_all_press(move |_| confirm.set(Some(url.clone()))) + .on_press(move |_| confirm.set(Some(url.clone()))) .child( Icon::new(IconType::Link03) .size(14.) 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 0f27bfb7..0d8d72ef 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -1,8 +1,10 @@ use freya::prelude::*; use oneclient_common::Patch; -use oneclient_common::domain::GameLoader; -use oneclient_core::settings::{GameSettingsProfile, PackageUpdateMode, ProfileUpdate, Resolution}; use oneclient_java::JavaRuntime; +use oneclient_common::domain::GameLoader; +use oneclient_core::settings::{ + GameSettingsProfile, PackageUpdateMode, ProfileUpdate, Resolution, +}; use crate::components::{ Button, Dropdown, Icon, IconType, ScrollArea, TextInput, memory_field, toggle, @@ -68,27 +70,6 @@ impl Component for ClusterSettings { .width(Size::fill()) .height(Size::fill()) .spacing(4.) - .child(section_header("LOADER")) - .child( - LoaderRow { - cluster_id, - loader, - selected: cluster.mc_loader_version.clone(), - versions, - } - .into_element(), - ) - .child(section_header("JAVA")) - .child( - JavaRow { - cluster_id, - value: profile.java_path.clone(), - global: global.java_path.clone(), - runtimes, - } - .into_element(), - ) - .child(text_row(cluster_id, TextField::JvmArgs, &profile, &global)) .child(section_header("GAME")) .child( ToggleRow { @@ -110,16 +91,19 @@ impl Component for ClusterSettings { MemoryRow { cluster_id, value: profile.mem_max, - global: global.mem_max.unwrap_or(4096), + global: global + .mem_max + .unwrap_or_else(oneclient_common::default_mem_max), } .into_element(), ) - .child(section_header("CONTENT")) + .child(section_header("LOADER")) .child( - BrowserUpdateModeRow { + LoaderRow { cluster_id, - value: profile.browser_update_mode, - global: global.browser_update_mode.unwrap_or_default(), + loader, + selected: cluster.mc_loader_version.clone(), + versions, } .into_element(), ) @@ -131,10 +115,32 @@ impl Component for ClusterSettings { } .into_element(), ) + .child(section_header("SHORTCUT")) + .child(ShortcutRow { cluster_id }.into_element()) + .child(section_header("JAVA")) + .child( + JavaRow { + cluster_id, + value: profile.java_path.clone(), + global: global.java_path.clone(), + runtimes, + } + .into_element(), + ) + .child(text_row(cluster_id, TextField::JvmArgs, &profile, &global)) .child(section_header("PROCESS")) .child(text_row(cluster_id, TextField::Pre, &profile, &global)) .child(text_row(cluster_id, TextField::Wrapper, &profile, &global)) .child(text_row(cluster_id, TextField::Post, &profile, &global)) + .child(section_header("CONTENT")) + .child( + BrowserUpdateModeRow { + cluster_id, + value: profile.browser_update_mode, + global: global.browser_update_mode.unwrap_or_default(), + } + .into_element(), + ) .child(section_header("REPAIR")) .child(VerifyFilesRow { cluster_id }.into_element()), ) @@ -250,6 +256,32 @@ impl Component for ToggleRow { } } +#[derive(PartialEq)] +struct ShortcutRow { + cluster_id: i64, +} + +impl Component for ShortcutRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let dispatch = use_dispatch(); + + let button = Button::new() + .small() + .secondary() + .on_press(move |_| dispatch.create_cluster_shortcut(cluster_id)) + .text("Create Shortcut"); + + settings_row( + IconType::Rocket02, + "Desktop Shortcut", + "Save a shortcut that starts this version straight from your desktop, \ + without opening the launcher first.", + button, + ) + } +} + #[derive(PartialEq)] struct VerifyFilesRow { cluster_id: i64, @@ -271,11 +303,7 @@ 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, diff --git a/packages/oneclient_app/src/view/app/cluster/logs.rs b/packages/oneclient_app/src/view/app/cluster/logs.rs index 0ac84ca0..b21594d7 100644 --- a/packages/oneclient_app/src/view/app/cluster/logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/logs.rs @@ -14,7 +14,7 @@ use crate::components::{ use crate::hooks::{ ClusterLogsQuery, LogAction, UploadLogKeys, UseLogAction, UseUploadLog, invalidate_logs_queries, try_cluster_logs, try_log_content, use_cluster_logs, use_log_action, - use_log_content, use_upload_log, + use_log_content, use_overlay_claim_when, use_upload_log, }; use crate::layout::cluster_content; use crate::theme::colors; @@ -336,7 +336,9 @@ pub fn viewer_header( ) .into_element(), ) - .child(UploadToMclogs::new(has_log, confirm)); + .child( + UploadToMclogs::new(has_log, confirm) + ); rect() .vertical() @@ -375,6 +377,8 @@ impl Component for LogPicker { // Stays usable with an empty list the category selector lives inside the panel let is_open = open(); + use_overlay_claim_when(is_open); + let query = SearchQuery::new(&filter.read()); let no_files = files.is_empty(); let rows: Vec = files @@ -400,6 +404,11 @@ impl Component for LogPicker { rect() .width(Size::px(260.)) + .on_global_key_down(move |e: Event| { + if e.key == Key::Named(NamedKey::Escape) { + open.set_if_modified(false); + } + }) .child( rect() .width(Size::fill()) @@ -423,7 +432,7 @@ impl Component for LogPicker { }, )) .cursor(CursorIcon::Pointer) - .on_all_press(move |e: Event| { + .on_press(move |e: Event| { e.stop_propagation(); open.toggle(); }) @@ -553,7 +562,7 @@ impl Component for PickerRow { .cursor(CursorIcon::Pointer) .on_pointer_enter(move |_| hovering.set(true)) .on_pointer_leave(move |_| hovering.set(false)) - .on_all_press(self.on_press.clone()) + .on_press(self.on_press.clone()) .child( rect() .vertical() diff --git a/packages/oneclient_app/src/view/app/cluster/mods.rs b/packages/oneclient_app/src/view/app/cluster/mods.rs index e5afc692..09ab2da0 100644 --- a/packages/oneclient_app/src/view/app/cluster/mods.rs +++ b/packages/oneclient_app/src/view/app/cluster/mods.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/overview.rs b/packages/oneclient_app/src/view/app/cluster/overview.rs index d2cb0ce3..73fd5153 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; 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 22587ce6..29153440 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 @@ -6,7 +6,7 @@ use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId}; use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; use oneclient_db::models::OverrideType; -use crate::components::{CardLayout, PackageEntry}; +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}; mod views; @@ -14,9 +14,6 @@ use views::{ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, tool const CARD_H: f32 = 84.; const CARD_SPACING: f32 = 8.; -const CARD_GRID_H: f32 = 148.; -const GRID_GAP: f32 = 10.; -const GRID_MIN_W: f32 = 260.; const LAZY_OVERSCAN: i64 = 2; pub type PackageMetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; @@ -55,6 +52,14 @@ fn provider_project_ids( ids } +fn local_project_ids(content: &[LinkedArtifactInfo], content_type: ContentType) -> Vec { + content + .iter() + .filter(|info| info.content_type == content_type && info.provider.is_none()) + .map(|info| info.hash.clone()) + .collect() +} + pub fn use_content_meta( content: &[LinkedArtifactInfo], bundles: &[BundleWithUpdateStatus], @@ -68,6 +73,15 @@ pub fn use_content_meta( out.insert((provider, project_id), meta); } } + + let local = use_package_meta_batch( + ProviderId::Local, + local_project_ids(content, content_type), + ); + for (hash, meta) in package_meta_batch(&local) { + out.insert((ProviderId::Local, hash), meta); + } + out } @@ -243,6 +257,9 @@ 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(), } } @@ -376,7 +393,26 @@ impl Component for PackageManager { let cluster_id = self.cluster_id; let content_type = self.content_type; - // Minecraft reads its content once at startup so a toggle now cannot reach the running session + // Cleared once on mount so the rows already rendered keep their badges for this visit + use_hook(|| { + spawn_forever(async move { + let Ok(state) = crate::launcher::state() else { + return; + }; + match oneclient_content::packages::PackageStore::retire_seen_badges( + &state.services.content(), + ) + .await + { + Ok(cleared) if cleared > 0 => { + tracing::debug!(cleared, "retired package badges after the list was viewed"); + } + Ok(_) => {} + Err(err) => tracing::warn!(%err, "failed to retire package badges"), + } + }); + }); + let session_live = use_game_snapshot().is_active(cluster_id); let active = use_state(|| 0usize); @@ -386,6 +422,7 @@ impl Component for PackageManager { let view = use_view_state("cluster.packages"); let sort = view.sort; let layout = view.layout; + let grid_columns = view.columns; let query = SearchQuery::new(&search.read()); let sort_mode = sort .read() @@ -438,10 +475,11 @@ impl Component for PackageManager { enabled_filter, hidden_filter, layout, + grid_columns, cluster_id, package_type, )) - .maybe_child(session_live.then(|| views::running_notice(noun_plural))) + .maybe_child(session_live.then(|| views::running_notice(noun_plural, content_type))) .child(ContentBox::new( filtered, noun_plural, @@ -450,6 +488,7 @@ impl Component for PackageManager { cluster_id, content_kind, card_layout, + *grid_columns.read(), )) } } 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 302f6c07..b9ff7074 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 @@ -7,9 +7,9 @@ use oneclient_core::settings::ViewLayout; use crate::components::{ Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, ScrollAreaCtx, - Segment, SegmentedControl, TabBar, TabItem, TextInput, + Segment, SegmentedControl, TabBar, TabItem, TextInput, grid_columns_picker, resolved_columns, }; -use crate::hooks::use_dispatch; +use crate::hooks::{use_dispatch, use_overlay_claim}; use crate::routes::Route; use crate::theme::colors; use crate::{Actions, utils}; @@ -53,13 +53,7 @@ impl SortMode { } pub(super) fn sort(self, rows: &mut [PackageEntry]) { - let title = |p: &PackageEntry| { - if p.is_remote() { - p.name.to_lowercase() - } else { - p.file_name.to_lowercase() - } - }; + let title = |p: &PackageEntry| p.name.to_lowercase(); match self { SortMode::NameDesc => rows.sort_by_key(|p| std::cmp::Reverse(title(p))), @@ -140,6 +134,7 @@ pub(super) fn toolbar_bar( enabled_filter: State, hidden_filter: State, layout: State, + grid_columns: State, cluster_id: i64, package_type: &'static str, ) -> impl IntoElement { @@ -193,6 +188,10 @@ pub(super) fn toolbar_bar( enabled_filter, hidden_filter, }) + .maybe_child( + (*layout.read() == ViewLayout::Grid) + .then(|| grid_columns_picker(grid_columns, 34.).into_element()), + ) .child( SegmentedControl::new(layout) .height(34.) @@ -213,8 +212,19 @@ pub(super) fn toolbar_bar( .into_element() } -/// Enabling still stores and applies at the next launch but the running session cannot pick it up -pub(super) fn running_notice(noun_plural: &'static str) -> Element { +pub(super) fn running_notice(noun_plural: &'static str, content_type: ContentType) -> Element { + let text = 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." + ), + ContentType::Shader => format!( + "Minecraft is running. New {noun_plural} usually go in right away, open the shader pack screen in game to turn them on. OneClient tells you when one has to wait for the next launch." + ), + _ => format!( + "Minecraft is running. Changes to your {noun_plural} are saved, and take effect the next time you launch this version." + ), + }; + rect() .horizontal() .width(Size::fill()) @@ -233,9 +243,7 @@ pub(super) fn running_notice(noun_plural: &'static str) -> Element { ) .child( label() - .text(format!( - "Minecraft is running. Changes to your {noun_plural} are saved, and take effect the next time you launch this version." - )) + .text(text) .font_size(12.) .width(Size::flex(1.0)) .color(colors::fg_secondary()), @@ -311,6 +319,8 @@ struct FilterPopover { 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; @@ -523,13 +533,13 @@ fn add_from_file_button( .pick_files() .await { - for handle in handles { - dispatch.import_local_file( - cluster_id, - content_type, - handle.path().to_path_buf(), - ); - } + dispatch.import_local_files( + cluster_id, + handles + .iter() + .map(|handle| (handle.path().to_path_buf(), content_type)) + .collect(), + ); } }); }) @@ -573,6 +583,7 @@ pub(super) struct ContentBox { cluster_id: i64, kind: ContentKind, layout: CardLayout, + grid_columns: u8, } impl ContentBox { @@ -585,6 +596,7 @@ impl ContentBox { cluster_id: i64, kind: ContentKind, layout: CardLayout, + grid_columns: u8, ) -> Self { Self { items, @@ -594,6 +606,7 @@ impl ContentBox { cluster_id, kind, layout, + grid_columns, } } } @@ -607,6 +620,7 @@ impl Component for ContentBox { let noun_plural = self.noun_plural; let kind = &self.kind; let layout = self.layout; + let grid_columns = self.grid_columns; let dispatch = use_dispatch(); @@ -631,7 +645,7 @@ impl Component for ContentBox { .width(Size::fill()) .height(Size::fill()) .content(move |ctx: ScrollAreaCtx| { - grid_content(&items, package_type, cluster_id, ctx).into_element() + grid_content(&items, package_type, cluster_id, grid_columns, ctx).into_element() }) .into_element(), }); @@ -696,11 +710,12 @@ fn grid_content( items: &[PackageEntry], package_type: &'static str, cluster_id: i64, + grid_columns: u8, 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, 3); + let fits = ((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize; + let cols = resolved_columns(grid_columns).min(fits.max(1)); let rows_total = count.div_ceil(cols); let slot = CARD_GRID_H + GRID_GAP; 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 4f8ca2cf..a6325538 100644 --- a/packages/oneclient_app/src/view/app/cluster/process_logs.rs +++ b/packages/oneclient_app/src/view/app/cluster/process_logs.rs @@ -70,11 +70,7 @@ 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 f5a7e849..cb7e8ce2 100644 --- a/packages/oneclient_app/src/view/app/cluster/screenshots.rs +++ b/packages/oneclient_app/src/view/app/cluster/screenshots.rs @@ -6,16 +6,14 @@ use oneclient_core::ScreenshotInfo; use oneclient_core::settings::ViewLayout; use crate::components::{ - Button, ContextMenu, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, - Segment, SegmentedControl, open_folder_button, -}; -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, + Button, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, Segment, + SegmentedControl, cell_width, grid_columns_picker, open_folder_button, resolved_columns, + screenshot_context_menu, }; +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}; +use crate::ui::{border_all_color, flow_grid, fmt_date}; use crate::utils::{format_res, format_size}; use super::cluster_not_found; @@ -25,6 +23,8 @@ const THUMB_EDGE: u32 = 480; const MAX_COL_W: f32 = 400.; const GRID_GAP: f32 = 16.; const TILE_PREVIEW_H: f32 = 168.; +const TILE_PREVIEW_RATIO: f32 = TILE_PREVIEW_H / MAX_COL_W; +const TILE_PREVIEW_MAX_H: f32 = 420.; const CARD_BG: Color = Color::from_rgb(26, 34, 41); const CARD_NAME: Color = Color::from_rgb(213, 219, 255); @@ -48,7 +48,9 @@ impl Component for ClusterScreenshots { let shots = try_cluster_screenshots(&query).unwrap_or_default(); - let view_mode = use_view_state("cluster.screenshots").layout; + let view = use_view_state("cluster.screenshots"); + let view_mode = view.layout; + let grid_columns = view.columns; let menu_dispatch = use_dispatch(); let mut edit_mode = use_state(|| false); @@ -62,13 +64,19 @@ impl Component for ClusterScreenshots { &shots, folder, view_mode, + grid_columns, edit_mode, selected, confirm_delete, ); + let cols = resolved_columns(*grid_columns.read()); + let tile_w = cell_width(*grid_width.read(), cols, GRID_GAP); + let preview_h = (tile_w * TILE_PREVIEW_RATIO).clamp(TILE_PREVIEW_H, TILE_PREVIEW_MAX_H); + 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() { @@ -100,6 +108,7 @@ impl Component for ClusterScreenshots { info, selected: is_selected, edit_mode: editing, + preview_h, on_activate: on_activate.into(), on_context, } @@ -117,10 +126,7 @@ impl Component for ClusterScreenshots { } match *view_mode.read() { - ViewLayout::Grid => { - let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP); - flow_grid(items, cols, grid_width, GRID_GAP) - } + ViewLayout::Grid => flow_grid(items, cols, grid_width, GRID_GAP), ViewLayout::List => rect() .vertical() .width(Size::fill()) @@ -149,32 +155,8 @@ impl Component for ClusterScreenshots { }); let menu_overlay = menu.read().clone().map(|(x, y, path)| { - let copy_dispatch = menu_dispatch.clone(); - let open_path = path.clone(); - let copy_path = path.clone(); - let delete_path = path.clone(); - ContextMenu::new(x, y) + screenshot_context_menu(x, y, path, action, menu_dispatch.clone(), |()| {}) .on_close(move |_| menu.set(None)) - .action(IconType::Folder, "Open in folder", move |()| { - if let Some(dir) = open_path.parent() { - crate::platform::open_url(&dir.to_string_lossy()); - } - }) - .action(IconType::Copy01, "Copy", move |()| { - crate::platform::copy_image_to_clipboard(copy_path.clone()); - copy_dispatch - .notify("Copied to clipboard") - .body("Screenshot copied to your clipboard.") - .info() - .icon(IconType::ClipboardCheck) - .send(); - }) - .separator() - .danger_action(IconType::Trash01, "Delete", move |()| { - action.mutate(ScreenshotAction::Delete { - path: delete_path.clone(), - }); - }) .into_element() }); @@ -202,6 +184,7 @@ fn toolbar_row( shots: &[ScreenshotInfo], folder: Option, view_mode: State, + grid_columns: State, mut edit_mode: State, mut selected: State>, mut confirm_delete: State, @@ -269,6 +252,10 @@ fn toolbar_row( ); } else { right = right + .maybe_child( + (*view_mode.read() == ViewLayout::Grid) + .then(|| grid_columns_picker(grid_columns, 36.).into_element()), + ) .child( SegmentedControl::new(view_mode) .equal_width(40.) @@ -418,6 +405,7 @@ struct ScreenshotTile { info: ScreenshotInfo, selected: bool, edit_mode: bool, + preview_h: f32, on_activate: EventHandler<()>, on_context: EventHandler<(f32, f32)>, } @@ -442,7 +430,6 @@ impl Component for ScreenshotTile { rect() .vertical() .width(Size::flex(1.0)) - .max_width(Size::px(MAX_COL_W)) .corner_radius(CornerRadius::new_all(10.)) .background(CARD_BG) .overflow(Overflow::Clip) @@ -458,7 +445,7 @@ impl Component for ScreenshotTile { }) .child(preview_box( info.path.clone(), - TILE_PREVIEW_H, + self.preview_h, info.resolution, )) .child( diff --git a/packages/oneclient_app/src/view/app/cluster/shaders.rs b/packages/oneclient_app/src/view/app/cluster/shaders.rs index cea5ad8c..ab75017e 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 bfdf038a..ccf90c38 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/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs index 5a3c5807..14b74fa0 100644 --- a/packages/oneclient_app/src/view/app/clusters/page.rs +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -1,16 +1,13 @@ use freya::prelude::*; use freya::router::RouterContext; use oneclient_common::VersionKey; -use oneclient_common::domain::GameLoader; use oneclient_core::clusters::Cluster; +use oneclient_common::domain::GameLoader; use crate::components::{ Button, ClusterLandscapeArt, Dropdown, Icon, IconType, ScrollArea, VersionCard, }; -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::ui::border_all_color; @@ -239,12 +236,7 @@ impl Component for DetailSidebar { .border(border_all_color(1., colors::component_border())) .overflow(Overflow::Clip) .child(rect().width(Size::fill()).max_height(Size::px(140.)).child( - ClusterLandscapeArt::for_version( - self.line.major, - version_value, - loader_value, - false, - ), + ClusterLandscapeArt::for_version(self.line.major, version_value, loader_value, false), )) .child( rect() @@ -274,12 +266,7 @@ impl Component for DetailSidebar { .font_size(12.) .color(colors::fg_secondary()), ) - .children(version_rows( - self.line, - &keys, - version_value, - self.selected_version, - )) + .children(version_rows(self.line, &keys, version_value, self.selected_version)) .children(loader_rows(&loaders, loader_value, self.selected_loader)), ) .child( diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index 5c001b65..552b462a 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -1,6 +1,7 @@ use freya::prelude::*; use freya::router::RouterContext; use oneclient_db::console::{ConsoleQueryResult, run_console_query}; +use oneclient_db::models::OptionalModStatus; use oneclient_auth::preview_samples; use oneclient_core::simulate::Damage; @@ -11,6 +12,7 @@ use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialo use crate::hooks::use_dispatch; use crate::notifications::{ ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind, + OptionalModsGroup, }; use crate::routes::Route; use crate::theme::colors; @@ -23,7 +25,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); @@ -99,15 +101,12 @@ 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), + ])], )), ) } @@ -214,6 +213,7 @@ impl Component for ClusterUpdateSimulator { let updated = use_state(|| "Sodium 0.5 → 0.6, Iris 1.7 → 1.8".to_string()); let added = use_state(|| "Lithium".to_string()); let removed = use_state(|| "OptiFine".to_string()); + let optional = use_state(|| "Lithium".to_string()); let simulate = dispatch.clone(); @@ -223,7 +223,7 @@ impl Component for ClusterUpdateSimulator { .spacing(10.) .child( label() - .text("Builds the same notification the bundle sync sends: one \"View changes\" action carrying every changed cluster, opened into the changes modal.") + .text("Builds the same notification the bundle sync sends: one \"View changes\" action carrying every changed cluster, opened into the changes modal. Anything listed under Optional also raises the \"Optional mods available\" prompt, exactly as the real sync does. Optional mods are not installed, so they are independent of Added rather than a subset of it.") .font_size(13.) .color(colors::fg_secondary()), ) @@ -262,6 +262,11 @@ impl Component for ClusterUpdateSimulator { rect().width(Size::flex(1.0)).child( TextInput::new(removed).placeholder("Removed (comma separated)"), ), + ) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(optional).placeholder("Optional (comma separated)"), + ), ), ) .child( @@ -276,6 +281,7 @@ impl Component for ClusterUpdateSimulator { updated: cluster_update_items(&split_csv(&updated.read())), added: cluster_update_items(&split_csv(&added.read())), removed: cluster_update_items(&split_csv(&removed.read())), + optional: cluster_update_items(&split_csv(&optional.read())) }; send_cluster_update(&simulate, vec![summary]); }), @@ -294,7 +300,7 @@ impl Component for ClusterUpdateSimulator { type ClusterUpdatePreset = fn() -> Vec; /// Each preset pins one variation plural copy empty category tab single- vs multi-cluster footer grouping header overflow -const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [ +const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 7] = [ ( "1 cluster · all categories", IconType::DownloadCloud02, @@ -305,6 +311,7 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [ &["Sodium 0.5 → 0.6", "Iris 1.7 → 1.8", "Patcher 1.8 → 1.9"], &["Lithium", "FerriteCore"], &["OptiFine"], + &["Distant Horizons"], )] }, ), @@ -320,10 +327,11 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [ ], &[], &[], + &[], )] }), ("1 cluster · single change", IconType::Plus, || { - vec![preset_summary(1, "PolyBlock", &[], &["Lithium"], &[])] + vec![preset_summary(1, "PolyBlock", &[], &["Lithium"], &[], &[])] }), ("3 clusters · mixed", IconType::DotsGrid, || { vec![ @@ -333,33 +341,57 @@ const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [ &["Sodium 0.5 → 0.6", "Iris 1.7 → 1.8"], &["Lithium"], &[], + &[], ), - preset_summary(2, "Skyblock", &[], &[], &["OptiFine", "Skytils"]), - preset_summary(3, "Vanilla+", &[], &["Sodium", "Iris", "FerriteCore"], &[]), + preset_summary(2, "Skyblock", &[], &[], &["OptiFine", "Skytils"], &[]), + 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(1, "PolyBlock", &[], &[], &["OptiFine"], &[]), + 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(), + .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"], + ), + ] + }), ]; fn cluster_update_preset_rows(dispatch: &crate::Actions) -> Vec { @@ -391,6 +423,7 @@ fn preset_summary( updated: &[&str], added: &[&str], removed: &[&str], + optional: &[&str], ) -> ClusterUpdateSummary { ClusterUpdateSummary { cluster_id, @@ -398,17 +431,26 @@ fn preset_summary( updated: cluster_update_items(updated), added: cluster_update_items(added), removed: cluster_update_items(removed), + optional: cluster_update_items(optional) } } fn cluster_update_items(names: &[impl AsRef]) -> Vec { names .iter() - .map(|name| ClusterUpdateItem::from_name(name.as_ref())) + .enumerate() + .map(|(index, name)| ClusterUpdateItem { + offer: Some(("simulated-bundle".to_string(), name.as_ref().to_string())), + status: Some(if index % 2 == 0 { + OptionalModStatus::New + } else { + OptionalModStatus::Skipped + }), + ..ClusterUpdateItem::from_name(name.as_ref()) + }) .collect() } -/// Mirrors the copy the bridge builds so the simulated notification matches the real one fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec) { if summaries.is_empty() { return; @@ -430,6 +472,11 @@ fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec = summaries + .iter() + .filter_map(OptionalModsGroup::from_summary) + .collect(); + dispatch .notify(title) .body(body) @@ -439,6 +486,9 @@ fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec { - 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 @@ -1164,8 +1212,9 @@ fn action_row(dispatch: &Actions, buttons: Vec<(&'static str, IconType)>) -> Ele .text(text); if text == "Open Onboarding" { - dispatch.reset_onboarding(); - button = button.on_press(|_| { + 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/settings/changelog.rs b/packages/oneclient_app/src/view/app/settings/changelog.rs index 110d71d5..3c78dfa3 100644 --- a/packages/oneclient_app/src/view/app/settings/changelog.rs +++ b/packages/oneclient_app/src/view/app/settings/changelog.rs @@ -137,11 +137,7 @@ 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() diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs index bf191f72..3bf11880 100644 --- a/packages/oneclient_app/src/view/app/settings/java.rs +++ b/packages/oneclient_app/src/view/app/settings/java.rs @@ -1,8 +1,10 @@ +use std::path::Path; + use freya::prelude::*; -use oneclient_java::{JavaRuntime, JavaVendor}; +use oneclient_java::{JavaRuntime, JavaVendor, is_launcher_managed}; use super::settings_page; -use crate::components::{Button, Icon, IconType, JavaInstallManager, ScrollArea}; +use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup, ScrollArea}; use crate::hooks::{Actions, java_runtimes, use_dispatch, use_java_runtimes}; use crate::invalidate_java_queries; use crate::theme::colors; @@ -15,10 +17,13 @@ pub struct SettingsJava; impl Component for SettingsJava { fn render(&self) -> impl IntoElement { let dispatch = use_dispatch(); + let removing_dispatch = dispatch.clone(); let runtimes_query = use_java_runtimes(); let runtimes = java_runtimes(&runtimes_query); let mut show_manager = use_state(|| false); + let pending_remove = use_state(|| None::); + #[allow(dead_code)] fn invalidate_runtimes(dispatch: Actions) { spawn(async move { invalidate_java_queries().await; @@ -30,30 +35,13 @@ impl Component for SettingsJava { }); } - let refresh_dispatch = dispatch.clone(); + let _refresh_dispatch = dispatch.clone(); let mut shell = settings_page() .child(section_header("ADD RUNTIME")) .child(AddRow { show_manager }.into_element()) - .child( - rect() - .width(Size::Fill) - .direction(Direction::Horizontal) - .main_align(Alignment::SpaceBetween) - .cross_align(Alignment::Center) - .child(section_header("INSTALLED RUNTIMES")) - .child( - Button::new() - .secondary() - .small() - .enabled(false) // disabled for now - .on_press(move |_| { - invalidate_runtimes(refresh_dispatch.clone()); - }) - .child(label().text("Refresh")), - ), - ) - .child(runtimes_table(runtimes)); + .child(section_header("INSTALLED RUNTIMES")) + .child(runtimes_table(runtimes, pending_remove)); if *show_manager.read() { shell = shell.child( @@ -67,6 +55,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.into_element() } } @@ -115,7 +108,10 @@ impl Component for AddRow { } } -fn runtimes_table(runtimes: Vec) -> impl IntoElement { +fn runtimes_table( + runtimes: Vec, + pending_remove: State>, +) -> impl IntoElement { if runtimes.is_empty() { return rect() .width(Size::fill()) @@ -146,6 +142,7 @@ fn runtimes_table(runtimes: Vec) -> impl IntoElement { RuntimeRow { runtime, last: idx + 1 == count, + pending_remove } .into_element(), ); @@ -195,11 +192,11 @@ fn table_header() -> impl IntoElement { struct RuntimeRow { runtime: JavaRuntime, last: bool, + pending_remove: State> } impl Component for RuntimeRow { fn render(&self) -> impl IntoElement { - let dispatch = use_dispatch(); let runtime = &self.runtime; let path = runtime.absolute_path.clone(); @@ -271,7 +268,7 @@ impl Component for RuntimeRow { ), ), ) - .child(remove_button(dispatch, path)) + .child(remove_button(self.pending_remove, path)) } } @@ -279,11 +276,108 @@ fn path_content_width(path: &str) -> f32 { (path.chars().count() as f32 * 7.0).max(1.0) } -fn remove_button(dispatch: Actions, path: String) -> impl IntoElement { +#[derive(Clone, PartialEq)] +struct PendingRemove { + path: String, + /// Whether the files live in OneClient's own java dir, which is the only + /// case where removal takes them off disk + managed: bool, +} + +fn confirm_remove_modal( + dispatch: Actions, + mut pending: State>, + target: PendingRemove, +) -> impl IntoElement { + let PendingRemove { path, managed } = target; + let remove_path = path.clone(); + + let consequence = if managed { + "This runtime is managed by OneClient. It's files WILL be deleted." + } else { + "This runtime is not managed by OneClient. It's files will not be deleted." + }; + + let confirm = if managed { + Button::new().danger().text("Remove and delete files") + } else { + Button::new().primary().text("Remove from list") + }; + + 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(colors::page_elevated()) + .child( + label() + .text("Remove Java Runtime?") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(consequence) + .font_size(12.) + .max_lines(4) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(path) + .font_size(12.) + .max_lines(3) + .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 |_| pending.set(None)) + .text("Cancel"), + ) + .child(confirm.on_press(move |_| { + dispatch.remove_java_runtime(remove_path.clone()); + pending.set(None); + })), + ), + ) + ) + .into_element() +} + +fn remove_button( + mut pending_remove: State>, + path: String, +) -> impl IntoElement { Button::new() .ghost() .small() - .on_press(move |_| dispatch.remove_java_runtime(path.clone())) + .on_press(move |_| { + pending_remove.set(Some(PendingRemove { + managed: is_launcher_managed(Path::new(&path)), + path: path.clone(), + })); + }) .child( Icon::new(IconType::Trash01) .size(14.) diff --git a/packages/oneclient_app/src/view/app/settings/launcher.rs b/packages/oneclient_app/src/view/app/settings/launcher.rs index c5420fb7..55580965 100644 --- a/packages/oneclient_app/src/view/app/settings/launcher.rs +++ b/packages/oneclient_app/src/view/app/settings/launcher.rs @@ -1,10 +1,20 @@ -use freya::prelude::*; +use std::path::{Path, PathBuf}; -use super::settings_page; -use crate::components::{IconType, link_button, toggle}; -use crate::hooks::{use_dispatch, use_launcher, use_settings_snapshot}; -use crate::platform; -use crate::view::app::settings::{section_header, settings_row}; +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::relocate::RelocationPlan; +use oneclient_core::settings::data_dir; +use oneclient_core::storage::format_bytes; + +use super::{section_header, settings_page, settings_row}; +use crate::Route; +use crate::components::{Button, 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, +}; +use crate::theme::colors; +use crate::ui::{border_all_color, note, path_block}; #[derive(PartialEq)] pub struct SettingsLauncher; @@ -12,7 +22,6 @@ pub struct SettingsLauncher; impl Component for SettingsLauncher { fn render(&self) -> impl IntoElement { let settings = use_settings_snapshot().settings; - let data_dir = use_launcher().data_dir; let dispatch = use_dispatch(); let discord_rpc = use_state({ @@ -25,12 +34,18 @@ impl Component for SettingsLauncher { move || v }); + let start_maximized = use_state({ + let v = settings.start_maximized; + 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; @@ -38,12 +53,24 @@ impl Component for SettingsLauncher { let mut next = settings.clone(); next.discord_enabled = discord; next.crash_reporting = crash; + next.start_maximized = maximized; dispatch.set_settings(next); }); } - let folder = data_dir.clone(); - let open_folder = link_button().on_press(move |_| platform::open_url(&folder)); + // The only way back for someone who declined during onboarding + let consent_summary = if settings.declined_tos { + "Declined. Poly+ and crash reporting stay off until you accept and restart OneClient." + } else { + "Accepted. Review them again at any time." + }; + let review_terms = Button::new() + .secondary() + .small() + .on_press(|_| { + let _ = RouterContext::get().replace(Route::OnboardingTerms {}); + }) + .text("Review"); settings_page() .child(section_header("GENERAL")) @@ -59,13 +86,289 @@ impl Component for SettingsLauncher { "Send anonymous crash and error reports to help fix bugs. Applies on restart.", toggle(crash_reporting), )) - .child(section_header("FOLDERS AND FILES")) .child(settings_row( - IconType::Folder, - "Launcher Folder", - data_dir, - open_folder, + IconType::Maximize01, + "Start Maximized", + "Open the launcher window maximized. Applies on restart.", + toggle(start_maximized), )) + .child(settings_row( + IconType::File02, + "Terms & Privacy", + consent_summary, + review_terms, + )) + .child(section_header("FOLDERS AND FILES")) + .child(DataFolder.into_element()) .into_element() } } + +#[derive(PartialEq)] +struct DataFolder; + +impl Component for DataFolder { + fn render(&self) -> impl IntoElement { + let discard = use_discard_leftovers(); + let leftovers_query = use_leftovers(); + let data_dir = use_launcher().data_dir; + let actions = use_dispatch(); + + let pending = use_state(|| None::); + let error = use_state(|| None::); + let checking = use_state(|| false); + + let checking_now = *checking.read(); + + let change = Button::new() + .secondary() + .small() + .disabled(checking_now) + .on_press(move |_| browse(pending, error, checking)) + .text(if checking_now { "Checking…" } else { "Change…" }); + + let mut buttons = Vec::new(); + + if !data_dir::is_default(Path::new(&data_dir)) { + buttons.push( + Button::new() + .secondary() + .small() + .disabled(checking_now) + .on_press(move |_| plan(pending, error, checking, Source::Default)) + .text("Reset"), + ); + } + + buttons.push(change); + + let mut section = rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .child(settings_row( + IconType::Folder, + "Launcher Folder", + data_dir.clone(), + row_actions(PathBuf::from(data_dir), buttons), + )); + + if let Some(message) = error.read().clone() { + section = section.child(note(message, colors::danger())); + } + + if let Some(left) = try_leftovers(&leftovers_query) { + let clearing = mutation_is_running(&discard); + + let remove = Button::new() + .danger() + .small() + .disabled(clearing) + .on_press(move |_| { + discard.mutate(DiscardLeftoversKeys); + }) + .text(if clearing { "Removing…" } else { "Remove" }); + + section = section.child(settings_row( + IconType::Database01, + "Old location", + format!( + "{} still sitting in {}. Nothing uses it any more.", + format_bytes(left.bytes), + left.path.display() + ), + row_actions(left.path, vec![remove]), + )); + } + + if let Some(message) = mutation_error(&discard) { + section = section.child(note(message, colors::danger())); + } + + if let Some(planned) = pending.read().clone() { + section = section.child(confirm_move(planned, pending, actions)); + } + + section.into_element() + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Source { + Picked, + Default, +} + +fn browse( + pending: State>, + error: State>, + checking: State, +) { + plan(pending, error, checking, Source::Picked); +} + +fn plan( + mut pending: State>, + mut error: State>, + mut checking: State, + source: Source, +) { + if *checking.peek() { + return; + } + + spawn(async move { + let picked = match source { + Source::Picked => { + let mut dialog = rfd::AsyncFileDialog::new() + .set_title("Choose where OneClient should store game data"); + + if let Some(start) = oneclient_common::paths::picker_start_dir() { + dialog = dialog.set_directory(start); + } + + let Some(handle) = dialog.pick_folder().await else { + return; + }; + + Some(handle.path().to_path_buf()) + } + Source::Default => None, + }; + + checking.set(true); + error.set(None); + + match plan_move(picked.as_deref()).await { + Ok(planned) => pending.set(Some(planned)), + Err(message) => error.set(Some(message)), + } + + checking.set(false); + }); +} + +async fn plan_move(picked: Option<&Path>) -> Result { + let state = crate::launcher::state().map_err(|err| err.to_string())?; + + match picked { + Some(picked) => oneclient_core::relocate::plan(&state, picked).await, + None => oneclient_core::relocate::plan_default(&state).await, + } +} + +fn row_actions(folder: PathBuf, actions: Vec