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:
inputs:
tag:
description: 'Release Tag (e.g. v2.2.3)'
description: 'Release Tag (e.g. v2.3.1)'
required: false
default: 'v2.2.3-c2'
default: 'v2.3.1-c1'
type: string
permissions:
@ -138,7 +138,7 @@ jobs:
elif [ -n "${{ inputs.tag }}" ]; then
echo "tag=${{ inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=v2.2.3-c2" >> $GITHUB_OUTPUT
echo "tag=v2.3.1-c1" >> $GITHUB_OUTPUT
fi
- name: Clean up previous release if exists
@ -155,7 +155,10 @@ jobs:
name: "OneLauncher-Cracked ${{ steps.release_tag.outputs.tag }}"
body: |
### What's New in ${{ steps.release_tag.outputs.tag }}
- **Fixed In-Game Multiplayer Skins**: Fixed an issue where custom skins were cloned across all players in multiplayer servers. Custom skins now target only your account's specific character slot while preserving all other players' skins/models, alongside pre-configured CustomSkinLoader support for Ely.by & SkinRestorer.
- **Rebased onto Official Upstream 2.3.1**: Integrated all upstream OneLauncher 2.3.1 updates, fixes, and performance improvements.
- **Offline / Cracked Accounts**: Full offline account support with customizable username and avatar caching.
- **Custom Offline Skins & targeted multiplayer injection**: Custom 3D skin changer, per-account UUID hash targeting preventing multiplayer skin cloning, and automatic CustomSkinLoader config generation.
- **Integrated Auto-Updater**: Native background check and direct GitHub Releases binary downloads.
draft: false
prerelease: false
generate_release_notes: false

8
.gitignore vendored
View file

@ -1,6 +1,6 @@
/target
/dist
*.swp
*~
.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",
"query": "\n\t\tSELECT cluster_id, hash, cluster_file_name, enabled\n\t\tFROM cluster_artifacts\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\t",
"query": "\n\t\tSELECT cluster_id, hash, cluster_file_name, enabled, seen_status\n\t\tFROM cluster_artifacts\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\t",
"describe": {
"columns": [
{
@ -46,6 +46,17 @@
"name": "enabled"
}
}
},
{
"name": "seen_status",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "seen_status"
}
}
}
],
"parameters": {
@ -55,8 +66,9 @@
false,
false,
false,
false,
false
]
},
"hash": "b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af"
"hash": "071d02aba0d2eb073b8ca1e6488087101255147c064612fad99500d3c993f61f"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n FROM java_versions\n ORDER BY major DESC, version DESC\n ",
"query": "\n SELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\", probe_version\n FROM java_versions\n ORDER BY major DESC, version DESC\n ",
"describe": {
"columns": [
{
@ -68,6 +68,17 @@
"name": "is_jdk"
}
}
},
{
"name": "probe_version",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "java_versions",
"name": "probe_version"
}
}
}
],
"parameters": {
@ -79,8 +90,9 @@
false,
false,
false,
false,
false
]
},
"hash": "0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba"
"hash": "0c3651cf175cb900c2466f0e97beca338b95637d43d9d067b974327f7720637b"
}

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",
"query": "\n\t\tSELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n\t\tFROM java_versions\n\t\tWHERE major = ?\n\t\tORDER BY version DESC\n\t\tLIMIT 1\n\t\t",
"query": "\n\t\tSELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\", probe_version\n\t\tFROM java_versions\n\t\tWHERE major = ?\n\t\tORDER BY version DESC\n\t\tLIMIT 1\n\t\t",
"describe": {
"columns": [
{
@ -68,6 +68,17 @@
"name": "is_jdk"
}
}
},
{
"name": "probe_version",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "java_versions",
"name": "probe_version"
}
}
}
],
"parameters": {
@ -79,8 +90,9 @@
false,
false,
false,
false,
false
]
},
"hash": "33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b"
"hash": "14ab205c5b8d4c1c435814e4b7a155acbae41a67925b6b179b82dea1758d621a"
}

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",
"query": "\n\t\tINSERT INTO cluster_artifacts (cluster_id, hash, cluster_file_name, enabled)\n\t\tVALUES (?, ?, ?, 1)\n\t\tON CONFLICT(cluster_id, hash) DO UPDATE SET\n\t\t\tcluster_file_name = excluded.cluster_file_name\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled\n\t\t",
"query": "\n\t\tINSERT INTO cluster_artifacts (cluster_id, hash, cluster_file_name, enabled)\n\t\tVALUES (?, ?, ?, 1)\n\t\tON CONFLICT(cluster_id, hash) DO UPDATE SET\n\t\t\tcluster_file_name = excluded.cluster_file_name\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled, seen_status\n\t\t",
"describe": {
"columns": [
{
@ -46,6 +46,17 @@
"name": "enabled"
}
}
},
{
"name": "seen_status",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "seen_status"
}
}
}
],
"parameters": {
@ -55,8 +66,9 @@
false,
false,
false,
false,
false
]
},
"hash": "24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51"
"hash": "25259e9d6eda7f5113e0d88f00b24b28b98de1eda40a1e25d48bbcf212af42b5"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled\n\t\tFROM cluster_artifacts ca\n\t\tJOIN provider_releases pr ON pr.hash = ca.hash\n\t\tWHERE ca.cluster_id = ?\n\t\t\tAND pr.provider = ?\n\t\t\tAND pr.project_id = ?\n\t\t\tAND ca.hash <> ?\n\t\t",
"query": "\n\t\tSELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled, ca.seen_status\n\t\tFROM cluster_artifacts ca\n\t\tJOIN provider_releases pr ON pr.hash = ca.hash\n\t\tWHERE ca.cluster_id = ?\n\t\t\tAND pr.provider = ?\n\t\t\tAND pr.project_id = ?\n\t\t\tAND ca.hash <> ?\n\t\t",
"describe": {
"columns": [
{
@ -46,6 +46,17 @@
"name": "enabled"
}
}
},
{
"name": "seen_status",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "seen_status"
}
}
}
],
"parameters": {
@ -55,8 +66,9 @@
false,
false,
false,
false,
false
]
},
"hash": "bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca"
"hash": "25f4eb2e9040bb8f1c9a38ca41a773225099f150c3fc8a41d31fd40c09711d0c"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n FROM java_versions\n WHERE absolute_path = ?\n ",
"query": "\n SELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\", probe_version\n FROM java_versions\n WHERE absolute_path = ?\n ",
"describe": {
"columns": [
{
@ -68,6 +68,17 @@
"name": "is_jdk"
}
}
},
{
"name": "probe_version",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "java_versions",
"name": "probe_version"
}
}
}
],
"parameters": {
@ -79,8 +90,9 @@
false,
false,
false,
false,
false
]
},
"hash": "f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2"
"hash": "331bee3da454f39a8a2cc8ed4e5338b743a7cd033fdef023c4b3b93827248be1"
}

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",
"query": "\n SELECT\n cluster_id, hash, cluster_file_name, enabled,\n bundle_name, bundle_version_id, package_id, installed_at\n FROM cluster_artifacts\n WHERE cluster_id = ? AND bundle_name IS NOT NULL\n ",
"query": "\n SELECT id, cluster_id, bundle_name, package_id, bundle_version_id,\n seen_status, queued_at\n FROM cluster_optional_mods\n WHERE cluster_id = ?\n ORDER BY queued_at, id\n ",
"describe": {
"columns": [
{
"name": "cluster_id",
"name": "id",
"ordinal": 0,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"table": "cluster_optional_mods",
"name": "id"
}
}
},
{
"name": "cluster_id",
"ordinal": 1,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_optional_mods",
"name": "cluster_id"
}
}
},
{
"name": "hash",
"ordinal": 1,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "hash"
}
}
},
{
"name": "cluster_file_name",
"name": "bundle_name",
"ordinal": 2,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "cluster_file_name"
}
}
},
{
"name": "enabled",
"ordinal": 3,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "enabled"
}
}
},
{
"name": "bundle_name",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_artifacts",
"table": "cluster_optional_mods",
"name": "bundle_name"
}
}
},
{
"name": "bundle_version_id",
"ordinal": 5,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "bundle_version_id"
}
}
},
{
"name": "package_id",
"ordinal": 6,
"ordinal": 3,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_artifacts",
"table": "cluster_optional_mods",
"name": "package_id"
}
}
},
{
"name": "installed_at",
"ordinal": 7,
"name": "bundle_version_id",
"ordinal": 4,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "installed_at"
"table": "cluster_optional_mods",
"name": "bundle_version_id"
}
}
},
{
"name": "seen_status",
"ordinal": 5,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_optional_mods",
"name": "seen_status"
}
}
},
{
"name": "queued_at",
"ordinal": 6,
"type_info": "Text",
"origin": {
"Table": {
"table": "cluster_optional_mods",
"name": "queued_at"
}
}
}
@ -100,11 +89,10 @@
false,
false,
false,
true,
true,
true,
true
false,
false,
false
]
},
"hash": "0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813"
"hash": "5b3dd7940a9bd6f0067fac7b5f21e83f8d9eeceda6b1c53c5ce5b735a8fe2a71"
}

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",
"query": "\n\t\tUPDATE cluster_artifacts\n\t\tSET cluster_file_name = ?, enabled = ?\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled\n\t\t",
"query": "\n\t\tUPDATE cluster_artifacts\n\t\tSET cluster_file_name = ?, enabled = ?\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled, seen_status\n\t\t",
"describe": {
"columns": [
{
@ -46,6 +46,17 @@
"name": "enabled"
}
}
},
{
"name": "seen_status",
"ordinal": 4,
"type_info": "Integer",
"origin": {
"Table": {
"table": "cluster_artifacts",
"name": "seen_status"
}
}
}
],
"parameters": {
@ -55,8 +66,9 @@
false,
false,
false,
false,
false
]
},
"hash": "4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f"
"hash": "b68194fc40d9fdd508ab6cc354fff662ba4b7ca17c1c4be3e51e30fcb3d0cb16"
}

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",
"query": "\n\t\tINSERT INTO java_versions (absolute_path, major, version, vendor, os_arch, is_jdk)\n\t\tVALUES (?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(absolute_path) DO UPDATE SET\n\t\t\tmajor = excluded.major,\n\t\t\tversion = excluded.version,\n\t\t\tvendor = excluded.vendor,\n\t\t\tos_arch = excluded.os_arch,\n\t\t\tis_jdk = excluded.is_jdk\n\t\tRETURNING absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n\t\t",
"query": "\n\t\tINSERT INTO java_versions (absolute_path, major, version, vendor, os_arch, is_jdk, probe_version)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(absolute_path) DO UPDATE SET\n\t\t\tmajor = excluded.major,\n\t\t\tversion = excluded.version,\n\t\t\tvendor = excluded.vendor,\n\t\t\tos_arch = excluded.os_arch,\n\t\t\tis_jdk = excluded.is_jdk,\n\t\t\tprobe_version = excluded.probe_version\n\t\tRETURNING absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\", probe_version\n\t\t",
"describe": {
"columns": [
{
@ -68,10 +68,21 @@
"name": "is_jdk"
}
}
},
{
"name": "probe_version",
"ordinal": 6,
"type_info": "Integer",
"origin": {
"Table": {
"table": "java_versions",
"name": "probe_version"
}
}
}
],
"parameters": {
"Right": 6
"Right": 7
},
"nullable": [
false,
@ -79,8 +90,9 @@
false,
false,
false,
false,
false
]
},
"hash": "010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc"
"hash": "f5dbcf7798afb495efe228a4ba7d1ddd9904cc25f34bef3c16f29cfb141158f0"
}

