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
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:
parent
c063430e26
commit
163eb0ac0c
421 changed files with 24611 additions and 16499 deletions
7
.cargo/config.toml
Normal file
7
.cargo/config.toml
Normal 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
20
.editorconfig
Normal 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
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
* text=auto eol=lf
|
||||
Cargo.lock -diff linguist-generated
|
||||
0
.github/CODEOWNERS
vendored
Normal file
0
.github/CODEOWNERS
vendored
Normal file
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
59
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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
48
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
Normal 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
24
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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.
|
||||
-->
|
||||
47
.github/actions/install-cargo-tool/action.yml
vendored
Normal file
47
.github/actions/install-cargo-tool/action.yml
vendored
Normal 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
43
.github/actions/setup-rust/action.yml
vendored
Normal 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
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
97
.github/workflows/aur_publish.yml
vendored
Normal 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
281
.github/workflows/oneclient_nightly.yml
vendored
Normal 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
640
.github/workflows/oneclient_release.yml
vendored
Normal 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"
|
||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
|
|
@ -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
8
.gitignore
vendored
|
|
@ -1,6 +1,6 @@
|
|||
/target
|
||||
/dist
|
||||
*.swp
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
/.idea
|
||||
OneLauncher.iml
|
||||
|
||||
/packages/oneclient_app/icons/generated/
|
||||
|
|
|
|||
8
.signpath/oneclient-binary.artifact-configuration.xml
Normal file
8
.signpath/oneclient-binary.artifact-configuration.xml
Normal 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>
|
||||
21
.signpath/oneclient.artifact-configuration.xml
Normal file
21
.signpath/oneclient.artifact-configuration.xml
Normal 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>
|
||||
12
.sqlx/query-011448f2ce79e981700ee75c8cd5db5e0fcb793b72671c20a925b73ee6fb9456.json
generated
Normal file
12
.sqlx/query-011448f2ce79e981700ee75c8cd5db5e0fcb793b72671c20a925b73ee6fb9456.json
generated
Normal 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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
12
.sqlx/query-3b87531a1231c467072ec4d1f9afd3c93114777031250c7f9e74f6b9eef4ab9f.json
generated
Normal file
12
.sqlx/query-3b87531a1231c467072ec4d1f9afd3c93114777031250c7f9e74f6b9eef4ab9f.json
generated
Normal 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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
12
.sqlx/query-cd9609d707fa11bc4c246cd69c879159d23af897bdcb0bd9f04df73a7805a713.json
generated
Normal file
12
.sqlx/query-cd9609d707fa11bc4c246cd69c879159d23af897bdcb0bd9f04df73a7805a713.json
generated
Normal 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"
|
||||
}
|
||||
158
.sqlx/query-d1fc4c93f507d459e590cda5b5830a1e26e3047139db08a9f6b90c84185402a6.json
generated
Normal file
158
.sqlx/query-d1fc4c93f507d459e590cda5b5830a1e26e3047139db08a9f6b90c84185402a6.json
generated
Normal 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"
|
||||
}
|
||||
12
.sqlx/query-e03d85ee97b3a632d5946019865d9efb5d665e521519d05b16a4b66b066987ca.json
generated
Normal file
12
.sqlx/query-e03d85ee97b3a632d5946019865d9efb5d665e521519d05b16a4b66b066987ca.json
generated
Normal 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"
|
||||
}
|
||||
12
.sqlx/query-e2ee6fbe534dd47ce658d547c2bfc3a4e86dfce86da3a4ffe35d25d1fa412d13.json
generated
Normal file
12
.sqlx/query-e2ee6fbe534dd47ce658d547c2bfc3a4e86dfce86da3a4ffe35d25d1fa412d13.json
generated
Normal 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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
317
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
30
Cargo.toml
30
Cargo.toml
|
|
@ -9,13 +9,13 @@ authors = [
|
|||
"Polyfrost",
|
||||
"Polyfrost Contributors",
|
||||
]
|
||||
documentation = "https://github.com/Polyfrost/OneLauncher"
|
||||
documentation = "https://github.com/oiupoyt/OneLauncher-Cracked"
|
||||
edition = "2024"
|
||||
homepage = "https://polyfrost.org/projects/oneclient"
|
||||
license = "GPL-3.0-only"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/Polyfrost/OneLauncher"
|
||||
version = "2.2.3-c2"
|
||||
repository = "https://github.com/oiupoyt/OneLauncher-Cracked"
|
||||
version = "2.3.1-c1"
|
||||
|
||||
[workspace.dependencies]
|
||||
# core
|
||||
|
|
@ -115,6 +115,7 @@ indicatif = {version = "=0.18.4", features = ["tokio"]}
|
|||
junction = {version = "=2.0.0"}
|
||||
notify = {version = "=8.2.0"}
|
||||
parking_lot = {version = "=0.12.5"}
|
||||
pulldown-cmark = {version = "=0.13.4", default-features = false}
|
||||
rand = {version = "=0.10.1"}
|
||||
regex = {version = "=1.12.3"}
|
||||
rust-embed = {version = "=8.12.0", features = ["include-exclude", "compression", "debug-embed"]}
|
||||
|
|
@ -123,7 +124,9 @@ serde = {version = "=1.0.228"}
|
|||
serde_json = {version = "=1.0.150"}
|
||||
serde_repr = {version = "=0.1.20"}
|
||||
sysinfo = {version = "=0.39.5"}
|
||||
toml = {version = "=1.1.4", default-features = false, features = ["parse", "serde"]}
|
||||
trash = {version = "=5.2.6"}
|
||||
percent-encoding = {version = "=2.3.2"}
|
||||
url = {version = "=2.5.8", features = ["serde"]}
|
||||
uuid = {version = "=1.23.1", features = ["serde", "v4"]}
|
||||
|
||||
|
|
@ -136,17 +139,12 @@ syn = {version = "=2.0.117"}
|
|||
thiserror = {version = "=2.0.18"}
|
||||
|
||||
# ui
|
||||
freya = {version = "=0.5.0-rc.4", features = ["engine", "winit", "router", "remote-asset", "sdk", "markdown", "query", "radio", "titlebar"]}
|
||||
freya-skia-safe = "=0.99.1" # required for the SKSL player renderer
|
||||
freya = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver", features = ["engine", "winit", "router", "remote-asset", "sdk", "query", "radio", "titlebar"]}
|
||||
freya-core = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver"}
|
||||
freya-testing = {git = "https://github.com/marc2332/freya", branch = "fix/skip-basic-render-driver"}
|
||||
freya-skia-safe = "=0.100.0" # required for the SKSL player renderer
|
||||
image = {version = "=0.25.10", default-features = false, features = ["png", "jpeg"]}
|
||||
|
||||
[profile.dev]
|
||||
debug = 1
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
debug = false
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
debug = "line-tables-only"
|
||||
|
|
@ -161,3 +159,11 @@ inherits = "release"
|
|||
|
||||
debug = true
|
||||
strip = false
|
||||
|
||||
[profile.dev]
|
||||
debug = 1
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
debug = false
|
||||
|
||||
|
|
|
|||
3
LICENSE
3
LICENSE
|
|
@ -636,8 +636,7 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
the Free Software Foundation, version 3 of the License.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
84
packages/oneclient_app/src/cli.rs
Normal file
84
packages/oneclient_app/src/cli.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
133
packages/oneclient_app/src/components/checkbox.rs
Normal file
133
packages/oneclient_app/src/components/checkbox.rs
Normal 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()
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
27
packages/oneclient_app/src/components/grid_columns.rs
Normal file
27
packages/oneclient_app/src/components/grid_columns.rs
Normal 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.)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
328
packages/oneclient_app/src/components/markdown/image.rs
Normal file
328
packages/oneclient_app/src/components/markdown/image.rs
Normal 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.));
|
||||
}
|
||||
}
|
||||
118
packages/oneclient_app/src/components/markdown/mod.rs
Normal file
118
packages/oneclient_app/src/components/markdown/mod.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
|
||||
589
packages/oneclient_app/src/components/markdown/parse.rs
Normal file
589
packages/oneclient_app/src/components/markdown/parse.rs
Normal 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("")[0]), "[img:alt]");
|
||||
assert_eq!(paragraph_text(&blocks("see  here")[0]), "see [img:alt] here");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_inside_a_link_stays_in_the_link() {
|
||||
assert_eq!(
|
||||
paragraph_text(&blocks("text [](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| [](https://curseforge.com) | [](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));
|
||||
}
|
||||
}
|
||||
275
packages/oneclient_app/src/components/markdown/render.rs
Normal file
275
packages/oneclient_app/src/components/markdown/render.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
383
packages/oneclient_app/src/components/optional_mods_popup.rs
Normal file
383
packages/oneclient_app/src/components/optional_mods_popup.rs
Normal 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()),
|
||||
)
|
||||
}
|
||||
|
|
@ -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(());
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(());
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
248
packages/oneclient_app/src/file_content.rs
Normal file
248
packages/oneclient_app/src/file_content.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
pub fn bundle_slug(folder: &str) -> String {
|
||||
let slug: String = folder
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let trimmed = slug.trim_matches('-').to_string();
|
||||
if trimmed.is_empty() {
|
||||
"cluster".to_string()
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info_plist(name: &str, executable: &str, folder: &str, icon: Option<&str>) -> String {
|
||||
let display = xml_escape(&single_line(name));
|
||||
let icon_entry = icon.map_or_else(String::new, |icon| {
|
||||
format!("\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 & <B></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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
64
packages/oneclient_app/src/hooks/overlay_claims.rs
Normal file
64
packages/oneclient_app/src/hooks/overlay_claims.rs
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue