feat: update base to official upstream 2.3.1 with all cracked features intact (2.3.1-c1)
Some checks are pending
Release Multiplatform Binaries / build (deb,appimage, linux-x86_64, ubuntu-22.04, x86_64-unknown-linux-gnu) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-arm64, macos-14, aarch64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (dmg,app, macos-x86_64, macos-14, x86_64-apple-darwin) (push) Waiting to run
Release Multiplatform Binaries / build (nsis, windows-x86_64, windows-latest, x86_64-pc-windows-msvc) (push) Waiting to run
Release Multiplatform Binaries / create-release (push) Blocked by required conditions

- Rebased onto official Polyfrost OneLauncher v2.3.1 (commit 0e49c691)
- Preserved 100% offline authentication without requiring Microsoft accounts
- Integrated custom 3D offline skin changer with UUID character slot targeting
- Maintained CustomSkinLoader auto-configuration for Ely.by and SkinRestorer
- Maintained direct GitHub Releases auto-updater pointing to oiupoyt/OneLauncher-Cracked
- Maintained mods filter popover fix eliminating text ghosting/duplication
- Configured dev profile to prevent debug symbol bloat
This commit is contained in:
katR 2026-09-07 17:38:22 +05:30
parent c063430e26
commit 163eb0ac0c
421 changed files with 24611 additions and 16499 deletions

7
.cargo/config.toml Normal file
View file

@ -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"]

20
.editorconfig Normal file
View file

@ -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

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
* text=auto eol=lf
Cargo.lock -diff linguist-generated

0
.github/CODEOWNERS vendored Normal file
View file

59
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -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

11
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -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

48
.github/ISSUE_TEMPLATE/enhancement.yml vendored Normal file
View file

@ -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

24
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,24 @@
## Description
<!-- Describe your changes in detail along with its rationale -->
## Related Issue(s)
<!-- List the issue(s) this PR solves, if any -->
Fixes #
## How to test
<!-- Provide steps to test this PR -->
## Documentation
<!--
Does this PR require updates to the documentation at docs.polyfrost.org?
* Yes
* 1. Please create a docs issue: https://github.com/Polyfrost/Nexus/issues/new?assignees=&labels=bug&template=new_feature.yml
* 2. Make sure this api is cross compatible with the existing api (or at least documented as such, see our policy on cross compatibility at https://contributing.polyfrost.org/)
* 2. Paste the link to the docs issue below this comment
* No
* Are you sure? If so, nothing to do here.
-->

View file

@ -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

43
.github/actions/setup-rust/action.yml vendored Normal file
View file

@ -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

BIN
.github/media/RepoBanner.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

97
.github/workflows/aur_publish.yml vendored Normal file
View file

@ -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"

281
.github/workflows/oneclient_nightly.yml vendored Normal file
View file

@ -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

640
.github/workflows/oneclient_release.yml vendored Normal file
View file

@ -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 <glob> | 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_<ver>_amd64.deb).
# Rename it to the same OneClient_<ver>_<os>_<arch> 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-<ver>-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"

View file

@ -7,9 +7,9 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
description: 'Release Tag (e.g. v2.2.3)' description: 'Release Tag (e.g. v2.3.1)'
required: false required: false
default: 'v2.2.3-c2' default: 'v2.3.1-c1'
type: string type: string
permissions: permissions:
@ -138,7 +138,7 @@ jobs:
elif [ -n "${{ inputs.tag }}" ]; then elif [ -n "${{ inputs.tag }}" ]; then
echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT
else else
echo "tag=v2.2.3-c2" >> $GITHUB_OUTPUT echo "tag=v2.3.1-c1" >> $GITHUB_OUTPUT
fi fi
- name: Clean up previous release if exists - name: Clean up previous release if exists
@ -155,7 +155,10 @@ jobs:
name: "OneLauncher-Cracked ${{ steps.release_tag.outputs.tag }}" name: "OneLauncher-Cracked ${{ steps.release_tag.outputs.tag }}"
body: | body: |
### What's New in ${{ steps.release_tag.outputs.tag }} ### 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 draft: false
prerelease: false prerelease: false
generate_release_notes: false generate_release_notes: false

8
.gitignore vendored
View file

@ -1,6 +1,6 @@
/target /target
/dist
*.swp
*~
.DS_Store .DS_Store
Thumbs.db /.idea
OneLauncher.iml
/packages/oneclient_app/icons/generated/

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="oneclient_app.exe">
<authenticode-sign />
</pe-file>
</zip-file>
</artifact-configuration>

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SignPath artifact configuration for the OneClient Windows installer.
GitHub Actions wraps every uploaded artifact in a ZIP archive, so the root
element MUST be <zip-file> (see https://docs.signpath.io/trusted-build-systems/github).
The NSIS bundle produced by Tauri is named e.g. "OneClient_1.1.6_x64-setup.exe",
so we match it with a wildcard instead of a fixed, version-specific name.
Upload this file in the SignPath portal:
Project "onelauncher" -> Artifact Configurations -> slug "onelauncher-installer"
-->
<artifact-configuration xmlns="http://signpath.io/artifact-configuration/v1">
<zip-file>
<pe-file path="*-setup.exe" min-matches="1" max-matches="unbounded">
<!-- optionally, add metadata constraints: product-name, product-version, file-version, company-name, copyright, original-filename -->
<authenticode-sign />
</pe-file>
</zip-file>
</artifact-configuration>

View file

@ -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"
}

View file

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -46,6 +46,17 @@
"name": "enabled" "name": "enabled"
} }
} }
},
{
"name": "seen_status",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "seen_status"
}
}
} }
], ],
"parameters": { "parameters": {
@ -55,8 +66,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af" "hash": "071d02aba0d2eb073b8ca1e6488087101255147c064612fad99500d3c993f61f"
} }

View file

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -68,6 +68,17 @@
"name": "is_jdk" "name": "is_jdk"
} }
} }
},
{
"name": "probe_version",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "java_versions",
"name": "probe_version"
}
}
} }
], ],
"parameters": { "parameters": {
@ -79,8 +90,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba" "hash": "0c3651cf175cb900c2466f0e97beca338b95637d43d9d067b974327f7720637b"
} }

View file

@ -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"
}

View file

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -68,6 +68,17 @@
"name": "is_jdk" "name": "is_jdk"
} }
} }
},
{
"name": "probe_version",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "java_versions",
"name": "probe_version"
}
}
} }
], ],
"parameters": { "parameters": {
@ -79,8 +90,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b" "hash": "14ab205c5b8d4c1c435814e4b7a155acbae41a67925b6b179b82dea1758d621a"
} }

View file

@ -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"
}

View file

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -46,6 +46,17 @@
"name": "enabled" "name": "enabled"
} }
} }
},
{
"name": "seen_status",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "seen_status"
}
}
} }
], ],
"parameters": { "parameters": {
@ -55,8 +66,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51" "hash": "25259e9d6eda7f5113e0d88f00b24b28b98de1eda40a1e25d48bbcf212af42b5"
} }

View file

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -46,6 +46,17 @@
"name": "enabled" "name": "enabled"
} }
} }
},
{
"name": "seen_status",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "seen_status"
}
}
} }
], ],
"parameters": { "parameters": {
@ -55,8 +66,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca" "hash": "25f4eb2e9040bb8f1c9a38ca41a773225099f150c3fc8a41d31fd40c09711d0c"
} }

View file

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -68,6 +68,17 @@
"name": "is_jdk" "name": "is_jdk"
} }
} }
},
{
"name": "probe_version",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "java_versions",
"name": "probe_version"
}
}
} }
], ],
"parameters": { "parameters": {
@ -79,8 +90,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2" "hash": "331bee3da454f39a8a2cc8ed4e5338b743a7cd033fdef023c4b3b93827248be1"
} }

View file

@ -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"
}

View file

@ -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"
}

View file

@ -1,93 +1,82 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
"name": "cluster_id", "name": "id",
"ordinal": 0, "ordinal": 0,
"type_info": "Integer", "type_info": "Integer",
"origin": { "origin": {
"Table": { "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": "cluster_id"
} }
} }
}, },
{ {
"name": "hash", "name": "bundle_name",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "hash"
}
}
},
{
"name": "cluster_file_name",
"ordinal": 2, "ordinal": 2,
"type_info": "Text", "type_info": "Text",
"origin": { "origin": {
"Table": { "Table": {
"table": "cluster_artifacts", "table": "cluster_optional_mods",
"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",
"name": "bundle_name" "name": "bundle_name"
} }
} }
}, },
{
"name": "bundle_version_id",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "bundle_version_id"
}
}
},
{ {
"name": "package_id", "name": "package_id",
"ordinal": 6, "ordinal": 3,
"type_info": "Text", "type_info": "Text",
"origin": { "origin": {
"Table": { "Table": {
"table": "cluster_artifacts", "table": "cluster_optional_mods",
"name": "package_id" "name": "package_id"
} }
} }
}, },
{ {
"name": "installed_at", "name": "bundle_version_id",
"ordinal": 7, "ordinal": 4,
"type_info": "Text", "type_info": "Text",
"origin": { "origin": {
"Table": { "Table": {
"table": "cluster_artifacts", "table": "cluster_optional_mods",
"name": "installed_at" "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, false,
false, false,
true, false,
true, false,
true, false
true
] ]
}, },
"hash": "0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813" "hash": "5b3dd7940a9bd6f0067fac7b5f21e83f8d9eeceda6b1c53c5ce5b735a8fe2a71"
} }

View file

@ -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"
}

View file

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -46,6 +46,17 @@
"name": "enabled" "name": "enabled"
} }
} }
},
{
"name": "seen_status",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "seen_status"
}
}
} }
], ],
"parameters": { "parameters": {
@ -55,8 +66,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f" "hash": "b68194fc40d9fdd508ab6cc354fff662ba4b7ca17c1c4be3e51e30fcb3d0cb16"
} }

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -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"
}

View file

@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "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": { "describe": {
"columns": [ "columns": [
{ {
@ -68,10 +68,21 @@
"name": "is_jdk" "name": "is_jdk"
} }
} }
},
{
"name": "probe_version",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "java_versions",
"name": "probe_version"
}
}
} }
], ],
"parameters": { "parameters": {
"Right": 6 "Right": 7
}, },
"nullable": [ "nullable": [
false, false,
@ -79,8 +90,9 @@
false, false,
false, false,
false, false,
false,
false false
] ]
}, },
"hash": "010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc" "hash": "f5dbcf7798afb495efe228a4ba7d1ddd9904cc25f34bef3c16f29cfb141158f0"
} }

View file

@ -32,7 +32,7 @@ To find an issue that interests you, you can browse through our [existing issues
#### Making Changes Locally #### Making Changes Locally
This project is a pure Rust [`Cargo`] workspace (edition 2024). Make sure you have a 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]. is via [rustup].
To make changes locally, follow these steps: To make changes locally, follow these steps:

317
Cargo.lock generated
View file