View file

@ -32,7 +32,7 @@ To find an issue that interests you, you can browse through our [existing issues
#### Making Changes Locally
This project is a pure Rust [`Cargo`] workspace (edition 2024). Make sure you have a
recent Rust toolchain (`rustc` **1.85+**) installed before proceeding — the easiest way
recent Rust toolchain (`rustc` **1.85+**) installed before proceeding - the easiest way
is via [rustup].
To make changes locally, follow these steps:

317
Cargo.lock generated
View file

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

View file

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

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

View file

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

View file

@ -4,18 +4,6 @@
<dict>
<key>CFBundleIconName</key>
<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) -->
<key>NSCameraUsageDescription</key>
<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 oneclient_common::parse_mc_version;
use crate::hooks::{
settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot,
use_launcher, use_version_metadata,
};
use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata};
use crate::routes::Route;
use crate::theme::colors;
use crate::utils::sort_clusters_for_home;

View file

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

View file

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

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

View file

@ -1,6 +1,6 @@
use freya::prelude::*;
use crate::components::{Icon, IconType, OverlayPopup};
use crate::components::{Icon, IconType, OVERLAY_BASE_LEVEL, OverlayPopup};
use crate::theme::colors;
const MENU_BG: Color = Color::from_rgb(25, 32, 38);
@ -8,6 +8,11 @@ const MENU_BORDER: Color = Color::from_argb(26, 255, 255, 255);
const MENU_FG: Color = Color::from_rgb(155, 161, 166);
const MENU_DANGER: Color = Color::from_rgb(242, 84, 90);
/// Panel padding and border on both axes, which the measured list sits inside of
const PANEL_INSET: f32 = 14.;
/// Gap the clamped menu keeps from the window edges
const EDGE_MARGIN: f32 = 8.;
enum Entry {
Action {
icon: IconType,
@ -18,11 +23,25 @@ enum Entry {
Separator,
}
fn separator(item_width: Option<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 {
x: f32,
y: f32,
upwards: bool,
title: Option<String>,
entries: Vec<Entry>,
on_close: EventHandler<()>,
overlay_level: u8,
}
impl ContextMenu {
@ -30,16 +49,34 @@ impl ContextMenu {
Self {
x,
y,
upwards: false,
title: None,
entries: Vec::new(),
on_close: (|()| {}).into(),
overlay_level: OVERLAY_BASE_LEVEL,
}
}
pub fn title(mut self, title: impl Into<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 {
self.on_close = on_close.into();
self
}
pub fn overlay_level(mut self, level: u8) -> Self {
self.overlay_level = level;
self
}
pub fn action(
mut self,
icon: IconType,
@ -87,26 +124,32 @@ impl Component for ContextMenu {
fn render(&self) -> impl IntoElement {
let on_close = self.on_close.clone();
let mut width = use_state(|| 0f32);
let mut size = use_state(Size2D::zero);
let item_width = {
let w = *width.read();
(w > 0.).then_some(w)
};
let measured = *size.read();
let item_width = (measured.width > 0.).then_some(measured.width);
let mut list = rect().vertical().spacing(4.);
if let Some(title) = &self.title {
list = list
.child(
// Asymmetric on purpose
rect().padding(Gaps::new(6., 8., 4., 8.)).child(
label()
.text(title.clone())
.font_size(11.)
.font_weight(FontWeight::SEMI_BOLD)
.max_lines(1)
.color(colors::fg_secondary()),
),
)
.child(separator(item_width));
}
for entry in &self.entries {
list = match entry {
Entry::Separator => {
let mut sep = rect()
.height(Size::px(1.))
.margin(Gaps::new_symmetric(4., 0.))
.background(MENU_BORDER);
if let Some(w) = item_width {
sep = sep.width(Size::px(w));
}
list.child(sep)
}
Entry::Separator => list.child(separator(item_width)),
Entry::Action {
icon,
label,
@ -127,12 +170,28 @@ impl Component for ContextMenu {
}
let list = list.on_sized(move |e: Event<SizedEventData>| {
let measured = e.data().area.width();
if (measured - *width.peek()).abs() > 0.5 {
width.set(measured);
let area = e.data().area.size;
let prev = *size.peek();
if (area.width - prev.width).abs() > 0.5 || (area.height - prev.height).abs() > 0.5 {
size.set(area);
}
});
let placed = measured.width > 0.;
let menu_width = measured.width + PANEL_INSET;
let menu_height = measured.height + PANEL_INSET;
let (x, y) = if placed {
let top = if self.upwards && self.y - menu_height >= 0. {
self.y - menu_height
} else {
self.y
};
clamp_to_window(self.x, top, menu_width, menu_height)
} else {
(self.x, self.y)
};
let panel = rect()
.vertical()
.padding(Gaps::new_all(6.))
@ -144,16 +203,38 @@ impl Component for ContextMenu {
bottom: 1.,
left: 1.,
}))
.opacity(if placed { 1. } else { 0. })
.child(list);
OverlayPopup::new()
.backdrop(false)
.position(Position::new_global().top(self.y).left(self.x))
.overlay_level(self.overlay_level)
.position(Position::new_global().top(y).left(x))
.on_close(move |_| on_close.call(()))
.child(panel.into_element())
}
}
/// `root_size` is physical while the press position is logical, so it has to be
/// scaled down before the two are compared
fn clamp_to_window(x: f32, y: f32, menu_width: f32, menu_height: f32) -> (f32, f32) {
let platform = Platform::get();
let scale = *platform.scale_factor.peek() as f32;
if scale <= 0. {
return (x, y);
}
let window = *platform.root_size.peek();
let clamp = |pos: f32, len: f32, limit: f32| {
pos.clamp(EDGE_MARGIN, (limit - len - EDGE_MARGIN).max(EDGE_MARGIN))
};
(
clamp(x, menu_width, window.width / scale),
clamp(y, menu_height, window.height / scale),
)
}
#[derive(PartialEq)]
struct ContextMenuRow {
icon: IconType,

View file

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

View file

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

View file

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

View file

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

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

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 img = SkImage::from_encoded(data)?;
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(
TextInput::new(memory)
.width(Size::px(90.))
.placeholder("4096")
.placeholder(oneclient_common::default_mem_max().to_string())
.on_validate(validate_memory)
.trailing(
label()

View file

@ -3,19 +3,23 @@ mod active_cluster_panel;
mod avatar;
mod button;
mod charts;
mod checkbox;
mod cluster_landscape_art;
mod cluster_update_popup;
mod context_menu;
mod optional_mods_popup;
mod dropdown;
mod dynamic_art;
mod file_drop;
mod generic_prompt;
mod grid_columns;
mod icons;
mod java_install_manager;
mod generic_prompt;
mod java_prompt;
mod link_confirm;
mod local_image;
mod log_viewer;
mod markdown;
mod memory_field;
mod microsoft_login;
mod navbar;
@ -37,41 +41,46 @@ mod text_input;
mod toasts;
mod toggle;
mod update_prompt;
pub mod upload_mclogs;
mod version_card;
pub mod upload_mclogs;
pub use account_switcher::AccountSwitcher;
pub use active_cluster_panel::ActiveClusterPanel;
pub use avatar::Avatar;
pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button};
pub use charts::{BarChart, PieChart, ValueUnit, slice_color};
pub use checkbox::checkbox_labeled;
pub use cluster_landscape_art::ClusterLandscapeArt;
pub use cluster_update_popup::ClusterUpdatePopup;
pub use context_menu::ContextMenu;
pub use optional_mods_popup::OptionalModsPopup;
pub use dropdown::Dropdown;
pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt};
pub use file_drop::FileDropOverlay;
pub use generic_prompt::GenericPromptOverlay;
pub use grid_columns::{cell_width, grid_columns_picker, resolved_columns};
pub use icons::{Icon, IconTint, IconType};
pub use java_install_manager::JavaInstallManager;
pub use generic_prompt::GenericPromptOverlay;
pub use java_prompt::JavaPromptOverlay;
pub use link_confirm::ConfirmLinkOverlay;
pub use local_image::LocalImage;
pub use markdown::{Markdown, MarkdownStyle};
pub use log_viewer::LogViewer;
pub use memory_field::memory_field;
pub(crate) use microsoft_login::login_dialog;
pub use microsoft_login::use_microsoft_login;
pub(crate) use microsoft_login::login_dialog;
pub(crate) use navbar::window_controls;
pub use navbar::{AppNavbar, OnboardingNavbar};
pub use notifications::NotificationCenter;
pub use overlay_popup::OverlayPopup;
pub use overlay_popup::{OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL, OverlayPopup, overlay_is_topmost};
pub use package_row::{CardLayout, PackageEntry, PackageRow, provider_badge};
pub(crate) use package_row::{CARD_GRID_H, GRID_GAP, GRID_MIN_W, grid_card, package_icon};
pub use package_update_popup::PackageUpdatePopup;
pub use pagination::Pagination;
pub use player_model::PlayerModel;
pub use progress_track::progress_track;
pub use recents_row::RecentsRow;
pub use screenshot_viewer::ScreenshotViewer;
pub use screenshot_viewer::{ScreenshotViewer, screenshot_context_menu};
pub(crate) use scrollview::corrected_scroll;
pub use scrollview::{ScrollArea, ScrollAreaCtx};
pub use segmented_control::{Segment, SegmentedControl};

View file

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

View file

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

View file

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

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::{
animation::{AnimNum, Ease, OnCreation, use_animation},
prelude::*,
};
use crate::hooks::use_overlay_claim;
const SCRIM_ALPHA: f32 = 90.;
/// Overlay level of a top level popup. Its scrim sits two levels below it.
pub const OVERLAY_BASE_LEVEL: u8 = 12;
/// Freya maps a level to `(level.max(1) as i16) * (i16::MAX / 16)`, which
/// saturates above 16 so every higher level collides with 16.
pub const OVERLAY_MAX_LEVEL: u8 = 16;
thread_local! {
static OPEN_LEVELS: RefCell<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)]
pub struct OverlayPopup {
children: Vec<Element>,
on_close: Option<EventHandler<()>>,
position: Position,
backdrop: bool,
overlay_level: u8,
key: DiffKey,
}
@ -27,6 +50,7 @@ impl OverlayPopup {
on_close: None,
position: Position::new_global().top(0.).left(0.),
backdrop: true,
overlay_level: OVERLAY_BASE_LEVEL,
key: DiffKey::None,
}
}
@ -36,6 +60,15 @@ impl OverlayPopup {
self
}
pub fn overlay_level(mut self, level: u8) -> Self {
debug_assert!(
(1..=OVERLAY_MAX_LEVEL).contains(&level),
"overlay levels above {OVERLAY_MAX_LEVEL} saturate onto {OVERLAY_MAX_LEVEL}"
);
self.overlay_level = level;
self
}
pub fn backdrop(mut self, backdrop: bool) -> Self {
self.backdrop = backdrop;
self
@ -61,9 +94,21 @@ impl KeyExt for OverlayPopup {
impl Component for OverlayPopup {
fn render(&self) -> impl IntoElement {
use_overlay_claim();
let a11y_id = use_a11y();
let scrim_close = self.on_close.clone();
let key_close = self.on_close.clone();
let level = self.overlay_level;
use_hook(move || OPEN_LEVELS.with_borrow_mut(|open| open.push(level)));
use_drop(move || {
OPEN_LEVELS.with_borrow_mut(|open| {
if let Some(pos) = open.iter().rposition(|&other| other == level) {
open.remove(pos);
}
});
});
let fade = use_animation(|conf| {
conf.on_creation(OnCreation::Run);
@ -83,7 +128,7 @@ impl Component for OverlayPopup {
.position(Position::new_global().top(0.).left(0.))
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.layer(Layer::OverlayLevel(10))
.layer(Layer::OverlayLevel(self.overlay_level.saturating_sub(2)))
.background(Color::from_argb(alpha, 0, 0, 0))
.on_press(move |_| {
if let Some(on_close) = scrim_close.as_ref() {
@ -98,9 +143,10 @@ impl Component for OverlayPopup {
.a11y_focusable(true)
.a11y_auto_focus(true)
.a11y_role(AccessibilityRole::Dialog)
.layer(Layer::OverlayLevel(12))
.layer(Layer::OverlayLevel(self.overlay_level))
.on_global_key_down(move |e: Event<KeyboardEventData>| {
if e.key == Key::Named(NamedKey::Escape)
&& overlay_is_topmost(level)
&& let Some(on_close) = key_close.as_ref()
{
on_close.call(());

View file

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

View file

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

View file

@ -3,7 +3,7 @@ use freya::prelude::*;
use freya::router::RouterContext;
use oneclient_core::clusters::Cluster;
use crate::components::{ART_PREVIEW_EDGE, DynamicArt, Icon, IconType};
use crate::components::{ART_PREVIEW_EDGE, ContextMenu, DynamicArt, Icon, IconType};
use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters};
use crate::routes::Route;
use crate::theme;
@ -35,8 +35,10 @@ impl Component for RecentsRow {
let sorted: Vec<Cluster> = sort_clusters_for_home(clusters);
let slots =
recent_card_slots_for_window(root_size.read().width, *scale_factor.read() as f32);
let slots = recent_card_slots_for_window(
root_size.read().width,
*scale_factor.read() as f32,
);
// Budget off cluster count not slot count an offset-only change makes the layout
// engine reuse a card's cached area freezing survivors at pre-resize widths
@ -65,7 +67,9 @@ impl Component for RecentsRow {
index,
items,
progress,
key: DiffKey::None,
}
.key(cluster.id)
.into_element()
}))
.child(OtherVersionsTile {
@ -92,11 +96,58 @@ fn stagger_eased(progress: f32, index: usize, items: usize) -> f32 {
1.0 - (1.0 - local).powi(3)
}
fn cluster_menu_entries(cluster_id: i64) -> [(IconType, &'static str, Route); 7] {
[
(
IconType::InfoCircle,
"Overview",
Route::ClusterOverview { cluster_id },
),
(
IconType::Terminal,
"Logs",
Route::ClusterLogs { cluster_id },
),
(
IconType::Eye,
"Screenshots",
Route::ClusterScreenshots { cluster_id },
),
(
IconType::CodeSnippet02,
"Mods",
Route::ClusterMods { cluster_id },
),
(
IconType::PaintPour,
"Shaders",
Route::ClusterShaders { cluster_id },
),
(
IconType::Colors,
"Textures",
Route::ClusterTextures { cluster_id },
),
(
IconType::Settings01,
"Settings",
Route::ClusterSettings { cluster_id },
),
]
}
struct ClusterCard {
cluster: Cluster,
index: usize,
items: usize,
progress: f32,
key: DiffKey,
}
impl KeyExt for ClusterCard {
fn write_key(&mut self) -> &mut DiffKey {
&mut self.key
}
}
impl PartialEq for ClusterCard {
@ -113,6 +164,7 @@ impl Component for ClusterCard {
let mut active_id = use_active_cluster_id();
let active = *active_id.read() == Some(self.cluster.id);
let mut hovering = use_state(|| false);
let mut menu = use_state(|| None::<(f32, f32)>);
let a11y_id = use_a11y();
let focus = use_focus(a11y_id);
@ -128,6 +180,19 @@ impl Component for ClusterCard {
*active_id.write() = Some(cluster_id);
};
let menu_overlay = (*menu.read()).map(|(x, y)| {
let mut context = ContextMenu::new(x, y)
.open_upwards()
.title(title.clone())
.on_close(move |_| menu.set(None));
for (icon, label, route) in cluster_menu_entries(cluster_id) {
context = context.action(icon, label, move |()| {
let _ = RouterContext::get().push(route.clone());
});
}
context.into_element()
});
rect()
.key(self.cluster.id)
.width(Size::flex(1.0))
@ -171,7 +236,15 @@ impl Component for ClusterCard {
.y(0.)
.color(Color::from_af32rgb(0.3, 0, 0, 0)),
)
.on_all_press(on_press)
.on_press(on_press)
.on_secondary_down(move |e: Event<PressEventData>| {
if let PressEventData::Mouse(m) = e.data() {
menu.set(Some((
m.global_location.x as f32,
m.global_location.y as f32,
)));
}
})
.child(
rect()
.width(Size::fill())
@ -182,10 +255,7 @@ impl Component for ClusterCard {
.height(Size::fill())
.position(Position::new_absolute())
.layer(Layer::Relative(1))
.child(
DynamicArt::for_cluster(&self.cluster)
.max_edge(ART_PREVIEW_EDGE),
),
.child(DynamicArt::for_cluster(&self.cluster).max_edge(ART_PREVIEW_EDGE)),
)
.child(
rect()
@ -224,6 +294,11 @@ impl Component for ClusterCard {
),
),
)
.maybe_child(menu_overlay)
}
fn render_key(&self) -> DiffKey {
self.key.clone().or(self.default_key())
}
}
@ -282,7 +357,7 @@ impl Component for OtherVersionsTile {
.y(0.)
.color(Color::from_af32rgb(0.3, 0, 0, 0)),
)
.on_all_press(|_| {
.on_press(|_| {
let _ = RouterContext::get().push(Route::Clusters {});
})
.child(

View file

@ -1,8 +1,15 @@
use std::path::PathBuf;
use freya::prelude::*;
use oneclient_core::ScreenshotInfo;
use crate::components::{Button, Icon, IconType, LocalImage, OverlayPopup};
use crate::hooks::{ScreenshotAction, use_dispatch, use_screenshot_action};
use crate::components::{
Button, ContextMenu, Icon, IconType, LocalImage, OVERLAY_BASE_LEVEL, OVERLAY_MAX_LEVEL,
OverlayPopup, overlay_is_topmost,
};
use crate::hooks::{
Actions, ScreenshotAction, UseScreenshotAction, use_dispatch, use_screenshot_action,
};
use crate::theme::colors;
use crate::ui::fmt_date;
use crate::utils::{format_res, format_size};
@ -37,6 +44,7 @@ impl Component for ScreenshotViewer {
let start = self.start.min(len.saturating_sub(1));
move || start
});
let mut menu = use_state(|| None::<(f32, f32)>);
if len == 0 {
return rect().into_element();
@ -50,6 +58,7 @@ impl Component for ScreenshotViewer {
let close = self.on_close.clone();
let scrim_close = self.on_close.clone();
let delete_close = self.on_close.clone();
let menu_delete_close = self.on_close.clone();
let has_prev = idx > 0;
let has_next = idx + 1 < len;
@ -61,6 +70,23 @@ impl Component for ScreenshotViewer {
let copy_dispatch = dispatch.clone();
let delete_path = info.path.clone();
let menu_overlay = (*menu.read()).map(|(x, y)| {
let menu_close = menu_delete_close.clone();
// Nested inside this viewer's popup, so it has to be raised above it
screenshot_context_menu(
x,
y,
info.path.clone(),
action,
dispatch.clone(),
move |()| menu_close.call(()),
)
.overlay_level(OVERLAY_MAX_LEVEL)
.on_close(move |_| menu.set(None))
.into_element()
});
OverlayPopup::new()
.on_close(move |_| scrim_close.call(()))
.child(
@ -68,10 +94,15 @@ impl Component for ScreenshotViewer {
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.on_global_key_down(move |e: Event<KeyboardEventData>| match &e.key {
Key::Named(NamedKey::ArrowLeft) if idx > 0 => index.set(idx - 1),
Key::Named(NamedKey::ArrowRight) if idx + 1 < len => index.set(idx + 1),
_ => {}
.on_global_key_down(move |e: Event<KeyboardEventData>| {
if !overlay_is_topmost(OVERLAY_BASE_LEVEL) {
return;
}
match &e.key {
Key::Named(NamedKey::ArrowLeft) if idx > 0 => index.set(idx - 1),
Key::Named(NamedKey::ArrowRight) if idx + 1 < len => index.set(idx + 1),
_ => {}
}
})
.child(
rect()
@ -100,6 +131,14 @@ impl Component for ScreenshotViewer {
rect()
.width(Size::flex(1.0))
.height(Size::fill())
.on_secondary_down(move |e: Event<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(chevron_btn(
@ -123,7 +162,7 @@ impl Component for ScreenshotViewer {
.secondary()
.on_press(move |_| {
if let Some(dir) = open_path.parent() {
crate::platform::open_url(
crate::platform::open_path(
&dir.to_string_lossy(),
);
}
@ -163,10 +202,50 @@ impl Component for ScreenshotViewer {
),
),
)
.maybe_child(menu_overlay)
.into_element()
}
}
/// The screenshot menu shown both by the grid and by this viewer, so the two
/// cannot drift apart
pub fn screenshot_context_menu(
x: f32,
y: f32,
path: PathBuf,
action: UseScreenshotAction,
dispatch: Actions,
on_deleted: impl Into<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(
icon: IconType,
enabled: bool,

View file

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

View file

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

View file

@ -187,7 +187,7 @@ impl Component for TabButton {
.cursor(CursorIcon::Pointer);
if let Some(handler) = self.on_press.clone() {
el = el.on_all_press(move |e| handler.call(e));
el = el.on_press(move |e| handler.call(e));
}
el.child(

View file

@ -10,12 +10,12 @@ use freya::{
use oneclient_events::Level;
use crate::{
ui::{divider},
components::{Button, ButtonSize, Icon, IconType, progress_track},
hooks::{use_dispatch, use_notifications_snapshot},
motion::use_animations_enabled,
notifications::{InboxEntry, NotificationActionKind},
theme::colors,
ui::divider,
};
const TOAST_W: f32 = 300.;
@ -337,7 +337,12 @@ fn close_button(dispatch: crate::Actions, id: u64) -> impl IntoElement {
)
}
fn message_card(entry: &InboxEntry, dispatch: crate::Actions, id: u64, ttl_pct: f32) -> Rect {
fn message_card(
entry: &InboxEntry,
dispatch: crate::Actions,
id: u64,
ttl_pct: f32,
) -> Rect {
toast_shell(entry)
.child(
rect()

View file

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

View file

@ -14,7 +14,10 @@ pub struct UploadToMclogs {
impl UploadToMclogs {
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_focusable(true)
.a11y_role(AccessibilityRole::Button)
.on_all_press(move |e| on_press.call(e))
.on_press(move |e| on_press.call(e))
.on_pointer_enter(move |_| {
*hovering.write() = true;
})

View file

@ -4,4 +4,4 @@ pub const WINDOW_TITLE: &str = "OneClient";
pub const GITHUB_REPO: &str = "oiupoyt/OneLauncher-Cracked";
pub const GITHUB_API_LATEST_RELEASE: &str =
"https://api.github.com/repos/oiupoyt/OneLauncher-Cracked/releases/latest";
pub const RELEASES_URL: &str = "https://github.com/oiupoyt/OneLauncher-Cracked/releases/latest";
pub const RELEASES_URL: &str = "https://github.com/oiupoyt/OneLauncher-Cracked/releases/latest";

View file

@ -171,10 +171,7 @@ impl EventPump {
guard.game.stages.insert(cluster_id, stage);
if stage == LaunchStage::Checking {
guard.game.error = None;
guard
.game
.logs
.insert(cluster_id, std::sync::Arc::new(Vec::new()));
guard.game.logs.insert(cluster_id, std::sync::Arc::new(Vec::new()));
}
}
for (cluster_id, line) in logs {
@ -249,9 +246,7 @@ fn reconcile(
.collect();
for id in &want {
armed
.entry(*id)
.or_insert_with(|| ToastTimer::armed(paused));
armed.entry(*id).or_insert_with(|| ToastTimer::armed(paused));
}
armed.retain(|id, _| want.contains(id));
}
@ -313,7 +308,7 @@ pub async fn start_launcher(
oneclient_polyplus::start(std::sync::Arc::clone(&state.auth));
oneclient_core::run_startup_tasks(&state);
let data_dir = oneclient_common::paths::launcher_dir()
let data_dir = oneclient_common::paths::data_dir()
.map(|p| p.display().to_string())
.unwrap_or_default();
@ -330,6 +325,7 @@ pub async fn start_launcher(
error: None,
snapshots: 0,
data_dir,
needs_location: false,
};
}
{
@ -344,11 +340,14 @@ pub async fn start_launcher(
Ok(())
}
pub fn report_startup_failure(station: &RadioStation<AppState, AppChannel>, err: &anyhow::Error) {
pub fn report_startup_failure(
station: &RadioStation<AppState, AppChannel>,
err: &anyhow::Error,
) {
let message = err.to_string();
tracing::error!("launcher init failed: {err:#}");
let data_dir = oneclient_common::paths::launcher_dir()
let data_dir = oneclient_common::paths::data_dir()
.map(|p| p.display().to_string())
.unwrap_or_default();
@ -362,9 +361,7 @@ pub fn report_startup_failure(station: &RadioStation<AppState, AppChannel>, err:
let mut guard = station.write_channel(AppChannel::Notifications);
let AppState {
notifications,
inbox,
..
notifications, inbox, ..
} = &mut **guard;
notifications.dispatch(
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
//! the calling component unmounts this work is app-scoped not component-scoped
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use freya::prelude::spawn_forever;
@ -13,6 +14,8 @@ use oneclient_cluster::{
ClusterStage, ClusterUpdate, GameSettingsProfile, PackageUpdateMode, ProfileUpdate,
};
use oneclient_common::domain::{ContentType, ProviderId};
use oneclient_content::packages::{LiveSync, LocalImportReport};
use oneclient_core::relocate::RelocationPlan;
use oneclient_core::settings::LauncherSettings;
use oneclient_core::settings::store::{save_global_profile, save_settings_and_apply};
use oneclient_db::models::ClusterId;
@ -20,11 +23,12 @@ use oneclient_events::{Answer, Level};
use tokio::sync::mpsc;
use crate::components::IconType;
use crate::notifications::{
ClusterUpdateSummary, NotificationAction, NotificationSpec, PackageUpdateGroup, PendingPrompt,
};
use crate::state::{AppChannel, AppState, AsyncStatus};
use crate::{invalidate_java_queries, launcher};
use crate::notifications::{
ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, OptionalModsGroup,
PackageUpdateGroup, PendingPrompt,
};
use crate::state::{AppChannel, AppState, AsyncStatus, RelocationState};
/// Over-disabling is the cheaper mistake a dead button beats a second game
const LAUNCH_HOLD: Duration = Duration::from_secs(2);
@ -32,6 +36,28 @@ const LAUNCH_HOLD: Duration = Duration::from_secs(2);
/// Mirrors `oneclient_net`'s reachability probe not reqwest's per-request
/// timeouts which are minutes long This sits between Play and the game
const UPDATE_CHECK_BUDGET: Duration = Duration::from_secs(8);
const BUNDLE_CHECK_INTERVAL: Duration = Duration::from_secs(15 * 60);
const BUNDLE_SYNC_BUDGET: Duration = Duration::from_secs(30);
/// The downloads need one too the net stack alone retries for minutes and the
/// launch button is already claimed by then
const BUNDLE_APPLY_BUDGET: Duration = Duration::from_secs(60);
#[derive(Debug, Default)]
struct BundleCheckLog {
checked_at: HashMap<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)]
enum LaunchUpdatePlan {
@ -75,6 +101,7 @@ pub struct Actions {
station: RadioStation<AppState, AppChannel>,
pump: mpsc::UnboundedSender<PumpSignal>,
events: oneclient_events::EventBus,
bundle_checks: Arc<Mutex<BundleCheckLog>>,
}
impl Actions {
@ -99,6 +126,7 @@ impl Actions {
station,
pump,
events,
bundle_checks: Arc::new(Mutex::new(BundleCheckLog::default())),
}
}
@ -106,12 +134,24 @@ impl Actions {
let _ = self.pump.send(signal);
}
fn bundle_check_due(&self, cluster_id: ClusterId) -> bool {
self.bundle_checks
.lock()
.unwrap()
.due(cluster_id, Instant::now())
}
fn record_bundle_checks(&self, cluster_ids: impl IntoIterator<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)) {
{
let mut guard = self
.station
.clone()
.write_channel(AppChannel::Notifications);
let mut guard = self.station.clone().write_channel(AppChannel::Notifications);
mutate(&mut guard);
}
self.nudge(PumpSignal::Reconcile);
@ -135,8 +175,10 @@ impl Actions {
guard.settings.error = None;
}
let loaded =
oneclient_core::settings::store::load_settings(Some(&state.services.events)).await;
let loaded = oneclient_core::settings::store::load_settings(Some(
&state.services.events,
))
.await;
let discord_enabled = loaded.discord_enabled;
*state.settings.write() = loaded.clone();
state.discord.set_enabled(discord_enabled);
@ -168,10 +210,7 @@ impl Actions {
});
}
fn mutate_settings(
&self,
mutate: impl FnOnce(&mut LauncherSettings),
) -> Option<LauncherSettings> {
fn mutate_settings(&self, mutate: impl FnOnce(&mut LauncherSettings)) -> Option<LauncherSettings> {
let state = launcher::state().ok()?;
let updated = {
let mut lock = state.settings.write();
@ -248,9 +287,37 @@ impl Actions {
}
pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) {
if let Some(updated) = self.mutate_settings(|settings| {
let mut was_declined = false;
let Some(updated) = self.mutate_settings(|settings| {
was_declined = settings.declined_tos;
settings.accepted_tos_version = terms_version;
settings.accepted_privacy_version = privacy_version;
settings.declined_tos = false;
}) else {
return;
};
self.persist(updated);
if was_declined {
self.notify("Restart to finish")
.body(
"Thanks. OneClient reconnects to Polyfrost services the next time you start \
it.",
)
.icon(IconType::RefreshCw01)
.send();
}
}
pub fn decline_tos(&self) {
oneclient_common::consent::decline();
if let Some(updated) = self.mutate_settings(|settings| {
settings.declined_tos = true;
settings.accepted_tos_version = 0;
settings.accepted_privacy_version = 0;
}) {
self.persist(updated);
}
@ -456,12 +523,14 @@ impl Actions {
Ok(_) => {
events.signal(oneclient_events::Signal::JavaChanged);
invalidate_java_queries().await
}
Err(err) => events
},
Err(err) => {
events
.notify("Java install failed")
.body(err.to_string())
.error()
.send(),
.send()
}
}
});
}
@ -533,11 +602,7 @@ impl Actions {
}
pub fn dismiss_toast(&self, entry_id: u64) {
self.with_engine(|state| {
state
.notifications
.dismiss_toast(&mut state.inbox, entry_id)
});
self.with_engine(|state| state.notifications.dismiss_toast(&mut state.inbox, entry_id));
}
pub fn mark_notification_read(&self, entry_id: u64) {
@ -563,6 +628,173 @@ impl Actions {
self.with_engine(|state| state.notifications.close_cluster_update());
}
pub fn open_optional_mods(
&self,
groups: Vec<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) {
self.with_engine(|state| state.notifications.close_package_updates());
}
@ -631,8 +863,6 @@ impl Actions {
.progress(id, "Downloading assets", current, total);
}
/// The pending flag is raised synchronously before the first `await` the
/// claim itself is the guard against a double-click spawning two games
pub fn launch_cluster(&self, cluster_id: ClusterId) {
let claimed = self
.station
@ -678,28 +908,44 @@ impl Actions {
.error = None;
}
pub fn import_local_file(
pub fn import_local_file(&self, cluster_id: ClusterId, content_type: ContentType, path: PathBuf) {
self.import_local_files(cluster_id, vec![(path, content_type)]);
}
pub fn import_local_files(
&self,
cluster_id: ClusterId,
content_type: ContentType,
path: PathBuf,
files: Vec<(PathBuf, ContentType)>,
) {
if files.is_empty() {
return;
}
spawn_forever(async move {
let Ok(state) = launcher::state() else { return };
let events = state.services.events.clone();
match oneclient_content::packages::PackageStore::import_local_file(
&path,
content_type,
match oneclient_content::packages::PackageStore::import_local_files(
&files,
cluster_id,
&state.services.content(),
)
.await
{
Ok(row) => {
events
.notify("Imported")
.body(format!("Added {}", row.file_name))
.send();
Ok(report) => {
let mut deferred = false;
for row in &report.imported {
let live = oneclient_content::packages::PackageStore::sync_live_content(
cluster_id,
row,
&state.services.content(),
)
.await
.unwrap_or(LiveSync::Skipped);
deferred |= live == LiveSync::Deferred;
}
notify_import(&events, &report, deferred && state.games.is_active(cluster_id));
super::invalidate_cluster_queries().await;
}
Err(err) => events
@ -760,6 +1006,7 @@ impl Actions {
name,
&install.dependencies,
&install.missing_dependencies,
install.live_deferred,
),
level: Level::Info,
icon: Some(IconType::Download01),
@ -931,17 +1178,82 @@ impl Actions {
}
};
actions.record_bundle_checks([cluster_id]);
super::invalidate_cluster_queries().await;
if let Some(spec) =
crate::install::cluster_update_notification(cluster_id, &result, &state.services)
.await
{
// The offers are queued by the sync itself and raised at launch
// this path only reports what changed
actions.push_notification(spec);
}
});
}
/// `syncing_bundles` gates every launch button so the readiness check must
/// App-scoped on purpose: the copy has to outlive the settings page, which
/// unmounts as soon as the router swaps to the move screen
pub fn relocate(&self, plan: RelocationPlan) {
let state = match launcher::state() {
Ok(state) => state,
Err(err) => {
tracing::error!("move skipped, launcher not ready: {err:#}");
self.write_relocation(RelocationState {
plan: Some(plan),
result: Some(Err(format!("OneClient is not ready yet: {err}"))),
..RelocationState::default()
});
return;
}
};
self.write_relocation(RelocationState {
plan: Some(plan.clone()),
..RelocationState::default()
});
let mut station = self.station;
spawn_forever(async move {
// The copy is on this thread's executor, so it can write the count
// straight into the channel the move screen reads
let result = oneclient_core::relocate::relocate(&state, &plan, |copied, total| {
let mut guard = station.write_channel(AppChannel::Relocation);
guard.relocation.copied = copied;
guard.relocation.total = total;
})
.await;
if let Err(message) = &result {
tracing::error!("moving the data folder failed: {message}");
}
let moved = result.is_ok();
station
.write_channel(AppChannel::Relocation)
.relocation
.result = Some(result);
if moved {
super::invalidate_leftovers_queries().await;
}
});
}
/// Leaves the move screen, which only the user can do and only once the
/// copy has settled
pub fn end_relocation(&self) {
self.write_relocation(RelocationState::default());
}
fn write_relocation(&self, relocation: RelocationState) {
self.station
.clone()
.write_channel(AppChannel::Relocation)
.relocation = relocation;
}
/// `syncing_bundles` gates every launch button so the readiness check must
/// happen before the flag is raised not inside the task
pub fn sync_bundles(&self) {
let state = match launcher::state() {
@ -960,9 +1272,14 @@ impl Actions {
.syncing_bundles = true;
spawn_forever(async move {
if let Err(err) = state.bundles.sync(&state.services.content()).await {
tracing::error!("bundle catalog sync failed: {err:#}");
}
let synced = match state.bundles.sync(&state.services.content()).await {
Ok(_) => true,
Err(err) => {
tracing::error!("bundle catalog sync failed: {err:#}");
false
}
};
if let Err(err) = oneclient_core::clusters::apply_remote_migrations(&state).await {
tracing::error!("cluster migrations failed: {err:#}");
}
@ -984,6 +1301,12 @@ impl Actions {
.await;
let session_id = session.detach();
if synced
&& let Ok(clusters) = state.clusters.list().await
{
actions.record_bundle_checks(clusters.iter().map(|cluster| cluster.id));
}
actions
.station
.clone()
@ -992,8 +1315,7 @@ impl Actions {
.syncing_bundles = false;
super::invalidate_cluster_queries().await;
let spec =
crate::install::combined_cluster_update_spec(&changed, &state.services).await;
let spec = crate::install::combined_cluster_update_spec(&changed, &state.services).await;
actions.with_engine(|app| {
app.notifications
.finish_grouped_as_actions(&mut app.inbox, session_id, spec);
@ -1001,6 +1323,115 @@ impl Actions {
});
}
async fn resolve_bundle_updates_before_launch(
&self,
state: &Arc<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 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) {
let (done, wait) = tokio::sync::oneshot::channel();
self.with_engine(move |state| {
state
.notifications
.open_package_updates(vec![group], Some(done));
state.notifications.open_package_updates(vec![group], Some(done));
state.center_open = false;
});
// A replaced or torn-down modal is equally a reason to stop waiting
let _ = wait.await;
}
@ -1245,8 +1713,19 @@ impl Actions {
}
}
/// Failures are reported as game events so the caller only has to know the
/// attempt is over Everything before `launch_cluster` is pre-launch work
fn group_by_cluster(mods: &[(ClusterId, OptionalModRef)]) -> Vec<(ClusterId, Vec<String>)> {
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) {
let Ok(state) = launcher::state() else { return };
let events = state.services.events.clone();
@ -1271,10 +1750,18 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) {
// Before the game process never after Minecraft reads its mods once at
// startup
actions
.resolve_bundle_updates_before_launch(&state, cluster_id)
.await;
actions
.resolve_package_updates_before_launch(&state, cluster_id)
.await;
// After the updates so the player never faces two modals at once
actions
.resolve_optional_mods_before_launch(&state, cluster_id)
.await;
if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await {
// A missing file is the one failure the launcher can fix itself and a
// path inside our metadata folder gives the user nothing to act on
@ -1331,6 +1818,39 @@ async fn repair_and_relaunch(
}
}
fn notify_import(events: &oneclient_events::EventBus, report: &LocalImportReport, deferred: bool) {
let failed = report.failed.len();
let Some(mut body) = (match report.imported.as_slice() {
[] => None,
[only] => Some(format!("Added {}", only.file_name)),
rows => Some(format!("Added {} files", rows.len())),
}) else {
let reason = report
.failed
.first()
.map_or_else(|| "Nothing could be read".to_string(), |(_, err)| err.to_string());
events.notify("Import failed").body(reason).error().send();
return;
};
if failed > 0 {
body.push_str(&format!(", {failed} could not be read"));
}
if deferred {
body.push_str(". Minecraft is running, so it will be there at the next launch");
}
if failed == 0 {
events.notify("Imported").body(body).send();
return;
}
events.notify("Imported").body(body).error().send();
}
#[must_use = "the notification is not raised until `.send()` is called"]
pub struct NotificationBuilder {
actions: Actions,
@ -1425,6 +1945,41 @@ mod tests {
);
}
#[test]
fn a_cluster_that_was_never_checked_is_due() {
assert!(BundleCheckLog::default().due(1, Instant::now()));
}
#[test]
fn a_relaunch_inside_the_window_reuses_the_last_check() {
let mut log = BundleCheckLog::default();
let now = Instant::now();
log.record(1, now);
assert!(!log.due(1, now));
assert!(!log.due(1, now + BUNDLE_CHECK_INTERVAL / 2));
}
#[test]
fn the_window_reopens_once_the_interval_has_passed() {
let mut log = BundleCheckLog::default();
let now = Instant::now();
log.record(1, now);
assert!(log.due(1, now + BUNDLE_CHECK_INTERVAL));
assert!(log.due(1, now + BUNDLE_CHECK_INTERVAL * 2));
}
#[test]
fn clusters_are_throttled_independently() {
let mut log = BundleCheckLog::default();
let now = Instant::now();
log.record(1, now);
assert!(!log.due(1, now));
assert!(log.due(2, now));
}
#[test]
fn nothing_pending_never_opens_a_modal() {
for mode in PackageUpdateMode::ALL.iter().copied() {

View file

@ -8,7 +8,7 @@ use oneclient_db::models::ClusterId;
pub struct ActiveClusterState(pub State<Option<ClusterId>>);
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>> {
@ -31,11 +31,24 @@ pub fn use_splash() -> 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)]
pub struct BrowserCompatState(pub State<bool>);
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> {
@ -53,7 +66,7 @@ pub fn use_link_confirm() -> State<Option<String>> {
consume_root_context::<LinkConfirmState>().0
}
#[derive(Clone, PartialEq, Eq)]
#[derive(Clone)]
pub struct BrowserUiState {
pub query: String,
pub provider: ProviderId,
@ -76,7 +89,7 @@ impl Default for BrowserUiState {
pub struct BrowserStateStore(pub State<HashMap<String, BrowserUiState>>);
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>> {
@ -98,12 +111,67 @@ pub struct OnboardingSelectionState {
/// `None` = don't import
pub import_folder: State<Option<String>>,
pub import_dedicated: State<bool>,
pub picks_location: State<bool>,
}
pub fn use_provide_onboarding_selection(state: OnboardingSelectionState) {
use_provide_root_context(move || state.clone());
use_hook(move || provide_root_context(state));
}
pub fn use_onboarding_selection() -> OnboardingSelectionState {
consume_root_context::<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 debounce;
mod actions;
mod overlay_claims;
mod queries;
mod shortcut_actions;
mod view_state;
pub use debounce::use_debounced;
pub use overlay_claims::{
OverlayClaims, use_overlay_claim, use_overlay_claim_when, use_overlay_claims,
use_provide_overlay_claims,
};
pub use view_state::{PersistedView, use_view_state};
pub use active_cluster::{
ActiveClusterState, BrowserCompatState, BrowserStateStore, BrowserUiState, LinkConfirmState,
OnboardingSelectionState, SplashState, use_active_cluster_id, use_browser_compat,
use_browser_state_store, use_link_confirm, use_onboarding_selection,
OnboardingSelectionState, SplashState, StartMaximizedState, use_active_cluster_id,
use_browser_compat, use_browser_state_store, use_link_confirm, use_onboarding_selection,
use_provide_active_cluster, use_provide_browser_compat, use_provide_browser_state,
use_provide_link_confirm, use_provide_onboarding_selection, use_provide_splash, use_splash,
use_provide_link_confirm, use_provide_onboarding_selection, use_provide_splash,
use_provide_start_maximized, use_splash, use_start_maximized,
};
pub use actions::{Actions, NotificationBuilder, PumpSignal};
@ -22,42 +29,50 @@ pub use queries::{
ClusterLogsQuery, FinishMicrosoftLoginMutation, LogAction, LogContentQuery, MigrationQuery,
OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction,
SetDefaultAccountKeys, StorageAction, StorageActionMutation, StorageReportQuery, TermsQuery,
UploadLogKeys, UploadLogMutation, UseLogAction, UseRefreshAccount, UseRemoveAccount,
UseScreenshotAction, UseSetDefaultAccount, UseStorageAction, UseUploadLog, VERSIONS_PAGE_SIZE,
accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, category_list,
changelog_error, changelog_groups, changelog_is_loading, cluster_content_items,
UploadLogKeys, UploadLogMutation, UseLogAction,
UseRefreshAccount, UseRemoveAccount, UseScreenshotAction, UseSetDefaultAccount,
UseStorageAction, UseUploadLog,
VERSIONS_PAGE_SIZE, accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items,
category_list, changelog_error, changelog_groups, changelog_is_loading, cluster_content_items,
content_type_for_slug, has_migration_data, invalidate_cluster_content_queries,
invalidate_cluster_queries, invalidate_java_queries, invalidate_logs_queries,
invalidate_profile_queries, invalidate_screenshots_queries, invalidate_storage_queries,
invalidate_cluster_queries, invalidate_java_queries,
invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries,
invalidate_storage_queries, try_storage_report, use_storage_action, use_storage_report,
DiscardLeftoversKeys, DiscardLeftoversMutation, LeftoversQuery,
UseDiscardLeftovers, invalidate_leftovers_queries, mutation_ok, try_leftovers,
use_discard_leftovers, use_leftovers,
java_runtimes, latest_changelog_version, loaded_image, loader_versions,
login_code_already_handled, migration_detection, mutation_error, mutation_is_pending,
mutation_is_running, onboarding_bundles_items, package_meta_batch, package_updates,
pick_version_metadata, project_detail, provider_versions, query_error, query_is_busy,
login_code_already_handled, migration_detection,
mutation_error, mutation_is_pending, mutation_is_running, onboarding_bundles_items, package_meta_batch,
package_updates, pick_version_metadata, project_detail, provider_versions, query_error,
query_is_busy, stale_hashes, use_package_updates,
query_is_loading, reset_login_code_dedup, search_items, search_pending, search_total,
settled_or_loading, stale_hashes, terms_document, terms_error, terms_is_loading, try_account,
settled_or_loading, terms_document, terms_error, terms_is_loading, try_account,
try_accounts, try_cluster_analytics, try_cluster_logs, try_cluster_screenshots,
try_default_account, try_game_profile, try_global_analytics, try_log_content,
try_storage_report, use_account, use_accounts, use_add_microsoft_account,
use_add_offline_account, use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates,
use_bundles_with_status, use_cached_image, use_cancel_microsoft_login, use_changelog,
use_cluster, use_cluster_analytics, use_cluster_content, use_cluster_logs,
use_cluster_mutation, use_cluster_profile, use_cluster_screenshots, use_cluster_settings,
use_clusters, use_current_account, use_default_account, use_finish_microsoft_login,
use_game_profile, use_global_analytics, use_java_runtimes, use_loader_versions,
use_local_image, use_log_action, use_log_content, use_migration, use_named_profiles,
use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project,
use_package_search, use_package_updates, use_package_versions, use_package_versions_when,
use_player_profile, use_player_skin, use_custom_skin, delete_account_skin,
save_account_skin, invalidate_skin_queries, use_provider_versions, use_refresh_account,
use_refresh_all_accounts, use_remove_account, use_screenshot_action,
use_screenshot_folder_watch, use_set_default_account, use_storage_action, use_storage_report,
use_terms, use_upload_log, use_version_metadata, use_versions, version_list, versions_metadata,
versions_total,
try_default_account, try_game_profile, try_global_analytics, try_log_content, use_account,
use_accounts, use_add_microsoft_account, use_add_offline_account, use_begin_microsoft_login,
use_bundle_overrides, use_bundle_updates, use_bundles_with_status, use_cached_image,
use_cancel_microsoft_login, use_changelog, use_cluster_analytics, use_cluster_content,
use_cluster_logs, use_cluster_mutation, use_cluster_profile, use_cluster_screenshots,
use_cluster, use_cluster_settings, use_clusters, use_current_account, use_default_account,
use_finish_microsoft_login, use_game_profile, use_global_analytics, use_java_runtimes,
use_loader_versions, use_local_image, use_log_action, use_log_content, use_migration,
use_named_profiles, use_onboarding_bundles, use_package_categories, use_package_meta_batch,
use_package_project, use_package_search, use_package_versions, use_package_versions_when,
use_player_profile,
use_player_skin, use_custom_skin, delete_account_skin, save_account_skin, invalidate_skin_queries,
use_provider_versions, use_refresh_account, use_refresh_all_accounts,
use_remove_account, use_screenshot_action, use_screenshot_folder_watch, use_set_default_account,
use_terms, use_upload_log,
use_version_metadata, use_versions, version_list, versions_metadata, versions_total,
};
pub(crate) use queries::invalidate_auth_queries;
use crate::notifications::NotificationSnapshot;
use crate::state::{
AppChannel, GameState, InstallState, LauncherInit, LoginProgress, SettingsState,
AppChannel, GameState, InstallState, LauncherInit, LoginProgress, RelocationState,
SettingsState,
};
use freya::prelude::*;
use freya::radio::use_radio;
@ -77,6 +92,10 @@ pub fn use_launcher() -> LauncherInit {
use_radio(AppChannel::Launcher).read().launcher.clone()
}
pub fn use_relocation() -> RelocationState {
use_radio(AppChannel::Relocation).read().relocation.clone()
}
pub fn use_settings_snapshot() -> SettingsState {
use_radio(AppChannel::Settings).read().settings.clone()
}
@ -107,6 +126,13 @@ pub fn use_installs_snapshot() -> InstallState {
use_radio(AppChannel::Installs).read().installs.clone()
}
pub fn use_pending_launch() -> Option<String> {
use_radio(AppChannel::PendingLaunch)
.read()
.pending_launch
.clone()
}
pub fn use_microsoft_login_status() -> Option<LoginProgress> {
use_radio(AppChannel::MicrosoftLogin)
.read()
@ -114,4 +140,3 @@ pub fn use_microsoft_login_status() -> Option<LoginProgress> {
.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 freya::query::{
Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability,
UseMutation, UseQuery, use_mutation, use_query,
Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, use_mutation, use_query,
};
use oneclient_auth::{AccountKind, MicrosoftLoginSession, MinecraftAccount};
use oneclient_core::LauncherError;
use oneclient_auth::{AccountKind, MicrosoftLoginSession, MinecraftAccount};
use uuid::Uuid;
static HANDLED_LOGIN_CODE: Mutex<Option<String>> = Mutex::new(None);
@ -133,10 +132,7 @@ impl MutationCapability for BeginMicrosoftLoginMutation {
type Keys = ();
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
Ok(crate::launcher::state()?
.auth
.begin_microsoft_login()
.await?)
Ok(crate::launcher::state()?.auth.begin_microsoft_login().await?)
}
}
@ -149,10 +145,7 @@ impl MutationCapability for FinishMicrosoftLoginMutation {
type Keys = MicrosoftLoginSession;
async fn run(&self, flow: &Self::Keys) -> Result<Self::Ok, Self::Err> {
Ok(crate::launcher::state()?
.auth
.finish_microsoft_login(flow.clone())
.await?)
Ok(crate::launcher::state()?.auth.finish_microsoft_login(flow.clone()).await?)
}
async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -177,10 +170,7 @@ impl MutationCapability for CancelMicrosoftLoginMutation {
type Keys = CancelMicrosoftLoginKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
crate::launcher::state()?
.auth
.cancel_microsoft_login(&keys.state_token)
.await;
crate::launcher::state()?.auth.cancel_microsoft_login(&keys.state_token).await;
Ok(())
}
}
@ -220,10 +210,7 @@ impl MutationCapability for AddOfflineAccountMutation {
type Keys = AddOfflineAccountKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
Ok(crate::launcher::state()?
.auth
.add_offline_account(keys.username.clone())
.await?)
Ok(crate::launcher::state()?.auth.add_offline_account(keys.username.clone()).await?)
}
async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -247,10 +234,7 @@ impl MutationCapability for RemoveAccountMutation {
type Keys = RemoveAccountKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
Ok(crate::launcher::state()?
.auth
.remove_account(keys.id)
.await?)
Ok(crate::launcher::state()?.auth.remove_account(keys.id).await?)
}
async fn on_settled(&self, keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -274,10 +258,7 @@ impl MutationCapability for SetDefaultAccountMutation {
type Keys = SetDefaultAccountKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
Ok(crate::launcher::state()?
.auth
.set_default_account(keys.id)
.await?)
Ok(crate::launcher::state()?.auth.set_default_account(keys.id).await?)
}
async fn on_settled(&self, keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -301,10 +282,7 @@ impl MutationCapability for RefreshAccountMutation {
type Keys = RefreshAccountKeys;
async fn run(&self, keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
Ok(crate::launcher::state()?
.auth
.refresh_account(keys.id)
.await?)
Ok(crate::launcher::state()?.auth.refresh_account(keys.id).await?)
}
async fn on_settled(&self, keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -323,10 +301,7 @@ impl MutationCapability for RefreshAllAccountsMutation {
type Keys = ();
async fn run(&self, _keys: &Self::Keys) -> Result<Self::Ok, Self::Err> {
Ok(crate::launcher::state()?
.auth
.refresh_all_accounts()
.await?)
Ok(crate::launcher::state()?.auth.refresh_all_accounts().await?)
}
async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
@ -389,6 +364,18 @@ pub fn mutation_is_running<M: MutationCapability>(mutation: &UseMutation<M>) ->
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>
where
M: MutationCapability,

View file

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

View file

@ -1,5 +1,7 @@
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_core::{ChangelogGroup, LauncherError, fetch_changelog, parse_changelog};
use oneclient_core::{
ChangelogGroup, LauncherError, fetch_changelog, parse_changelog,
};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ChangelogKeys {

View file

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

View file

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

View file

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

View file

@ -40,11 +40,11 @@ pub use auth::{
CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys,
RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount,
UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error,
mutation_is_pending, mutation_is_running, reset_login_code_dedup, try_account, try_accounts,
try_default_account, use_account, use_accounts, use_add_microsoft_account,
use_add_offline_account, use_begin_microsoft_login, use_cancel_microsoft_login,
use_current_account, use_default_account, use_finish_microsoft_login, use_refresh_account,
use_refresh_all_accounts, use_remove_account, use_set_default_account,
mutation_is_pending, mutation_is_running, mutation_ok, reset_login_code_dedup, try_account, try_accounts, try_default_account,
use_account, use_accounts, use_add_microsoft_account, use_add_offline_account,
use_begin_microsoft_login, use_cancel_microsoft_login, use_current_account,
use_default_account, use_finish_microsoft_login, use_refresh_account, use_refresh_all_accounts,
use_remove_account, use_set_default_account,
};
pub use bundles::{
ClusterBundles, OnboardingBundlesQuery, bundle_overrides_map, bundles_with_status_items,
@ -52,8 +52,7 @@ pub use bundles::{
use_onboarding_bundles,
};
pub use changelog::{
changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version,
use_changelog,
changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, use_changelog,
};
pub use cluster_content::{cluster_content_items, use_cluster_content};
pub use clusters::{use_cluster, use_clusters};
@ -68,6 +67,13 @@ pub use logs::{
use_log_action, use_log_content, use_upload_log,
};
pub use migration::{MigrationQuery, has_migration_data, migration_detection, use_migration};
pub use storage::{
DiscardLeftoversKeys, DiscardLeftoversMutation, LeftoversQuery,
StorageAction, StorageActionMutation, StorageReportQuery, UseDiscardLeftovers,
UseStorageAction, invalidate_leftovers_queries, invalidate_storage_queries, try_leftovers,
try_storage_report, use_discard_leftovers, use_leftovers, use_storage_action,
use_storage_report,
};
pub use mutations::{
ClusterAction, invalidate_cluster_content_queries, invalidate_cluster_queries,
invalidate_profile_queries, use_cluster_mutation,
@ -77,7 +83,8 @@ pub use packages::{
BROWSE_PAGE_SIZE, VERSIONS_PAGE_SIZE, category_list, content_type_for_slug, package_meta_batch,
project_detail, search_items, search_pending, search_total, use_package_categories,
use_package_meta_batch, use_package_project, use_package_search, use_package_versions,
use_package_versions_when, version_list, versions_total,
use_package_versions_when,
version_list, versions_total,
};
pub use player_profile::use_player_profile;
pub use screenshots::{
@ -93,10 +100,6 @@ pub use skin::{
use_player_skin,
};
pub use state::{query_error, query_is_busy, query_is_loading, settled_or_loading};
pub use storage::{
StorageAction, StorageActionMutation, StorageReportQuery, UseStorageAction,
invalidate_storage_queries, try_storage_report, use_storage_action, use_storage_report,
};
pub use tos::{TermsQuery, terms_document, terms_error, terms_is_loading, use_terms};
pub use version_metadata::{pick_version_metadata, use_version_metadata};
pub use versions::{loader_versions, use_loader_versions, use_versions, versions_metadata};

View file

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

View file

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

View file

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

View file

@ -5,6 +5,7 @@ use freya::query::{
use_mutation, use_query,
};
use oneclient_core::LauncherError;
use oneclient_core::relocate::{Leftovers, discard_leftovers, leftovers};
use oneclient_core::storage::{StorageReport, storage_report};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@ -62,7 +63,7 @@ impl MutationCapability for StorageActionMutation {
.map(|_| ())
}
StorageAction::CleanLegacyClusterContent => {
oneclient_core::storage::clean_legacy_cluster_content(&state)
oneclient_core::storage::clean_cluster_leftovers(&state)
.await
.map(|_| ())
}
@ -82,3 +83,62 @@ pub type UseStorageAction = UseMutation<StorageActionMutation>;
pub fn use_storage_action() -> UseStorageAction {
use_mutation(Mutation::new(StorageActionMutation))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct LeftoversKeys;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct LeftoversQuery;
impl QueryCapability for LeftoversQuery {
type Ok = Option<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::domain::GameLoader;
use oneclient_core::VersionMetadata;
use oneclient_common::domain::GameLoader;
use super::use_versions;

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