@ -1133,6 +1133,42 @@ dependencies = [
"wayland-client", "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]] [[package]]
name = "cc" name = "cc"
version = "1.4.0" version = "1.4.0"
@ -1501,6 +1537,16 @@ dependencies = [
"hybrid-array", "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]] [[package]]
name = "ctor" name = "ctor"
version = "0.10.1" version = "0.10.1"
@ -1610,7 +1656,28 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
dependencies = [ 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]] [[package]]
@ -1621,7 +1688,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users", "redox_users 0.5.2",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -2027,8 +2094,7 @@ dependencies = [
[[package]] [[package]]
name = "freya" name = "freya"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "7a0902ffd592df176d3088ad001788f9a9438c21c40b4cbdec4dc3ef8bd16247"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"freya-android", "freya-android",
@ -2051,8 +2117,7 @@ dependencies = [
[[package]] [[package]]
name = "freya-android" name = "freya-android"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "fe3befea9d845539f4428f232ec6b1661a54e974a6e7b852f82fef90d994ccba"
dependencies = [ dependencies = [
"freya-components", "freya-components",
"freya-core", "freya-core",
@ -2065,10 +2130,8 @@ dependencies = [
[[package]] [[package]]
name = "freya-animation" name = "freya-animation"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "d9f9429ff1e0a793068ebdd4be84c03e722fdfb3d20c8bf3a63a7a875ba0a6de"
dependencies = [ dependencies = [
"async-io",
"easer", "easer",
"freya-core", "freya-core",
] ]
@ -2076,8 +2139,7 @@ dependencies = [
[[package]] [[package]]
name = "freya-clipboard" name = "freya-clipboard"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "c8b2076b0a2d040ef2153cdd624dba28a6f4fabd64ca6182b128f642e2423b32"
dependencies = [ dependencies = [
"copypasta", "copypasta",
"freya-core", "freya-core",
@ -2086,14 +2148,11 @@ dependencies = [
[[package]] [[package]]
name = "freya-components" name = "freya-components"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "bd20c885e7e29f43e0279affea98ae3e1392c9d9f07a09b0ce0fc65d46fc09dc"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"anyhow", "anyhow",
"async-io",
"async-lock", "async-lock",
"blocking",
"bytes 1.11.1", "bytes 1.11.1",
"cfg-if", "cfg-if",
"euclid", "euclid",
@ -2103,22 +2162,24 @@ dependencies = [
"freya-engine", "freya-engine",
"freya-router", "freya-router",
"freya-sdk", "freya-sdk",
"open", "futures-channel",
"paste", "paste",
"reqwest 0.12.28", "reqwest 0.12.28",
"thiserror 2.0.18", "thiserror 2.0.18",
"torin", "torin",
"tracing", "tracing",
"url",
] ]
[[package]] [[package]]
name = "freya-core" name = "freya-core"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "4ee644e4c5b4686b4cf59463347df7b64d9eb057c4a251e4528e90a1ecbf707d"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"async-io",
"bitflags 2.13.1", "bitflags 2.13.1",
"blocking",
"bytes 1.11.1", "bytes 1.11.1",
"cursor-icon", "cursor-icon",
"freya-engine", "freya-engine",
@ -2143,8 +2204,7 @@ dependencies = [
[[package]] [[package]]
name = "freya-edit" name = "freya-edit"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "b0e20601f2d927591cace6bdaf731397908105a572eeaa934b387471bc6cd3b1"
dependencies = [ dependencies = [
"bitflags 2.13.1", "bitflags 2.13.1",
"freya-clipboard", "freya-clipboard",
@ -2158,8 +2218,7 @@ dependencies = [
[[package]] [[package]]
name = "freya-engine" name = "freya-engine"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "95c5ba0dae520d78222758cffdcb335ba9cac6f846721b544fe216d894866b8c"
dependencies = [ dependencies = [
"freya-skia-safe", "freya-skia-safe",
] ]
@ -2167,8 +2226,7 @@ dependencies = [
[[package]] [[package]]
name = "freya-markdown" name = "freya-markdown"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "94eb6ca3e8f9f18654ff14589c94155d7fa60c697dcbbbf0f02cb0a9e72e538c"
dependencies = [ dependencies = [
"freya-components", "freya-components",
"freya-core", "freya-core",
@ -2179,8 +2237,7 @@ dependencies = [
[[package]] [[package]]
name = "freya-performance-plugin" name = "freya-performance-plugin"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "e2c69c000dd001e2df79a8b9ad0977cc0f835c494d2e17a2848b80495c293fce"
dependencies = [ dependencies = [
"freya-core", "freya-core",
"freya-engine", "freya-engine",
@ -2190,10 +2247,8 @@ dependencies = [
[[package]] [[package]]
name = "freya-query" name = "freya-query"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "9db86c4a0dea3ce04df0defa4ce62dad8e7c32bad05090e3644ff6d2f2e0655e"
dependencies = [ dependencies = [
"async-io",
"freya-core", "freya-core",
"futures-util", "futures-util",
] ]
@ -2201,8 +2256,7 @@ dependencies = [
[[package]] [[package]]
name = "freya-radio" name = "freya-radio"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "35a2458e07d312a2365951a216a8fa14a604159f8ae7702a7ccb2ffbc3fccf0a"
dependencies = [ dependencies = [
"freya-core", "freya-core",
] ]
@ -2210,8 +2264,7 @@ dependencies = [
[[package]] [[package]]
name = "freya-router" name = "freya-router"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "f26a8c7b3b3c6432de8d90c01821f5206ab6d6c2c8417fe7b873d7a6586e7580"
dependencies = [ dependencies = [
"freya-core", "freya-core",
"freya-router-macro", "freya-router-macro",
@ -2223,8 +2276,7 @@ dependencies = [
[[package]] [[package]]
name = "freya-router-macro" name = "freya-router-macro"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "105a54bccebe377d4ce12066813737a0f86e1cc587cdc98c4df92fe48c800be4"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -2235,18 +2287,16 @@ dependencies = [
[[package]] [[package]]
name = "freya-sdk" name = "freya-sdk"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "0fe2c1e80db9c6c782536bf1e8f8e4859ed43359e7c0775c619be8826e16dd18"
dependencies = [ dependencies = [
"async-io",
"freya-core", "freya-core",
] ]
[[package]] [[package]]
name = "freya-skia-bindings" name = "freya-skia-bindings"
version = "0.99.1" version = "0.100.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e65f9ff56a7d08451c839386a86842f065552c561041d0cdcc213390a2361a2" checksum = "2a59be930a7bfe544a7f16fcc6d7326dfabff3ffe65c3321932f4e61234f4941"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cc", "cc",
@ -2261,9 +2311,9 @@ dependencies = [
[[package]] [[package]]
name = "freya-skia-safe" name = "freya-skia-safe"
version = "0.99.1" version = "0.100.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567c808eff01a4ad7d9d3dc42ce0fb4335dce76a2398d0cd18f1357bf2450c81" checksum = "7975576c62caa0b8bccb6f4cfd16c61f57b27fffa15540f37deba9bd246bf009"
dependencies = [ dependencies = [
"base64 0.23.1", "base64 0.23.1",
"bitflags 2.13.1", "bitflags 2.13.1",
@ -2283,11 +2333,25 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "freya-winit" name = "freya-winit"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "5f3e4172e15aa5bd524db527733fec0ba9ac3e4191388064c9ff0d4f5a23e091"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"accesskit_winit", "accesskit_winit",
@ -2313,6 +2377,7 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
"objc2-metal 0.3.2", "objc2-metal 0.3.2",
"objc2-quartz-core 0.3.2", "objc2-quartz-core 0.3.2",
"open",
"ragnarok", "ragnarok",
"raw-window-handle", "raw-window-handle",
"rfd", "rfd",
@ -2320,6 +2385,7 @@ dependencies = [
"softbuffer", "softbuffer",
"torin", "torin",
"tracing", "tracing",
"windows-sys 0.60.2",
"winit", "winit",
] ]
@ -2881,6 +2947,7 @@ dependencies = [
"hyper", "hyper",
"hyper-util", "hyper-util",
"rustls", "rustls",
"rustls-native-certs",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
@ -3605,12 +3672,6 @@ dependencies = [
"digest 0.11.3", "digest 0.11.3",
] ]
[[package]]
name = "md5"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.3" version = "2.8.3"
@ -3666,6 +3727,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "minisign-verify"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@ -4397,14 +4464,19 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "oneclient_app" name = "oneclient_app"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"arboard", "arboard",
"bytes 1.11.1", "bytes 1.11.1",
"cargo-packager-updater",
"chrono", "chrono",
"directories",
"dunce",
"freya", "freya",
"freya-core",
"freya-skia-safe", "freya-skia-safe",
"freya-testing",
"futures-util", "futures-util",
"image", "image",
"mimalloc", "mimalloc",
@ -4422,7 +4494,9 @@ dependencies = [
"oneclient_net", "oneclient_net",
"oneclient_polyplus", "oneclient_polyplus",
"open", "open",
"percent-encoding",
"polyio", "polyio",
"pulldown-cmark",
"regex", "regex",
"reqwest 0.13.3", "reqwest 0.13.3",
"rfd", "rfd",
@ -4434,12 +4508,13 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid 1.23.1", "uuid 1.23.1",
"windows 0.62.2",
"winresource", "winresource",
] ]
[[package]] [[package]]
name = "oneclient_auth" name = "oneclient_auth"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
@ -4463,7 +4538,7 @@ dependencies = [
[[package]] [[package]]
name = "oneclient_cluster" name = "oneclient_cluster"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"bytes 1.11.1", "bytes 1.11.1",
"chrono", "chrono",
@ -4486,18 +4561,20 @@ dependencies = [
[[package]] [[package]]
name = "oneclient_common" name = "oneclient_common"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"directories", "directories",
"interfrost", "interfrost",
"serde", "serde",
"strum", "strum",
"sysinfo",
"thiserror 2.0.18", "thiserror 2.0.18",
"url",
] ]
[[package]] [[package]]
name = "oneclient_content" name = "oneclient_content"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"async_zip", "async_zip",
@ -4510,12 +4587,14 @@ dependencies = [
"oneclient_events", "oneclient_events",
"oneclient_net", "oneclient_net",
"polyio", "polyio",
"pulldown-cmark",
"reqwest 0.13.3", "reqwest 0.13.3",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"toml",
"tracing", "tracing",
"url", "url",
"uuid 1.23.1", "uuid 1.23.1",
@ -4523,7 +4602,7 @@ dependencies = [
[[package]] [[package]]
name = "oneclient_core" name = "oneclient_core"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"async-compression", "async-compression",
"async-stream", "async-stream",
@ -4545,7 +4624,6 @@ dependencies = [
"indicatif", "indicatif",
"interfrost", "interfrost",
"md-5 0.10.6", "md-5 0.10.6",
"md5",
"oneclient_auth", "oneclient_auth",
"oneclient_cluster", "oneclient_cluster",
"oneclient_common", "oneclient_common",
@ -4588,7 +4666,7 @@ dependencies = [
[[package]] [[package]]
name = "oneclient_db" name = "oneclient_db"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"async-tempfile", "async-tempfile",
"chrono", "chrono",
@ -4603,7 +4681,7 @@ dependencies = [
[[package]] [[package]]
name = "oneclient_discord" name = "oneclient_discord"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"chrono", "chrono",
"discord-rich-presence", "discord-rich-presence",
@ -4615,7 +4693,7 @@ dependencies = [
[[package]] [[package]]
name = "oneclient_events" name = "oneclient_events"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
@ -4625,7 +4703,7 @@ dependencies = [
[[package]] [[package]]
name = "oneclient_java" name = "oneclient_java"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"astral-tokio-tar", "astral-tokio-tar",
"async-tempfile", "async-tempfile",
@ -4647,7 +4725,7 @@ dependencies = [
[[package]] [[package]]
name = "oneclient_macro" name = "oneclient_macro"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -4656,7 +4734,7 @@ dependencies = [
[[package]] [[package]]
name = "oneclient_mc" name = "oneclient_mc"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
@ -4678,7 +4756,7 @@ dependencies = [
[[package]] [[package]]
name = "oneclient_net" name = "oneclient_net"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"async-trait", "async-trait",
@ -4700,7 +4778,7 @@ dependencies = [
[[package]] [[package]]
name = "oneclient_polyplus" name = "oneclient_polyplus"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"async-tungstenite", "async-tungstenite",
"bytes 1.11.1", "bytes 1.11.1",
@ -4832,8 +4910,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "pathgraph" name = "pathgraph"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "e2f33d657119e71a8b28da5c8d52ade41672d873c292ae5bf4833b8fa658c86a"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
@ -4978,7 +5055,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]] [[package]]
name = "polyio" name = "polyio"
version = "2.2.3-c2" version = "2.3.1-c1"
dependencies = [ dependencies = [
"astral-tokio-tar", "astral-tokio-tar",
"async-compression", "async-compression",
@ -5260,8 +5337,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "ragnarok" name = "ragnarok"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "5c5bbbde00035bdece3362a320eb982f23a57f82bf5f480a74f6aec4aaa01203"
dependencies = [ dependencies = [
"euclid", "euclid",
"hotpath", "hotpath",
@ -5455,6 +5531,17 @@ dependencies = [
"bitflags 2.13.1", "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]] [[package]]
name = "redox_users" name = "redox_users"
version = "0.5.2" version = "0.5.2"
@ -5525,6 +5612,7 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",
"rustls", "rustls",
"rustls-native-certs",
"rustls-pki-types", "rustls-pki-types",
"serde", "serde",
"serde_json", "serde_json",
@ -5532,12 +5620,14 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tokio-util",
"tower", "tower",
"tower-http", "tower-http",
"tower-service", "tower-service",
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots",
] ]
@ -5584,7 +5674,7 @@ dependencies = [
"url", "url",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"wasm-streams", "wasm-streams 0.5.0",
"web-sys", "web-sys",
] ]
@ -6885,7 +6975,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4"
dependencies = [ dependencies = [
"as-raw-xcb-connection", "as-raw-xcb-connection",
"ctor", "ctor 0.10.1",
"libloading", "libloading",
"pkg-config", "pkg-config",
"tracing", "tracing",
@ -7048,8 +7138,7 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]] [[package]]
name = "torin" name = "torin"
version = "0.5.0-rc.4" version = "0.5.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#631db7cd14befdf6daf3b7c73e6e36117bffaf59"
checksum = "e4fb54c965056694c8616b9e0a6dfa151207c3599cd0c18f93acdb7172949bee"
dependencies = [ dependencies = [
"euclid", "euclid",
"itertools 0.14.0", "itertools 0.14.0",
@ -7411,7 +7500,6 @@ checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
dependencies = [ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"js-sys", "js-sys",
"md-5 0.10.6",
"serde_core", "serde_core",
"wasm-bindgen", "wasm-bindgen",
] ]
@ -7534,6 +7622,19 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "wasm-streams" name = "wasm-streams"
version = "0.5.0" version = "0.5.0"
@ -7940,6 +8041,15 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@ -7976,6 +8086,21 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
@ -8018,6 +8143,12 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
@ -8030,6 +8161,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
@ -8042,6 +8179,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@ -8066,6 +8209,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
@ -8078,6 +8227,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" 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]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
@ -8090,6 +8245,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
@ -8102,6 +8263,12 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"

View file

@ -9,13 +9,13 @@ authors = [
"Polyfrost", "Polyfrost",
"Polyfrost Contributors", "Polyfrost Contributors",
] ]
documentation = "https://github.com/Polyfrost/OneLauncher" documentation = "https://github.com/oiupoyt/OneLauncher-Cracked"
edition = "2024" edition = "2024"
homepage = "https://polyfrost.org/projects/oneclient" homepage = "https://polyfrost.org/projects/oneclient"
license = "GPL-3.0-only" license = "GPL-3.0-only"
readme = "README.md" readme = "README.md"
repository = "https://github.com/Polyfrost/OneLauncher" repository = "https://github.com/oiupoyt/OneLauncher-Cracked"
version = "2.2.3-c2" version = "2.3.1-c1"
[workspace.dependencies] [workspace.dependencies]
# core # core
@ -115,6 +115,7 @@ indicatif = {version = "=0.18.4", features = ["tokio"]}
junction = {version = "=2.0.0"} junction = {version = "=2.0.0"}
notify = {version = "=8.2.0"} notify = {version = "=8.2.0"}
parking_lot = {version = "=0.12.5"} parking_lot = {version = "=0.12.5"}
pulldown-cmark = {version = "=0.13.4", default-features = false}
rand = {version = "=0.10.1"} rand = {version = "=0.10.1"}
regex = {version = "=1.12.3"} regex = {version = "=1.12.3"}
rust-embed = {version = "=8.12.0", features = ["include-exclude", "compression", "debug-embed"]} 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_json = {version = "=1.0.150"}
serde_repr = {version = "=0.1.20"} serde_repr = {version = "=0.1.20"}
sysinfo = {version = "=0.39.5"} sysinfo = {version = "=0.39.5"}
toml = {version = "=1.1.4", default-features = false, features = ["parse", "serde"]}
trash = {version = "=5.2.6"} trash = {version = "=5.2.6"}
percent-encoding = {version = "=2.3.2"}
url = {version = "=2.5.8", features = ["serde"]} url = {version = "=2.5.8", features = ["serde"]}
uuid = {version = "=1.23.1", features = ["serde", "v4"]} uuid = {version = "=1.23.1", features = ["serde", "v4"]}
@ -136,17 +139,12 @@ syn = {version = "=2.0.117"}
thiserror = {version = "=2.0.18"} thiserror = {version = "=2.0.18"}
# ui # ui
freya = {version = "=0.5.0-rc.4", features = ["engine", "winit", "router", "remote-asset", "sdk", "markdown", "query", "radio", "titlebar"]} freya = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver", features = ["engine", "winit", "router", "remote-asset", "sdk", "query", "radio", "titlebar"]}
freya-skia-safe = "=0.99.1" # required for the SKSL player renderer 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"]} 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] [profile.release]
codegen-units = 1 codegen-units = 1
debug = "line-tables-only" debug = "line-tables-only"
@ -161,3 +159,11 @@ inherits = "release"
debug = true debug = true
strip = false strip = false
[profile.dev]
debug = 1
[profile.dev.package."*"]
opt-level = 3
debug = false

View file

@ -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 This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, version 3 of the License.
(at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of

View file

@ -42,11 +42,15 @@ notify.workspace = true
anyhow.workspace = true anyhow.workspace = true
regex.workspace = true regex.workspace = true
directories.workspace = true
dunce.workspace = true
percent-encoding.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true serde_json.workspace = true
reqwest.workspace = true reqwest.workspace = true
futures-util = { workspace = true, features = ["std"] } futures-util = { workspace = true, features = ["std"] }
cargo-packager-updater = "=0.2.3"
rfd = "=0.17.2" rfd = "=0.17.2"
open = "=5.4.0" open = "=5.4.0"
@ -57,10 +61,21 @@ image.workspace = true
freya-skia-safe.workspace = true freya-skia-safe.workspace = true
freya.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] [target.'cfg(target_os = "macos")'.dependencies]
mimalloc = "=0.1.52" mimalloc = "=0.1.52"
[target.'cfg(windows)'.dependencies]
windows = { version = "=0.62.2", features = [
"Win32_Foundation",
"Win32_System_Registry",
] }
[target.'cfg(windows)'.build-dependencies] [target.'cfg(windows)'.build-dependencies]
winresource = "=0.1.31" winresource = "=0.1.31"
@ -72,6 +87,7 @@ publisher = "Polyfrost Inc."
copyright = "© 2026 Polyfrost Inc." copyright = "© 2026 Polyfrost Inc."
homepage = "https://polyfrost.org/projects/oneclient" homepage = "https://polyfrost.org/projects/oneclient"
long-description = "Next-generation open source Minecraft launcher" long-description = "Next-generation open source Minecraft launcher"
formats = ["nsis", "app", "dmg", "deb", "appimage"]
binaries = [{ path = "oneclient_app", main = true }] binaries = [{ path = "oneclient_app", main = true }]
icons = [ icons = [
"icons/32x32.png", "icons/32x32.png",
@ -143,7 +159,7 @@ desktop-template = "distribution/linux/org.polyfrost.oneclient.template"
auto-req = "builtin" auto-req = "builtin"
assets = [ assets = [
{ source = "target/release/oneclient_app", dest = "/usr/bin/oneclient_app", mode = "755" }, { 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/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/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" }, { source = "icons/128x128.png", dest = "/usr/share/icons/hicolor/128x128/apps/oneclient_app.png", mode = "644" },

View file

@ -4,18 +4,6 @@
<dict> <dict>
<key>CFBundleIconName</key> <key>CFBundleIconName</key>
<string>OneClient</string> <string>OneClient</string>
<!-- registers our uri handler on macos -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>OneClient</string>
<key>CFBundleURLSchemes</key>
<array>
<string>oneclient</string>
</array>
</dict>
</array>
<!-- allows us to request camera permissions (for things like voice chat) --> <!-- allows us to request camera permissions (for things like voice chat) -->
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>A Minecraft mod is requesting access to your camera.</string> <string>A Minecraft mod is requesting access to your camera.</string>

View file

@ -0,0 +1,84 @@
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Cli {
pub launch: Option<String>,
}
impl Cli {
fn from_args(args: impl IntoIterator<Item = String>) -> 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<String> {
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);
}
}

View file

@ -5,10 +5,7 @@ use freya::router::RouterContext;
use crate::components::{Button, Icon, IconType}; use crate::components::{Button, Icon, IconType};
use oneclient_common::parse_mc_version; use oneclient_common::parse_mc_version;
use crate::hooks::{ use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata};
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::routes::Route;
use crate::theme::colors; use crate::theme::colors;
use crate::utils::sort_clusters_for_home; use crate::utils::sort_clusters_for_home;

View file

@ -327,7 +327,7 @@ impl Component for Button {
}) })
.cursor(cursor_icon) .cursor(cursor_icon)
.map(on_press.clone(), |rect, handler| { .map(on_press.clone(), |rect, handler| {
rect.on_all_press(move |event: Event<PressEventData>| { rect.on_press(move |event: Event<PressEventData>| {
handler.call(event); handler.call(event);
}) })
}); });
@ -446,7 +446,7 @@ pub fn open_folder_button(folder: std::path::PathBuf) -> Button {
.icon() .icon()
.on_press(move |_| { .on_press(move |_| {
std::fs::create_dir_all(&folder).ok(); 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.)) .child(Icon::new(IconType::Folder).size(16.))
} }

View file

@ -363,7 +363,7 @@ impl Component for BarChart {
rect() rect()
.width(Size::fill()) .width(Size::fill())
.height(Size::px(h)) .height(Size::px(h))
.corner_radius(CornerRadius::new(4., 4., 0., 0.)) .corner_radius(CornerRadius::new(4., 4., 0., 0.))
.background(color), .background(color),
), ),
); );

View file

@ -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<bool>, text: impl Into<Box<str>>) -> 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<bool>,
label: Option<Box<str>>,
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()
})
}))
}
}

View file

@ -4,9 +4,7 @@ use freya::{prelude::*, router::RouterContext};
use oneclient_content::packages::{CachedPackageMeta, ProviderId}; use oneclient_content::packages::{CachedPackageMeta, ProviderId};
use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea, TabBar, TabItem}; use crate::components::{Button, Icon, IconType, OverlayPopup, ScrollArea, TabBar, TabItem};
use crate::hooks::{ use crate::hooks::{package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch};
package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch,
};
use crate::notifications::{ClusterUpdateItem, ClusterUpdateSummary}; use crate::notifications::{ClusterUpdateItem, ClusterUpdateSummary};
use crate::routes::Route; use crate::routes::Route;
use crate::theme::colors; use crate::theme::colors;
@ -23,13 +21,15 @@ enum UpdateTab {
Updates, Updates,
Additions, Additions,
Removals, Removals,
Optional,
} }
impl UpdateTab { impl UpdateTab {
const ALL: [UpdateTab; 3] = [ const ALL: [UpdateTab; 4] = [
UpdateTab::Updates, UpdateTab::Updates,
UpdateTab::Additions, UpdateTab::Additions,
UpdateTab::Removals, UpdateTab::Removals,
UpdateTab::Optional,
]; ];
fn label(self) -> &'static str { fn label(self) -> &'static str {
@ -37,6 +37,7 @@ impl UpdateTab {
UpdateTab::Updates => "Updated", UpdateTab::Updates => "Updated",
UpdateTab::Additions => "Added", UpdateTab::Additions => "Added",
UpdateTab::Removals => "Removed", UpdateTab::Removals => "Removed",
UpdateTab::Optional => "Optional",
} }
} }
@ -45,6 +46,7 @@ impl UpdateTab {
UpdateTab::Updates => "Nothing was updated.", UpdateTab::Updates => "Nothing was updated.",
UpdateTab::Additions => "Nothing was added.", UpdateTab::Additions => "Nothing was added.",
UpdateTab::Removals => "Nothing was removed.", UpdateTab::Removals => "Nothing was removed.",
UpdateTab::Optional => "Nothing optional was offered.",
} }
} }
@ -53,6 +55,7 @@ impl UpdateTab {
UpdateTab::Updates => IconType::RefreshCw01, UpdateTab::Updates => IconType::RefreshCw01,
UpdateTab::Additions => IconType::Plus, UpdateTab::Additions => IconType::Plus,
UpdateTab::Removals => IconType::Trash01, UpdateTab::Removals => IconType::Trash01,
UpdateTab::Optional => IconType::Plus,
} }
} }
@ -61,6 +64,7 @@ impl UpdateTab {
UpdateTab::Updates => colors::brand(), UpdateTab::Updates => colors::brand(),
UpdateTab::Additions => colors::success(), UpdateTab::Additions => colors::success(),
UpdateTab::Removals => colors::danger(), UpdateTab::Removals => colors::danger(),
UpdateTab::Optional => colors::brand(),
} }
} }
@ -69,6 +73,7 @@ impl UpdateTab {
UpdateTab::Updates => &summary.updated, UpdateTab::Updates => &summary.updated,
UpdateTab::Additions => &summary.added, UpdateTab::Additions => &summary.added,
UpdateTab::Removals => &summary.removed, UpdateTab::Removals => &summary.removed,
UpdateTab::Optional => &summary.optional,
} }
} }
} }
@ -94,7 +99,13 @@ impl Component for ClusterUpdatePopup {
let all_items: Vec<&ClusterUpdateItem> = summaries let all_items: Vec<&ClusterUpdateItem> = summaries
.iter() .iter()
.flatten() .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(); .collect();
let mut meta = MetaMap::new(); let mut meta = MetaMap::new();
for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { 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 /// 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 cluster_id = group.cluster_id;
let count = group.names.len(); let count = group.names.len();

View file

@ -1,6 +1,6 @@
use freya::prelude::*; use freya::prelude::*;
use crate::components::{Icon, IconType, OverlayPopup}; use crate::components::{Icon, IconType, OVERLAY_BASE_LEVEL, OverlayPopup};
use crate::theme::colors; use crate::theme::colors;
const MENU_BG: Color = Color::from_rgb(25, 32, 38); 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_FG: Color = Color::from_rgb(155, 161, 166);
const MENU_DANGER: Color = Color::from_rgb(242, 84, 90); 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 { enum Entry {
Action { Action {
icon: IconType, icon: IconType,
@ -18,11 +23,25 @@ enum Entry {
Separator, Separator,
} }
fn separator(item_width: Option<f32>) -> 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 { pub struct ContextMenu {
x: f32, x: f32,
y: f32, y: f32,
upwards: bool,
title: Option<String>,
entries: Vec<Entry>, entries: Vec<Entry>,
on_close: EventHandler<()>, on_close: EventHandler<()>,
overlay_level: u8,
} }
impl ContextMenu { impl ContextMenu {
@ -30,16 +49,34 @@ impl ContextMenu {
Self { Self {
x, x,
y, y,
upwards: false,
title: None,
entries: Vec::new(), entries: Vec::new(),
on_close: (|()| {}).into(), on_close: (|()| {}).into(),
overlay_level: OVERLAY_BASE_LEVEL,
} }
} }
pub fn title(mut self, title: impl Into<String>) -> 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<EventHandler<()>>) -> Self { pub fn on_close(mut self, on_close: impl Into<EventHandler<()>>) -> Self {
self.on_close = on_close.into(); self.on_close = on_close.into();
self self
} }
pub fn overlay_level(mut self, level: u8) -> Self {
self.overlay_level = level;
self
}
pub fn action( pub fn action(
mut self, mut self,
icon: IconType, icon: IconType,
@ -87,26 +124,32 @@ impl Component for ContextMenu {
fn render(&self) -> impl IntoElement { fn render(&self) -> impl IntoElement {
let on_close = self.on_close.clone(); let on_close = self.on_close.clone();
let mut width = use_state(|| 0f32); let mut size = use_state(Size2D::zero);
let item_width = { let measured = *size.read();
let w = *width.read(); let item_width = (measured.width > 0.).then_some(measured.width);
(w > 0.).then_some(w)
};
let mut list = rect().vertical().spacing(4.); 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 { for entry in &self.entries {
list = match entry { list = match entry {
Entry::Separator => { Entry::Separator => list.child(separator(item_width)),
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::Action { Entry::Action {
icon, icon,
label, label,
@ -127,12 +170,28 @@ impl Component for ContextMenu {
} }
let list = list.on_sized(move |e: Event<SizedEventData>| { let list = list.on_sized(move |e: Event<SizedEventData>| {
let measured = e.data().area.width(); let area = e.data().area.size;
if (measured - *width.peek()).abs() > 0.5 { let prev = *size.peek();
width.set(measured); 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() let panel = rect()
.vertical() .vertical()
.padding(Gaps::new_all(6.)) .padding(Gaps::new_all(6.))
@ -144,16 +203,38 @@ impl Component for ContextMenu {
bottom: 1., bottom: 1.,
left: 1., left: 1.,
})) }))
.opacity(if placed { 1. } else { 0. })
.child(list); .child(list);
OverlayPopup::new() OverlayPopup::new()
.backdrop(false) .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(())) .on_close(move |_| on_close.call(()))
.child(panel.into_element()) .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)] #[derive(PartialEq)]
struct ContextMenuRow { struct ContextMenuRow {
icon: IconType, icon: IconType,

View file

@ -1,6 +1,7 @@
use freya::prelude::*; use freya::prelude::*;
use crate::components::{Icon, IconType, ScrollArea}; use crate::components::{Icon, IconType, ScrollArea};
use crate::hooks::use_overlay_claim_when;
use crate::theme::colors; use crate::theme::colors;
use crate::ui::border_all_color; use crate::ui::border_all_color;
@ -70,6 +71,8 @@ impl Component for Dropdown {
let on_select = self.on_select.clone(); let on_select = self.on_select.clone();
let is_open = open(); let is_open = open();
use_overlay_claim_when(is_open);
if !is_open && list_size().is_some() { if !is_open && list_size().is_some() {
let _ = list_size.take(); let _ = list_size.take();
} }
@ -108,6 +111,11 @@ impl Component for Dropdown {
rect() rect()
.width(self.width.clone()) .width(self.width.clone())
.on_global_key_down(move |e: Event<KeyboardEventData>| {
if e.key == Key::Named(NamedKey::Escape) {
open.set_if_modified(false);
}
})
.child( .child(
rect() rect()
.width(Size::fill()) .width(Size::fill())
@ -130,7 +138,7 @@ impl Component for Dropdown {
.on_sized(move |e: Event<SizedEventData>| { .on_sized(move |e: Event<SizedEventData>| {
button_area.set_if_modified(Some(e.area)); button_area.set_if_modified(Some(e.area));
}) })
.on_all_press(move |e: Event<PressEventData>| { .on_press(move |e: Event<PressEventData>| {
e.prevent_default(); e.prevent_default();
e.stop_propagation(); e.stop_propagation();
open.toggle(); open.toggle();
@ -237,7 +245,7 @@ impl Component for DropdownOption {
}) })
.on_pointer_enter(move |_| hovering.set(true)) .on_pointer_enter(move |_| hovering.set(true))
.on_pointer_leave(move |_| hovering.set(false)) .on_pointer_leave(move |_| hovering.set(false))
.on_all_press(self.on_press.clone()) .on_press(self.on_press.clone())
.child( .child(
label() label()
.text(self.text.clone()) .text(self.text.clone())

View file

@ -1,9 +1,9 @@
use bytes::Bytes; use bytes::Bytes;
use freya::prelude::*; use freya::prelude::*;
use oneclient_common::domain::GameLoader;
use oneclient_common::{VersionKey, parse_mc_version};
use oneclient_core::clusters::Cluster; use oneclient_core::clusters::Cluster;
use oneclient_core::images::DEFAULT_IMAGE_EDGE; use oneclient_core::images::DEFAULT_IMAGE_EDGE;
use oneclient_common::domain::GameLoader;
use oneclient_common::{VersionKey, parse_mc_version};
use crate::AppAssets; use crate::AppAssets;
use crate::hooks::{loaded_image, use_cached_image, use_version_metadata}; use crate::hooks::{loaded_image, use_cached_image, use_version_metadata};

View file

@ -7,8 +7,8 @@ use std::path::{Path, PathBuf};
use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation}; use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation};
use freya::prelude::*; use freya::prelude::*;
use freya::router::use_route; use freya::router::use_route;
use oneclient_content::packages::ContentType;
use oneclient_core::clusters::Cluster; use oneclient_core::clusters::Cluster;
use oneclient_content::packages::ContentType;
use oneclient_db::models::ClusterId; use oneclient_db::models::ClusterId;
use crate::Route; use crate::Route;
@ -330,9 +330,7 @@ fn prompt_body(
let import_list = resolved.clone(); let import_list = resolved.clone();
let import = move |_| { let import = move |_| {
for (path, content_type) in &import_list { dispatch.import_local_files(cluster_id, import_list.clone());
dispatch.import_local_file(cluster_id, *content_type, path.clone());
}
pending.set(Vec::new()); pending.set(Vec::new());
}; };

View file

@ -76,7 +76,9 @@ impl Component for GenericPromptOverlay {
.pick_folder() .pick_folder()
.await .await
{ {
dispatch.answer_prompt(Answer::new(id).with_folder(handle.path())); dispatch.answer_prompt(
Answer::new(id).with_folder(handle.path()),
);
} }
}); });
} }

View file

@ -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<u8>, height: f32) -> impl IntoElement {
let options: Vec<String> = (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.)
}

View file

@ -1,6 +1,6 @@
use freya::prelude::*; use freya::prelude::*;
use oneclient_events::Answer;
use oneclient_java::{JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JavaVendor}; use oneclient_java::{JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JavaVendor};
use oneclient_events::Answer;
use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup}; use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup};
use crate::hooks::{use_dispatch, use_notifications_snapshot}; use crate::hooks::{use_dispatch, use_notifications_snapshot};
@ -65,8 +65,9 @@ impl Component for JavaPromptOverlay {
.pick_folder() .pick_folder()
.await .await
{ {
dispatch dispatch.answer_prompt(
.answer_prompt(Answer::new(JAVA_CHOICE_FOLDER).with_folder(handle.path())); Answer::new(JAVA_CHOICE_FOLDER).with_folder(handle.path()),
);
} }
}); });
}; };
@ -123,7 +124,9 @@ impl Component for JavaPromptOverlay {
.child( .child(
Button::new() Button::new()
.secondary() .secondary()
.on_press(move |_| cancel.dismiss_prompt()) .on_press(move |_| {
cancel.dismiss_prompt()
})
.text("Cancel"), .text("Cancel"),
) )
.child( .child(

View file

@ -122,7 +122,7 @@ impl Component for LocalImage {
} }
} }
fn decode(bytes: &Bytes) -> Option<ImageHandle> { pub(super) fn decode(bytes: &Bytes) -> Option<ImageHandle> {
let data = unsafe { SkData::new_bytes(bytes) }; let data = unsafe { SkData::new_bytes(bytes) };
let img = SkImage::from_encoded(data)?; let img = SkImage::from_encoded(data)?;
Some(ImageHandle::new(img, bytes.clone())) Some(ImageHandle::new(img, bytes.clone()))

View file

@ -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<String>, alt: impl Into<String>) -> 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<ScaledImage> 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<dyn ElementExt>) -> bool {
let Some(other) = (other.as_ref() as &dyn Any).downcast_ref::<ScaledImageElement>() else {
return false;
};
self != other
}
fn diff(&self, other: &Rc<dyn ElementExt>) -> DiffModifies {
let Some(other) = (other.as_ref() as &dyn Any).downcast_ref::<ScaledImageElement>() 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<dyn Any>)> {
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::<Size2D>())
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::<ScaledImageElement>()
.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::<ScaledImageElement>()
.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.));
}
}

View file

@ -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<String>) -> 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())
}
}

View file

@ -0,0 +1,589 @@
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
#[derive(Clone, PartialEq)]
pub enum Block {
Heading {
level: HeadingLevel,
spans: Vec<TextSpan>,
},
Paragraph {
content: Vec<Inline>,
},
Code {
code: String,
},
List(List),
Image {
url: String,
alt: String,
},
Link {
url: String,
title: Option<String>,
content: Vec<Inline>,
},
Blockquote {
content: Vec<Inline>,
},
Table {
headers: Vec<Vec<TextSpan>>,
rows: Vec<Vec<Vec<TextSpan>>>,
},
Rule,
}
#[derive(Clone, PartialEq)]
pub struct List {
pub start: Option<u64>,
pub items: Vec<ListItem>,
}
#[derive(Clone, PartialEq)]
pub struct ListItem {
pub content: Vec<Inline>,
pub nested_lists: Vec<List>,
}
#[derive(Clone, PartialEq)]
pub enum Inline {
Span(TextSpan),
Image {
url: String,
alt: String,
},
Link {
url: String,
title: Option<String>,
content: Vec<Inline>,
},
}
#[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<String>) -> Self {
Self {
text: text.into(),
bold: false,
italic: false,
code: false,
}
}
}
pub fn parse(content: &str) -> Vec<Block> {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
Walker::default().walk(Parser::new_ext(content, options))
}
#[derive(Default)]
struct Walker {
blocks: Vec<Block>,
spans: Vec<TextSpan>,
content: Vec<Inline>,
lists: Vec<List>,
items: Vec<ListItem>,
heading: Option<HeadingLevel>,
in_paragraph: bool,
in_code_block: bool,
code: String,
in_blockquote: bool,
blockquote: Vec<Inline>,
in_table_cell: bool,
headers: Vec<Vec<TextSpan>>,
rows: Vec<Vec<Vec<TextSpan>>>,
row: Vec<Vec<TextSpan>>,
cell: Vec<TextSpan>,
in_link: bool,
link_url: Option<String>,
link_title: Option<String>,
link_content: Vec<Inline>,
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<Block> {
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<Inline> = 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<TextSpan>) {
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<Block> {
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 <b>bold</b> word")[0]), "a bold word");
}
#[test]
fn block_html_keeps_its_text() {
assert_eq!(paragraph_text(&blocks("<div>\nhello\n</div>")[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));
}
}

View file

@ -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<TextSpan>],
rows: &[Vec<Vec<TextSpan>>],
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<TextSpan>], 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<String>,
content: Vec<Inline>,
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<String>,
content: Vec<Inline>,
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::<LinkConfirmState>().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)
}
}

View file

@ -39,7 +39,7 @@ pub fn memory_field(mut memory: State<String>) -> impl IntoElement {
.child( .child(
TextInput::new(memory) TextInput::new(memory)
.width(Size::px(90.)) .width(Size::px(90.))
.placeholder("4096") .placeholder(oneclient_common::default_mem_max().to_string())
.on_validate(validate_memory) .on_validate(validate_memory)
.trailing( .trailing(
label() label()

View file

@ -3,19 +3,23 @@ mod active_cluster_panel;
mod avatar; mod avatar;
mod button; mod button;
mod charts; mod charts;
mod checkbox;
mod cluster_landscape_art; mod cluster_landscape_art;
mod cluster_update_popup; mod cluster_update_popup;
mod context_menu; mod context_menu;
mod optional_mods_popup;
mod dropdown; mod dropdown;
mod dynamic_art; mod dynamic_art;
mod file_drop; mod file_drop;
mod generic_prompt; mod grid_columns;
mod icons; mod icons;
mod java_install_manager; mod java_install_manager;
mod generic_prompt;
mod java_prompt; mod java_prompt;
mod link_confirm; mod link_confirm;
mod local_image; mod local_image;
mod log_viewer; mod log_viewer;
mod markdown;
mod memory_field; mod memory_field;
mod microsoft_login; mod microsoft_login;
mod navbar; mod navbar;
@ -37,41 +41,46 @@ mod text_input;
mod toasts; mod toasts;
mod toggle; mod toggle;
mod update_prompt; mod update_prompt;
pub mod upload_mclogs;
mod version_card; mod version_card;
pub mod upload_mclogs;
pub use account_switcher::AccountSwitcher; pub use account_switcher::AccountSwitcher;
pub use active_cluster_panel::ActiveClusterPanel; pub use active_cluster_panel::ActiveClusterPanel;
pub use avatar::Avatar; pub use avatar::Avatar;
pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button}; pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button};
pub use charts::{BarChart, PieChart, ValueUnit, slice_color}; pub use charts::{BarChart, PieChart, ValueUnit, slice_color};
pub use checkbox::checkbox_labeled;
pub use cluster_landscape_art::ClusterLandscapeArt; pub use cluster_landscape_art::ClusterLandscapeArt;
pub use cluster_update_popup::ClusterUpdatePopup; pub use cluster_update_popup::ClusterUpdatePopup;
pub use context_menu::ContextMenu; pub use context_menu::ContextMenu;
pub use optional_mods_popup::OptionalModsPopup;
pub use dropdown::Dropdown; pub use dropdown::Dropdown;
pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt}; pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt};
pub use file_drop::FileDropOverlay; 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 icons::{Icon, IconTint, IconType};
pub use java_install_manager::JavaInstallManager; pub use java_install_manager::JavaInstallManager;
pub use generic_prompt::GenericPromptOverlay;
pub use java_prompt::JavaPromptOverlay; pub use java_prompt::JavaPromptOverlay;
pub use link_confirm::ConfirmLinkOverlay; pub use link_confirm::ConfirmLinkOverlay;
pub use local_image::LocalImage; pub use local_image::LocalImage;
pub use markdown::{Markdown, MarkdownStyle};
pub use log_viewer::LogViewer; pub use log_viewer::LogViewer;
pub use memory_field::memory_field; pub use memory_field::memory_field;
pub(crate) use microsoft_login::login_dialog;
pub use microsoft_login::use_microsoft_login; pub use microsoft_login::use_microsoft_login;
pub(crate) use microsoft_login::login_dialog;
pub(crate) use navbar::window_controls; pub(crate) use navbar::window_controls;
pub use navbar::{AppNavbar, OnboardingNavbar}; pub use navbar::{AppNavbar, OnboardingNavbar};
pub use notifications::NotificationCenter; 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 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 package_update_popup::PackageUpdatePopup;
pub use pagination::Pagination; pub use pagination::Pagination;
pub use player_model::PlayerModel; pub use player_model::PlayerModel;
pub use progress_track::progress_track; pub use progress_track::progress_track;
pub use recents_row::RecentsRow; 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(crate) use scrollview::corrected_scroll;
pub use scrollview::{ScrollArea, ScrollAreaCtx}; pub use scrollview::{ScrollArea, ScrollAreaCtx};
pub use segmented_control::{Segment, SegmentedControl}; pub use segmented_control::{Segment, SegmentedControl};

View file

@ -8,9 +8,11 @@ use crate::{
Route, Route,
components::{Avatar, Icon, IconType}, components::{Avatar, Icon, IconType},
hooks::{ 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, theme,
utils::sort_clusters_for_home,
}; };
const NAVBAR_INTRO_MS: u64 = 460; const NAVBAR_INTRO_MS: u64 = 460;
@ -45,8 +47,8 @@ impl Component for Navbar {
.padding(Gaps::new_symmetric(0.0, 40.0)) .padding(Gaps::new_symmetric(0.0, 40.0))
.offset_y(slide) .offset_y(slide)
.opacity(eased) .opacity(eased)
.child(NavbarLeft) .child(navbar_left())
.child(NavbarCenter) .child(navbar_center())
.child(NavbarRight), .child(NavbarRight),
) )
.child( .child(
@ -59,83 +61,75 @@ impl Component for Navbar {
} }
} }
#[derive(PartialEq)] fn navbar_left() -> impl IntoElement {
struct NavbarLeft; rect()
.horizontal()
impl Component for NavbarLeft { .width(Size::flex(1.0))
fn render(&self) -> impl IntoElement { .cross_align(Alignment::Center)
let logo_bytes = crate::AppAssets::get_bytes("logo.svg").unwrap_or_default(); .child(navbar_logo())
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()),
)
}
} }
#[derive(PartialEq)] fn navbar_logo() -> impl IntoElement {
struct NavbarCenter; let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default());
impl Component for NavbarCenter { SvgViewer::new(("logo.svg", bytes.read().cloned()))
fn render(&self) -> impl IntoElement { .show_loader(false)
let route = use_route::<Route>(); .height(Size::px(44.))
let browse_target = browse_target(); .width(Size::px(214.))
.color(theme::colors::fg_primary())
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 to active cluster then most recently played then Versions when none exist fn navbar_center() -> impl IntoElement {
let route = use_route::<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 { fn browse_target() -> Route {
let clusters = crate::hooks::settled_or_loading(&crate::hooks::use_clusters()).unwrap_or_default(); let clusters = settled_or_loading(&use_clusters()).unwrap_or_default();
let active = *crate::hooks::use_active_cluster_id().read(); let active = *use_active_cluster_id().read();
let cluster_id = active let cluster_id = active
.filter(|id| clusters.iter().any(|cluster| cluster.id == *id)) .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 { match cluster_id {
Some(cluster_id) => Route::Browser { Some(cluster_id) => Route::Browser {
@ -196,7 +190,7 @@ impl Component for NavLink {
.a11y_id(a11y_id) .a11y_id(a11y_id)
.a11y_focusable(true) .a11y_focusable(true)
.a11y_role(AccessibilityRole::Button) .a11y_role(AccessibilityRole::Button)
.on_all_press(move |e: Event<PressEventData>| { .on_press(move |e: Event<PressEventData>| {
e.prevent_default(); e.prevent_default();
match &target { match &target {
NavTarget::Route(route) => { NavTarget::Route(route) => {

View file

@ -18,20 +18,20 @@ pub(super) fn navbar_button() -> Button {
pub fn window_controls() -> impl IntoElement { pub fn window_controls() -> impl IntoElement {
let minimize = |_| { let minimize = |_| {
Platform::get().with_window(None, |win| { Platform::get().with_window(Platform::window_id(), |win| {
win.set_minimized(true); win.set_minimized(true);
}); });
}; };
let maximize = |_| { let maximize = |_| {
Platform::get().with_window(None, |win| { Platform::get().with_window(Platform::window_id(), |win| {
win.set_maximized(!win.is_maximized()); win.set_maximized(!win.is_maximized());
}); });
}; };
let close = |_| { let close = |_| {
let platform = Platform::get(); 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()); platform.close_window(window.id());
}); });
}; };

View file

@ -5,12 +5,12 @@ use freya::{
use oneclient_events::Level; use oneclient_events::Level;
use crate::{ use crate::{
ui::{divider, relative_time},
components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea, progress_track}, components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea, progress_track},
hooks::{use_dispatch, use_notifications_snapshot}, hooks::{use_dispatch, use_notifications_snapshot},
notifications::{InboxEntry, NotificationActionKind}, notifications::{InboxEntry, NotificationActionKind},
theme::colors, theme::colors,
transfer::TransferStats, transfer::TransferStats,
ui::{divider, relative_time},
utils::{format_duration_hms, format_size}, utils::{format_duration_hms, format_size},
}; };
@ -212,9 +212,7 @@ fn tasks_section(entry: &InboxEntry, mut expanded: State<bool>) -> impl IntoElem
let now = !*expanded.peek(); let now = !*expanded.peek();
expanded.set(now); expanded.set(now);
}) })
.child(ChevronToggle { .child(ChevronToggle { expanded: is_expanded })
expanded: is_expanded,
})
.child( .child(
label() label()
.text(if is_expanded { .text(if is_expanded {
@ -527,3 +525,4 @@ fn level_color(level: &Level) -> Color {
Level::Error => colors::danger(), Level::Error => colors::danger(),
} }
} }

View file

@ -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<String> = 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<Picked>,
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<Picked>,
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<Picked>,
) -> 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<Picked>,
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<Picked>,
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()),
)
}

View file

@ -1,16 +1,39 @@
use std::cell::RefCell;
use freya::{ use freya::{
animation::{AnimNum, Ease, OnCreation, use_animation}, animation::{AnimNum, Ease, OnCreation, use_animation},
prelude::*, prelude::*,
}; };
use crate::hooks::use_overlay_claim;
const SCRIM_ALPHA: f32 = 90.; 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<Vec<u8>> = 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)] #[derive(PartialEq)]
pub struct OverlayPopup { pub struct OverlayPopup {
children: Vec<Element>, children: Vec<Element>,
on_close: Option<EventHandler<()>>, on_close: Option<EventHandler<()>>,
position: Position, position: Position,
backdrop: bool, backdrop: bool,
overlay_level: u8,
key: DiffKey, key: DiffKey,
} }
@ -27,6 +50,7 @@ impl OverlayPopup {
on_close: None, on_close: None,
position: Position::new_global().top(0.).left(0.), position: Position::new_global().top(0.).left(0.),
backdrop: true, backdrop: true,
overlay_level: OVERLAY_BASE_LEVEL,
key: DiffKey::None, key: DiffKey::None,
} }
} }
@ -36,6 +60,15 @@ impl OverlayPopup {
self 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 { pub fn backdrop(mut self, backdrop: bool) -> Self {
self.backdrop = backdrop; self.backdrop = backdrop;
self self
@ -61,9 +94,21 @@ impl KeyExt for OverlayPopup {
impl Component for OverlayPopup { impl Component for OverlayPopup {
fn render(&self) -> impl IntoElement { fn render(&self) -> impl IntoElement {
use_overlay_claim();
let a11y_id = use_a11y(); let a11y_id = use_a11y();
let scrim_close = self.on_close.clone(); let scrim_close = self.on_close.clone();
let key_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| { let fade = use_animation(|conf| {
conf.on_creation(OnCreation::Run); conf.on_creation(OnCreation::Run);
@ -83,7 +128,7 @@ impl Component for OverlayPopup {
.position(Position::new_global().top(0.).left(0.)) .position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.)) .width(Size::window_percent(100.))
.height(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)) .background(Color::from_argb(alpha, 0, 0, 0))
.on_press(move |_| { .on_press(move |_| {
if let Some(on_close) = scrim_close.as_ref() { if let Some(on_close) = scrim_close.as_ref() {
@ -98,9 +143,10 @@ impl Component for OverlayPopup {
.a11y_focusable(true) .a11y_focusable(true)
.a11y_auto_focus(true) .a11y_auto_focus(true)
.a11y_role(AccessibilityRole::Dialog) .a11y_role(AccessibilityRole::Dialog)
.layer(Layer::OverlayLevel(12)) .layer(Layer::OverlayLevel(self.overlay_level))
.on_global_key_down(move |e: Event<KeyboardEventData>| { .on_global_key_down(move |e: Event<KeyboardEventData>| {
if e.key == Key::Named(NamedKey::Escape) if e.key == Key::Named(NamedKey::Escape)
&& overlay_is_topmost(level)
&& let Some(on_close) = key_close.as_ref() && let Some(on_close) = key_close.as_ref()
{ {
on_close.call(()); on_close.call(());

View file

@ -1,6 +1,7 @@
use freya::prelude::*; use freya::prelude::*;
use freya::router::RouterContext; use freya::router::RouterContext;
use oneclient_content::packages::ProviderId; use oneclient_content::packages::ProviderId;
use oneclient_core::SeenStatus;
use crate::components::{Icon, IconType, toggle_controlled}; use crate::components::{Icon, IconType, toggle_controlled};
use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation}; 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_BG: Color = Color::from_rgb(26, 34, 41);
pub(crate) const CARD_NAME: Color = Color::from_rgb(213, 219, 255); pub(crate) const CARD_NAME: Color = Color::from_rgb(213, 219, 255);
pub(crate) const CARD_H: f32 = 84.; 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)] #[derive(Clone, Copy, PartialEq)]
pub enum CardLayout { pub enum CardLayout {
@ -48,6 +52,8 @@ pub struct PackageEntry {
pub hidden: bool, pub hidden: bool,
/// Only set for browser-installed content bundle packages use the bundle update flow /// Only set for browser-installed content bundle packages use the bundle update flow
pub update_available: bool, pub update_available: bool,
/// Recency badge state cleared once the user views the list
pub seen_status: SeenStatus,
} }
impl PackageEntry { impl PackageEntry {
@ -63,6 +69,14 @@ impl PackageEntry {
pub fn is_outdated(&self) -> bool { pub fn is_outdated(&self) -> bool {
self.update_available && self.is_remote() && !self.in_bundle() self.update_available && self.is_remote() && !self.in_bundle()
} }
pub fn recency_badge(&self) -> Option<Element> {
match self.seen_status {
SeenStatus::New => Some(new_badge()),
SeenStatus::Updated => Some(updated_badge()),
SeenStatus::Seen => None,
}
}
} }
#[derive(PartialEq)] #[derive(PartialEq)]
@ -111,11 +125,7 @@ impl Component for PackageRow {
CardLayout::Grid => 52., CardLayout::Grid => 52.,
}; };
let icon_query = use_cached_image(item.icon_url.clone(), 256); let icon_query = use_cached_image(item.icon_url.clone(), 256);
let icon = if item.is_remote() { let icon = package_icon(&item, &icon_query, icon_size);
remote_icon(&item.icon_url, &icon_query, icon_size)
} else {
local_icon(icon_size)
};
let on_toggle: EventHandler<()> = { let on_toggle: EventHandler<()> = {
let hash = item.hash.clone(); let hash = item.hash.clone();
@ -125,9 +135,10 @@ impl Component for PackageRow {
let manifest_default = item.manifest_default; let manifest_default = item.manifest_default;
(move |()| { (move |()| {
if let Some(h) = &hash { if let Some(h) = &hash {
cluster.mutate(ClusterAction::ToggleArtifact { cluster.mutate(ClusterAction::SetArtifactEnabled {
cluster_id, cluster_id,
hash: h.clone(), hash: h.clone(),
enabled: !enabled_now,
}); });
} else if let Some(bundle) = &bundle_name { } else if let Some(bundle) = &bundle_name {
cluster.mutate(ClusterAction::SetBundlePackageEnabled { cluster.mutate(ClusterAction::SetBundlePackageEnabled {
@ -166,7 +177,7 @@ impl Component for PackageRow {
remove_hover, 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() .into_element()
} }
fn grid_card( pub(crate) fn grid_card(
item: &PackageEntry, item: &PackageEntry,
package_type: &'static str, package_type: &'static str,
cluster_id: i64, cluster_id: i64,
icon: impl IntoElement, icon: impl IntoElement,
on_toggle: EventHandler<()>, on_toggle: EventHandler<()>,
navigable: bool, // if true, it takes the user to the mod page
) -> Element { ) -> Element {
let remote = item.is_remote(); let remote = item.is_remote();
let enabled = item.enabled; let enabled = item.enabled;
let title = if remote { let title = item.name.clone();
item.name.clone()
} else {
item.file_name.clone()
};
let (bg, border, content_alpha) = if enabled { let (bg, border, content_alpha) = if enabled {
(colors::brand().with_a(38), colors::brand(), 255) (colors::brand().with_a(38), colors::brand(), 255)
@ -220,7 +228,7 @@ fn grid_card(
(CARD_BG, colors::component_border(), 140) (CARD_BG, colors::component_border(), 140)
}; };
let badge = if remote { let badge = if remote && navigable {
let provider = item.provider; let provider = item.provider;
let package_id = item.package_id.clone(); let package_id = item.package_id.clone();
let package_type_owned = package_type.to_string(); let package_type_owned = package_type.to_string();
@ -236,6 +244,8 @@ fn grid_card(
}) })
.child(provider_badge(item.provider)) .child(provider_badge(item.provider))
.into_element() .into_element()
} else if remote {
provider_badge(item.provider)
} else { } else {
local_badge() local_badge()
}; };
@ -274,10 +284,13 @@ fn grid_card(
.child( .child(
rect() rect()
.horizontal() .horizontal()
.width(Size::fill())
.cross_align(Alignment::Center) .cross_align(Alignment::Center)
.spacing(6.) .spacing(6.)
.content(Content::wrap_spacing(4.))
.child(badge) .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(); .into_element();
@ -329,11 +342,7 @@ fn package_info(
let provider = item.provider; let provider = item.provider;
let package_id = item.package_id.clone(); let package_id = item.package_id.clone();
let package_type = package_type.to_string(); let package_type = package_type.to_string();
let title = if remote { let title = item.name.clone();
item.name.clone()
} else {
item.file_name.clone()
};
rect() rect()
.horizontal() .horizontal()
@ -374,7 +383,8 @@ fn package_info(
} else { } else {
local_badge() 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| { .maybe(!item.author.is_empty(), |el| {
el.child( el.child(
@ -398,11 +408,12 @@ fn package_info(
.into_element() .into_element()
} }
fn remote_icon( pub(crate) fn package_icon(
icon_url: &Option<String>, item: &PackageEntry,
icon_query: &freya::query::UseQuery<crate::hooks::CachedImageQuery>, icon_query: &freya::query::UseQuery<crate::hooks::CachedImageQuery>,
size: f32, size: f32,
) -> Element { ) -> Element {
let icon_url = &item.icon_url;
let loaded = loaded_image(icon_url.as_deref(), icon_query); let loaded = loaded_image(icon_url.as_deref(), icon_query);
match loaded { match loaded {
@ -413,14 +424,11 @@ fn remote_icon(
.corner_radius(CornerRadius::new_all(8.)) .corner_radius(CornerRadius::new_all(8.))
.into_element(), .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 { fn icon_box(icon: IconType, size: f32) -> Element {
rect() rect()
.center() .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 { fn local_badge() -> Element {
badge( badge(
Icon::new(IconType::File02) Icon::new(IconType::File02)

View file

@ -3,11 +3,12 @@
use std::collections::HashMap; use std::collections::HashMap;
use freya::prelude::*; use freya::prelude::*;
use oneclient_common::{PackageUpdateMode, Patch};
use oneclient_content::packages::{CachedPackageMeta, ProviderId}; use oneclient_content::packages::{CachedPackageMeta, ProviderId};
use oneclient_core::BrowserPackageUpdate; use oneclient_core::{BrowserPackageUpdate, ProfileUpdate};
use oneclient_db::models::ClusterId; 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::{ use crate::hooks::{
package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch, 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 // Absent entries default to Update so groups arriving mid-modal need no reconciliation
let choices = use_state(HashMap::<RowKey, RowChoice>::new); let choices = use_state(HashMap::<RowKey, RowChoice>::new);
let mut dont_show_again = use_state(|| false);
let groups = snapshot.package_updates.clone(); let groups = snapshot.package_updates.clone();
@ -83,6 +85,8 @@ impl Component for PackageUpdatePopup {
} }
let Some(groups) = groups.filter(|groups| !groups.is_empty()) else { 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(); return rect().into_element();
}; };
@ -95,7 +99,7 @@ impl Component for PackageUpdatePopup {
.width(Size::window_percent(100.)) .width(Size::window_percent(100.))
.height(Size::window_percent(100.)) .height(Size::window_percent(100.))
.center() .center()
.child(dialog(&groups, &meta, dispatch, choices)), .child(dialog(&groups, &meta, dispatch, choices, dont_show_again)),
) )
.into_element() .into_element()
} }
@ -113,6 +117,7 @@ fn dialog(
meta: &MetaMap, meta: &MetaMap,
dispatch: crate::Actions, dispatch: crate::Actions,
choices: State<HashMap<RowKey, RowChoice>>, choices: State<HashMap<RowKey, RowChoice>>,
dont_show_again: State<bool>,
) -> impl IntoElement { ) -> impl IntoElement {
rect() rect()
.vertical() .vertical()
@ -130,7 +135,7 @@ fn dialog(
0., 0.,
Color::from_argb(150, 0, 0, 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( fn content(
@ -138,10 +143,13 @@ fn content(
meta: &MetaMap, meta: &MetaMap,
dispatch: crate::Actions, dispatch: crate::Actions,
choices: State<HashMap<RowKey, RowChoice>>, choices: State<HashMap<RowKey, RowChoice>>,
dont_show_again: State<bool>,
) -> impl IntoElement { ) -> impl IntoElement {
let dismiss = dispatch.clone(); let dismiss = dispatch.clone();
let proceed_dispatch = dispatch.clone(); let proceed_dispatch = dispatch.clone();
let dsa_dispatch = dispatch.clone();
let total: usize = groups.iter().map(|group| group.packages.len()).sum(); let total: usize = groups.iter().map(|group| group.packages.len()).sum();
let clusters: Vec<ClusterId> = groups.iter().map(|group| group.cluster_id).collect();
let subtitle = match groups { let subtitle = match groups {
[only] => format!( [only] => format!(
@ -194,35 +202,73 @@ fn content(
.horizontal() .horizontal()
.width(Size::fill()) .width(Size::fill())
.cross_align(Alignment::Center) .cross_align(Alignment::Center)
.main_align(Alignment::End) .main_align(Alignment::SpaceBetween)
.spacing(8.) .spacing(8.)
.child( .child(
Button::new() rect()
.ghost() .direction(Direction::Horizontal)
.on_press(move |_| dismiss.close_package_updates()) .main_align(Alignment::Start)
.text("Cancel"), .cross_align(Alignment::Center)
.spacing(6.)
.child(
checkbox_labeled(dont_show_again, "Don't show again?")
)
) )
.child( .child(
Button::new() rect()
.primary() .width(Size::fill())
.on_press(move |_| { .direction(Direction::Horizontal)
let answers = choices.read(); .cross_align(Alignment::Center)
for update in &all { .main_align(Alignment::End)
match answers.get(&row_key(update)).copied().unwrap_or_default() { .child(
RowChoice::Update => { Button::new()
proceed_dispatch.apply_package_update(update.clone()); .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( let answers = choices.read();
update.cluster_id, for update in &all {
update.hash.clone(), match answers
), .get(&row_key(update))
} .copied()
} .unwrap_or_default()
proceed_dispatch.close_package_updates(); {
}) RowChoice::Update => {
.child(Icon::new(IconType::DownloadCloud02).size(15.)) proceed_dispatch.apply_package_update(update.clone());
.text("Proceed"), }
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"),
)
), ),
) )
} }

View file

@ -3,7 +3,7 @@ use freya::prelude::*;
use freya::router::RouterContext; use freya::router::RouterContext;
use oneclient_core::clusters::Cluster; 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::hooks::{settled_or_loading, use_active_cluster_id, use_clusters};
use crate::routes::Route; use crate::routes::Route;
use crate::theme; use crate::theme;
@ -35,8 +35,10 @@ impl Component for RecentsRow {
let sorted: Vec<Cluster> = sort_clusters_for_home(clusters); let sorted: Vec<Cluster> = sort_clusters_for_home(clusters);
let slots = let slots = recent_card_slots_for_window(
recent_card_slots_for_window(root_size.read().width, *scale_factor.read() as f32); root_size.read().width,
*scale_factor.read() as f32,
);
// Budget off cluster count not slot count an offset-only change makes the layout // 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 // engine reuse a card's cached area freezing survivors at pre-resize widths
@ -65,7 +67,9 @@ impl Component for RecentsRow {
index, index,
items, items,
progress, progress,
key: DiffKey::None,
} }
.key(cluster.id)
.into_element() .into_element()
})) }))
.child(OtherVersionsTile { .child(OtherVersionsTile {
@ -92,11 +96,58 @@ fn stagger_eased(progress: f32, index: usize, items: usize) -> f32 {
1.0 - (1.0 - local).powi(3) 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 { struct ClusterCard {
cluster: Cluster, cluster: Cluster,
index: usize, index: usize,
items: usize, items: usize,
progress: f32, progress: f32,
key: DiffKey,
}
impl KeyExt for ClusterCard {
fn write_key(&mut self) -> &mut DiffKey {
&mut self.key
}
} }
impl PartialEq for ClusterCard { impl PartialEq for ClusterCard {
@ -113,6 +164,7 @@ impl Component for ClusterCard {
let mut active_id = use_active_cluster_id(); let mut active_id = use_active_cluster_id();
let active = *active_id.read() == Some(self.cluster.id); let active = *active_id.read() == Some(self.cluster.id);
let mut hovering = use_state(|| false); let mut hovering = use_state(|| false);
let mut menu = use_state(|| None::<(f32, f32)>);
let a11y_id = use_a11y(); let a11y_id = use_a11y();
let focus = use_focus(a11y_id); let focus = use_focus(a11y_id);
@ -128,6 +180,19 @@ impl Component for ClusterCard {
*active_id.write() = Some(cluster_id); *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() rect()
.key(self.cluster.id) .key(self.cluster.id)
.width(Size::flex(1.0)) .width(Size::flex(1.0))
@ -171,7 +236,15 @@ impl Component for ClusterCard {
.y(0.) .y(0.)
.color(Color::from_af32rgb(0.3, 0, 0, 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<PressEventData>| {
if let PressEventData::Mouse(m) = e.data() {
menu.set(Some((
m.global_location.x as f32,
m.global_location.y as f32,
)));
}
})
.child( .child(
rect() rect()
.width(Size::fill()) .width(Size::fill())
@ -182,10 +255,7 @@ impl Component for ClusterCard {
.height(Size::fill()) .height(Size::fill())
.position(Position::new_absolute()) .position(Position::new_absolute())
.layer(Layer::Relative(1)) .layer(Layer::Relative(1))
.child( .child(DynamicArt::for_cluster(&self.cluster).max_edge(ART_PREVIEW_EDGE)),
DynamicArt::for_cluster(&self.cluster)
.max_edge(ART_PREVIEW_EDGE),
),
) )
.child( .child(
rect() 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.) .y(0.)
.color(Color::from_af32rgb(0.3, 0, 0, 0)), .color(Color::from_af32rgb(0.3, 0, 0, 0)),
) )
.on_all_press(|_| { .on_press(|_| {
let _ = RouterContext::get().push(Route::Clusters {}); let _ = RouterContext::get().push(Route::Clusters {});
}) })
.child( .child(

View file

@ -1,8 +1,15 @@
use std::path::PathBuf;
use freya::prelude::*; use freya::prelude::*;
use oneclient_core::ScreenshotInfo; use oneclient_core::ScreenshotInfo;
use crate::components::{Button, Icon, IconType, LocalImage, OverlayPopup}; use crate::components::{
use crate::hooks::{ScreenshotAction, use_dispatch, use_screenshot_action}; 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::theme::colors;
use crate::ui::fmt_date; use crate::ui::fmt_date;
use crate::utils::{format_res, format_size}; use crate::utils::{format_res, format_size};
@ -37,6 +44,7 @@ impl Component for ScreenshotViewer {
let start = self.start.min(len.saturating_sub(1)); let start = self.start.min(len.saturating_sub(1));
move || start move || start
}); });
let mut menu = use_state(|| None::<(f32, f32)>);
if len == 0 { if len == 0 {
return rect().into_element(); return rect().into_element();
@ -50,6 +58,7 @@ impl Component for ScreenshotViewer {
let close = self.on_close.clone(); let close = self.on_close.clone();
let scrim_close = self.on_close.clone(); let scrim_close = self.on_close.clone();
let delete_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_prev = idx > 0;
let has_next = idx + 1 < len; let has_next = idx + 1 < len;
@ -61,6 +70,23 @@ impl Component for ScreenshotViewer {
let copy_dispatch = dispatch.clone(); let copy_dispatch = dispatch.clone();
let delete_path = info.path.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() OverlayPopup::new()
.on_close(move |_| scrim_close.call(())) .on_close(move |_| scrim_close.call(()))
.child( .child(
@ -68,10 +94,15 @@ impl Component for ScreenshotViewer {
.width(Size::window_percent(100.)) .width(Size::window_percent(100.))
.height(Size::window_percent(100.)) .height(Size::window_percent(100.))
.center() .center()
.on_global_key_down(move |e: Event<KeyboardEventData>| match &e.key { .on_global_key_down(move |e: Event<KeyboardEventData>| {
Key::Named(NamedKey::ArrowLeft) if idx > 0 => index.set(idx - 1), if !overlay_is_topmost(OVERLAY_BASE_LEVEL) {
Key::Named(NamedKey::ArrowRight) if idx + 1 < len => index.set(idx + 1), 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( .child(
rect() rect()
@ -100,6 +131,14 @@ impl Component for ScreenshotViewer {
rect() rect()
.width(Size::flex(1.0)) .width(Size::flex(1.0))
.height(Size::fill()) .height(Size::fill())
.on_secondary_down(move |e: Event<PressEventData>| {
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(preview),
) )
.child(chevron_btn( .child(chevron_btn(
@ -123,7 +162,7 @@ impl Component for ScreenshotViewer {
.secondary() .secondary()
.on_press(move |_| { .on_press(move |_| {
if let Some(dir) = open_path.parent() { if let Some(dir) = open_path.parent() {
crate::platform::open_url( crate::platform::open_path(
&dir.to_string_lossy(), &dir.to_string_lossy(),
); );
} }
@ -163,10 +202,50 @@ impl Component for ScreenshotViewer {
), ),
), ),
) )
.maybe_child(menu_overlay)
.into_element() .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<EventHandler<()>>,
) -> 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( fn chevron_btn(
icon: IconType, icon: IconType,
enabled: bool, enabled: bool,

View file

@ -10,11 +10,11 @@ pub(crate) fn normalize_wheel_delta(delta: f64, scale_factor: f64) -> f32 {
return 0.0; return 0.0;
} }
const FREYA_LINE_SPEED: f64 = 53.0; const FREYA_LINE_SPEED: f64 = 53.0;
const FREYA_PIXEL_SPEED: f64 = 2.0; const FREYA_PIXEL_SPEED: f64 = 2.0;
const LINE_DELTA_TOLERANCE: f64 = 1e-6; const LINE_DELTA_TOLERANCE: f64 = 1e-6;
const LINE_SCROLL: f64 = 53.0; const LINE_SCROLL: f64 = 53.0;
const PIXEL_SCROLL: f64 = 1.0; const PIXEL_SCROLL: f64 = 1.0;
let lines = (delta / FREYA_LINE_SPEED).round(); let lines = (delta / FREYA_LINE_SPEED).round();
if lines != 0.0 && (delta - lines * FREYA_LINE_SPEED).abs() <= LINE_DELTA_TOLERANCE { if lines != 0.0 && (delta - lines * FREYA_LINE_SPEED).abs() <= LINE_DELTA_TOLERANCE {

View file

@ -185,7 +185,7 @@ impl<T: Copy + PartialEq + 'static> Component for SegmentButton<T> {
}) })
.maybe(!disabled, |el| { .maybe(!disabled, |el| {
el.cursor(CursorIcon::Pointer) el.cursor(CursorIcon::Pointer)
.on_all_press(move |_| *selected.write() = value) .on_press(move |_| *selected.write() = value)
}) })
.maybe_child(self.icon.map(|icon| { .maybe_child(self.icon.map(|icon| {
let mut ic = Icon::new(icon); let mut ic = Icon::new(icon);

View file

@ -187,7 +187,7 @@ impl Component for TabButton {
.cursor(CursorIcon::Pointer); .cursor(CursorIcon::Pointer);
if let Some(handler) = self.on_press.clone() { 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( el.child(

View file

@ -10,12 +10,12 @@ use freya::{
use oneclient_events::Level; use oneclient_events::Level;
use crate::{ use crate::{
ui::{divider},
components::{Button, ButtonSize, Icon, IconType, progress_track}, components::{Button, ButtonSize, Icon, IconType, progress_track},
hooks::{use_dispatch, use_notifications_snapshot}, hooks::{use_dispatch, use_notifications_snapshot},
motion::use_animations_enabled, motion::use_animations_enabled,
notifications::{InboxEntry, NotificationActionKind}, notifications::{InboxEntry, NotificationActionKind},
theme::colors, theme::colors,
ui::divider,
}; };
const TOAST_W: f32 = 300.; 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) toast_shell(entry)
.child( .child(
rect() rect()

View file

@ -106,7 +106,7 @@ impl Component for Switch {
.a11y_focusable(!disabled) .a11y_focusable(!disabled)
.a11y_role(AccessibilityRole::Button) .a11y_role(AccessibilityRole::Button)
.maybe(!disabled, |el| { .maybe(!disabled, |el| {
el.cursor(CursorIcon::Pointer).on_all_press(move |_| { el.cursor(CursorIcon::Pointer).on_press(move |_| {
on_press.call(()); on_press.call(());
}) })
}) })

View file

@ -14,7 +14,10 @@ pub struct UploadToMclogs {
impl UploadToMclogs { impl UploadToMclogs {
pub fn new(has_log: bool, confirm: State<Option<Confirm>>) -> Self { pub fn new(has_log: bool, confirm: State<Option<Confirm>>) -> Self {
Self { has_log, confirm } Self {
has_log,
confirm
}
} }
} }

View file

@ -79,7 +79,7 @@ impl Component for VersionCard {
.a11y_id(a11y_id) .a11y_id(a11y_id)
.a11y_focusable(true) .a11y_focusable(true)
.a11y_role(AccessibilityRole::Button) .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 |_| { .on_pointer_enter(move |_| {
*hovering.write() = true; *hovering.write() = true;
}) })

View file

@ -4,4 +4,4 @@ pub const WINDOW_TITLE: &str = "OneClient";
pub const GITHUB_REPO: &str = "oiupoyt/OneLauncher-Cracked"; pub const GITHUB_REPO: &str = "oiupoyt/OneLauncher-Cracked";
pub const GITHUB_API_LATEST_RELEASE: &str = pub const GITHUB_API_LATEST_RELEASE: &str =
"https://api.github.com/repos/oiupoyt/OneLauncher-Cracked/releases/latest"; "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";

View file

@ -171,10 +171,7 @@ impl EventPump {
guard.game.stages.insert(cluster_id, stage); guard.game.stages.insert(cluster_id, stage);
if stage == LaunchStage::Checking { if stage == LaunchStage::Checking {
guard.game.error = None; guard.game.error = None;
guard guard.game.logs.insert(cluster_id, std::sync::Arc::new(Vec::new()));
.game
.logs
.insert(cluster_id, std::sync::Arc::new(Vec::new()));
} }
} }
for (cluster_id, line) in logs { for (cluster_id, line) in logs {
@ -249,9 +246,7 @@ fn reconcile(
.collect(); .collect();
for id in &want { for id in &want {
armed armed.entry(*id).or_insert_with(|| ToastTimer::armed(paused));
.entry(*id)
.or_insert_with(|| ToastTimer::armed(paused));
} }
armed.retain(|id, _| want.contains(id)); 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_polyplus::start(std::sync::Arc::clone(&state.auth));
oneclient_core::run_startup_tasks(&state); 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()) .map(|p| p.display().to_string())
.unwrap_or_default(); .unwrap_or_default();
@ -330,6 +325,7 @@ pub async fn start_launcher(
error: None, error: None,
snapshots: 0, snapshots: 0,
data_dir, data_dir,
needs_location: false,
}; };
} }
{ {
@ -344,11 +340,14 @@ pub async fn start_launcher(
Ok(()) Ok(())
} }
pub fn report_startup_failure(station: &RadioStation<AppState, AppChannel>, err: &anyhow::Error) { pub fn report_startup_failure(
station: &RadioStation<AppState, AppChannel>,
err: &anyhow::Error,
) {
let message = err.to_string(); let message = err.to_string();
tracing::error!("launcher init failed: {err:#}"); 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()) .map(|p| p.display().to_string())
.unwrap_or_default(); .unwrap_or_default();
@ -362,9 +361,7 @@ pub fn report_startup_failure(station: &RadioStation<AppState, AppChannel>, err:
let mut guard = station.write_channel(AppChannel::Notifications); let mut guard = station.write_channel(AppChannel::Notifications);
let AppState { let AppState {
notifications, notifications, inbox, ..
inbox,
..
} = &mut **guard; } = &mut **guard;
notifications.dispatch( notifications.dispatch(
inbox, inbox,

View file

@ -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::<Vec<_>>().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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
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!("\t<key>CFBundleIconFile</key>\n\t<string>{}</string>\n", xml_escape(icon))
});
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
<plist version=\"1.0\">\n\
<dict>\n\
\t<key>CFBundleName</key>\n\t<string>{display}</string>\n\
\t<key>CFBundleDisplayName</key>\n\t<string>{display}</string>\n\
\t<key>CFBundleIdentifier</key>\n\t<string>org.polyfrost.OneClient.shortcut.{slug}</string>\n\
\t<key>CFBundleExecutable</key>\n\t<string>{executable}</string>\n\
{icon_entry}\
\t<key>CFBundlePackageType</key>\n\t<string>APPL</string>\n\
\t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\
\t<key>CFBundleShortVersionString</key>\n\t<string>1.0</string>\n\
\t<key>LSUIElement</key>\n\t<true/>\n\
</dict>\n\
</plist>\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 & <B>", "launch", "pack", Some("icon"));
assert!(plist.contains("<string>A &amp; &lt;B&gt;</string>"));
assert!(!plist.contains("<B>"));
}
#[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"));
}
}

View file

@ -3,8 +3,9 @@
//! Always `spawn_forever` never `spawn` Freya's `spawn` cancels the task when //! Always `spawn_forever` never `spawn` Freya's `spawn` cancels the task when
//! the calling component unmounts this work is app-scoped not component-scoped //! the calling component unmounts this work is app-scoped not component-scoped
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use freya::prelude::spawn_forever; use freya::prelude::spawn_forever;
@ -13,6 +14,8 @@ use oneclient_cluster::{
ClusterStage, ClusterUpdate, GameSettingsProfile, PackageUpdateMode, ProfileUpdate, ClusterStage, ClusterUpdate, GameSettingsProfile, PackageUpdateMode, ProfileUpdate,
}; };
use oneclient_common::domain::{ContentType, ProviderId}; 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::LauncherSettings;
use oneclient_core::settings::store::{save_global_profile, save_settings_and_apply}; use oneclient_core::settings::store::{save_global_profile, save_settings_and_apply};
use oneclient_db::models::ClusterId; use oneclient_db::models::ClusterId;
@ -20,11 +23,12 @@ use oneclient_events::{Answer, Level};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::components::IconType; 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::{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 /// Over-disabling is the cheaper mistake a dead button beats a second game
const LAUNCH_HOLD: Duration = Duration::from_secs(2); 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 /// Mirrors `oneclient_net`'s reachability probe not reqwest's per-request
/// timeouts which are minutes long This sits between Play and the game /// timeouts which are minutes long This sits between Play and the game
const UPDATE_CHECK_BUDGET: Duration = Duration::from_secs(8); 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<ClusterId, Instant>,
}
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)] #[derive(Debug, Clone, PartialEq)]
enum LaunchUpdatePlan { enum LaunchUpdatePlan {
@ -75,6 +101,7 @@ pub struct Actions {
station: RadioStation<AppState, AppChannel>, station: RadioStation<AppState, AppChannel>,
pump: mpsc::UnboundedSender<PumpSignal>, pump: mpsc::UnboundedSender<PumpSignal>,
events: oneclient_events::EventBus, events: oneclient_events::EventBus,
bundle_checks: Arc<Mutex<BundleCheckLog>>,
} }
impl Actions { impl Actions {
@ -99,6 +126,7 @@ impl Actions {
station, station,
pump, pump,
events, events,
bundle_checks: Arc::new(Mutex::new(BundleCheckLog::default())),
} }
} }
@ -106,12 +134,24 @@ impl Actions {
let _ = self.pump.send(signal); 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<Item = ClusterId>) {
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)) { fn with_engine(&self, mutate: impl FnOnce(&mut AppState)) {
{ {
let mut guard = self let mut guard = self.station.clone().write_channel(AppChannel::Notifications);
.station
.clone()
.write_channel(AppChannel::Notifications);
mutate(&mut guard); mutate(&mut guard);
} }
self.nudge(PumpSignal::Reconcile); self.nudge(PumpSignal::Reconcile);
@ -135,8 +175,10 @@ impl Actions {
guard.settings.error = None; guard.settings.error = None;
} }
let loaded = let loaded = oneclient_core::settings::store::load_settings(Some(
oneclient_core::settings::store::load_settings(Some(&state.services.events)).await; &state.services.events,
))
.await;
let discord_enabled = loaded.discord_enabled; let discord_enabled = loaded.discord_enabled;
*state.settings.write() = loaded.clone(); *state.settings.write() = loaded.clone();
state.discord.set_enabled(discord_enabled); state.discord.set_enabled(discord_enabled);
@ -168,10 +210,7 @@ impl Actions {
}); });
} }
fn mutate_settings( fn mutate_settings(&self, mutate: impl FnOnce(&mut LauncherSettings)) -> Option<LauncherSettings> {
&self,
mutate: impl FnOnce(&mut LauncherSettings),
) -> Option<LauncherSettings> {
let state = launcher::state().ok()?; let state = launcher::state().ok()?;
let updated = { let updated = {
let mut lock = state.settings.write(); let mut lock = state.settings.write();
@ -248,9 +287,37 @@ impl Actions {
} }
pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) { 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_tos_version = terms_version;
settings.accepted_privacy_version = privacy_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); self.persist(updated);
} }
@ -456,12 +523,14 @@ impl Actions {
Ok(_) => { Ok(_) => {
events.signal(oneclient_events::Signal::JavaChanged); events.signal(oneclient_events::Signal::JavaChanged);
invalidate_java_queries().await invalidate_java_queries().await
} },
Err(err) => events Err(err) => {
events
.notify("Java install failed") .notify("Java install failed")
.body(err.to_string()) .body(err.to_string())
.error() .error()
.send(), .send()
}
} }
}); });
} }
@ -533,11 +602,7 @@ impl Actions {
} }
pub fn dismiss_toast(&self, entry_id: u64) { pub fn dismiss_toast(&self, entry_id: u64) {
self.with_engine(|state| { self.with_engine(|state| state.notifications.dismiss_toast(&mut state.inbox, entry_id));
state
.notifications
.dismiss_toast(&mut state.inbox, entry_id)
});
} }
pub fn mark_notification_read(&self, entry_id: u64) { pub fn mark_notification_read(&self, entry_id: u64) {
@ -563,6 +628,173 @@ impl Actions {
self.with_engine(|state| state.notifications.close_cluster_update()); self.with_engine(|state| state.notifications.close_cluster_update());
} }
pub fn open_optional_mods(
&self,
groups: Vec<OptionalModsGroup>,
done: Option<tokio::sync::oneshot::Sender<()>>,
) {
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<ClusterId> = 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<ClusterId> = 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) { pub fn close_package_updates(&self) {
self.with_engine(|state| state.notifications.close_package_updates()); self.with_engine(|state| state.notifications.close_package_updates());
} }
@ -631,8 +863,6 @@ impl Actions {
.progress(id, "Downloading assets", current, total); .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) { pub fn launch_cluster(&self, cluster_id: ClusterId) {
let claimed = self let claimed = self
.station .station
@ -678,28 +908,44 @@ impl Actions {
.error = None; .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, &self,
cluster_id: ClusterId, cluster_id: ClusterId,
content_type: ContentType, files: Vec<(PathBuf, ContentType)>,
path: PathBuf,
) { ) {
if files.is_empty() {
return;
}
spawn_forever(async move { spawn_forever(async move {
let Ok(state) = launcher::state() else { return }; let Ok(state) = launcher::state() else { return };
let events = state.services.events.clone(); let events = state.services.events.clone();
match oneclient_content::packages::PackageStore::import_local_file( match oneclient_content::packages::PackageStore::import_local_files(
&path, &files,
content_type,
cluster_id, cluster_id,
&state.services.content(), &state.services.content(),
) )
.await .await
{ {
Ok(row) => { Ok(report) => {
events let mut deferred = false;
.notify("Imported") for row in &report.imported {
.body(format!("Added {}", row.file_name)) let live = oneclient_content::packages::PackageStore::sync_live_content(
.send(); 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; super::invalidate_cluster_queries().await;
} }
Err(err) => events Err(err) => events
@ -760,6 +1006,7 @@ impl Actions {
name, name,
&install.dependencies, &install.dependencies,
&install.missing_dependencies, &install.missing_dependencies,
install.live_deferred,
), ),
level: Level::Info, level: Level::Info,
icon: Some(IconType::Download01), icon: Some(IconType::Download01),
@ -931,17 +1178,82 @@ impl Actions {
} }
}; };
actions.record_bundle_checks([cluster_id]);
super::invalidate_cluster_queries().await; super::invalidate_cluster_queries().await;
if let Some(spec) = if let Some(spec) =
crate::install::cluster_update_notification(cluster_id, &result, &state.services) crate::install::cluster_update_notification(cluster_id, &result, &state.services)
.await .await
{ {
// The offers are queued by the sync itself and raised at launch
// this path only reports what changed
actions.push_notification(spec); 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 /// happen before the flag is raised not inside the task
pub fn sync_bundles(&self) { pub fn sync_bundles(&self) {
let state = match launcher::state() { let state = match launcher::state() {
@ -960,9 +1272,14 @@ impl Actions {
.syncing_bundles = true; .syncing_bundles = true;
spawn_forever(async move { 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 { if let Err(err) = oneclient_core::clusters::apply_remote_migrations(&state).await {
tracing::error!("cluster migrations failed: {err:#}"); tracing::error!("cluster migrations failed: {err:#}");
} }
@ -984,6 +1301,12 @@ impl Actions {
.await; .await;
let session_id = session.detach(); 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 actions
.station .station
.clone() .clone()
@ -992,8 +1315,7 @@ impl Actions {
.syncing_bundles = false; .syncing_bundles = false;
super::invalidate_cluster_queries().await; super::invalidate_cluster_queries().await;
let spec = let spec = crate::install::combined_cluster_update_spec(&changed, &state.services).await;
crate::install::combined_cluster_update_spec(&changed, &state.services).await;
actions.with_engine(|app| { actions.with_engine(|app| {
app.notifications app.notifications
.finish_grouped_as_actions(&mut app.inbox, session_id, spec); .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<oneclient_core::LauncherState>,
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 check runs and the cache is written whatever the mode says keeping
/// the package manager's "Update available" markers honest /// the package manager's "Update available" markers honest
/// ///
@ -1142,17 +1573,54 @@ impl Actions {
} }
} }
async fn resolve_optional_mods_before_launch(
&self,
state: &Arc<oneclient_core::LauncherState>,
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) { async fn prompt_package_updates(&self, group: PackageUpdateGroup) {
let (done, wait) = tokio::sync::oneshot::channel(); let (done, wait) = tokio::sync::oneshot::channel();
self.with_engine(move |state| { self.with_engine(move |state| {
state state.notifications.open_package_updates(vec![group], Some(done));
.notifications
.open_package_updates(vec![group], Some(done));
state.center_open = false; state.center_open = false;
}); });
// A replaced or torn-down modal is equally a reason to stop waiting
let _ = wait.await; let _ = wait.await;
} }
@ -1245,8 +1713,19 @@ impl Actions {
} }
} }
/// Failures are reported as game events so the caller only has to know the fn group_by_cluster(mods: &[(ClusterId, OptionalModRef)]) -> Vec<(ClusterId, Vec<String>)> {
/// attempt is over Everything before `launch_cluster` is pre-launch work let mut grouped: Vec<(ClusterId, Vec<String>)> = 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) { async fn launch(actions: &Actions, cluster_id: ClusterId) {
let Ok(state) = launcher::state() else { return }; let Ok(state) = launcher::state() else { return };
let events = state.services.events.clone(); 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 // Before the game process never after Minecraft reads its mods once at
// startup // startup
actions
.resolve_bundle_updates_before_launch(&state, cluster_id)
.await;
actions actions
.resolve_package_updates_before_launch(&state, cluster_id) .resolve_package_updates_before_launch(&state, cluster_id)
.await; .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 { 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 // 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 // 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"] #[must_use = "the notification is not raised until `.send()` is called"]
pub struct NotificationBuilder { pub struct NotificationBuilder {
actions: Actions, 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] #[test]
fn nothing_pending_never_opens_a_modal() { fn nothing_pending_never_opens_a_modal() {
for mode in PackageUpdateMode::ALL.iter().copied() { for mode in PackageUpdateMode::ALL.iter().copied() {

View file

@ -8,7 +8,7 @@ use oneclient_db::models::ClusterId;
pub struct ActiveClusterState(pub State<Option<ClusterId>>); pub struct ActiveClusterState(pub State<Option<ClusterId>>);
pub fn use_provide_active_cluster(active: ActiveClusterState) { 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<Option<ClusterId>> { pub fn use_active_cluster_id() -> State<Option<ClusterId>> {
@ -31,11 +31,24 @@ pub fn use_splash() -> SplashState {
consume_root_context::<SplashState>() consume_root_context::<SplashState>()
} }
/// 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::<StartMaximizedState>().0
}
#[derive(Clone)] #[derive(Clone)]
pub struct BrowserCompatState(pub State<bool>); pub struct BrowserCompatState(pub State<bool>);
pub fn use_provide_browser_compat(state: BrowserCompatState) { 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<bool> { pub fn use_browser_compat() -> State<bool> {
@ -53,7 +66,7 @@ pub fn use_link_confirm() -> State<Option<String>> {
consume_root_context::<LinkConfirmState>().0 consume_root_context::<LinkConfirmState>().0
} }
#[derive(Clone, PartialEq, Eq)] #[derive(Clone)]
pub struct BrowserUiState { pub struct BrowserUiState {
pub query: String, pub query: String,
pub provider: ProviderId, pub provider: ProviderId,
@ -76,7 +89,7 @@ impl Default for BrowserUiState {
pub struct BrowserStateStore(pub State<HashMap<String, BrowserUiState>>); pub struct BrowserStateStore(pub State<HashMap<String, BrowserUiState>>);
pub fn use_provide_browser_state(store: BrowserStateStore) { 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<HashMap<String, BrowserUiState>> { pub fn use_browser_state_store() -> State<HashMap<String, BrowserUiState>> {
@ -98,12 +111,67 @@ pub struct OnboardingSelectionState {
/// `None` = don't import /// `None` = don't import
pub import_folder: State<Option<String>>, pub import_folder: State<Option<String>>,
pub import_dedicated: State<bool>, pub import_dedicated: State<bool>,
pub picks_location: State<bool>,
} }
pub fn use_provide_onboarding_selection(state: OnboardingSelectionState) { 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 { pub fn use_onboarding_selection() -> OnboardingSelectionState {
consume_root_context::<OnboardingSelectionState>() consume_root_context::<OnboardingSelectionState>()
} }
#[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::<ClusterId>);
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::<State<bool>>();
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();
}
}

View file

@ -1,18 +1,25 @@
mod actions;
mod active_cluster; mod active_cluster;
mod debounce; mod debounce;
mod actions;
mod overlay_claims;
mod queries; mod queries;
mod shortcut_actions;
mod view_state; mod view_state;
pub use debounce::use_debounced; 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 view_state::{PersistedView, use_view_state};
pub use active_cluster::{ pub use active_cluster::{
ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserUiState, LinkConfirmState, ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserUiState, LinkConfirmState,
OnboardingSelectionState, SplashState, use_active_cluster_id, use_browser_compat, OnboardingSelectionState, SplashState, StartMaximizedState, use_active_cluster_id,
use_browser_state_store, use_link_confirm, use_onboarding_selection, 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_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}; pub use actions::{Actions, NotificationBuilder, PumpSignal};
@ -22,42 +29,50 @@ pub use queries::{
ClusterLogsQuery, FinishMicrosoftLoginMutation, LogAction, LogContentQuery, MigrationQuery, ClusterLogsQuery, FinishMicrosoftLoginMutation, LogAction, LogContentQuery, MigrationQuery,
OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction,
SetDefaultAccountKeys, StorageAction, StorageActionMutation, StorageReportQuery, TermsQuery, SetDefaultAccountKeys, StorageAction, StorageActionMutation, StorageReportQuery, TermsQuery,
UploadLogKeys, UploadLogMutation, UseLogAction, UseRefreshAccount, UseRemoveAccount, UploadLogKeys, UploadLogMutation, UseLogAction,
UseScreenshotAction, UseSetDefaultAccount, UseStorageAction, UseUploadLog, VERSIONS_PAGE_SIZE, UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount,
accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, category_list, UseStorageAction, UseUploadLog,
changelog_error, changelog_groups, changelog_is_loading, cluster_content_items, 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, content_type_for_slug, has_migration_data, invalidate_cluster_content_queries,
invalidate_cluster_queries, invalidate_java_queries, invalidate_logs_queries, invalidate_cluster_queries, invalidate_java_queries,
invalidate_profile_queries, invalidate_screenshots_queries, invalidate_storage_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, java_runtimes, latest_changelog_version, loaded_image, loader_versions,
login_code_already_handled, migration_detection, mutation_error, mutation_is_pending, login_code_already_handled, migration_detection,
mutation_is_running, onboarding_bundles_items, package_meta_batch, package_updates, mutation_error, mutation_is_pending, mutation_is_running, onboarding_bundles_items, package_meta_batch,
pick_version_metadata, project_detail, provider_versions, query_error, query_is_busy, 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, 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_accounts, try_cluster_analytics, try_cluster_logs, try_cluster_screenshots,
try_default_account, try_game_profile, try_global_analytics, try_log_content, try_default_account, try_game_profile, try_global_analytics, try_log_content, use_account,
try_storage_report, use_account, use_accounts, use_add_microsoft_account, use_accounts, use_add_microsoft_account, use_add_offline_account, use_begin_microsoft_login,
use_add_offline_account, use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates, use_bundle_overrides, use_bundle_updates, use_bundles_with_status, use_cached_image,
use_bundles_with_status, use_cached_image, use_cancel_microsoft_login, use_changelog, use_cancel_microsoft_login, use_changelog, use_cluster_analytics, use_cluster_content,
use_cluster, use_cluster_analytics, use_cluster_content, use_cluster_logs, use_cluster_logs, use_cluster_mutation, use_cluster_profile, use_cluster_screenshots,
use_cluster_mutation, use_cluster_profile, use_cluster_screenshots, use_cluster_settings, use_cluster, use_cluster_settings, use_clusters, use_current_account, use_default_account,
use_clusters, use_current_account, use_default_account, use_finish_microsoft_login, use_finish_microsoft_login, use_game_profile, use_global_analytics, use_java_runtimes,
use_game_profile, use_global_analytics, use_java_runtimes, use_loader_versions, use_loader_versions, use_local_image, use_log_action, use_log_content, use_migration,
use_local_image, use_log_action, use_log_content, use_migration, use_named_profiles, use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch,
use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project, use_package_project, use_package_search, use_package_versions, use_package_versions_when,
use_package_search, use_package_updates, use_package_versions, use_package_versions_when, use_player_profile,
use_player_profile, use_player_skin, use_custom_skin, delete_account_skin, use_player_skin, use_custom_skin, delete_account_skin, save_account_skin, invalidate_skin_queries,
save_account_skin, invalidate_skin_queries, use_provider_versions, use_refresh_account, use_provider_versions, use_refresh_account, use_refresh_all_accounts,
use_refresh_all_accounts, use_remove_account, use_screenshot_action, use_remove_account, use_screenshot_action, use_screenshot_folder_watch, use_set_default_account,
use_screenshot_folder_watch, use_set_default_account, use_storage_action, use_storage_report, use_terms, use_upload_log,
use_terms, use_upload_log, use_version_metadata, use_versions, version_list, versions_metadata, use_version_metadata, use_versions, version_list, versions_metadata, versions_total,
versions_total,
}; };
pub(crate) use queries::invalidate_auth_queries;
use crate::notifications::NotificationSnapshot; use crate::notifications::NotificationSnapshot;
use crate::state::{ use crate::state::{
AppChannel, GameState, InstallState, LauncherInit, LoginProgress, SettingsState, AppChannel, GameState, InstallState, LauncherInit, LoginProgress, RelocationState,
SettingsState,
}; };
use freya::prelude::*; use freya::prelude::*;
use freya::radio::use_radio; use freya::radio::use_radio;
@ -77,6 +92,10 @@ pub fn use_launcher() -> LauncherInit {
use_radio(AppChannel::Launcher).read().launcher.clone() 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 { pub fn use_settings_snapshot() -> SettingsState {
use_radio(AppChannel::Settings).read().settings.clone() use_radio(AppChannel::Settings).read().settings.clone()
} }
@ -107,6 +126,13 @@ pub fn use_installs_snapshot() -> InstallState {
use_radio(AppChannel::Installs).read().installs.clone() use_radio(AppChannel::Installs).read().installs.clone()
} }
pub fn use_pending_launch() -> Option<String> {
use_radio(AppChannel::PendingLaunch)
.read()
.pending_launch
.clone()
}
pub fn use_microsoft_login_status() -> Option<LoginProgress> { pub fn use_microsoft_login_status() -> Option<LoginProgress> {
use_radio(AppChannel::MicrosoftLogin) use_radio(AppChannel::MicrosoftLogin)
.read() .read()
@ -114,4 +140,3 @@ pub fn use_microsoft_login_status() -> Option<LoginProgress> {
.clone() .clone()
} }
pub(crate) use queries::invalidate_auth_queries;

View file

@ -0,0 +1,64 @@
use std::cell::Cell;
use std::rc::Rc;
use freya::prelude::*;
#[derive(Clone, Default)]
pub struct OverlayClaims(Rc<Cell<usize>>);
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::<OverlayClaims>().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();
}
});
}

View file

@ -1,11 +1,10 @@
use std::sync::Mutex; use std::sync::Mutex;
use freya::query::{ use freya::query::{
Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability, Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, use_mutation, use_query,
UseMutation, UseQuery, use_mutation, use_query,
}; };
use oneclient_auth::{AccountKind, MicrosoftLoginSession, MinecraftAccount};
use oneclient_core::LauncherError; use oneclient_core::LauncherError;
use oneclient_auth::{AccountKind, MicrosoftLoginSession, MinecraftAccount};
use uuid::Uuid; use uuid::Uuid;
static HANDLED_LOGIN_CODE: Mutex<Option<String>> = Mutex::new(None); static HANDLED_LOGIN_CODE: Mutex<Option<String>> = Mutex::new(None);
@ -133,10 +132,7 @@ impl MutationCapability for BeginMicrosoftLoginMutation {
type Keys = (); type Keys = ();
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> { async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
Ok(crate::launcher::state()? Ok(crate::launcher::state()?.auth.begin_microsoft_login().await?)
.auth
.begin_microsoft_login()
.await?)
} }
} }
@ -149,10 +145,7 @@ impl MutationCapability for FinishMicrosoftLoginMutation {
type Keys = MicrosoftLoginSession; type Keys = MicrosoftLoginSession;
async fn run(&self, flow: &Self::Keys) -> Result<Self::Ok, Self::Err> { async fn run(&self, flow: &Self::Keys) -> Result<Self::Ok, Self::Err> {
Ok(crate::launcher::state()? Ok(crate::launcher::state()?.auth.finish_microsoft_login(flow.clone()).await?)
.auth
.finish_microsoft_login(flow.clone())
.await?)
} }
async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) { async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -177,10 +170,7 @@ impl MutationCapability for CancelMicrosoftLoginMutation {
type Keys = CancelMicrosoftLoginKeys; type Keys = CancelMicrosoftLoginKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> { async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
crate::launcher::state()? crate::launcher::state()?.auth.cancel_microsoft_login(&keys.state_token).await;
.auth
.cancel_microsoft_login(&keys.state_token)
.await;
Ok(()) Ok(())
} }
} }
@ -220,10 +210,7 @@ impl MutationCapability for AddOfflineAccountMutation {
type Keys = AddOfflineAccountKeys; type Keys = AddOfflineAccountKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> { async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
Ok(crate::launcher::state()? Ok(crate::launcher::state()?.auth.add_offline_account(keys.username.clone()).await?)
.auth
.add_offline_account(keys.username.clone())
.await?)
} }
async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) { async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -247,10 +234,7 @@ impl MutationCapability for RemoveAccountMutation {
type Keys = RemoveAccountKeys; type Keys = RemoveAccountKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> { async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
Ok(crate::launcher::state()? Ok(crate::launcher::state()?.auth.remove_account(keys.id).await?)
.auth
.remove_account(keys.id)
.await?)
} }
async fn on_settled(&self, keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) { async fn on_settled(&self, keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -274,10 +258,7 @@ impl MutationCapability for SetDefaultAccountMutation {
type Keys = SetDefaultAccountKeys; type Keys = SetDefaultAccountKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> { async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
Ok(crate::launcher::state()? Ok(crate::launcher::state()?.auth.set_default_account(keys.id).await?)
.auth
.set_default_account(keys.id)
.await?)
} }
async fn on_settled(&self, keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) { async fn on_settled(&self, keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -301,10 +282,7 @@ impl MutationCapability for RefreshAccountMutation {
type Keys = RefreshAccountKeys; type Keys = RefreshAccountKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> { async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
Ok(crate::launcher::state()? Ok(crate::launcher::state()?.auth.refresh_account(keys.id).await?)
.auth
.refresh_account(keys.id)
.await?)
} }
async fn on_settled(&self, keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) { async fn on_settled(&self, keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -323,10 +301,7 @@ impl MutationCapability for RefreshAllAccountsMutation {
type Keys = (); type Keys = ();
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> { async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
Ok(crate::launcher::state()? Ok(crate::launcher::state()?.auth.refresh_all_accounts().await?)
.auth
.refresh_all_accounts()
.await?)
} }
async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) { async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -389,6 +364,18 @@ pub fn mutation_is_running<M: MutationCapability>(mutation: &UseMutation<M>) ->
mutation.read().state().is_loading() mutation.read().state().is_loading()
} }
pub fn mutation_ok<M>(mutation: &UseMutation<M>) -> Option<M::Ok>
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<M>(mutation: &UseMutation<M>) -> Option<M::Err> pub fn mutation_error<M>(mutation: &UseMutation<M>) -> Option<M::Err>
where where
M: MutationCapability, M: MutationCapability,

View file

@ -33,11 +33,7 @@ impl QueryCapability for OnboardingBundlesQuery {
for cluster in clusters { for cluster in clusters {
let archives = state let archives = state
.bundles .bundles
.archives_for( .archives_for(&state.services.content(), &cluster.mc_version, cluster.mc_loader)
&state.services.content(),
&cluster.mc_version,
cluster.mc_loader,
)
.await .await
.unwrap_or_default(); .unwrap_or_default();
out.push(ClusterBundles { cluster, archives }); out.push(ClusterBundles { cluster, archives });
@ -74,12 +70,14 @@ impl QueryCapability for BundlesWithStatusQuery {
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> { async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let _ = keys; let _ = keys;
let state = crate::launcher::state()?; let state = crate::launcher::state()?;
Ok(get_bundles_with_update_status( Ok(
self.cluster_id, get_bundles_with_update_status(
state.bundles.as_ref(), self.cluster_id,
&state.services.content(), 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<Self::Ok, Self::Err> { async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let _ = keys; let _ = keys;
let state = crate::launcher::state()?; let state = crate::launcher::state()?;
let rows = let rows = list_cluster_bundle_overrides(self.cluster_id, &state.services.content()).await?;
list_cluster_bundle_overrides(self.cluster_id, &state.services.content()).await?;
Ok(rows Ok(rows
.into_iter() .into_iter()
.map(|(bundle, pkg, ty)| ((bundle, pkg), ty)) .map(|(bundle, pkg, ty)| ((bundle, pkg), ty))

View file

@ -1,5 +1,7 @@
use freya::query::{Query, QueryCapability, UseQuery, use_query}; 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)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ChangelogKeys { pub struct ChangelogKeys {

View file

@ -22,8 +22,7 @@ impl QueryCapability for ClusterContentQuery {
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> { async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = crate::launcher::state()?; let state = crate::launcher::state()?;
let all = let all = PackageStore::list_linked_artifacts(self.cluster_id, &state.services.content()).await?;
PackageStore::list_linked_artifacts(self.cluster_id, &state.services.content()).await?;
Ok(all Ok(all
.into_iter() .into_iter()
.filter(|item| item.content_type == self.content_type) .filter(|item| item.content_type == self.content_type)

View file

@ -37,10 +37,7 @@ impl QueryCapability for CachedImageQuery {
/// Returns the url alongside the bytes callers key their image handle on it /// 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 /// and a stale url with fresh bytes renders the wrong picture for a frame
pub fn loaded_image( pub fn loaded_image(url: Option<&str>, query: &UseQuery<CachedImageQuery>) -> Option<(String, Bytes)> {
url: Option<&str>,
query: &UseQuery<CachedImageQuery>,
) -> Option<(String, Bytes)> {
let url = url?; let url = url?;
let bytes = super::state::settled_or_loading(query)?; let bytes = super::state::settled_or_loading(query)?;
Some((url.to_string(), bytes)) Some((url.to_string(), bytes))

View file

@ -14,7 +14,9 @@ impl QueryCapability for ListJavaRuntimesQuery {
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> { async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = crate::launcher::state().map_err(|e| e.to_string())?; 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<Self::Ok, Self::Err> { async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = crate::launcher::state().map_err(|e| e.to_string())?; let state = crate::launcher::state().map_err(|e| e.to_string())?;
state state.java.available_versions(&keys.vendor)
.java
.available_versions(&keys.vendor)
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }

View file

@ -1,8 +1,8 @@
use std::path::PathBuf; use std::path::PathBuf;
use freya::query::{ use freya::query::{
Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, Mutation, MutationCapability, QueriesStorage, Query, QueryCapability,
use_mutation, use_query, UseMutation, UseQuery, use_mutation, use_query,
}; };
use oneclient_core::{ use oneclient_core::{
LauncherError, LogFileInfo, LogLevel, LogLine, MclogsUploadResponse, ReadOptions, LauncherError, LogFileInfo, LogLevel, LogLine, MclogsUploadResponse, ReadOptions,

View file

@ -40,11 +40,11 @@ pub use auth::{
CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys, CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys,
RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount, RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount,
UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error, UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error,
mutation_is_pending, mutation_is_running, reset_login_code_dedup, try_account, try_accounts, mutation_is_pending, mutation_is_running, mutation_ok, reset_login_code_dedup, try_account, try_accounts, try_default_account,
try_default_account, use_account, use_accounts, use_add_microsoft_account, use_account, use_accounts, use_add_microsoft_account, use_add_offline_account,
use_add_offline_account, use_begin_microsoft_login, use_cancel_microsoft_login, use_begin_microsoft_login, use_cancel_microsoft_login, use_current_account,
use_current_account, use_default_account, use_finish_microsoft_login, use_refresh_account, use_default_account, use_finish_microsoft_login, use_refresh_account, use_refresh_all_accounts,
use_refresh_all_accounts, use_remove_account, use_set_default_account, use_remove_account, use_set_default_account,
}; };
pub use bundles::{ pub use bundles::{
ClusterBundles, OnboardingBundlesQuery, bundle_overrides_map, bundles_with_status_items, ClusterBundles, OnboardingBundlesQuery, bundle_overrides_map, bundles_with_status_items,
@ -52,8 +52,7 @@ pub use bundles::{
use_onboarding_bundles, use_onboarding_bundles,
}; };
pub use changelog::{ pub use changelog::{
changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, use_changelog,
use_changelog,
}; };
pub use cluster_content::{cluster_content_items, use_cluster_content}; pub use cluster_content::{cluster_content_items, use_cluster_content};
pub use clusters::{use_cluster, use_clusters}; pub use clusters::{use_cluster, use_clusters};
@ -68,6 +67,13 @@ pub use logs::{
use_log_action, use_log_content, use_upload_log, use_log_action, use_log_content, use_upload_log,
}; };
pub use migration::{MigrationQuery, has_migration_data, migration_detection, use_migration}; 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::{ pub use mutations::{
ClusterAction, invalidate_cluster_content_queries, invalidate_cluster_queries, ClusterAction, invalidate_cluster_content_queries, invalidate_cluster_queries,
invalidate_profile_queries, use_cluster_mutation, 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, 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, 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_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 player_profile::use_player_profile;
pub use screenshots::{ pub use screenshots::{
@ -93,10 +100,6 @@ pub use skin::{
use_player_skin, use_player_skin,
}; };
pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading}; 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 tos::{TermsQuery, terms_document, terms_error, terms_is_loading, use_terms};
pub use version_metadata::{pick_version_metadata, use_version_metadata}; pub use version_metadata::{pick_version_metadata, use_version_metadata};
pub use versions::{loader_versions, use_loader_versions, use_versions, versions_metadata}; pub use versions::{loader_versions, use_loader_versions, use_versions, versions_metadata};

View file

@ -1,7 +1,5 @@
use std::path::PathBuf;
use freya::query::{Mutation, MutationCapability, QueriesStorage, UseMutation, use_mutation}; 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 oneclient_db::models::ClusterId;
use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery}; use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery};
@ -25,36 +23,12 @@ async fn timed(step: &'static str, fut: impl std::future::Future<Output = ()>) {
pub async fn invalidate_cluster_queries() { pub async fn invalidate_cluster_queries() {
let started = std::time::Instant::now(); let started = std::time::Instant::now();
timed( timed("cluster_content", QueriesStorage::<ClusterContentQuery>::invalidate_all()).await;
"cluster_content", timed("bundle_overrides", QueriesStorage::<BundleOverridesQuery>::invalidate_all()).await;
QueriesStorage::<ClusterContentQuery>::invalidate_all(), timed("bundles_with_status", QueriesStorage::<BundlesWithStatusQuery>::invalidate_all()).await;
) timed("clusters", QueriesStorage::<ListClustersQuery>::invalidate_all()).await;
.await; timed("bundle_updates", QueriesStorage::<BundleUpdatesQuery>::invalidate_all()).await;
timed( timed("package_updates", QueriesStorage::<PackageUpdatesQuery>::invalidate_all()).await;
"bundle_overrides",
QueriesStorage::<BundleOverridesQuery>::invalidate_all(),
)
.await;
timed(
"bundles_with_status",
QueriesStorage::<BundlesWithStatusQuery>::invalidate_all(),
)
.await;
timed(
"clusters",
QueriesStorage::<ListClustersQuery>::invalidate_all(),
)
.await;
timed(
"bundle_updates",
QueriesStorage::<BundleUpdatesQuery>::invalidate_all(),
)
.await;
timed(
"package_updates",
QueriesStorage::<PackageUpdatesQuery>::invalidate_all(),
)
.await;
tracing::debug!( tracing::debug!(
target: "oneclient_app::perf", target: "oneclient_app::perf",
ms = started.elapsed().as_millis() as u64, ms = started.elapsed().as_millis() as u64,
@ -68,6 +42,17 @@ pub async fn invalidate_cluster_content_queries() {
QueriesStorage::<ClusterContentQuery>::invalidate_all().await; QueriesStorage::<ClusterContentQuery>::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::<ClusterContentQuery>::invalidate_all()).await;
timed("bundle_overrides", QueriesStorage::<BundleOverridesQuery>::invalidate_all()).await;
timed("bundles_with_status", QueriesStorage::<BundlesWithStatusQuery>::invalidate_all()).await;
timed("bundle_updates", QueriesStorage::<BundleUpdatesQuery>::invalidate_all()).await;
}
pub async fn invalidate_profile_queries() { pub async fn invalidate_profile_queries() {
QueriesStorage::<ListNamedProfilesQuery>::invalidate_all().await; QueriesStorage::<ListNamedProfilesQuery>::invalidate_all().await;
QueriesStorage::<GameProfileQuery>::invalidate_all().await; QueriesStorage::<GameProfileQuery>::invalidate_all().await;
@ -81,9 +66,10 @@ pub struct ClusterMutation;
#[derive(Clone, PartialEq, Eq, Hash)] #[derive(Clone, PartialEq, Eq, Hash)]
pub enum ClusterAction { pub enum ClusterAction {
ToggleArtifact { SetArtifactEnabled {
cluster_id: ClusterId, cluster_id: ClusterId,
hash: String, hash: String,
enabled: bool,
}, },
RemoveArtifact { RemoveArtifact {
cluster_id: ClusterId, cluster_id: ClusterId,
@ -102,11 +88,6 @@ pub enum ClusterAction {
/// writes `Enabled` / `Disabled` /// writes `Enabled` / `Disabled`
manifest_default: bool, manifest_default: bool,
}, },
ImportLocalFile {
cluster_id: ClusterId,
content_type: ContentType,
path: PathBuf,
},
SetDedicatedDir { SetDedicatedDir {
cluster_id: ClusterId, cluster_id: ClusterId,
dedicated: bool, dedicated: bool,
@ -127,19 +108,26 @@ impl MutationCapability for ClusterMutation {
let services = &state.services; let services = &state.services;
let content = &state.services.content(); let content = &state.services.content();
let result = match keys { let result = match keys {
ClusterAction::ToggleArtifact { cluster_id, hash } => { ClusterAction::SetArtifactEnabled {
// Applied to the game folder at next launch never mid-session cluster_id,
// Minecraft reads its mods once at startup hash,
oneclient_core::toggle_artifact_enabled(*cluster_id, hash, content) enabled,
.await } => oneclient_core::set_artifact_enabled_to(*cluster_id, hash, *enabled, content)
.map(|_| ()) .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 } => { ClusterAction::RemoveArtifact { cluster_id, hash } => {
oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, true, content).await oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, true, content).await
} }
ClusterAction::RemoveBundlePackageFromDisk { cluster_id, hash } => { ClusterAction::RemoveBundlePackageFromDisk { cluster_id, hash } => {
oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, false, content) oneclient_core::remove_artifact_from_cluster(*cluster_id, hash, false, content).await
.await
} }
ClusterAction::SetBundlePackageEnabled { ClusterAction::SetBundlePackageEnabled {
cluster_id, cluster_id,
@ -158,29 +146,22 @@ impl MutationCapability for ClusterMutation {
) )
.await .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 { ClusterAction::SetDedicatedDir {
cluster_id, cluster_id,
dedicated, dedicated,
} => state } => {
.clusters state
.set_dedicated_dir(*cluster_id, *dedicated, state.games.is_active(*cluster_id)) .clusters
.await .set_dedicated_dir(
*cluster_id,
*dedicated,
state.games.is_active(*cluster_id),
)
.await
.map_err(|err| oneclient_content::ContentError::InvalidData { .map_err(|err| oneclient_content::ContentError::InvalidData {
reason: err.to_string(), reason: err.to_string(),
}), })
}
ClusterAction::VerifyFiles { cluster_id } => { ClusterAction::VerifyFiles { cluster_id } => {
// Reports its own outcome not the generic failure toast a // Reports its own outcome not the generic failure toast a
// verify that finds nothing wrong is still a useful result // 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()) 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 if let Err(err) = result
&& let Ok(state) = crate::launcher::state() && let Ok(state) = crate::launcher::state()
{ {
state state.services.events.notify("Action failed").body(err).error().send();
.services }
.events if matches!(keys, ClusterAction::SetArtifactEnabled { .. }) {
.notify("Action failed") invalidate_enabled_flag_queries().await;
.body(err) } else {
.error() invalidate_cluster_queries().await;
.send();
} }
invalidate_cluster_queries().await;
} }
} }

View file

@ -6,8 +6,8 @@ use std::time::Duration;
use bytes::Bytes; use bytes::Bytes;
use freya::prelude::{spawn, use_hook}; use freya::prelude::{spawn, use_hook};
use freya::query::{ use freya::query::{
Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, Mutation, MutationCapability, QueriesStorage, Query, QueryCapability,
use_mutation, use_query, UseMutation, UseQuery, use_mutation, use_query,
}; };
use notify::{EventKind, RecursiveMode, Watcher}; use notify::{EventKind, RecursiveMode, Watcher};
use oneclient_core::{LauncherError, ScreenshotInfo}; use oneclient_core::{LauncherError, ScreenshotInfo};
@ -119,14 +119,15 @@ async fn watch_folder(
std::fs::create_dir_all(folder)?; std::fs::create_dir_all(folder)?;
let (tx, mut rx) = mpsc::unbounded_channel(); let (tx, mut rx) = mpsc::unbounded_channel();
let mut watcher = let mut watcher = notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
notify::recommended_watcher(move |event: notify::Result<notify::Event>| match event { match event {
Ok(event) if touches_image(&event) => { Ok(event) if touches_image(&event) => {
let _ = tx.send(()); let _ = tx.send(());
} }
Ok(_) => {} Ok(_) => {}
Err(err) => tracing::debug!(error = %err, "screenshot watcher reported an error"), Err(err) => tracing::debug!(error = %err, "screenshot watcher reported an error"),
})?; }
})?;
watcher.watch(folder, RecursiveMode::NonRecursive)?; watcher.watch(folder, RecursiveMode::NonRecursive)?;
let mut pending = false; let mut pending = false;

View file

@ -1,8 +1,8 @@
use freya::query::{Query, QueryCapability, UseQuery, use_query}; use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_core::settings::GameSettingsProfile;
use oneclient_cluster::profiles::{ use oneclient_cluster::profiles::{
get_profile_or_default, list_named_profiles, resolve_cluster_profile, get_profile_or_default, list_named_profiles, resolve_cluster_profile,
}; };
use oneclient_core::settings::GameSettingsProfile;
use oneclient_db::models::ClusterId; use oneclient_db::models::ClusterId;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -110,15 +110,11 @@ impl QueryCapability for ClusterSettingsQuery {
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> { async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let cluster_id = keys.cluster_id; let cluster_id = keys.cluster_id;
let state = crate::launcher::state().map_err(|e| e.to_string())?; let state = crate::launcher::state().map_err(|e| e.to_string())?;
let cluster = state let cluster = state.clusters.get(cluster_id)
.clusters
.get(cluster_id)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let global = state.settings.read().global_game_settings.clone(); let global = state.settings.read().global_game_settings.clone();
state state.clusters.resolve_settings(&global, &cluster)
.clusters
.resolve_settings(&global, &cluster)
.await .await
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }

View file

@ -5,6 +5,7 @@ use freya::query::{
use_mutation, use_query, use_mutation, use_query,
}; };
use oneclient_core::LauncherError; use oneclient_core::LauncherError;
use oneclient_core::relocate::{Leftovers, discard_leftovers, leftovers};
use oneclient_core::storage::{StorageReport, storage_report}; use oneclient_core::storage::{StorageReport, storage_report};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -62,7 +63,7 @@ impl MutationCapability for StorageActionMutation {
.map(|_| ()) .map(|_| ())
} }
StorageAction::CleanLegacyClusterContent => { StorageAction::CleanLegacyClusterContent => {
oneclient_core::storage::clean_legacy_cluster_content(&state) oneclient_core::storage::clean_cluster_leftovers(&state)
.await .await
.map(|_| ()) .map(|_| ())
} }
@ -82,3 +83,62 @@ pub type UseStorageAction = UseMutation<StorageActionMutation>;
pub fn use_storage_action() -> UseStorageAction { pub fn use_storage_action() -> UseStorageAction {
use_mutation(Mutation::new(StorageActionMutation)) 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<Leftovers>;
type Err = LauncherError;
type Keys = LeftoversKeys;
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
let state = crate::launcher::state()?;
Ok(leftovers(&state).await)
}
}
pub fn use_leftovers() -> UseQuery<LeftoversQuery> {
use_query(Query::new(LeftoversKeys, LeftoversQuery).stale_time(STORAGE_STALE_TIME))
}
pub fn try_leftovers(query: &UseQuery<LeftoversQuery>) -> Option<Leftovers> {
super::state::settled_or_loading(query).flatten()
}
pub async fn invalidate_leftovers_queries() {
QueriesStorage::<LeftoversQuery>::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<Self::Ok, Self::Err> {
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<Self::Ok, Self::Err>) {
if result.is_ok() {
invalidate_leftovers_queries().await;
invalidate_storage_queries().await;
}
}
}
pub type UseDiscardLeftovers = UseMutation<DiscardLeftoversMutation>;
pub fn use_discard_leftovers() -> UseDiscardLeftovers {
use_mutation(Mutation::new(DiscardLeftoversMutation))
}

View file

@ -1,6 +1,6 @@
use oneclient_common::VersionKey; use oneclient_common::VersionKey;
use oneclient_common::domain::GameLoader;
use oneclient_core::VersionMetadata; use oneclient_core::VersionMetadata;
use oneclient_common::domain::GameLoader;
use super::use_versions; use super::use_versions;

Some files were not shown because too many files have changed in this diff Show more