diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md new file mode 100644 index 00000000..1c162ee9 --- /dev/null +++ b/ATTRIBUTION.md @@ -0,0 +1,108 @@ +# Attribution + +The source code of this repository is licensed under the GNU General Public License, Version 3 only, which is provided in the file [LICENSE](./LICENSE), with a header provided below. Certain files in our library wrappers may be under a different license or [require credit](#additional-libraries), with their specific headers outlined in the below non-exhaustive list. + +## OneLauncher + +```txt +OneLauncher - Open source Rust-based Minecraft Launcher +Copyright (C) 2023~CURRENT Polyfrost and Contributors + +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, 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 +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with this program. If not, see . +``` + +## OneClient + +```txt +OneClient - A Minecraft Launcher +Copyright (C) 2023~CURRENT Polyfrost and Contributors + +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, 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 +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with this program. If not, see . +``` + +## Polyfrost branding + +Any files depicting [Polyfrost branding](https://polyfrost.org/legal/branding), including the Polyfrost logo, any landing and background images, the OneConfig logo, the OneLauncher logo, and variations thereof, are licensed under the following: + +```txt +All rights reserved. Copyright (C) 2022~CURRENT Polyfrost +``` + +This includes, but may not be limited to, the following files: + +* [`packages/distribution/icons/*`](./packages/distribution/icons/) +* [`apps/frontend/src/assets/licensed/*`](./apps/frontend/src/assets/licensed/) + +## Additional Libraries + +The following are libraries, projects, and codebases which we (Polyfrost and Contributors) copied or modified, with additional attribution inline (THIS IS A NON-EXHAUSTIVE LIST, IF WE ARE MISSING A LICENSE, PLEASE LEAVE A TICKET IN [our Discord server](https://polyfrost.org/discord)): + +## [Gamemode] + +```txt +Copyright (c) 2017-2025, Feral Interactive and the GameMode contributors +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of Feral Interactive nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +``` + +## [Rust Gamemode Internal Linking] + +```txt +MIT License + +Copyright (c) 2024 Sophie Leochina + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +[Gamemode]: https://github.com/FeralInteractive/gamemode +[Rust Gamemode Internal Linking]: https://github.com/SophieSilver/linking-gamemode diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..7fb65515 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,136 @@ +# Contributor Covenant Code of Conduct + +This is a modified version of the Contributor Covenant to clarify trolling (line 35), +and add contact information (lines 67-68). + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling (excessive antagonistic, inflammatory, insincere behaviour), insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement via +[our Discord server](https://polyfrost.org/discord), or via +[email](mailto:coc@polyfrost.org). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..4d0b3af4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,122 @@ +# Contributing to OneLauncher/OneClient + +Welcome! + +Please read our [Code of Conduct] to keep our community approachable and respectable. + +## New Contributor Guide + +To familiarize yourself with the project, please read the [README]. Here are some resources to help you get started with open-source contributions: + +- [Finding ways to contribute to open-source on GitHub] +- [Setting up Git] +- [GitHub flow] +- [Collaborating with pull requests] +- [The Rust Book] +- [The `cargo` CLI] + +## Getting Started + +### Issues + +#### Creating a New Issue + +If you come across an issue or have a feature request for OneLauncher/OneClient, please [search if a related issue has already been reported]. If no relevant issue exists, you can open a new issue using the appropriate [issue form]. + +#### Solving an Issue + +To find an issue that interests you, you can browse through our [existing issues] and use the available `labels` to narrow down your search (See [Labels] for more information). As a general rule, if you find an issue you want to work on, you are welcome to open a PR with a fix. + +### Making Changes + +#### 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 +is via [rustup]. + +To make changes locally, follow these steps: + +1. Clone the repository: `git clone https://github.com/Polyfrost/OneLauncher` +2. Navigate to the project directory: `cd OneLauncher` +3. Configure your system environment for OneLauncher/OneClient development: + 1. Install a Rust toolchain that supports edition 2024: `rustup toolchain install stable` + 2. Freya renders with [Skia]; on Linux you may also need system libraries such as a C + compiler, `pkg-config`, and the usual GUI/graphics dev packages. See the + [Freya setup guide] for platform-specific prerequisites. + +### Running + +The app crate is `oneclient_app`: + +- `cargo run -p oneclient_app` - Runs the **OneClient** desktop application. +- `cargo build --release -p oneclient_app` - Builds an optimized release binary. + +After you finish making your changes and committed them to your branch, run +`cargo fmt` and `cargo clippy` to fix style inconsistencies and catch lints. + +### Packaging + +Installers are built with [cargo-packager] (config in +`packages/oneclient_app/Cargo.toml` under `[package.metadata.packager]`): + +- `cargo install cargo-packager --locked` +- `cargo build --release -p oneclient_app` +- `cargo packager --release -p oneclient_app --formats ` + +Releases are cut by dispatching the `OneClient Release Build` GitHub Actions +workflow. See the **Packaging / Releasing** section of the [README] for details +and per-OS prerequisites. + +### Pull Request + +Once you have finished making your changes, create a pull request (PR) to submit them. + +- Fill out the template to help reviewers understand your changes and the purpose of your PR. +- If you are addressing an existing issue, don't forget to [link your PR to the issue]. +- Enable the checkbox to [allow maintainer edits] so that the branch can be updated for merging. +- Once you submit your PR, a team member will review your proposal. They may ask questions or request additional information. +- You may be asked to make changes before the PR can be merged, either through [suggested changes] or pull request comments. You can apply suggested changes directly through the UI. For other changes, you can make them in your fork and commit them to your branch. +- As you update your PR and apply changes, [mark each conversation as resolved]. +- If you run into any merge issues, refer to this [git tutorial] to help you resolve merge conflicts and other issues. + +### Your PR is Merged! + +Congratulations! 🎉🎉 Polyfrost thanks you for your contribution! ✨ +Once your PR is merged, your changes will be included in the next release. + +### Common Errors + +#### macOS errors with Xcode command line tools + +Build errors can occur when the Xcode command line tools are not installed or not in your `PATH`. + +Run `xcode-select --install` in the terminal to install them. If they are already installed, ensure that macOS is updated to the latest version available. + +### Credits + +This CONTRIBUTING.md file was inspired by the [`github/docs` CONTRIBUTING.md] file, and we extend our gratitude to the original authors. + +[cargo-packager]: https://github.com/crabnebula-dev/cargo-packager +[Skia]: https://skia.org/ +[Freya setup guide]: https://book.freyaui.dev/setup.html +[`cargo`]: https://doc.rust-lang.org/cargo/getting-started/installation.html +[rustup]: https://rustup.rs/ +[Labels]: https://github.com/Polyfrost/OneLauncher/labels +[The Rust Book]: https://doc.rust-lang.org/book/ +[The `cargo` CLI]: https://doc.rust-lang.org/cargo/ +[Collaborating with pull requests]: https://docs.github.com/en/github/collaborating-with-pull-requests +[GitHub flow]: https://docs.github.com/en/get-started/quickstart/github-flow +[Setting up Git]: https://docs.github.com/en/get-started/quickstart/set-up-git +[Finding ways to contribute to open-source on GitHub]: https://docs.github.com/en/get-started/exploring-projects-on-github/finding-ways-to-contribute-to-open-source-on-github +[Code of Conduct]: ./CODE_OF_CONDUCT.md +[README]: ./README.md +[search if a related issue has already been reported]: https://docs.github.com/en/github/searching-for-information-on-github/searching-on-github/searching-issues-and-pull-requests#search-by-the-title-body-or-comments +[issue form]: https://github.com/Polyfrost/OneLauncher/issues/new/choose +[existing issues]: https://github.com/Polyfrost/OneLauncher/issues +[`github/docs` CONTRIBUTING.md]: https://github.com/github/docs/blob/main/CONTRIBUTING.md +[link your PR to the issue]: https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue +[allow maintainer edits]: https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/allowing-changes-to-a-pull-request-branch-created-from-a-fork +[suggested changes]: https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/incorporating-feedback-in-your-pull-request +[mark each conversation as resolved]: https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/commenting-on-a-pull-request#resolving-conversations +[git tutorial]: https://lab.github.com/githubtraining/managing-merge-conflicts diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..9293654f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,8759 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid 1.23.1", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636" +dependencies = [ + "accesskit", + "accesskit_consumer", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_ios" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "750c4e9f6ce888dfe8a10c0f1b5ceb646a7854fd46579d74919219d1bb314083" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "accesskit_winit" +version = "0.33.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5b41e63a69f36d9f1f41e70464c7e5f72eee485ef26aab19f0b4f86e6c0a84c" +dependencies = [ + "accesskit", + "accesskit_ios", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.13.1", + "bytes 1.11.1", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "bitflags 2.13.1", + "bytes 1.11.1", + "bytestring", + "derive_more", + "encoding_rs", + "foldhash", + "futures-core", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "mime", + "percent-encoding", + "pin-project-lite", + "smallvec", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4" +dependencies = [ + "actix-codec", + "actix-http", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "bytes 1.11.1", + "bytestring", + "cfg-if", + "derive_more", + "encoding_rs", + "foldhash", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.5", + "time", + "tracing", + "url", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.18", +] + +[[package]] +name = "android-build" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ash-window" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82" +dependencies = [ + "ash", + "raw-window-handle", + "raw-window-metal", +] + +[[package]] +name = "astral-tokio-tar" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb50a7aae84a03bf55b067832bc376f4961b790c97e64d3eacee97d389b90277" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-tempfile" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8a57b75c36e16f4d015e60e6a177552976a99b6947724403c551bcfa7cb1207" +dependencies = [ + "tokio", + "uuid 1.23.1", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-tungstenite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc405d38be14342132609f06f02acaf825ddccfe76c4824a69281e0458ebd4" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tokio", + "tungstenite", +] + +[[package]] +name = "async_zip" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6" +dependencies = [ + "async-compression", + "chrono", + "crc32fast", + "futures-lite", + "pin-project", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "beul" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3c3baedf037b161e69da9200947329f52afa3b6e1cdbe6f344d6aa350fedd2f" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.117", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206fdffcfa2df7cbe15601ef46c813fce0965eb3286db6b56c583b814b51c81c" +dependencies = [ + "byteorder", + "iovec", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes 1.11.1", +] + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes 1.11.1", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "deflate64", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "copypasta" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6811e17f81fe246ef2bc553f76b6ee6ab41a694845df1d37e52a92b7bbd38a" +dependencies = [ + "clipboard-win", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "smithay-clipboard", + "x11-clipboard", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" +dependencies = [ + "dtor", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid 1.23.1", +] + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "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]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "discord-rich-presence" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494" +dependencies = [ + "log", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "thiserror 2.0.18", + "uuid 0.8.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "drm" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "drm-ffi", + "drm-fourcc", + "libc", + "rustix 0.38.44", +] + +[[package]] +name = "drm-ffi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" +dependencies = [ + "drm-sys", + "rustix 1.1.4", +] + +[[package]] +name = "drm-fourcc" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" + +[[package]] +name = "drm-sys" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" +dependencies = [ + "libc", + "linux-raw-sys 0.9.4", +] + +[[package]] +name = "dtor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "easer" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba524f8b83c9c5bde02c2bb1627de9d1f81980489a6d54168cdfd08c258f917" +dependencies = [ + "num-traits", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +dependencies = [ + "serde", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freya" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0902ffd592df176d3088ad001788f9a9438c21c40b4cbdec4dc3ef8bd16247" +dependencies = [ + "cfg-if", + "freya-android", + "freya-animation", + "freya-clipboard", + "freya-components", + "freya-core", + "freya-edit", + "freya-engine", + "freya-markdown", + "freya-performance-plugin", + "freya-query", + "freya-radio", + "freya-router", + "freya-sdk", + "freya-winit", + "torin", +] + +[[package]] +name = "freya-android" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3befea9d845539f4428f232ec6b1661a54e974a6e7b852f82fef90d994ccba" +dependencies = [ + "freya-components", + "freya-core", + "freya-winit", + "jni", + "tracing", + "winit", +] + +[[package]] +name = "freya-animation" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f9429ff1e0a793068ebdd4be84c03e722fdfb3d20c8bf3a63a7a875ba0a6de" +dependencies = [ + "async-io", + "easer", + "freya-core", +] + +[[package]] +name = "freya-clipboard" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8b2076b0a2d040ef2153cdd624dba28a6f4fabd64ca6182b128f642e2423b32" +dependencies = [ + "copypasta", + "freya-core", +] + +[[package]] +name = "freya-components" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd20c885e7e29f43e0279affea98ae3e1392c9d9f07a09b0ce0fc65d46fc09dc" +dependencies = [ + "accesskit", + "anyhow", + "async-io", + "async-lock", + "blocking", + "bytes 1.11.1", + "cfg-if", + "euclid", + "freya-animation", + "freya-core", + "freya-edit", + "freya-engine", + "freya-router", + "freya-sdk", + "open", + "paste", + "reqwest 0.12.28", + "thiserror 2.0.18", + "torin", + "tracing", +] + +[[package]] +name = "freya-core" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee644e4c5b4686b4cf59463347df7b64d9eb057c4a251e4528e90a1ecbf707d" +dependencies = [ + "accesskit", + "bitflags 2.13.1", + "bytes 1.11.1", + "cursor-icon", + "freya-engine", + "futures-channel", + "futures-lite", + "futures-util", + "generational-box", + "hotpath", + "itertools 0.14.0", + "keyboard-types", + "mundy", + "paste", + "pathgraph", + "ragnarok", + "rustc-hash", + "smallvec", + "torin", + "tracing", + "unicode-segmentation", +] + +[[package]] +name = "freya-edit" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0e20601f2d927591cace6bdaf731397908105a572eeaa934b387471bc6cd3b1" +dependencies = [ + "bitflags 2.13.1", + "freya-clipboard", + "freya-core", + "keyboard-types", + "ropey", + "torin", + "unicode-segmentation", +] + +[[package]] +name = "freya-engine" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95c5ba0dae520d78222758cffdcb335ba9cac6f846721b544fe216d894866b8c" +dependencies = [ + "freya-skia-safe", +] + +[[package]] +name = "freya-markdown" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94eb6ca3e8f9f18654ff14589c94155d7fa60c697dcbbbf0f02cb0a9e72e538c" +dependencies = [ + "freya-components", + "freya-core", + "pulldown-cmark", + "torin", +] + +[[package]] +name = "freya-performance-plugin" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2c69c000dd001e2df79a8b9ad0977cc0f835c494d2e17a2848b80495c293fce" +dependencies = [ + "freya-core", + "freya-engine", + "freya-winit", +] + +[[package]] +name = "freya-query" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9db86c4a0dea3ce04df0defa4ce62dad8e7c32bad05090e3644ff6d2f2e0655e" +dependencies = [ + "async-io", + "freya-core", + "futures-util", +] + +[[package]] +name = "freya-radio" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a2458e07d312a2365951a216a8fa14a604159f8ae7702a7ccb2ffbc3fccf0a" +dependencies = [ + "freya-core", +] + +[[package]] +name = "freya-router" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26a8c7b3b3c6432de8d90c01821f5206ab6d6c2c8417fe7b873d7a6586e7580" +dependencies = [ + "freya-core", + "freya-router-macro", + "rustversion", + "url", + "urlencoding", +] + +[[package]] +name = "freya-router-macro" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "105a54bccebe377d4ce12066813737a0f86e1cc587cdc98c4df92fe48c800be4" +dependencies = [ + "proc-macro2", + "quote", + "slab", + "syn 2.0.117", +] + +[[package]] +name = "freya-sdk" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe2c1e80db9c6c782536bf1e8f8e4859ed43359e7c0775c619be8826e16dd18" +dependencies = [ + "async-io", + "freya-core", +] + +[[package]] +name = "freya-skia-bindings" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e65f9ff56a7d08451c839386a86842f065552c561041d0cdcc213390a2361a2" +dependencies = [ + "bindgen", + "cc", + "flate2", + "heck", + "pkg-config", + "regex", + "serde_json", + "tar", + "toml", +] + +[[package]] +name = "freya-skia-safe" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567c808eff01a4ad7d9d3dc42ce0fb4335dce76a2398d0cd18f1357bf2450c81" +dependencies = [ + "base64 0.23.1", + "bitflags 2.13.1", + "freya-skia-bindings", + "freya-skia-svg-macros", + "percent-encoding", +] + +[[package]] +name = "freya-skia-svg-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c622cedc8d1cdaf144bc779734e7f6c5c29876a63e114bd0424d47a0d1aad18e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "freya-winit" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f3e4172e15aa5bd524db527733fec0ba9ac3e4191388064c9ff0d4f5a23e091" +dependencies = [ + "accesskit", + "accesskit_winit", + "ash", + "ash-window", + "bytes 1.11.1", + "freya-clipboard", + "freya-components", + "freya-core", + "freya-engine", + "futures-channel", + "futures-lite", + "futures-util", + "gl", + "glutin", + "glutin-winit", + "hotpath", + "image", + "keyboard-types", + "mundy", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "ragnarok", + "raw-window-handle", + "rfd", + "rustc-hash", + "softbuffer", + "torin", + "tracing", + "winit", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a471a38ef8ed83cd6e40aa59c1ffe17db6855c18e3604d9c4ed8c08ebc28678" + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", + "tokio-io", +] + +[[package]] +name = "generational-box" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b2dc9b873cd1a8adb80aa5dcef9a8205f781a8a313d5ffb867248cb3bcb764" +dependencies = [ + "parking_lot", + "tracing", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes 1.11.1", + "fnv", + "futures-core", + "futures-sink", + "http 1.5.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "hotpath" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66750a77f4f6b408a148be5102ef1f3ba7172def7ee92b1cfc75d9f7a3870453" +dependencies = [ + "hotpath-macros", +] + +[[package]] +name = "hotpath-macros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afe0e1900d2dbe2e2df8e9522b97ebd7a5598ba18478f57e247957022dffedbe" + +[[package]] +name = "htmd" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eee9b00ee2e599b4f86507157e3db786e7a3319fc225f0e9584151dbea2291d" +dependencies = [ + "html5ever", + "markup5ever_rcdom", + "phf", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes 1.11.1", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes 1.11.1", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes 1.11.1", + "http 1.5.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes 1.11.1", + "futures-core", + "http 1.5.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes 1.11.1", + "futures-channel", + "futures-core", + "h2", + "http 1.5.0", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.5.0", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes 1.11.1", + "futures-channel", + "futures-util", + "http 1.5.0", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.5", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + +[[package]] +name = "impl-more" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" + +[[package]] +name = "include-flate" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f173716febb1ad596c16ea5637b5f1790ea32de8e627493ff82bc73b0876ce" +dependencies = [ + "include-flate-codegen", + "include-flate-compress", +] + +[[package]] +name = "include-flate-codegen" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a7875b62a72ad3f3203cdd8950d4cf9947db036030b974b8b37ceae90c8d8c0" +dependencies = [ + "include-flate-compress", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "include-flate-compress" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fbb9c5ccb9a5b67b4afa2974c27e5507ea1bf6d22828cef418e4dfaeca51dd" +dependencies = [ + "libflate", + "zstd", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console", + "portable-atomic", + "tokio", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "interfrost" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3449eb9b8a587b832db2eb3a45776928ce0927c0efa7d0be9a7f4c619140a12a" +dependencies = [ + "chrono", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "iovec" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "junction" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "160f2eade097f30263b548aae5deb12ad349c909baa710fa24b92c9090b2e006" +dependencies = [ + "scopeguard", + "windows-sys 0.61.2", +] + +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.1", + "serde", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libflate" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4da9b700e758e57152a1fd1c52cbdc5727c1aa6d8743dc1acda917398f1d76c" +dependencies = [ + "adler32", + "crc32fast", + "dary_heap", + "libflate_lz77", + "no_std_io2", +] + +[[package]] +name = "libflate_lz77" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7a10e427698aef6eef269482776debfef63384d30f13aad39a1a95e0e098fd" +dependencies = [ + "hashbrown 0.16.1", + "no_std_io2", + "rle-decode-fast", +] + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.38.0+unofficial" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333171ccdf66e915257740d44e38ea5b1b19ce7b45d33cc35cb6f118fbd981ff" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mundy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f32eb0db40f2df2bcfb05c93b8f73938d4c26ce9ac8881f1df0c8d3296921a73" +dependencies = [ + "android-build", + "async-io", + "beul", + "cfg-if", + "futures-channel", + "futures-lite", + "jni", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "pin-project-lite", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", + "zbus", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.1", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit 0.2.2", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-cloud-kit 0.3.2", + "objc2-core-data 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-location 0.3.2", + "objc2-core-text", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-user-notifications 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oneclient_app" +version = "2.2.3" +dependencies = [ + "anyhow", + "arboard", + "bytes 1.11.1", + "cargo-packager-updater", + "chrono", + "freya", + "freya-skia-safe", + "image", + "mimalloc", + "notify", + "oneclient_auth", + "oneclient_cluster", + "oneclient_common", + "oneclient_content", + "oneclient_core", + "oneclient_db", + "oneclient_events", + "oneclient_java", + "oneclient_macro", + "oneclient_mc", + "oneclient_net", + "oneclient_polyplus", + "open", + "polyio", + "regex", + "rfd", + "rust-embed", + "sysinfo", + "tokio", + "tracing", + "tracing-subscriber", + "uuid 1.23.1", + "winresource", +] + +[[package]] +name = "oneclient_auth" +version = "2.2.3" +dependencies = [ + "base64 0.22.1", + "chrono", + "md-5 0.10.6", + "oneclient_common", + "oneclient_events", + "oneclient_net", + "polyio", + "rand 0.10.1", + "reqwest 0.13.3", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_cluster" +version = "2.2.3" +dependencies = [ + "bytes 1.11.1", + "chrono", + "image", + "oneclient_common", + "oneclient_db", + "oneclient_net", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "serde_repr", + "sqlx", + "strum", + "thiserror 2.0.18", + "tokio", + "tracing", + "trash", +] + +[[package]] +name = "oneclient_common" +version = "2.2.3" +dependencies = [ + "directories", + "interfrost", + "serde", + "strum", + "thiserror 2.0.18", +] + +[[package]] +name = "oneclient_content" +version = "2.2.3" +dependencies = [ + "async-trait", + "async_zip", + "chrono", + "futures-lite", + "futures-util", + "htmd", + "oneclient_common", + "oneclient_db", + "oneclient_events", + "oneclient_net", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_core" +version = "2.2.3" +dependencies = [ + "async-compression", + "async-stream", + "async-tempfile", + "async-trait", + "async-tungstenite", + "async_zip", + "base64 0.22.1", + "bytes 1.11.1", + "chrono", + "digest 0.10.7", + "directories", + "discord-rich-presence", + "dunce", + "futures-lite", + "futures-util", + "htmd", + "image", + "indicatif", + "interfrost", + "md-5 0.10.6", + "md5", + "oneclient_auth", + "oneclient_cluster", + "oneclient_common", + "oneclient_content", + "oneclient_db", + "oneclient_discord", + "oneclient_events", + "oneclient_java", + "oneclient_mc", + "oneclient_net", + "oneclient_polyplus", + "parking_lot", + "polyio", + "rand 0.10.1", + "regex", + "reqwest 0.13.3", + "reqwest-websocket", + "sanitize-filename", + "sentry", + "sentry-tracing", + "serde", + "serde_json", + "serde_repr", + "sha1 0.10.6", + "sha2 0.10.9", + "sqlx", + "strum", + "sysinfo", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "tracing-futures", + "tracing-subscriber", + "trash", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_db" +version = "2.2.3" +dependencies = [ + "async-tempfile", + "chrono", + "dunce", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "oneclient_discord" +version = "2.2.3" +dependencies = [ + "chrono", + "discord-rich-presence", + "oneclient_common", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "oneclient_events" +version = "2.2.3" +dependencies = [ + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_java" +version = "2.2.3" +dependencies = [ + "astral-tokio-tar", + "async-tempfile", + "async-trait", + "async_zip", + "futures-lite", + "oneclient_common", + "oneclient_events", + "oneclient_net", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "oneclient_macro" +version = "2.2.3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "oneclient_mc" +version = "2.2.3" +dependencies = [ + "base64 0.22.1", + "chrono", + "futures-util", + "interfrost", + "oneclient_common", + "oneclient_events", + "oneclient_net", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_net" +version = "2.2.3" +dependencies = [ + "arc-swap", + "async-trait", + "bytes 1.11.1", + "futures-lite", + "futures-util", + "oneclient_common", + "oneclient_events", + "polyio", + "reqwest 0.13.3", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "oneclient_polyplus" +version = "2.2.3" +dependencies = [ + "async-tungstenite", + "bytes 1.11.1", + "futures-util", + "oneclient_auth", + "oneclient_common", + "polyio", + "rand 0.10.1", + "reqwest 0.13.3", + "reqwest-websocket", + "serde", + "serde_json", + "sha1 0.10.6", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "serde", + "windows-sys 0.61.2", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathgraph" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2f33d657119e71a8b28da5c8d52ade41672d873c292ae5bf4833b8fa658c86a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "polyio" +version = "2.2.3" +dependencies = [ + "astral-tokio-tar", + "async-compression", + "async-stream", + "async-tempfile", + "async_zip", + "bytes 1.11.1", + "digest 0.10.7", + "dunce", + "futures-lite", + "futures-util", + "junction", + "sanitize-filename", + "serde", + "serde_json", + "sha1 0.10.6", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes 1.11.1", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes 1.11.1", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ragnarok" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5bbbde00035bdece3362a320eb982f23a57f82bf5f480a74f6aec4aaa01203" +dependencies = [ + "euclid", + "hotpath", + "itertools 0.14.0", + "rustc-hash", + "tracing", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.5", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1" +dependencies = [ + "cocoa", + "core-graphics", + "objc", + "raw-window-handle", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes 1.11.1", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 1.5.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "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", +] + +[[package]] +name = "reqwest" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +dependencies = [ + "base64 0.22.1", + "bytes 1.11.1", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 1.5.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "reqwest-websocket" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7705b649c3b66b85c4e9c304a6898b1ae3eecb880c474720ebf925e4a932ae02" +dependencies = [ + "async-tungstenite", + "bytes 1.11.1", + "futures-util", + "reqwest 0.13.3", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "tungstenite", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "pollster", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + +[[package]] +name = "ropey" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5" +dependencies = [ + "smallvec", + "str_indices", +] + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "include-flate", + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "globset", + "include-flate", + "sha2 0.11.0", + "walkdir", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit 0.19.2", + "tiny-skia", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "sentry" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d631477761f57c76148456e55e80e9a479ff3fa4c65b2b4a0c3acf1167fd4638" +dependencies = [ + "cfg_aliases", + "httpdate", + "reqwest 0.13.3", + "rustls", + "sentry-actix", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-debug-images", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-actix" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "119ede4e37790ec04e8a14073c8414f0a4b2648402856c0563495a9a5cf54d57" +dependencies = [ + "actix-http", + "actix-web", + "bytes 1.11.1", + "futures-util", + "sentry-core", +] + +[[package]] +name = "sentry-backtrace" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "448b0981fbde6cdc9eb087ba3dc01035a253fef9a0d9e79aaf198fc26acb2e64" +dependencies = [ + "backtrace", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e909d02170ba6d1dc5ebd05bef7999280f5d960e3eaee5d1a18d88d41b334" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecf0b1a4a4e9ec88395b52e6fa4868b95564c1fa96b26a0606f5f6288a0f7149" +dependencies = [ + "rand 0.9.5", + "sentry-types", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "sentry-debug-images" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f81d7749c57fc78ed52134889e8121da874065bc40da788d5798cce0f8c19f15" +dependencies = [ + "findshlibs", + "sentry-core", +] + +[[package]] +name = "sentry-panic" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7d93d6ecb55d2251c5fc084c55c03a2bc68904918d76117e602c999e92f00f" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58d26379236c4ef97eaf081bca3c7bf0ef6f06b3aa881eca2ee8e8dbc923e5b8" +dependencies = [ + "bitflags 2.13.1", + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2239099d47e76857b0825a182ecdb90159add7aa1097b60247c6e7ca6bf49c6a" +dependencies = [ + "debugid", + "hex", + "rand 0.9.5", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.18", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "as-raw-xcb-connection", + "bytemuck", + "drm", + "fastrand", + "js-sys", + "memmap2", + "ndk", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "raw-window-handle", + "redox_syscall 0.5.18", + "rustix 1.1.4", + "tiny-xlib", + "tracing", + "wasm-bindgen", + "wayland-backend", + "wayland-client", + "wayland-sys", + "web-sys", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes 1.11.1", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rustls", + "rustls-native-certs", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "thiserror 2.0.18", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "bytes 1.11.1", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "uuid 1.23.1", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.13.1", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5 0.11.0", + "memchr", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid 1.23.1", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid 1.23.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sysinfo" +version = "0.39.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8bd2130a9b60bee2581bf82cfe89ee836424d1f37dcfa4ce21509611684673" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", + "windows 0.62.2", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tiny-xlib" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" +dependencies = [ + "as-raw-xcb-connection", + "ctor 0.10.1", + "libloading", + "pkg-config", + "tracing", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes 1.11.1", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.5", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-io" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fc868aae093479e3131e3d165c93b1c7474109d13c90ec0dda2a1bbfff0674" +dependencies = [ + "bytes 0.4.12", + "futures", + "log", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes 1.11.1", + "futures-core", + "futures-io", + "futures-sink", + "futures-util", + "hashbrown 0.15.5", + "pin-project-lite", + "slab", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "torin" +version = "0.5.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4fb54c965056694c8616b9e0a6dfa151207c3599cd0c18f93acdb7172949bee" +dependencies = [ + "euclid", + "itertools 0.14.0", + "rustc-hash", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.13.1", + "bytes 1.11.1", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "parking_lot", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "trash" +version = "5.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7602e0c7d66ec2d92a8c917219fbc7894039efa2063b9064260110828a356f46" +dependencies = [ + "chrono", + "libc", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "once_cell", + "percent-encoding", + "scopeguard", + "urlencoding", + "windows 0.56.0", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes 1.11.1", + "data-encoding", + "http 1.5.0", + "httparse", + "log", + "rand 0.9.5", + "sha1 0.10.6", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http 1.5.0", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "md-5 0.10.6", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core 0.62.2", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml", + "version_check", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xml5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dc9559429edf0cd3f327cc0afd9d6b36fa8cec6d93107b7fbe64f806b5f2d9" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid 1.23.1", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..ab0d1c44 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,156 @@ +[workspace] +members = [ + "packages/*", +] +resolver = "3" + +[workspace.package] +authors = [ + "Polyfrost", + "Polyfrost Contributors", +] +documentation = "https://github.com/Polyfrost/OneLauncher" +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" + +[workspace.dependencies] +# core +oneclient_auth = {path = "./packages/oneclient_auth"} +oneclient_cluster = {path = "./packages/oneclient_cluster"} +oneclient_common = {path = "./packages/oneclient_common"} +oneclient_content = {path = "./packages/oneclient_content"} +oneclient_core = {path = "./packages/oneclient_core"} +oneclient_db = {path = "./packages/oneclient_db"} +oneclient_discord = {path = "./packages/oneclient_discord"} +oneclient_events = {path = "./packages/oneclient_events"} +oneclient_java = {path = "./packages/oneclient_java"} +oneclient_macro = {path = "./packages/oneclient_macro"} +oneclient_mc = {path = "./packages/oneclient_mc"} +oneclient_net = {path = "./packages/oneclient_net"} +oneclient_polyplus = {path = "./packages/oneclient_polyplus"} +polyio = {path = "./packages/polyio"} + +# core (extra) +discord-rich-presence = {version = "=1.1.0"} +interfrost = {version = "=1.3.2"} + +# logging +sentry = {version = "=0.48.5", default-features = false, features = [ + "backtrace", + "contexts", + "debug-images", + "panic", + "release-health", + "reqwest", + "rustls", +]} +sentry-tracing = {version = "=0.48.5"} +tracing = {version = "=0.1.44"} +tracing-futures = {version = "=0.2.5"} +tracing-subscriber = {version = "=0.3.23", features = ["env-filter", "fmt", "ansi", "json", "parking_lot", "registry"]} + +# database +sqlx = {version = "=0.9.0", default-features = false, features = [ + "runtime-tokio", + "tls-rustls-ring-native-roots", + "sqlite", + "derive", + "macros", + "migrate", + "json", + "uuid", + "chrono", +]} + +# async +astral-tokio-tar = {version = "=0.6.2"} +async-compression = {version = "=0.4.42", features = ["tokio", "gzip"]} +async-stream = {version = "=0.3.6"} +async-tempfile = {version = "=0.7.0", features = ["uuid"]} +async-trait = {version = "=0.1.89"} +async_zip = {version = "=0.0.18", features = ["chrono", "tokio", "tokio-fs", "deflate", "deflate64"]} +futures-lite = {version = "=2.6.1"} +futures-util = {version = "=0.3.32", features = ["tokio-io"]} +reqwest = {version = "=0.13.3", default-features = false, features = [ + "json", + "stream", + "multipart", + "blocking", + "rustls", + "charset", + "http2", + "deflate", + "gzip", + "brotli", + "form", + "system-proxy", +]} +reqwest-websocket = {version = "=0.6.0"} +tokio = {version = "=1.52.3", features = ["full"]} +tokio-stream = {version = "=0.1.18", features = ["full"]} +tokio-util = {version = "=0.7.18", features = ["full"]} + +# crypto (content hashing) +byteorder = {version = "=1.5.0"} +digest = {version = "=0.10.7", features = ["std"]} +md-5 = {version = "=0.10.6", default-features = false} +p256 = {version = "=0.13.2", features = ["ecdsa", "pkcs8"]} +sha1 = {version = "=0.10.6", default-features = false} +sha2 = {version = "=0.10.9", default-features = false} + +# util +arboard = {version = "=3.6.1"} +arc-swap = {version = "=1.7.1"} +base64 = {version = "=0.22.1"} +bytes = {version = "=1.11.1"} +chrono = {version = "=0.4.44", features = ["serde"]} +directories = {version = "=6.0.0"} +dunce = {version = "=1.0.5"} +htmd = {version = "=0.5.4"} +indicatif = {version = "=0.18.4", features = ["tokio"]} +junction = {version = "=2.0.0"} +notify = {version = "=8.2.0"} +parking_lot = {version = "=0.12.5"} +rand = {version = "=0.10.1"} +regex = {version = "=1.12.3"} +rust-embed = {version = "=8.12.0", features = ["include-exclude", "compression", "debug-embed"]} +sanitize-filename = {version = "=0.6.0"} +serde = {version = "=1.0.228"} +serde_json = {version = "=1.0.150"} +serde_repr = {version = "=0.1.20"} +sysinfo = {version = "=0.39.5"} +trash = {version = "=5.2.6"} +url = {version = "=2.5.8", features = ["serde"]} +uuid = {version = "=1.23.1", features = ["serde", "v4"]} + +# util (codegen/macro) +anyhow = {version = "=1.0.102"} +proc-macro2 = {version = "=1.0.106"} +quote = {version = "=1.0.45"} +strum = {version = "=0.28.0", features = ["derive"]} +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 +image = {version = "=0.25.10", default-features = false, features = ["png", "jpeg"]} + +[profile.release] +codegen-units = 1 +debug = "line-tables-only" +lto = "fat" +opt-level = 3 +panic = "abort" +split-debuginfo = "packed" +strip = true + +[profile.release-profiling] +inherits = "release" + +debug = true +strip = false diff --git a/distribution/aur/oneclient-bin/PKGBUILD b/distribution/aur/oneclient-bin/PKGBUILD new file mode 100644 index 00000000..93a4c87c --- /dev/null +++ b/distribution/aur/oneclient-bin/PKGBUILD @@ -0,0 +1,36 @@ +# Maintainer: Polyfrost +pkgname=oneclient-bin +_appname=OneClient +pkgver=2.0.1 +pkgrel=1 +pkgdesc="Next-generation open source Minecraft launcher (prebuilt)" +arch=('x86_64') +url="https://github.com/Polyfrost/OneLauncher" +license=('GPL-3.0-only') +depends=( + 'fontconfig' + 'freetype2' + 'libglvnd' + 'libxcursor' + 'libxrandr' + 'libxi' + 'libxkbcommon' + 'wayland' + 'dbus' + 'gtk3' +) +provides=('oneclient') +conflicts=('oneclient') +options=('!strip') +_debfile="${_appname}_${pkgver}_linux_x86_64.deb" +source=("${_debfile}::${url}/releases/download/oneclient-${pkgver}/${_debfile}") +sha256sums=('SKIP') + +package() { + cd "$pkgdir" + bsdtar -xf "$srcdir/$_debfile" + bsdtar -xf data.tar.* + rm -f debian-binary control.tar.* data.tar.* + + ln -s oneclient_app "$pkgdir/usr/bin/oneclient" +} diff --git a/distribution/aur/oneclient/PKGBUILD b/distribution/aur/oneclient/PKGBUILD new file mode 100644 index 00000000..13315293 --- /dev/null +++ b/distribution/aur/oneclient/PKGBUILD @@ -0,0 +1,68 @@ +# Maintainer: Polyfrost +pkgname=oneclient +pkgver=2.0.1 +pkgrel=1 +pkgdesc="Next-generation open source Minecraft launcher (built from source)" +arch=('x86_64') +url="https://github.com/Polyfrost/OneLauncher" +license=('GPL-3.0-only') +depends=( + 'fontconfig' + 'freetype2' + 'libglvnd' + 'libxcursor' + 'libxrandr' + 'libxi' + 'libxkbcommon' + 'wayland' + 'dbus' + 'gtk3' +) +makedepends=( + 'rust' + 'cargo' + 'cmake' + 'clang' + 'pkgconf' + 'python' +) +provides=('oneclient') +conflicts=('oneclient-bin') +options=('!lto') +source=("$pkgname-$pkgver.tar.gz::${url}/archive/refs/tags/oneclient-${pkgver}.tar.gz") +sha256sums=('SKIP') + +_srcdir="OneLauncher-oneclient-${pkgver}" + +prepare() { + cd "$srcdir/$_srcdir" + export CARGO_HOME="$srcdir/cargo-home" + cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" +} + +build() { + cd "$srcdir/$_srcdir" + export CARGO_HOME="$srcdir/cargo-home" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=target + cargo build --release --frozen --bin oneclient_app +} + +package() { + cd "$srcdir/$_srcdir" + local appdir="packages/oneclient_app" + + install -Dm755 "target/release/oneclient_app" "$pkgdir/usr/bin/oneclient_app" + ln -s oneclient_app "$pkgdir/usr/bin/oneclient" + + install -dm755 "$pkgdir/usr/share/applications" + sed -e 's|{{exec}}|oneclient_app|g' \ + -e 's|{{icon}}|org.polyfrost.OneClient|g' \ + "$appdir/distribution/linux/org.polyfrost.oneclient.template" \ + > "$pkgdir/usr/share/applications/org.polyfrost.OneClient.desktop" + + install -Dm644 "$appdir/icons/32x32.png" "$pkgdir/usr/share/icons/hicolor/32x32/apps/org.polyfrost.OneClient.png" + install -Dm644 "$appdir/icons/128x128.png" "$pkgdir/usr/share/icons/hicolor/128x128/apps/org.polyfrost.OneClient.png" + install -Dm644 "$appdir/icons/128x128@2x.png" "$pkgdir/usr/share/icons/hicolor/256x256/apps/org.polyfrost.OneClient.png" + install -Dm644 "$appdir/icons/512x512.png" "$pkgdir/usr/share/icons/hicolor/512x512/apps/org.polyfrost.OneClient.png" +} diff --git a/distribution/windows/bundle-crt.py b/distribution/windows/bundle-crt.py new file mode 100644 index 00000000..09f5b8d4 --- /dev/null +++ b/distribution/windows/bundle-crt.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 + +import argparse +import re +import shutil +import subprocess +import sys +from pathlib import Path + +BEGIN_RES = "# >>> CRT_RESOURCES >>>" +END_RES = "# <<< CRT_RESOURCES <<<" +BEGIN_SEC = "; >>> VC_REDIST_SECTION >>>" +END_SEC = "; <<< VC_REDIST_SECTION <<<" + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_CARGO_TOML = REPO_ROOT / "packages" / "oneclient_app" / "Cargo.toml" +DEFAULT_OUT = REPO_ROOT / "target" / "crt" + +VSWHERE = Path( + r"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" +) +CRT_GLOB = r"VC\Redist\MSVC\**\x64\Microsoft.VC*.CRT\*.dll" + +SECTION_REPLACEMENT = """; >>> VC_REDIST_SECTION >>> +; <<< VC_REDIST_SECTION <<<""" + + +def region(begin: str, end: str) -> re.Pattern: + return re.compile( + r"^[ \t]*" + re.escape(begin) + r".*?^[ \t]*" + re.escape(end) + r"[ \t]*$", + re.DOTALL | re.MULTILINE, + ) + + +REGION_RES = region(BEGIN_RES, END_RES) +REGION_SEC = region(BEGIN_SEC, END_SEC) + + +def find_crt_dir() -> Path: + """Locate the newest x64 Microsoft.VC*.CRT redist directory via vswhere.""" + if not VSWHERE.is_file(): + sys.exit(f"error: vswhere not found at {VSWHERE}; pass --crt-dir explicitly") + + out = subprocess.run( + [str(VSWHERE), "-latest", "-products", "*", "-find", CRT_GLOB], + capture_output=True, + text=True, + check=True, + ).stdout + dirs = {Path(line).parent for line in out.splitlines() if line.strip()} + if not dirs: + sys.exit(f"error: vswhere found no DLLs matching {CRT_GLOB}") + + newest = sorted(dirs, key=lambda p: p.parts)[-1] + print(f"CRT redist dir: {newest}") + return newest + + +def collect(crt_dir: Path, out_dir: Path) -> list[Path]: + dlls = sorted(crt_dir.glob("*.dll")) + if not dlls: + sys.exit(f"error: no DLLs in {crt_dir}") + + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True) + + copied = [] + for dll in dlls: + dest = out_dir / dll.name + shutil.copy2(dll, dest) + copied.append(dest) + print(f" {dll.name} ({dest.stat().st_size} bytes)") + return copied + + +def patch(cargo_toml: Path, dlls: list[Path]) -> None: + text = cargo_toml.read_text(encoding="utf-8") + for pat, name in ((REGION_RES, "CRT_RESOURCES"), (REGION_SEC, "VC_REDIST_SECTION")): + if not pat.search(text): + sys.exit(f"error: {name} region not found in {cargo_toml}") + + entries = "\n".join( + f' {{ src = "{str(d).replace(chr(92), chr(92) * 2)}", target = "{d.name}" }},' + for d in dlls + ) + block = ( + f" {BEGIN_RES}\n" + f" # Generated by distribution/windows/bundle-crt.py -- do not edit by hand.\n" + f"{entries}\n" + f" {END_RES}" + ) + + text = REGION_RES.sub(lambda _: block, text, count=1) + text = REGION_SEC.sub(lambda _: SECTION_REPLACEMENT, text, count=1) + cargo_toml.write_text(text, encoding="utf-8") + print(f"patched {cargo_toml} with {len(dlls)} app-local CRT DLLs") + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--cargo-toml", type=Path, default=DEFAULT_CARGO_TOML) + ap.add_argument("--out-dir", type=Path, default=DEFAULT_OUT) + ap.add_argument( + "--crt-dir", + type=Path, + help="Microsoft.VC*.CRT directory to copy from (default: ask vswhere)", + ) + args = ap.parse_args() + + crt_dir = args.crt_dir.resolve() if args.crt_dir else find_crt_dir() + dlls = collect(crt_dir, args.out_dir.resolve()) + patch(args.cargo_toml.resolve(), dlls) + + +if __name__ == "__main__": + main() diff --git a/packages/oneclient_app/Cargo.toml b/packages/oneclient_app/Cargo.toml new file mode 100644 index 00000000..1c5c9b86 --- /dev/null +++ b/packages/oneclient_app/Cargo.toml @@ -0,0 +1,150 @@ +[package] +name = "oneclient_app" +description = "OneClient Freya App" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } +documentation = { workspace = true } +readme = { workspace = true } +homepage = { workspace = true } +authors = { workspace = true } + +[features] +default = [] + +[dependencies] +oneclient_auth.workspace = true +oneclient_cluster.workspace = true +oneclient_common.workspace = true +oneclient_content.workspace = true +oneclient_events.workspace = true +oneclient_java.workspace = true +oneclient_mc.workspace = true +oneclient_polyplus.workspace = true +oneclient_net.workspace = true +oneclient_core.workspace = true +oneclient_db.workspace = true +chrono.workspace = true +oneclient_macro = { workspace = true, features = ["freya"] } + +polyio.workspace = true +rust-embed.workspace = true + +tracing.workspace = true +tracing-subscriber.workspace = true +tokio.workspace = true +uuid.workspace = true +bytes.workspace = true +sysinfo.workspace = true + +notify.workspace = true + +anyhow.workspace = true +regex.workspace = true + +cargo-packager-updater = "=0.2.3" + +rfd = "=0.17.2" +open = "=5.4.0" + +# clipboard image copy + decode for it +arboard.workspace = true +image.workspace = true + +freya-skia-safe.workspace = true +freya.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +mimalloc = "=0.1.52" + +[target.'cfg(windows)'.build-dependencies] +winresource = "=0.1.31" + +[package.metadata.packager] +product-name = "OneClient" +identifier = "org.polyfrost.OneClient" +category = "Game" +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", + "icons/64x64.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico", +] +resources = [ + { src = "icons/generated/Assets.car", target = "Assets.car" }, + # >>> CRT_RESOURCES >>> + # <<< CRT_RESOURCES <<< +] + +[package.metadata.packager.macos] +minimum-system-version = "10.14" +entitlements = "distribution/App.entitlements" +info-plist-path = "distribution/Info.plist" + +[package.metadata.packager.dmg] +background = "distribution/dmg-background.tiff" +window-size = { width = 600, height = 400 } +app-position = { x = 160, y = 190 } +application-folder-position = { x = 440, y = 190 } + +[package.metadata.packager.windows] +digest-algorithm = "sha256" +timestamp-url = "https://timestamp.digicert.com" + +[package.metadata.packager.nsis] +preinstall-section = """ +Section "-OneClientTauriMigration" + ; Stale Tauri root start-menu shortcut (this build only writes the sub-folder one). + Delete "$SMPROGRAMS\\${PRODUCTNAME}.lnk" + ; Old Tauri main binary (renamed OneClient.exe -> oneclient_app.exe). Locked while + ; the outdated app is running during an auto-update, so fall back to reboot removal. + Delete /REBOOTOK "$INSTDIR\\OneClient.exe" +SectionEnd + +; >>> VC_REDIST_SECTION >>> +Section "-VCRedist" + SetRegView 64 + ReadRegDWORD $R0 HKLM "SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\X64" "Installed" + ReadRegDWORD $R1 HKLM "SOFTWARE\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\X64" "Minor" + SetRegView lastused + + ${If} $R0 == 1 + ${AndIf} $R1 >= 40 + Goto vcredist_done + ${EndIf} + + ${If} $PassiveMode != 1 + MessageBox MB_OK|MB_ICONEXCLAMATION "This build of OneClient needs the Microsoft Visual C++ Redistributable (x64) 14.40 or newer. Install it from https://aka.ms/vs/17/release/vc_redist.x64.exe, then run this installer again." + ${EndIf} + + vcredist_done: +SectionEnd +; <<< VC_REDIST_SECTION <<< +""" + +[package.metadata.packager.deb] +desktop-template = "distribution/linux/org.polyfrost.oneclient.template" + +[package.metadata.packager.appimage] +# desktop entry generated from the deb template above via the linux config + +[package.metadata.generate-rpm] +auto-req = "builtin" +assets = [ + { source = "target/release/oneclient_app", dest = "/usr/bin/oneclient_app", mode = "755" }, + { 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" }, + { source = "icons/512x512.png", dest = "/usr/share/icons/hicolor/512x512/apps/oneclient_app.png", mode = "644" }, +] + diff --git a/packages/oneclient_app/assets/alex.png b/packages/oneclient_app/assets/alex.png new file mode 100644 index 00000000..99af1933 Binary files /dev/null and b/packages/oneclient_app/assets/alex.png differ diff --git a/packages/oneclient_app/assets/backgrounds/CavesAndCliffs.jpg b/packages/oneclient_app/assets/backgrounds/CavesAndCliffs.jpg new file mode 100644 index 00000000..7a951f7b Binary files /dev/null and b/packages/oneclient_app/assets/backgrounds/CavesAndCliffs.jpg differ diff --git a/packages/oneclient_app/assets/fonts/JetBrainsMono/AUTHORS.txt b/packages/oneclient_app/assets/fonts/JetBrainsMono/AUTHORS.txt new file mode 100755 index 00000000..88149416 --- /dev/null +++ b/packages/oneclient_app/assets/fonts/JetBrainsMono/AUTHORS.txt @@ -0,0 +1,10 @@ +# This is the official list of project authors for copyright purposes. +# This file is distinct from the CONTRIBUTORS.txt file. +# See the latter for an explanation. +# +# Names should be added to this file as: +# Name or Organization + +JetBrains <> +Philipp Nurullin +Konstantin Bulenkov diff --git a/packages/oneclient_app/assets/fonts/JetBrainsMono/JetBrainsMonoVariable.ttf b/packages/oneclient_app/assets/fonts/JetBrainsMono/JetBrainsMonoVariable.ttf new file mode 100644 index 00000000..b60e77f5 Binary files /dev/null and b/packages/oneclient_app/assets/fonts/JetBrainsMono/JetBrainsMonoVariable.ttf differ diff --git a/packages/oneclient_app/assets/fonts/JetBrainsMono/OFL.txt b/packages/oneclient_app/assets/fonts/JetBrainsMono/OFL.txt new file mode 100644 index 00000000..8bee4148 --- /dev/null +++ b/packages/oneclient_app/assets/fonts/JetBrainsMono/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/oneclient_app/assets/fonts/Poppins/OFL.txt b/packages/oneclient_app/assets/fonts/Poppins/OFL.txt new file mode 100644 index 00000000..da31af08 --- /dev/null +++ b/packages/oneclient_app/assets/fonts/Poppins/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-Bold.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-Bold.ttf new file mode 100644 index 00000000..00559eeb Binary files /dev/null and b/packages/oneclient_app/assets/fonts/Poppins/Poppins-Bold.ttf differ diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-BoldItalic.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-BoldItalic.ttf new file mode 100644 index 00000000..e61e8e88 Binary files /dev/null and b/packages/oneclient_app/assets/fonts/Poppins/Poppins-BoldItalic.ttf differ diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-ExtraBold.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-ExtraBold.ttf new file mode 100644 index 00000000..df709360 Binary files /dev/null and b/packages/oneclient_app/assets/fonts/Poppins/Poppins-ExtraBold.ttf differ diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-ExtraBoldItalic.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-ExtraBoldItalic.ttf new file mode 100644 index 00000000..14d2b375 Binary files /dev/null and b/packages/oneclient_app/assets/fonts/Poppins/Poppins-ExtraBoldItalic.ttf differ diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-Italic.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-Italic.ttf new file mode 100644 index 00000000..12b7b3c4 Binary files /dev/null and b/packages/oneclient_app/assets/fonts/Poppins/Poppins-Italic.ttf differ diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-Light.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-Light.ttf new file mode 100644 index 00000000..bc36bcc2 Binary files /dev/null and b/packages/oneclient_app/assets/fonts/Poppins/Poppins-Light.ttf differ diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-LightItalic.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-LightItalic.ttf new file mode 100644 index 00000000..9e70be6a Binary files /dev/null and b/packages/oneclient_app/assets/fonts/Poppins/Poppins-LightItalic.ttf differ diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-Medium.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-Medium.ttf new file mode 100644 index 00000000..6bcdcc27 Binary files /dev/null and b/packages/oneclient_app/assets/fonts/Poppins/Poppins-Medium.ttf differ diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-MediumItalic.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-MediumItalic.ttf new file mode 100644 index 00000000..be67410f Binary files /dev/null and b/packages/oneclient_app/assets/fonts/Poppins/Poppins-MediumItalic.ttf differ diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-Regular.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-Regular.ttf new file mode 100644 index 00000000..9f0c71b7 Binary files /dev/null and b/packages/oneclient_app/assets/fonts/Poppins/Poppins-Regular.ttf differ diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-SemiBold.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-SemiBold.ttf new file mode 100644 index 00000000..74c726e3 Binary files /dev/null and b/packages/oneclient_app/assets/fonts/Poppins/Poppins-SemiBold.ttf differ diff --git a/packages/oneclient_app/assets/fonts/Poppins/Poppins-SemiBoldItalic.ttf b/packages/oneclient_app/assets/fonts/Poppins/Poppins-SemiBoldItalic.ttf new file mode 100644 index 00000000..3e6c9422 Binary files /dev/null and b/packages/oneclient_app/assets/fonts/Poppins/Poppins-SemiBoldItalic.ttf differ diff --git a/packages/oneclient_app/assets/icons/alert-circle.svg b/packages/oneclient_app/assets/icons/alert-circle.svg new file mode 100644 index 00000000..662bedce --- /dev/null +++ b/packages/oneclient_app/assets/icons/alert-circle.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/alert-triangle.svg b/packages/oneclient_app/assets/icons/alert-triangle.svg new file mode 100644 index 00000000..281d2121 --- /dev/null +++ b/packages/oneclient_app/assets/icons/alert-triangle.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/announcement-01.svg b/packages/oneclient_app/assets/icons/announcement-01.svg new file mode 100644 index 00000000..5165fe45 --- /dev/null +++ b/packages/oneclient_app/assets/icons/announcement-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/arrow-left.svg b/packages/oneclient_app/assets/icons/arrow-left.svg new file mode 100644 index 00000000..57c201ac --- /dev/null +++ b/packages/oneclient_app/assets/icons/arrow-left.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/arrow-right.svg b/packages/oneclient_app/assets/icons/arrow-right.svg new file mode 100644 index 00000000..b6817867 --- /dev/null +++ b/packages/oneclient_app/assets/icons/arrow-right.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/bell-01.svg b/packages/oneclient_app/assets/icons/bell-01.svg new file mode 100644 index 00000000..15ea8bfa --- /dev/null +++ b/packages/oneclient_app/assets/icons/bell-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/brush-01.svg b/packages/oneclient_app/assets/icons/brush-01.svg new file mode 100644 index 00000000..41a52dd9 --- /dev/null +++ b/packages/oneclient_app/assets/icons/brush-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/calendar.svg b/packages/oneclient_app/assets/icons/calendar.svg new file mode 100644 index 00000000..29d372a6 --- /dev/null +++ b/packages/oneclient_app/assets/icons/calendar.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/check-circle.svg b/packages/oneclient_app/assets/icons/check-circle.svg new file mode 100644 index 00000000..3f3eaa2d --- /dev/null +++ b/packages/oneclient_app/assets/icons/check-circle.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/check.svg b/packages/oneclient_app/assets/icons/check.svg new file mode 100644 index 00000000..181c08f2 --- /dev/null +++ b/packages/oneclient_app/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/chevron-down.svg b/packages/oneclient_app/assets/icons/chevron-down.svg new file mode 100644 index 00000000..695ac75e --- /dev/null +++ b/packages/oneclient_app/assets/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/chevron-right.svg b/packages/oneclient_app/assets/icons/chevron-right.svg new file mode 100644 index 00000000..4f862067 --- /dev/null +++ b/packages/oneclient_app/assets/icons/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/chevrons-left.svg b/packages/oneclient_app/assets/icons/chevrons-left.svg new file mode 100644 index 00000000..ef974f1d --- /dev/null +++ b/packages/oneclient_app/assets/icons/chevrons-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/oneclient_app/assets/icons/chevrons-right.svg b/packages/oneclient_app/assets/icons/chevrons-right.svg new file mode 100644 index 00000000..777101a2 --- /dev/null +++ b/packages/oneclient_app/assets/icons/chevrons-right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/oneclient_app/assets/icons/clipboard-check.svg b/packages/oneclient_app/assets/icons/clipboard-check.svg new file mode 100644 index 00000000..07f6ce97 --- /dev/null +++ b/packages/oneclient_app/assets/icons/clipboard-check.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/clock-rewind.svg b/packages/oneclient_app/assets/icons/clock-rewind.svg new file mode 100644 index 00000000..52c9fdff --- /dev/null +++ b/packages/oneclient_app/assets/icons/clock-rewind.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/code-snippet-02.svg b/packages/oneclient_app/assets/icons/code-snippet-02.svg new file mode 100644 index 00000000..40b3d670 --- /dev/null +++ b/packages/oneclient_app/assets/icons/code-snippet-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/colors.svg b/packages/oneclient_app/assets/icons/colors.svg new file mode 100644 index 00000000..d025a237 --- /dev/null +++ b/packages/oneclient_app/assets/icons/colors.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/copy-01.svg b/packages/oneclient_app/assets/icons/copy-01.svg new file mode 100644 index 00000000..bd55d6e2 --- /dev/null +++ b/packages/oneclient_app/assets/icons/copy-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/curseforge.svg b/packages/oneclient_app/assets/icons/curseforge.svg new file mode 100644 index 00000000..cf2e4d3b --- /dev/null +++ b/packages/oneclient_app/assets/icons/curseforge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/database-01.svg b/packages/oneclient_app/assets/icons/database-01.svg new file mode 100644 index 00000000..c5ff2e7e --- /dev/null +++ b/packages/oneclient_app/assets/icons/database-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/dots-grid.svg b/packages/oneclient_app/assets/icons/dots-grid.svg new file mode 100644 index 00000000..cdd3e0cd --- /dev/null +++ b/packages/oneclient_app/assets/icons/dots-grid.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/dots-vertical.svg b/packages/oneclient_app/assets/icons/dots-vertical.svg new file mode 100644 index 00000000..be024563 --- /dev/null +++ b/packages/oneclient_app/assets/icons/dots-vertical.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/download-01.svg b/packages/oneclient_app/assets/icons/download-01.svg new file mode 100644 index 00000000..2a0b2606 --- /dev/null +++ b/packages/oneclient_app/assets/icons/download-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/download-cloud-02.svg b/packages/oneclient_app/assets/icons/download-cloud-02.svg new file mode 100644 index 00000000..6b022447 --- /dev/null +++ b/packages/oneclient_app/assets/icons/download-cloud-02.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/oneclient_app/assets/icons/eye.svg b/packages/oneclient_app/assets/icons/eye.svg new file mode 100644 index 00000000..9491da74 --- /dev/null +++ b/packages/oneclient_app/assets/icons/eye.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/oneclient_app/assets/icons/file-02.svg b/packages/oneclient_app/assets/icons/file-02.svg new file mode 100644 index 00000000..4c73a639 --- /dev/null +++ b/packages/oneclient_app/assets/icons/file-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/file-plus-02.svg b/packages/oneclient_app/assets/icons/file-plus-02.svg new file mode 100644 index 00000000..0839c52c --- /dev/null +++ b/packages/oneclient_app/assets/icons/file-plus-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/file-x-02.svg b/packages/oneclient_app/assets/icons/file-x-02.svg new file mode 100644 index 00000000..7bd48be3 --- /dev/null +++ b/packages/oneclient_app/assets/icons/file-x-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/folder-check.svg b/packages/oneclient_app/assets/icons/folder-check.svg new file mode 100644 index 00000000..a8e6d11b --- /dev/null +++ b/packages/oneclient_app/assets/icons/folder-check.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/oneclient_app/assets/icons/folder-download.svg b/packages/oneclient_app/assets/icons/folder-download.svg new file mode 100644 index 00000000..4f931279 --- /dev/null +++ b/packages/oneclient_app/assets/icons/folder-download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/oneclient_app/assets/icons/folder.svg b/packages/oneclient_app/assets/icons/folder.svg new file mode 100644 index 00000000..d25758e4 --- /dev/null +++ b/packages/oneclient_app/assets/icons/folder.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/globe-01.svg b/packages/oneclient_app/assets/icons/globe-01.svg new file mode 100644 index 00000000..b69f30de --- /dev/null +++ b/packages/oneclient_app/assets/icons/globe-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/help-circle.svg b/packages/oneclient_app/assets/icons/help-circle.svg new file mode 100644 index 00000000..68c90ad0 --- /dev/null +++ b/packages/oneclient_app/assets/icons/help-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/info-circle.svg b/packages/oneclient_app/assets/icons/info-circle.svg new file mode 100644 index 00000000..8ac263ef --- /dev/null +++ b/packages/oneclient_app/assets/icons/info-circle.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/key-01.svg b/packages/oneclient_app/assets/icons/key-01.svg new file mode 100644 index 00000000..65157a49 --- /dev/null +++ b/packages/oneclient_app/assets/icons/key-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/layout-top.svg b/packages/oneclient_app/assets/icons/layout-top.svg new file mode 100644 index 00000000..b3aef099 --- /dev/null +++ b/packages/oneclient_app/assets/icons/layout-top.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/link-03.svg b/packages/oneclient_app/assets/icons/link-03.svg new file mode 100644 index 00000000..45526d26 --- /dev/null +++ b/packages/oneclient_app/assets/icons/link-03.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/link-external-01.svg b/packages/oneclient_app/assets/icons/link-external-01.svg new file mode 100644 index 00000000..db60113b --- /dev/null +++ b/packages/oneclient_app/assets/icons/link-external-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/loading-02.svg b/packages/oneclient_app/assets/icons/loading-02.svg new file mode 100644 index 00000000..60e67672 --- /dev/null +++ b/packages/oneclient_app/assets/icons/loading-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/maximize-01.svg b/packages/oneclient_app/assets/icons/maximize-01.svg new file mode 100644 index 00000000..12bd5818 --- /dev/null +++ b/packages/oneclient_app/assets/icons/maximize-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/message-text-square-01.svg b/packages/oneclient_app/assets/icons/message-text-square-01.svg new file mode 100644 index 00000000..cbb0ad05 --- /dev/null +++ b/packages/oneclient_app/assets/icons/message-text-square-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/minus.svg b/packages/oneclient_app/assets/icons/minus.svg new file mode 100644 index 00000000..39867488 --- /dev/null +++ b/packages/oneclient_app/assets/icons/minus.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/modrinth.svg b/packages/oneclient_app/assets/icons/modrinth.svg new file mode 100644 index 00000000..a8e487dd --- /dev/null +++ b/packages/oneclient_app/assets/icons/modrinth.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/onboarding-account.svg b/packages/oneclient_app/assets/icons/onboarding-account.svg new file mode 100644 index 00000000..1c13a38d --- /dev/null +++ b/packages/oneclient_app/assets/icons/onboarding-account.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/onboarding-complete.svg b/packages/oneclient_app/assets/icons/onboarding-complete.svg new file mode 100644 index 00000000..ce9c4b37 --- /dev/null +++ b/packages/oneclient_app/assets/icons/onboarding-complete.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/oneclient_app/assets/icons/onboarding-language.svg b/packages/oneclient_app/assets/icons/onboarding-language.svg new file mode 100644 index 00000000..bd37210b --- /dev/null +++ b/packages/oneclient_app/assets/icons/onboarding-language.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/oneclient_app/assets/icons/onboarding-preferences.svg b/packages/oneclient_app/assets/icons/onboarding-preferences.svg new file mode 100644 index 00000000..b3bfbbdb --- /dev/null +++ b/packages/oneclient_app/assets/icons/onboarding-preferences.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/oneclient_app/assets/icons/onboarding-welcome.svg b/packages/oneclient_app/assets/icons/onboarding-welcome.svg new file mode 100644 index 00000000..095f28d7 --- /dev/null +++ b/packages/oneclient_app/assets/icons/onboarding-welcome.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/oneclient_app/assets/icons/paint-pour.svg b/packages/oneclient_app/assets/icons/paint-pour.svg new file mode 100644 index 00000000..19f5388e --- /dev/null +++ b/packages/oneclient_app/assets/icons/paint-pour.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/paragraph-wrap.svg b/packages/oneclient_app/assets/icons/paragraph-wrap.svg new file mode 100644 index 00000000..c6ab0bce --- /dev/null +++ b/packages/oneclient_app/assets/icons/paragraph-wrap.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/pencil-01.svg b/packages/oneclient_app/assets/icons/pencil-01.svg new file mode 100644 index 00000000..5b1df4b7 --- /dev/null +++ b/packages/oneclient_app/assets/icons/pencil-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/play.svg b/packages/oneclient_app/assets/icons/play.svg new file mode 100644 index 00000000..25123712 --- /dev/null +++ b/packages/oneclient_app/assets/icons/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/plus.svg b/packages/oneclient_app/assets/icons/plus.svg new file mode 100644 index 00000000..7339441f --- /dev/null +++ b/packages/oneclient_app/assets/icons/plus.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/refresh-ccw-02.svg b/packages/oneclient_app/assets/icons/refresh-ccw-02.svg new file mode 100644 index 00000000..3352efc5 --- /dev/null +++ b/packages/oneclient_app/assets/icons/refresh-ccw-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/refresh-cw-01.svg b/packages/oneclient_app/assets/icons/refresh-cw-01.svg new file mode 100644 index 00000000..dde1891c --- /dev/null +++ b/packages/oneclient_app/assets/icons/refresh-cw-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/rocket-02.svg b/packages/oneclient_app/assets/icons/rocket-02.svg new file mode 100644 index 00000000..9f5bca64 --- /dev/null +++ b/packages/oneclient_app/assets/icons/rocket-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/search-md.svg b/packages/oneclient_app/assets/icons/search-md.svg new file mode 100644 index 00000000..0ac7a594 --- /dev/null +++ b/packages/oneclient_app/assets/icons/search-md.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/settings-01.svg b/packages/oneclient_app/assets/icons/settings-01.svg new file mode 100644 index 00000000..62d1fbbc --- /dev/null +++ b/packages/oneclient_app/assets/icons/settings-01.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/settings-02.svg b/packages/oneclient_app/assets/icons/settings-02.svg new file mode 100644 index 00000000..6f46f18b --- /dev/null +++ b/packages/oneclient_app/assets/icons/settings-02.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/settings-04.svg b/packages/oneclient_app/assets/icons/settings-04.svg new file mode 100644 index 00000000..e859b2b3 --- /dev/null +++ b/packages/oneclient_app/assets/icons/settings-04.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/sliders-04.svg b/packages/oneclient_app/assets/icons/sliders-04.svg new file mode 100644 index 00000000..4da984c5 --- /dev/null +++ b/packages/oneclient_app/assets/icons/sliders-04.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/square.svg b/packages/oneclient_app/assets/icons/square.svg new file mode 100644 index 00000000..b58884f8 --- /dev/null +++ b/packages/oneclient_app/assets/icons/square.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/terminal.svg b/packages/oneclient_app/assets/icons/terminal.svg new file mode 100644 index 00000000..c7f2b7a5 --- /dev/null +++ b/packages/oneclient_app/assets/icons/terminal.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/trash-01.svg b/packages/oneclient_app/assets/icons/trash-01.svg new file mode 100644 index 00000000..4c97afc4 --- /dev/null +++ b/packages/oneclient_app/assets/icons/trash-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/users-01.svg b/packages/oneclient_app/assets/icons/users-01.svg new file mode 100644 index 00000000..7fe7aad8 --- /dev/null +++ b/packages/oneclient_app/assets/icons/users-01.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/oneclient_app/assets/icons/x-close.svg b/packages/oneclient_app/assets/icons/x-close.svg new file mode 100644 index 00000000..a42153e7 --- /dev/null +++ b/packages/oneclient_app/assets/icons/x-close.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/icons/x.svg b/packages/oneclient_app/assets/icons/x.svg new file mode 100644 index 00000000..0fb0af9e --- /dev/null +++ b/packages/oneclient_app/assets/icons/x.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/oneclient_app/assets/logo.svg b/packages/oneclient_app/assets/logo.svg new file mode 100644 index 00000000..3f305562 --- /dev/null +++ b/packages/oneclient_app/assets/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/oneclient_app/assets/steve.png b/packages/oneclient_app/assets/steve.png new file mode 100644 index 00000000..16077860 Binary files /dev/null and b/packages/oneclient_app/assets/steve.png differ diff --git a/packages/oneclient_app/build.rs b/packages/oneclient_app/build.rs new file mode 100644 index 00000000..984deef0 --- /dev/null +++ b/packages/oneclient_app/build.rs @@ -0,0 +1,20 @@ +fn main() { + #[cfg(windows)] + { + let mut res = winresource::WindowsResource::new(); + res.set_icon("icons/icon.ico"); + + let version = env!("CARGO_PKG_VERSION"); + res.set("CompanyName", "Polyfrost Inc."); + res.set("ProductName", "OneClient"); + res.set("FileDescription", "OneClient"); + res.set("LegalCopyright", "© 2026 Polyfrost Inc."); + res.set("OriginalFilename", "oneclient_app.exe"); + res.set("InternalName", "oneclient_app"); + res.set("FileVersion", version); + res.set("ProductVersion", version); + + res.compile() + .expect("failed to embed Windows icon resource"); + } +} diff --git a/packages/oneclient_app/distribution/App.entitlements b/packages/oneclient_app/distribution/App.entitlements new file mode 100644 index 00000000..48f4264c --- /dev/null +++ b/packages/oneclient_app/distribution/App.entitlements @@ -0,0 +1,18 @@ + + + + + + com.apple.security.cs.disable-library-validation + + + com.apple.security.cs.allow-dyld-environment-variables + + + com.apple.security.device.audio-input + + + com.apple.security.device.camera + + + diff --git a/packages/oneclient_app/distribution/Info.plist b/packages/oneclient_app/distribution/Info.plist new file mode 100644 index 00000000..52b863f3 --- /dev/null +++ b/packages/oneclient_app/distribution/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleIconName + OneClient + + CFBundleURLTypes + + + CFBundleURLName + OneClient + CFBundleURLSchemes + + oneclient + + + + + NSCameraUsageDescription + A Minecraft mod is requesting access to your camera. + + NSMicrophoneUsageDescription + A Minecraft mod is requesting access to your microphone. + + diff --git a/packages/oneclient_app/distribution/dmg-background.png b/packages/oneclient_app/distribution/dmg-background.png new file mode 100644 index 00000000..ef311ae2 Binary files /dev/null and b/packages/oneclient_app/distribution/dmg-background.png differ diff --git a/packages/oneclient_app/distribution/dmg-background.tiff b/packages/oneclient_app/distribution/dmg-background.tiff new file mode 100644 index 00000000..c75e71e4 Binary files /dev/null and b/packages/oneclient_app/distribution/dmg-background.tiff differ diff --git a/packages/oneclient_app/distribution/dmg-background@2x.png b/packages/oneclient_app/distribution/dmg-background@2x.png new file mode 100644 index 00000000..4b868d82 Binary files /dev/null and b/packages/oneclient_app/distribution/dmg-background@2x.png differ diff --git a/packages/oneclient_app/distribution/linux/org.polyfrost.oneclient.template b/packages/oneclient_app/distribution/linux/org.polyfrost.oneclient.template new file mode 100644 index 00000000..e43eb2ad --- /dev/null +++ b/packages/oneclient_app/distribution/linux/org.polyfrost.oneclient.template @@ -0,0 +1,12 @@ +[Desktop Entry] +Name=OneClient +Comment=Next-generation open source Minecraft launcher +Exec={{exec}} +Icon={{icon}} +Type=Application +Terminal=false +StartupNotify=true +Categories=Game;ActionGame;AdventureGame;Simulation; +Keywords=mc;minecraft;game +StartupWMClass={{exec}} +Version=1.4 diff --git a/packages/oneclient_app/icons/128x128.png b/packages/oneclient_app/icons/128x128.png new file mode 100644 index 00000000..21b90752 Binary files /dev/null and b/packages/oneclient_app/icons/128x128.png differ diff --git a/packages/oneclient_app/icons/128x128@2x.png b/packages/oneclient_app/icons/128x128@2x.png new file mode 100644 index 00000000..0786a43c Binary files /dev/null and b/packages/oneclient_app/icons/128x128@2x.png differ diff --git a/packages/oneclient_app/icons/32x32.png b/packages/oneclient_app/icons/32x32.png new file mode 100644 index 00000000..d190be46 Binary files /dev/null and b/packages/oneclient_app/icons/32x32.png differ diff --git a/packages/oneclient_app/icons/512x512.png b/packages/oneclient_app/icons/512x512.png new file mode 100644 index 00000000..fbc4ca47 Binary files /dev/null and b/packages/oneclient_app/icons/512x512.png differ diff --git a/packages/oneclient_app/icons/64x64.png b/packages/oneclient_app/icons/64x64.png new file mode 100644 index 00000000..6c92d512 Binary files /dev/null and b/packages/oneclient_app/icons/64x64.png differ diff --git a/packages/oneclient_app/icons/OneClient.icon/Assets/oneclient-mark.svg b/packages/oneclient_app/icons/OneClient.icon/Assets/oneclient-mark.svg new file mode 100644 index 00000000..2f96ddac --- /dev/null +++ b/packages/oneclient_app/icons/OneClient.icon/Assets/oneclient-mark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/oneclient_app/icons/OneClient.icon/icon.json b/packages/oneclient_app/icons/OneClient.icon/icon.json new file mode 100644 index 00000000..b3ad6298 --- /dev/null +++ b/packages/oneclient_app/icons/OneClient.icon/icon.json @@ -0,0 +1,38 @@ +{ + "fill": "automatic", + "groups": [ + { + "layers": [ + { + "blend-mode": "normal", + "fill": "automatic", + "glass": true, + "hidden": false, + "image-name": "oneclient-mark.svg", + "name": "oneclient-mark", + "position": { + "scale": 3.6, + "translation-in-points": [ + 0, + 0 + ] + } + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "translucency": { + "enabled": true, + "value": 0.5 + } + } + ], + "supported-platforms": { + "circles": [ + "watchOS" + ], + "squares": "shared" + } +} diff --git a/packages/oneclient_app/icons/StoreLogo.png b/packages/oneclient_app/icons/StoreLogo.png new file mode 100644 index 00000000..431f57e5 Binary files /dev/null and b/packages/oneclient_app/icons/StoreLogo.png differ diff --git a/packages/oneclient_app/icons/icon.icns b/packages/oneclient_app/icons/icon.icns new file mode 100644 index 00000000..516ea0e7 Binary files /dev/null and b/packages/oneclient_app/icons/icon.icns differ diff --git a/packages/oneclient_app/icons/icon.ico b/packages/oneclient_app/icons/icon.ico new file mode 100644 index 00000000..446f9a84 Binary files /dev/null and b/packages/oneclient_app/icons/icon.ico differ diff --git a/packages/oneclient_app/icons/icon.png b/packages/oneclient_app/icons/icon.png new file mode 100644 index 00000000..fbc4ca47 Binary files /dev/null and b/packages/oneclient_app/icons/icon.png differ diff --git a/packages/oneclient_app/src/assets.rs b/packages/oneclient_app/src/assets.rs new file mode 100644 index 00000000..74287862 --- /dev/null +++ b/packages/oneclient_app/src/assets.rs @@ -0,0 +1,15 @@ +#[derive(rust_embed::RustEmbed)] +#[folder = "./assets/"] +pub struct AppAssets; + +impl AppAssets { + pub fn get_bytes(path: &str) -> Option { + AppAssets::get(path).map(|file| match file.data { + std::borrow::Cow::Borrowed(slice) => bytes::Bytes::copy_from_slice(slice), + std::borrow::Cow::Owned(vec) => { + tracing::debug!("Cloning asset data for '{}'", path); + bytes::Bytes::from(vec) + } + }) + } +} diff --git a/packages/oneclient_app/src/components/account_switcher.rs b/packages/oneclient_app/src/components/account_switcher.rs new file mode 100644 index 00000000..84dbb9df --- /dev/null +++ b/packages/oneclient_app/src/components/account_switcher.rs @@ -0,0 +1,244 @@ +use freya::{ + animation::{AnimNum, Ease, OnCreation, use_animation}, + prelude::*, + router::RouterContext, +}; +use oneclient_auth::MinecraftAccount; + +use crate::{ + Route, + components::{Avatar, Button, Icon, IconType, OverlayPopup}, + hooks::{ + SetDefaultAccountKeys, try_accounts, try_default_account, use_account_switcher_open, + use_accounts, use_current_account, use_dispatch, use_set_default_account, + }, + theme::colors, + ui::divider, +}; + +#[derive(PartialEq)] +pub struct AccountSwitcher; + +impl Component for AccountSwitcher { + fn render(&self) -> impl IntoElement { + let open = use_account_switcher_open(); + let dispatch = use_dispatch(); + + if !open { + return rect().into_element(); + } + + OverlayPopup::new() + .position(Position::new_global().top(72.).right(40.)) + .on_close(move |()| dispatch.close_account_switcher()) + .child(AccountPanel) + .into_element() + } +} + +#[derive(PartialEq)] +struct AccountPanel; + +impl Component for AccountPanel { + fn render(&self) -> impl IntoElement { + let accounts_query = use_accounts(); + let current_query = use_current_account(); + + let default_account = try_default_account(¤t_query); + let default_id = default_account.as_ref().map(|account| account.id); + let accounts = try_accounts(&accounts_query).unwrap_or_default(); + + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(200).ease(Ease::Out) + }); + let progress = intro.read().value(); + + let mut ordered: Vec = Vec::new(); + if let Some(account) = default_account.clone() { + ordered.push(account); + } + for account in accounts { + if Some(account.id) != default_id { + ordered.push(account); + } + } + + let mut rows = rect().vertical().width(Size::fill()).spacing(4.); + if ordered.is_empty() { + rows = rows.child( + label() + .text("No accounts yet") + .font_size(13.) + .color(colors::fg_secondary()), + ); + } else { + for account in ordered { + let id = account.id; + rows = rows.child( + AccountRow::new(account, Some(id) == default_id) + .key(id) + .into_element(), + ); + } + } + + rect() + .vertical() + .width(Size::px(300.)) + .padding(Gaps::new_all(12.)) + .spacing(8.) + .opacity(progress) + .margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.)) + .background(colors::page_elevated().with_a(220)) + .blur(12.) + .corner_radius(CornerRadius::new_all(12.)) + .border( + Border::new() + .fill(colors::component_border()) + .width(1.) + .alignment(BorderAlignment::Inner), + ) + .shadow(Shadow::from(( + 0., + 8., + 32., + 0., + Color::from_argb(120, 0, 0, 0), + ))) + .child( + label() + .text("Accounts") + .font_size(18.) + .font_weight(FontWeight::MEDIUM) + .a11y_role(AccessibilityRole::TitleBar), + ) + .child(divider()) + .child(rows) + .child(divider()) + .child(Footer) + } +} + +struct AccountRow { + account: MinecraftAccount, + active: bool, + key: DiffKey, +} + +impl PartialEq for AccountRow { + fn eq(&self, other: &Self) -> bool { + self.account.id == other.account.id + && self.account.username == other.account.username + && self.active == other.active + } +} + +impl KeyExt for AccountRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl AccountRow { + fn new(account: MinecraftAccount, active: bool) -> Self { + Self { + account, + active, + key: DiffKey::None, + } + } +} + +impl Component for AccountRow { + fn render(&self) -> impl IntoElement { + let id = self.account.id; + let username = self.account.username.clone(); + let active = self.active; + + let dispatch = use_dispatch(); + let set_default = use_set_default_account(); + let mut hovered = use_state(|| false); + + let switch = move |_| { + if active { + return; + } + set_default.mutate(SetDefaultAccountKeys { id: Some(id) }); + dispatch.close_account_switcher(); + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_all(8.)) + .corner_radius(CornerRadius::new_all(8.)) + .maybe(*hovered.read() && !active, |el| { + el.background(colors::ghost_overlay_hover()) + }) + .cursor((!active).then_some(CursorIcon::Pointer)) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(switch) + .child( + Avatar::new(id.to_string()) + .width(Size::px(32.)) + .height(Size::px(32.)), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .child( + label() + .text(username) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(colors::fg_primary()), + ) + .maybe(active, |el| { + el.child( + label() + .text("Active") + .font_size(11.) + .color(colors::fg_secondary()), + ) + }), + ) + .maybe(active, |el| { + el.child( + Icon::new(IconType::CheckCircle) + .size(18.) + .color(colors::success()), + ) + }) + } +} + +#[derive(PartialEq)] +struct Footer; + +impl Component for Footer { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let open_accounts = move |_| { + dispatch.close_account_switcher(); + let _ = RouterContext::get().push(Route::SettingsAccounts {}); + }; + + Button::new() + .ghost() + .small() + .on_press(open_accounts) + .child( + Icon::new(IconType::Users01) + .size(16.) + .color(colors::fg_primary()), + ) + .text("Manage accounts") + } +} diff --git a/packages/oneclient_app/src/components/active_cluster_panel.rs b/packages/oneclient_app/src/components/active_cluster_panel.rs new file mode 100644 index 00000000..14cdf34b --- /dev/null +++ b/packages/oneclient_app/src/components/active_cluster_panel.rs @@ -0,0 +1,151 @@ +use freya::animation::*; +use freya::prelude::*; +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::routes::Route; +use crate::theme::colors; +use crate::utils::sort_clusters_for_home; +use crate::view::app::launch_button_state; + +#[derive(PartialEq)] +pub struct ActiveClusterPanel; + +impl Component for ActiveClusterPanel { + fn render(&self) -> impl IntoElement { + let clusters_query = use_clusters(); + let mut active_id = use_active_cluster_id(); + let dispatch = use_dispatch(); + let game = use_game_snapshot(); + let launcher = use_launcher(); + let syncing = launcher.fetching || launcher.syncing_bundles; + + let clusters = settled_or_loading(&clusters_query).unwrap_or_default(); + + let sorted = sort_clusters_for_home(clusters); + + if active_id.read().is_none() + && let Some(first) = sorted.first() + { + *active_id.write() = Some(first.id); + } + + let active = active_id + .read() + .and_then(|id| sorted.iter().find(|c| c.id == id).cloned()) + .or_else(|| sorted.first().cloned()); + + let dep = active.as_ref().map(|c| c.id); + let intro = use_animation_with_dependencies(&dep, |conf, _| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + AnimNum::new(0., 1.) + .time(440) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let p = intro.get().value(); + let slide_x = (p - 1.0) * 48.0; + + let parsed = active + .as_ref() + .and_then(|c| parse_mc_version(&c.mc_version)); + let metadata = use_version_metadata( + parsed.as_ref().map(|p| p.major), + parsed.and_then(|p| p.key()), + active.as_ref().map(|c| c.mc_loader), + ); + + let Some(cluster) = active else { + return rect() + .vertical() + .width(Size::fill()) + .main_align(Alignment::Center) + .child( + label() + .text("No versions yet") + .font_size(24.) + .color(colors::fg_secondary()), + ); + }; + + let title = format!("{} {}", cluster.mc_version, cluster.mc_loader); + let subtitle = metadata + .map(|m| m.name) + .unwrap_or_else(|| cluster.name.clone()); + let cluster_id = cluster.id; + + rect() + .vertical() + .width(Size::fill()) + .main_align(Alignment::Center) + .cross_align(Alignment::Start) + .offset_x(slide_x) + .opacity(p) + .child( + label() + .text(title) + .font_size(56.) + .line_height(1.1) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .spacing(8.) + .cross_align(Alignment::Center) + .margin(Gaps::new(8., 0., 0., 0.)) + .child(launch_button( + cluster_id, + dispatch, + launch_button_state(&game, cluster_id, syncing), + )) + .child(cluster_settings_button(cluster_id)), + ) + } +} + +fn launch_button( + cluster_id: i64, + dispatch: crate::Actions, + state: (&'static str, bool), +) -> impl IntoElement { + let (label, enabled) = state; + Button::new() + .primary() + .medium() + .font_size(16.) + .font_weight(FontWeight::from(450)) + .enabled(enabled) + .padding(Gaps::new_symmetric(8., 24.)) + .on_press(move |_| { + if enabled { + dispatch.launch_cluster(cluster_id); + } + }) + .text(label) +} + +fn cluster_settings_button(cluster_id: i64) -> impl IntoElement { + Button::new() + .ghost() + .icon() + .on_press(move |_| { + let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); + }) + .child(Icon::new(IconType::Settings04).size(20.)) +} diff --git a/packages/oneclient_app/src/components/avatar.rs b/packages/oneclient_app/src/components/avatar.rs new file mode 100644 index 00000000..e1d5c488 --- /dev/null +++ b/packages/oneclient_app/src/components/avatar.rs @@ -0,0 +1,86 @@ +use bytes::Bytes; +use freya::elements::image::{ImageHandle, image}; +use freya::engine::prelude::{Paint, SkData, SkImage, raster_n32_premul}; +use freya::prelude::*; + +use crate::hooks::use_player_skin; + +const AVATAR_SIZE: f32 = 32.; +const FACE: f32 = 8.; +const HEAD_FACE: (f32, f32) = (8., 8.); +const HEAD_OVERLAY: (f32, f32) = (40., 8.); + +#[derive(PartialEq, Clone)] +pub struct Avatar { + uuid: String, + layout: LayoutData, +} + +impl Avatar { + pub fn new(uuid: impl Into) -> Self { + Self { + uuid: uuid.into(), + layout: LayoutData::default(), + } + } +} + +impl LayoutExt for Avatar { + fn get_layout(&mut self) -> &mut LayoutData { + &mut self.layout + } +} + +impl ContainerSizeExt for Avatar {} + +impl Component for Avatar { + fn render(&self) -> impl IntoElement { + let (skin_bytes, _is_slim) = use_player_skin(self.uuid.clone()); + + let mut cache = use_state(|| None::<(usize, ImageHandle)>); + let src_ptr = skin_bytes.as_ptr() as usize; + let cached = cache.read().clone(); + let head = match cached { + Some((ptr, holder)) if ptr == src_ptr => Some(holder), + _ => { + let holder = compose_head(&skin_bytes); + if let Some(holder) = &holder { + cache.set(Some((src_ptr, holder.clone()))); + } + holder + } + }; + + rect() + .width(Size::px(AVATAR_SIZE)) + .height(Size::px(AVATAR_SIZE)) + .corner_radius(CornerRadius::from(8.)) + .center() + .maybe_child(head.map(|holder| { + image(holder) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::None) + .sampling_mode(SamplingMode::Nearest) + .corner_radius(CornerRadius::from(8.)) + })) + } +} + +fn compose_head(skin_bytes: &Bytes) -> Option { + let data = unsafe { SkData::new_bytes(skin_bytes) }; + let skin = SkImage::from_encoded(data)?; + let skin = skin.make_raster_image(None, None).unwrap_or(skin); + + let mut surface = raster_n32_premul((FACE as i32, FACE as i32))?; + { + let canvas = surface.canvas(); + let paint = Paint::default(); + + canvas.draw_image(&skin, (-HEAD_FACE.0, -HEAD_FACE.1), Some(&paint)); + canvas.draw_image(&skin, (-HEAD_OVERLAY.0, -HEAD_OVERLAY.1), Some(&paint)); + } + + let head = surface.image_snapshot(); + Some(ImageHandle::new(head, skin_bytes.clone())) +} diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs new file mode 100644 index 00000000..8699a010 --- /dev/null +++ b/packages/oneclient_app/src/components/button.rs @@ -0,0 +1,452 @@ +#![allow(dead_code)] +use std::borrow::Cow; + +use freya::prelude::*; + +use crate::{ + components::{Icon, IconTint, IconType}, + theme::colors, +}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ButtonVariant { + #[default] + Primary, + Secondary, + Danger, + Ghost, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ButtonSize { + Small, + #[default] + Medium, + Large, + Icon, +} + +const DISABLED_OPACITY: f32 = 0.5; + +struct ButtonColors { + background: Color, + hover_background: Color, + pressed_background: Color, + disabled_background: Color, + foreground: Color, + disabled_foreground: Color, + border: Option, + hover_border: Option, + pressed_border: Option, + focus_border: Option, +} + +#[derive(Clone, PartialEq)] +pub struct Button { + size: ButtonSize, + variant: ButtonVariant, + + layout: LayoutData, + style: StyleState, + text_style: TextStyleData, + + enabled: bool, + focusable: bool, + alt: Option>, + on_press: Option>>, + + elements: Vec, + key: DiffKey, + cursor_icon: CursorIcon, +} + +impl Default for Button { + fn default() -> Self { + Self::new() + } +} + +impl Button { + pub fn new() -> Self { + let size = ButtonSize::default(); + let variant = ButtonVariant::default(); + + let mut layout = LayoutData::default(); + let mut style = StyleState::default(); + let mut text_style = TextStyleData::default(); + + let (padding, default_radius, font_size, width, height) = size_layout(size); + let colors = variant_colors(variant); + + layout.padding = padding; + layout.width = width; + layout.height = height; + + layout.content = Content::Flex; + layout.cross_alignment = Alignment::Center; + layout.main_alignment = Alignment::Center; + layout.direction = Direction::Horizontal; + layout.spacing.0 = 4.; + + style.background = Fill::Color(colors.background); + if let Some(border_color) = colors.border { + style.borders.push( + Border::new() + .fill(border_color) + .width(1.) + .alignment(BorderAlignment::Inner), + ); + } + + style.corner_radius = default_radius; + + text_style.font_size = Some(font_size.into()); + + Self { + size, + layout, + style, + text_style, + variant, + enabled: true, + focusable: true, + alt: None, + on_press: None, + elements: Vec::new(), + key: DiffKey::None, + cursor_icon: CursorIcon::Pointer, + } + } + + pub fn variant(mut self, variant: ButtonVariant) -> Self { + self.variant = variant; + self + } + + pub fn primary(self) -> Self { + self.variant(ButtonVariant::Primary) + } + + pub fn secondary(self) -> Self { + self.variant(ButtonVariant::Secondary) + } + + pub fn danger(self) -> Self { + self.variant(ButtonVariant::Danger) + } + + pub fn ghost(self) -> Self { + self.variant(ButtonVariant::Ghost) + } + + pub fn size(mut self, size: ButtonSize) -> Self { + self.size = size; + + let (padding, corner_radius, font_size, width, height) = size_layout(size); + self.layout.width = width; + self.layout.height = height; + self.layout.padding = padding; + self.style.corner_radius = corner_radius; + self.text_style.font_size = Some(font_size.into()); + + self + } + + pub fn font_size(mut self, font_size: impl Into) -> Self { + self.text_style.font_size = Some(font_size.into().into()); + self + } + + pub fn font_weight(mut self, weight: FontWeight) -> Self { + self.text_style.font_weight = Some(weight); + self + } + + pub fn small(self) -> Self { + self.size(ButtonSize::Small) + } + + pub fn medium(self) -> Self { + self.size(ButtonSize::Medium) + } + + pub fn large(self) -> Self { + self.size(ButtonSize::Large) + } + + pub fn icon(self) -> Self { + self.size(ButtonSize::Icon) + } + + pub fn enabled(mut self, enabled: impl Into) -> Self { + self.enabled = enabled.into(); + self + } + + pub fn focusable(mut self, focusable: impl Into) -> Self { + self.focusable = focusable.into(); + self + } + + pub fn disabled(self, disabled: bool) -> Self { + self.enabled(!disabled) + } + + pub fn on_press(mut self, on_press: impl Into>>) -> Self { + self.on_press = Some(on_press.into()); + self + } + + /// Accessible name for icon-only buttons which have no label to read out + pub fn alt(mut self, alt: impl Into>) -> Self { + self.alt = Some(alt.into()); + self + } + + pub fn cursor_icon(mut self, cursor_icon: CursorIcon) -> Self { + self.cursor_icon = cursor_icon; + self + } + + pub fn text(self, text: impl Into>) -> Self { + self.child(label().text(text)) + } +} + +impl ChildrenExt for Button { + fn get_children(&mut self) -> &mut Vec { + &mut self.elements + } +} + +impl KeyExt for Button { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl LayoutExt for Button { + fn get_layout(&mut self) -> &mut LayoutData { + &mut self.layout + } +} + +impl ContainerExt for Button {} + +impl StyleExt for Button { + fn get_style(&mut self) -> &mut StyleState { + &mut self.style + } +} + +impl Component for Button { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + let mut pressing = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let enabled = use_reactive(&self.enabled); + + let cursor_icon = self.cursor_icon; + + let palette = variant_colors(self.variant); + + let background = if !enabled() { + palette.disabled_background + } else if pressing() { + palette.pressed_background + } else if hovering() { + palette.hover_background + } else { + palette.background + }; + + let foreground = if enabled() { + palette.foreground + } else { + palette.disabled_foreground + }; + + // Child icons would otherwise inherit this through a `styled` event that lands a couple of + // frames after the button paints, so hand it straight to them instead + let mut icon_tint = use_state(|| foreground); + icon_tint.set_if_modified(foreground); + use_hook(|| provide_context(IconTint(icon_tint))); + + let border_color = if !enabled() { + palette.border + } else if pressing() { + palette.pressed_border + } else if hovering() { + palette.hover_border + } else if focus().is_focused() { + palette.focus_border.or(Some(Color::WHITE)) + } else { + palette.border + }; + + let on_press = self.on_press.clone(); + + let mut rect = rect() + .a11y_id(a11y_id) + .a11y_focusable(enabled() && self.focusable) + .a11y_role(AccessibilityRole::Button) + .map(self.alt.clone(), |rect, alt| rect.a11y_alt(alt)) + .overflow(Overflow::Clip) + .layout(self.layout.clone()) + .text_style(self.text_style.clone()) + .children(self.elements.clone()); + + rect.get_style().clone_from(&self.style); + + rect = rect.background(background).color(foreground); + + if !enabled() { + rect = rect.opacity(DISABLED_OPACITY); + } + + if let Some(border) = border_color { + rect = rect.border( + Border::new() + .fill(border) + .width(1.) + .alignment(BorderAlignment::Inner), + ); + } + + if enabled() { + rect = rect + .on_pointer_down(move |_| pressing.set(true)) + .on_global_pointer_press(move |_| pressing.set(false)) + .on_pointer_over(move |_| hovering.set(true)) + .on_pointer_out(move |_| { + hovering.set(false); + pressing.set(false); + }) + .cursor(cursor_icon) + .map(on_press.clone(), |rect, handler| { + rect.on_all_press(move |event: Event| { + handler.call(event); + }) + }); + } else { + rect = rect.cursor(CursorIcon::NotAllowed); + } + + rect + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) + } +} + +fn variant_colors(variant: ButtonVariant) -> ButtonColors { + match variant { + ButtonVariant::Primary => ButtonColors { + background: colors::brand(), + hover_background: colors::brand_hover(), + pressed_background: colors::brand_pressed(), + disabled_background: colors::brand_disabled(), + foreground: colors::fg_primary(), + disabled_foreground: colors::fg_primary_disabled(), + border: None, + hover_border: None, + pressed_border: None, + focus_border: None, + }, + ButtonVariant::Secondary => ButtonColors { + background: colors::component_bg(), + hover_background: colors::component_bg_hover(), + pressed_background: colors::component_bg_pressed(), + disabled_background: colors::component_bg_disabled(), + foreground: colors::fg_primary(), + disabled_foreground: colors::fg_primary_disabled(), + border: Some(colors::component_border()), + hover_border: Some(colors::component_border_hover()), + pressed_border: Some(colors::component_border_pressed()), + focus_border: Some(colors::brand()), + }, + ButtonVariant::Danger => ButtonColors { + background: colors::danger(), + hover_background: colors::danger_hover(), + pressed_background: colors::danger_pressed(), + disabled_background: colors::danger_disabled(), + foreground: colors::fg_primary(), + disabled_foreground: colors::fg_primary_disabled(), + border: None, + hover_border: None, + pressed_border: None, + focus_border: None, + }, + ButtonVariant::Ghost => ButtonColors { + background: Color::TRANSPARENT, + hover_background: colors::ghost_overlay_hover(), + pressed_background: colors::ghost_overlay_pressed(), + disabled_background: Color::TRANSPARENT, + foreground: colors::fg_primary(), + disabled_foreground: colors::fg_primary_disabled(), + border: None, + hover_border: None, + pressed_border: None, + focus_border: None, + }, + } +} + +fn size_layout(size: ButtonSize) -> (Gaps, CornerRadius, f32, Size, Size) { + match size { + ButtonSize::Small => ( + Gaps::new_symmetric(4., 8.), + CornerRadius::new_all(6.), + 12., + Size::auto(), + Size::auto(), + ), + ButtonSize::Medium => ( + Gaps::new_symmetric(6., 12.), + CornerRadius::new_all(8.), + 14., + Size::auto(), + Size::auto(), + ), + ButtonSize::Large => ( + Gaps::new_symmetric(8., 32.), + CornerRadius::new_all(10.), + 18., + Size::auto(), + Size::auto(), + ), + ButtonSize::Icon => ( + Gaps::new_all(6.), + CornerRadius::new_all(8.), + 14., + Size::px(32.), + Size::px(32.), + ), + } +} + +pub fn link_button() -> Button { + Button::new() + .primary() + .child( + Icon::new(IconType::LinkExternal01) + .size(14.) + .color(colors::fg_primary()), + ) + .child(label().text("Open")) +} + +pub fn open_folder_button(folder: std::path::PathBuf) -> Button { + Button::new() + .secondary() + .icon() + .on_press(move |_| { + std::fs::create_dir_all(&folder).ok(); + crate::platform::open_url(&folder.to_string_lossy()); + }) + .child(Icon::new(IconType::Folder).size(16.)) +} diff --git a/packages/oneclient_app/src/components/charts.rs b/packages/oneclient_app/src/components/charts.rs new file mode 100644 index 00000000..b254b010 --- /dev/null +++ b/packages/oneclient_app/src/components/charts.rs @@ -0,0 +1,553 @@ +use freya::engine::prelude::{Paint, PaintStyle, PathBuilder, SkColor, SkRect}; +use freya::prelude::*; + +use crate::theme::colors; +use crate::utils::format_duration_hm; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ValueUnit { + Duration, + Count, +} + +impl ValueUnit { + pub fn format(self, value: i64) -> String { + match self { + ValueUnit::Duration => format_duration_hm(value), + ValueUnit::Count => format!("{value}×"), + } + } +} + +const DEFAULT_HEIGHT: f32 = 150.; +const Y_AXIS_WIDTH: f32 = 44.; + +pub const SLICE_COLORS: [Color; 10] = [ + Color::from_rgb(43, 75, 255), + Color::from_rgb(27, 217, 106), + Color::from_rgb(241, 100, 54), + Color::from_rgb(198, 120, 221), + Color::from_rgb(97, 175, 239), + Color::from_rgb(229, 192, 123), + Color::from_rgb(35, 154, 96), + Color::from_rgb(224, 108, 117), + Color::from_rgb(120, 129, 141), + Color::from_rgb(152, 195, 121), +]; + +pub fn slice_color(i: usize) -> Color { + SLICE_COLORS[i % SLICE_COLORS.len()] +} + +const PIE_SIZE: f32 = 190.; +/// Fraction of the radius +const PIE_HOLE: f32 = 0.58; + +pub struct PieChart { + values: Vec, + labels: Vec, + unit: ValueUnit, + size: f32, + hovered: Option>>, +} + +impl PieChart { + pub fn new(values: Vec, labels: Vec) -> Self { + Self { + values, + labels, + unit: ValueUnit::Duration, + size: PIE_SIZE, + hovered: None, + } + } + + pub fn unit(mut self, unit: ValueUnit) -> Self { + self.unit = unit; + self + } + + pub fn hovered(mut self, hovered: State>) -> Self { + self.hovered = Some(hovered); + self + } +} + +impl PartialEq for PieChart { + fn eq(&self, other: &Self) -> bool { + self.values == other.values + && self.labels == other.labels + && self.unit == other.unit + && self.size == other.size + } +} + +impl Component for PieChart { + fn render(&self) -> impl IntoElement { + let internal = use_state(|| Option::::None); + let mut hovered = self.hovered.unwrap_or(internal); + let active = *hovered.read(); + + let size = self.size; + let total: i64 = self.values.iter().sum(); + let values = self.values.clone(); + let hit_values = self.values.clone(); + + let render_cb = RenderCallback::new(move |ctx: &mut CanvasContext| { + let (w, h) = (ctx.size.width, ctx.size.height); + if w <= 0.0 || h <= 0.0 { + return; + } + + let cx = w * 0.5; + let cy = h * 0.5; + let radius = (w.min(h) * 0.5) - 6.0; + if radius <= 0.0 { + return; + } + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(PaintStyle::Fill); + + if total <= 0 { + paint.set_color(SkColor::from(colors::component_bg_hover())); + ctx.canvas.draw_circle((cx, cy), radius - 5.0, &paint); + paint.set_color(SkColor::from(colors::page_elevated())); + ctx.canvas + .draw_circle((cx, cy), (radius - 5.0) * PIE_HOLE, &paint); + return; + } + + let mut start = -90.0_f32; + for (i, &v) in values.iter().enumerate() { + if v <= 0 { + continue; + } + let sweep = (v as f32 / total as f32) * 360.0; + let is_active = Some(i) == active; + let r = if is_active { radius } else { radius - 5.0 }; + let color = slice_color(i); + let color = if active.is_some() && !is_active { + color.with_a(120) + } else { + color + }; + paint.set_color(SkColor::from(color)); + + let oval = SkRect::new(cx - r, cy - r, cx + r, cy + r); + let mut builder = PathBuilder::new(); + builder.move_to((cx, cy)); + builder.arc_to(oval, start, sweep, false); + builder.close(); + ctx.canvas.draw_path(&builder.detach(), &paint); + + start += sweep; + } + + paint.set_color(SkColor::from(colors::page_elevated())); + ctx.canvas + .draw_circle((cx, cy), (radius - 5.0) * PIE_HOLE, &paint); + }); + + let (center_name, center_value) = match active { + Some(i) if i < self.values.len() => ( + self.labels + .get(i) + .cloned() + .unwrap_or_else(|| format!("#{}", i + 1)), + self.unit.format(self.values[i]), + ), + _ => ("Total".to_string(), self.unit.format(total)), + }; + + rect() + .width(Size::px(size)) + .height(Size::px(size)) + .on_pointer_move(move |e: Event| { + let loc = e.element_location(); + let next = slice_at(&hit_values, loc.x as f32, loc.y as f32, size); + if *hovered.peek() != next { + hovered.set(next); + } + }) + .on_pointer_leave(move |_| { + if hovered.peek().is_some() { + hovered.set(None); + } + }) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .interactive(false) + // Childless canvas auto-sizes to 0x0 without an explicit size + .child(canvas(render_cb).width(Size::fill()).height(Size::fill())), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .interactive(false) + // Sibling absolute rects on an equal layer paint in undefined order + .layer(Layer::Relative(1)) + .center() + .padding(Gaps::new_symmetric(0., size * 0.25)) + .child( + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(2.) + .child( + label() + .text(center_value) + .font_size(16.) + .font_weight(FontWeight::BOLD) + .max_lines(1) + .width(Size::fill()) + .text_align(TextAlign::Center) + .color(colors::fg_primary()), + ) + .child( + label() + .text(center_name) + .font_size(10.) + .max_lines(2) + .width(Size::fill()) + .text_align(TextAlign::Center) + .color(colors::fg_secondary()), + ), + ), + ) + } +} + +fn slice_at(values: &[i64], x: f32, y: f32, size: f32) -> Option { + let total: i64 = values.iter().sum(); + if total <= 0 { + return None; + } + + let cx = size * 0.5; + let cy = size * 0.5; + let radius = (size * 0.5) - 6.0; + let (dx, dy) = (x - cx, y - cy); + let dist = (dx * dx + dy * dy).sqrt(); + if dist > radius || dist < (radius - 5.0) * PIE_HOLE { + return None; + } + + // Clockwise from 12 o'clock matching the drawing order + let mut angle = dy.atan2(dx).to_degrees() + 90.0; + if angle < 0.0 { + angle += 360.0; + } + + let mut start = 0.0_f32; + for (i, &v) in values.iter().enumerate() { + if v <= 0 { + continue; + } + let sweep = (v as f32 / total as f32) * 360.0; + if angle >= start && angle < start + sweep { + return Some(i); + } + start += sweep; + } + None +} + +#[derive(PartialEq)] +pub struct BarChart { + values: Vec, + labels: Vec, + highlight: Option, + unit: ValueUnit, + height: f32, + gap: f32, +} + +impl BarChart { + pub fn new(values: Vec, labels: Vec) -> Self { + Self { + values, + labels, + highlight: None, + unit: ValueUnit::Duration, + height: DEFAULT_HEIGHT, + gap: 4., + } + } + + pub fn highlight(mut self, highlight: Option) -> Self { + self.highlight = highlight; + self + } + + pub fn unit(mut self, unit: ValueUnit) -> Self { + self.unit = unit; + self + } + + #[allow(dead_code)] + pub fn height(mut self, height: f32) -> Self { + self.height = height; + self + } + + pub fn gap(mut self, gap: f32) -> Self { + self.gap = gap; + self + } + + fn readout_name(&self, i: usize) -> String { + self.labels + .get(i) + .cloned() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("#{}", i + 1)) + } +} + +impl Component for BarChart { + fn render(&self) -> impl IntoElement { + let mut hovered = use_state(|| Option::::None); + let active = *hovered.read(); + let mut plot_width = use_state(|| 0f32); + + let unit = self.unit; + let height = self.height; + let max = self.values.iter().copied().max().unwrap_or(0); + + let focus = active.or(self.highlight); + let (readout_label, readout_value) = match focus { + Some(i) if i < self.values.len() => (self.readout_name(i), unit.format(self.values[i])), + _ => { + let total: i64 = self.values.iter().sum(); + ("Total".to_string(), unit.format(total)) + } + }; + + let mut bars = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .height(Size::fill()) + .cross_align(Alignment::End) + .spacing(self.gap) + .on_pointer_leave(move |_| *hovered.write() = None); + + for (i, &v) in self.values.iter().enumerate() { + let frac = if max > 0 { v as f32 / max as f32 } else { 0.0 }; + let h = if v > 0 { (frac * height).max(4.0) } else { 0.0 }; + let is_active = Some(i) == focus; + let color = if is_active { + colors::brand() + } else if active.is_some() { + colors::component_bg_hover().with_a(150) + } else { + colors::component_bg_hover() + }; + + bars = bars.child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .main_align(Alignment::End) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| *hovered.write() = Some(i)) + .child( + rect() + .width(Size::fill()) + .height(Size::px(h)) + .corner_radius(CornerRadius::new(4., 4., 0., 0.)) + .background(color), + ), + ); + } + + let stride = label_stride(&self.labels, *plot_width.read(), self.gap); + let mut label_row = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .height(Size::px(14.)) + .spacing(self.gap); + for (i, text) in self.labels.iter().enumerate() { + let shown = i % stride == 0; + label_row = label_row.child( + rect() + .width(Size::flex(1.0)) + .main_align(Alignment::Center) + .child( + label() + .text(if shown { text.clone() } else { String::new() }) + .font_size(10.) + .max_lines(1) + .width(Size::fill()) + .text_align(TextAlign::Center) + .color(colors::fg_secondary()), + ), + ); + } + + let plot = rect() + .width(Size::fill()) + .height(Size::px(height)) + .overflow(Overflow::Clip) + .child(gridlines(height)) + .child(bars); + + let plot_row = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .spacing(6.) + .child(y_axis(max, unit, height)) + .child( + rect() + .width(Size::flex(1.0)) + .on_sized(move |e: Event| { + let w = e.area.width(); + if (*plot_width.peek() - w).abs() > 0.5 { + plot_width.set(w); + } + }) + .child(plot), + ); + + let labels_row = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .spacing(6.) + .child(rect().width(Size::px(Y_AXIS_WIDTH))) + .child(rect().width(Size::flex(1.0)).child(label_row)); + + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child(readout(readout_label, readout_value)) + .child(plot_row) + .child(labels_row) + } +} + +/// Bars to skip between shown labels derived from measured plot width +fn label_stride(labels: &[String], plot_width: f32, gap: f32) -> usize { + let n = labels.len(); + if n == 0 { + return 1; + } + // Before the first measurement thin conservatively to avoid an overlap flash + if plot_width <= 0.0 { + return (n / 12).max(1); + } + + let max_chars = labels + .iter() + .map(|s| s.chars().count()) + .max() + .unwrap_or(0) + .max(1); + // ~6.2px per char at font_size 10 + let slot_px = max_chars as f32 * 6.2 + gap.max(8.0) + 8.0; + let max_labels = (plot_width / slot_px).floor().max(1.0) as usize; + + if n <= max_labels { + 1 + } else { + n.div_ceil(max_labels) + } +} + +fn gridlines(height: f32) -> Element { + let mut overlay = rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute().top(0.).left(0.)) + .interactive(false); + + for frac in [0.0_f32, 0.25, 0.5, 0.75, 1.0] { + let top = ((1.0 - frac) * height - if frac == 0.0 { 1.0 } else { 0.0 }).max(0.); + overlay = overlay.child( + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .position(Position::new_absolute().left(0.).top(top)) + .background(colors::component_border().with_a(110)), + ); + } + overlay.into_element() +} + +fn y_axis(max: i64, unit: ValueUnit, height: f32) -> Element { + let fmt = |v: i64| match unit { + ValueUnit::Duration => format_duration_hm(v), + ValueUnit::Count => v.to_string(), + }; + + let tick = |text: String| { + label() + .text(text) + .font_size(9.) + .max_lines(1) + .width(Size::fill()) + .text_align(TextAlign::Right) + .color(colors::fg_secondary().with_a(160)) + }; + + rect() + .vertical() + .width(Size::px(Y_AXIS_WIDTH)) + .height(Size::px(height)) + .main_align(Alignment::SpaceBetween) + .child(tick(fmt(max))) + .child(tick(fmt(max / 2))) + .child(tick(fmt(0))) + .into_element() +} + +fn readout(name: String, value: String) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .height(Size::px(20.)) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .padding(Gaps::new_symmetric(3., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::component_bg()) + .border(crate::ui::border_all_color(1., colors::component_border())) + .child( + rect() + .width(Size::px(8.)) + .height(Size::px(8.)) + .corner_radius(CornerRadius::new_all(2.)) + .background(colors::brand()), + ) + .child( + label() + .text(name) + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(value) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/components/cluster_landscape_art.rs b/packages/oneclient_app/src/components/cluster_landscape_art.rs new file mode 100644 index 00000000..cde8c815 --- /dev/null +++ b/packages/oneclient_app/src/components/cluster_landscape_art.rs @@ -0,0 +1,45 @@ +use freya::prelude::*; +use oneclient_common::VersionKey; +use oneclient_common::domain::GameLoader; + +use crate::components::{ART_PREVIEW_EDGE, DynamicArt}; +use crate::theme::colors; +use crate::ui::border_all_color; + +#[derive(PartialEq, Clone)] +pub struct ClusterLandscapeArt { + art: DynamicArt, + selected: bool, +} + +impl ClusterLandscapeArt { + pub fn for_version( + major: u32, + key: Option, + loader: Option, + selected: bool, + ) -> Self { + Self { + art: DynamicArt::for_version(major, key, loader).max_edge(ART_PREVIEW_EDGE), + selected, + } + } +} + +impl Component for ClusterLandscapeArt { + fn render(&self) -> impl IntoElement { + let border = if self.selected { + border_all_color(2., colors::brand()) + } else { + border_all_color(2., colors::component_border()) + }; + + rect() + .width(Size::fill()) + .height(Size::fill()) + .corner_radius(CornerRadius::new_all(12.)) + .overflow(Overflow::Clip) + .border(border.alignment(BorderAlignment::Inner)) + .child(self.art.clone()) + } +} diff --git a/packages/oneclient_app/src/components/cluster_update_popup.rs b/packages/oneclient_app/src/components/cluster_update_popup.rs new file mode 100644 index 00000000..1e7e794a --- /dev/null +++ b/packages/oneclient_app/src/components/cluster_update_popup.rs @@ -0,0 +1,383 @@ +use std::collections::HashMap; + +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::notifications::{ClusterUpdateItem, ClusterUpdateSummary}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 420.; +const DIALOG_H: f32 = 400.; + +type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum UpdateTab { + Updates, + Additions, + Removals, +} + +impl UpdateTab { + const ALL: [UpdateTab; 3] = [ + UpdateTab::Updates, + UpdateTab::Additions, + UpdateTab::Removals, + ]; + + fn label(self) -> &'static str { + match self { + UpdateTab::Updates => "Updated", + UpdateTab::Additions => "Added", + UpdateTab::Removals => "Removed", + } + } + + fn empty_text(self) -> &'static str { + match self { + UpdateTab::Updates => "Nothing was updated.", + UpdateTab::Additions => "Nothing was added.", + UpdateTab::Removals => "Nothing was removed.", + } + } + + fn icon(self) -> IconType { + match self { + UpdateTab::Updates => IconType::RefreshCw01, + UpdateTab::Additions => IconType::Plus, + UpdateTab::Removals => IconType::Trash01, + } + } + + fn accent(self) -> Color { + match self { + UpdateTab::Updates => colors::brand(), + UpdateTab::Additions => colors::success(), + UpdateTab::Removals => colors::danger(), + } + } + + fn items(self, summary: &ClusterUpdateSummary) -> &[ClusterUpdateItem] { + match self { + UpdateTab::Updates => &summary.updated, + UpdateTab::Additions => &summary.added, + UpdateTab::Removals => &summary.removed, + } + } +} + +struct ClusterGroup { + cluster_id: i64, + cluster_name: String, + names: Vec, +} + +#[derive(PartialEq)] +pub struct ClusterUpdatePopup; + +impl Component for ClusterUpdatePopup { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + let active = use_state(|| UpdateTab::Updates); + + let summaries = snapshot.cluster_update.clone(); + + // Hooks must run unconditionally so query every provider before the early return below + let all_items: Vec<&ClusterUpdateItem> = summaries + .iter() + .flatten() + .flat_map(|s| s.updated.iter().chain(&s.added).chain(&s.removed)) + .collect(); + let mut meta = MetaMap::new(); + for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { + let ids: Vec = all_items + .iter() + .filter(|i| i.provider == provider) + .filter_map(|i| i.project_id.clone()) + .collect(); + let query = use_package_meta_batch(provider, ids); + for (pid, m) in package_meta_batch(&query) { + meta.insert((provider, pid), m); + } + } + + let Some(summaries) = summaries.filter(|s| !s.is_empty()) else { + return rect().into_element(); + }; + + let close = dispatch.clone(); + + OverlayPopup::new() + .on_close(move |_| close.close_cluster_update()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog(&summaries, &meta, active, dispatch)), + ) + .into_element() + } +} + +fn resolve_name(item: &ClusterUpdateItem, meta: &MetaMap) -> String { + item.project_id + .as_ref() + .and_then(|pid| meta.get(&(item.provider, pid.clone()))) + .map(|m| m.name.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| item.fallback.clone()) +} + +fn dialog( + summaries: &[ClusterUpdateSummary], + meta: &MetaMap, + active: State, + dispatch: crate::Actions, +) -> impl IntoElement { + rect() + .vertical() + .width(Size::px(DIALOG_W)) + .height(Size::px(DIALOG_H)) + .max_width(Size::window_percent(95.)) + .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(summaries, meta, active, dispatch)) +} + +fn content( + summaries: &[ClusterUpdateSummary], + meta: &MetaMap, + active: State, + dispatch: crate::Actions, +) -> impl IntoElement { + let dismiss = dispatch.clone(); + let total: usize = summaries.iter().map(|s| s.total()).sum(); + let active_tab = *active.read(); + + // A batch sync can touch several clusters single-cluster keeps the footer shortcut + let single = match summaries { + [only] => Some((only.cluster_id, only.cluster_name.clone())), + _ => None, + }; + + let subtitle = match &single { + Some((_, name)) => format!( + "{total} change{} synced to {name}.", + if total == 1 { "" } else { "s" } + ), + None => format!( + "{total} change{} across {} clusters.", + if total == 1 { "" } else { "s" }, + summaries.len() + ), + }; + + // Every category is shown even at zero so the modal shape stays stable + let tabs = TabBar::new() + .width(Size::fill()) + .height(Size::px(30.)) + .spacing(20.) + .tabs(UpdateTab::ALL.into_iter().map(|tab| { + let count: usize = summaries.iter().map(|s| tab.items(s).len()).sum(); + let mut set = active; + TabItem::new(tab.label(), tab == active_tab) + .count_text(count.to_string()) + .on_press(move |_| *set.write() = tab) + })); + + // Clusters empty in the active category drop out avoiding empty section headers + let groups: Vec = summaries + .iter() + .filter_map(|summary| { + let names: Vec = active_tab + .items(summary) + .iter() + .map(|item| resolve_name(item, meta)) + .collect(); + (!names.is_empty()).then(|| ClusterGroup { + cluster_id: summary.cluster_id, + cluster_name: summary.cluster_name.clone(), + names, + }) + }) + .collect(); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new_all(22.)) + .spacing(14.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(3.) + .child( + label() + .text("Changes applied") + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.5) + .max_lines(2) + .color(colors::fg_secondary()), + ), + ) + .child(tabs) + .child(change_list( + active_tab, + &groups, + single.is_none(), + &dispatch, + )) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| dismiss.close_cluster_update()) + .text("Dismiss"), + ) + .maybe_child(single.map(|(cluster_id, _)| { + let open = dispatch.clone(); + Button::new() + .primary() + .on_press(move |_| open_cluster(&open, cluster_id)) + .text("Open cluster") + .child(Icon::new(IconType::ArrowRight).size(15.)) + .into_element() + })), + ) +} + +fn open_cluster(dispatch: &crate::Actions, cluster_id: i64) { + dispatch.close_cluster_update(); + let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); +} + +fn change_list( + tab: UpdateTab, + groups: &[ClusterGroup], + show_headers: bool, + dispatch: &crate::Actions, +) -> impl IntoElement { + let accent = tab.accent(); + + let mut scroll = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(1.); + + if groups.is_empty() { + return scroll + .child( + rect() + .width(Size::fill()) + .height(Size::px(80.)) + .center() + .child( + label() + .text(tab.empty_text()) + .font_size(12.5) + .color(colors::fg_secondary()), + ), + ) + .into_element(); + } + + for (index, group) in groups.iter().enumerate() { + if show_headers { + scroll = scroll.child(cluster_header(group, index == 0, dispatch.clone())); + } + + for name in &group.names { + scroll = scroll.child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(9.) + .padding(Gaps::new_symmetric(5., 6.)) + .child(Icon::new(tab.icon()).size(13.).color(accent)) + .child( + label() + .text(name.clone()) + .font_size(12.5) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ), + ); + } + } + + scroll.into_element() +} + +/// Multi-cluster stand-in for the footer's "Open cluster" shortcut +fn cluster_header(group: &ClusterGroup, first: bool, dispatch: crate::Actions) -> impl IntoElement { + let cluster_id = group.cluster_id; + let count = group.names.len(); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .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()) + .cursor(CursorIcon::Pointer) + .on_press(move |_| open_cluster(&dispatch, cluster_id)) + .child( + label() + .text(group.cluster_name.clone()) + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .child( + label() + .text(count.to_string()) + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + Icon::new(IconType::ArrowRight) + .size(12.) + .color(colors::fg_secondary()), + ) +} diff --git a/packages/oneclient_app/src/components/context_menu.rs b/packages/oneclient_app/src/components/context_menu.rs new file mode 100644 index 00000000..a2d1a3eb --- /dev/null +++ b/packages/oneclient_app/src/components/context_menu.rs @@ -0,0 +1,212 @@ +use freya::prelude::*; + +use crate::components::{Icon, IconType, OverlayPopup}; +use crate::theme::colors; + +const MENU_BG: Color = Color::from_rgb(25, 32, 38); +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); + +enum Entry { + Action { + icon: IconType, + label: String, + danger: bool, + on_select: EventHandler<()>, + }, + Separator, +} + +pub struct ContextMenu { + x: f32, + y: f32, + entries: Vec, + on_close: EventHandler<()>, +} + +impl ContextMenu { + pub fn new(x: f32, y: f32) -> Self { + Self { + x, + y, + entries: Vec::new(), + on_close: (|()| {}).into(), + } + } + + pub fn on_close(mut self, on_close: impl Into>) -> Self { + self.on_close = on_close.into(); + self + } + + pub fn action( + mut self, + icon: IconType, + label: impl Into, + on_select: impl Into>, + ) -> Self { + self.entries.push(Entry::Action { + icon, + label: label.into(), + danger: false, + on_select: on_select.into(), + }); + self + } + + pub fn danger_action( + mut self, + icon: IconType, + label: impl Into, + on_select: impl Into>, + ) -> Self { + self.entries.push(Entry::Action { + icon, + label: label.into(), + danger: true, + on_select: on_select.into(), + }); + self + } + + pub fn separator(mut self) -> Self { + self.entries.push(Entry::Separator); + self + } +} + +impl PartialEq for ContextMenu { + fn eq(&self, _other: &Self) -> bool { + // Always unequal the menu is rebuilt from scratch whenever it is reopened + false + } +} + +impl Component for ContextMenu { + fn render(&self) -> impl IntoElement { + let on_close = self.on_close.clone(); + + let mut width = use_state(|| 0f32); + + let item_width = { + let w = *width.read(); + (w > 0.).then_some(w) + }; + + let mut list = rect().vertical().spacing(4.); + 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::Action { + icon, + label, + danger, + on_select, + } => list.child( + ContextMenuRow { + icon: *icon, + label: label.clone(), + danger: *danger, + width: item_width, + on_select: on_select.clone(), + on_close: on_close.clone(), + } + .into_element(), + ), + }; + } + + let list = list.on_sized(move |e: Event| { + let measured = e.data().area.width(); + if (measured - *width.peek()).abs() > 0.5 { + width.set(measured); + } + }); + + let panel = rect() + .vertical() + .padding(Gaps::new_all(6.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(MENU_BG) + .border(Border::new().fill(MENU_BORDER).width(BorderWidth { + top: 1., + right: 1., + bottom: 1., + left: 1., + })) + .child(list); + + OverlayPopup::new() + .backdrop(false) + .position(Position::new_global().top(self.y).left(self.x)) + .on_close(move |_| on_close.call(())) + .child(panel.into_element()) + } +} + +#[derive(PartialEq)] +struct ContextMenuRow { + icon: IconType, + label: String, + danger: bool, + width: Option, + on_select: EventHandler<()>, + on_close: EventHandler<()>, +} + +impl Component for ContextMenuRow { + fn render(&self) -> impl IntoElement { + let mut hovered = use_state(|| false); + let on_select = self.on_select.clone(); + let on_close = self.on_close.clone(); + + let base = if self.danger { MENU_DANGER } else { MENU_FG }; + let color = if *hovered.read() { + colors::fg_primary() + } else { + base + }; + + let mut root = rect(); + if let Some(w) = self.width { + root = root.width(Size::px(w)); + } + + root.horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(5., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if *hovered.read() { + colors::component_bg_hover() + } else { + Color::TRANSPARENT + }) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(move |_| { + on_select.call(()); + on_close.call(()); + }) + .child(Icon::new(self.icon).size(18.).color(color)) + .child( + label() + .text(self.label.clone()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(color), + ) + } +} diff --git a/packages/oneclient_app/src/components/dropdown.rs b/packages/oneclient_app/src/components/dropdown.rs new file mode 100644 index 00000000..5ebecd2c --- /dev/null +++ b/packages/oneclient_app/src/components/dropdown.rs @@ -0,0 +1,248 @@ +use freya::prelude::*; + +use crate::components::{Icon, IconType, ScrollArea}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const OPTION_HEIGHT: f32 = 28.; +const OPTION_SPACING: f32 = 2.; +const MAX_VISIBLE_OPTIONS: usize = 8; + +#[derive(Clone, PartialEq)] +pub struct Dropdown { + selected: String, + options: Vec, + on_select: Option>, + width: Size, + height: Size, + key: DiffKey, +} + +impl Dropdown { + pub fn new(selected: impl Into, options: Vec) -> Self { + Self { + selected: selected.into(), + options, + on_select: None, + width: Size::px(72.), + height: Size::px(24.), + key: DiffKey::None, + } + } + + #[allow(dead_code)] + pub fn width(mut self, width: impl Into) -> Self { + self.width = width.into(); + self + } + + #[allow(dead_code)] + pub fn height(mut self, height: impl Into) -> Self { + self.height = height.into(); + self + } + + pub fn on_select(mut self, handler: impl Into>) -> Self { + self.on_select = Some(handler.into()); + self + } +} + +impl KeyExt for Dropdown { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for Dropdown { + fn render(&self) -> impl IntoElement { + let mut open = use_state(|| false); + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let mut button_area = use_state(|| None::); + let mut list_size = use_state(|| None::); + + let selected = self.selected.clone(); + let options = self.options.clone(); + let on_select = self.on_select.clone(); + let is_open = open(); + + if !is_open && list_size().is_some() { + let _ = list_size.take(); + } + + let trigger_bg = if hovering() { + colors::ghost_overlay_hover() + } else { + colors::ghost_overlay() + }; + + let visible = options.len().min(MAX_VISIBLE_OPTIONS); + let list_h = + visible as f32 * OPTION_HEIGHT + visible.saturating_sub(1) as f32 * OPTION_SPACING; + + let offset_y = match (button_area(), list_size()) { + (Some(button), Some(list)) => { + let root_height = Platform::get().root_size.peek().height; + let space_below = root_height - button.max_y(); + let space_above = button.min_y(); + if list.height > space_below && list.height <= space_above { + -(button.height() + list.height + 8.) + } else { + 0. + } + } + _ => 0., + }; + + let list_width = button_area() + .map(|b| Size::px(b.width())) + .unwrap_or_else(|| self.width.clone()); + + let on_global_pointer_press = move |_: Event| { + open.set_if_modified(false); + }; + + rect() + .width(self.width.clone()) + .child( + rect() + .width(Size::fill()) + .height(self.height.clone()) + .horizontal() + .center() + .spacing(4.) + .padding(Gaps::new_symmetric(0., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(trigger_bg) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .maybe(focus().is_focused(), |el| { + el.border(border_all_color(1., colors::brand())) + }) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_global_pointer_press(on_global_pointer_press) + .on_sized(move |e: Event| { + button_area.set_if_modified(Some(e.area)); + }) + .on_all_press(move |e: Event| { + e.prevent_default(); + e.stop_propagation(); + open.toggle(); + }) + .child( + label() + .text(selected) + .font_size(12.) + .color(colors::fg_primary()), + ) + .child( + Icon::new(IconType::ChevronDown) + .size(14.) + .color(colors::fg_secondary()), + ), + ) + .maybe_child(is_open.then(|| { + rect().width(Size::px(0.)).height(Size::px(0.)).child( + rect() + .layer(Layer::Overlay) + .width(list_width) + .margin(Gaps::new(4., 0., 0., 0.)) + .offset_y(offset_y) + .content(Content::Fit) + .on_sized(move |e: Event| { + list_size.set_if_modified(Some(e.area.size)); + }) + .child( + rect() + .width(Size::fill()) + .padding(4.) + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., colors::component_border())) + .background(colors::page_elevated()) + .overflow(Overflow::Clip) + .child( + ScrollArea::new() + .height(Size::px(list_h)) + .spacing(OPTION_SPACING) + .children(options.into_iter().enumerate().map( + |(idx, option)| { + let on_select = on_select.clone(); + DropdownOption::new(option, move |_| { + if let Some(handler) = &on_select { + handler.call(idx); + } + open.set(false); + }) + .into_element() + }, + )), + ), + ), + ) + })) + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) + } +} + +struct DropdownOption { + text: String, + on_press: EventHandler>, +} + +impl DropdownOption { + pub fn new( + text: impl Into, + on_press: impl Into>>, + ) -> Self { + Self { + text: text.into(), + on_press: on_press.into(), + } + } +} + +impl PartialEq for DropdownOption { + fn eq(&self, other: &Self) -> bool { + self.text == other.text + } +} + +impl Component for DropdownOption { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(6., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .background(if hovering() || focus().is_focused() { + colors::ghost_overlay_hover() + } else { + Color::TRANSPARENT + }) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_all_press(self.on_press.clone()) + .child( + label() + .text(self.text.clone()) + .font_size(12.) + .color(colors::fg_primary()), + ) + } +} diff --git a/packages/oneclient_app/src/components/dynamic_art.rs b/packages/oneclient_app/src/components/dynamic_art.rs new file mode 100644 index 00000000..62f9a1cc --- /dev/null +++ b/packages/oneclient_app/src/components/dynamic_art.rs @@ -0,0 +1,126 @@ +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 crate::AppAssets; +use crate::hooks::{loaded_image, use_cached_image, use_version_metadata}; +use crate::layout::HOME_BACKGROUND_ASSET; + +/// The size cluster cards request so it is usually already cached when anything else wants it +pub const ART_PREVIEW_EDGE: u32 = 512; + +#[derive(PartialEq, Clone)] +pub struct DynamicArt { + major: Option, + key: Option, + loader: Option, + max_edge: u32, + preview_edge: Option, +} + +impl DynamicArt { + pub fn for_version(major: u32, key: Option, loader: Option) -> Self { + Self { + major: Some(major), + key, + loader, + max_edge: DEFAULT_IMAGE_EDGE, + preview_edge: None, + } + } + + pub fn for_cluster(cluster: &Cluster) -> Self { + let parsed = parse_mc_version(&cluster.mc_version); + Self { + major: parsed.as_ref().map(|p| p.major), + key: parsed.and_then(|p| p.key()), + loader: Some(cluster.mc_loader), + max_edge: DEFAULT_IMAGE_EDGE, + preview_edge: None, + } + } + + pub fn fallback() -> Self { + Self { + major: None, + key: None, + loader: None, + max_edge: DEFAULT_IMAGE_EDGE, + preview_edge: None, + } + } + + #[must_use] + pub fn max_edge(mut self, max_edge: u32) -> Self { + self.max_edge = max_edge; + self + } + + /// Stand in with a smaller cached variant until the full-size art downloads + #[must_use] + pub fn preview_edge(mut self, preview_edge: u32) -> Self { + self.preview_edge = Some(preview_edge); + self + } + + pub fn use_bytes(&self) -> (String, Bytes) { + use_art_bytes( + self.major, + self.key, + self.loader, + self.max_edge, + self.preview_edge, + ) + } +} + +pub fn use_art_bytes( + major: Option, + key: Option, + loader: Option, + max_edge: u32, + preview_edge: Option, +) -> (String, Bytes) { + let fallback = use_memo(|| AppAssets::get_bytes(HOME_BACKGROUND_ASSET).unwrap_or_default()); + + let art_url = use_version_metadata(major, key, loader).and_then(|m| m.art_url); + + let image_query = use_cached_image(art_url.clone(), max_edge); + + let preview_edge = preview_edge.filter(|edge| *edge != max_edge); + let preview_query = match preview_edge { + Some(edge) => use_cached_image(art_url.clone(), edge), + None => use_cached_image(None, 0), + }; + + let full = loaded_image(art_url.as_deref(), &image_query) + .map(|(url, bytes)| (format!("{max_edge}|{url}"), bytes)); + let preview = || { + let edge = preview_edge?; + loaded_image(art_url.as_deref(), &preview_query) + .map(|(url, bytes)| (format!("{edge}|{url}"), bytes)) + }; + + full.or_else(preview).unwrap_or_else(|| { + ( + format!("{max_edge}|{HOME_BACKGROUND_ASSET}"), + fallback.read().clone(), + ) + }) +} + +impl Component for DynamicArt { + fn render(&self) -> impl IntoElement { + let (key, bytes) = self.use_bytes(); + + ImageViewer::new((key, bytes)) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::Max) + .image_cover(ImageCover::Center) + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/file_drop.rs b/packages/oneclient_app/src/components/file_drop.rs new file mode 100644 index 00000000..a363dc2e --- /dev/null +++ b/packages/oneclient_app/src/components/file_drop.rs @@ -0,0 +1,489 @@ +//! Freya emits one `FileDrop` event per file within a single batch so the shell +//! handler accumulates them before the prompt first renders + +use std::collections::HashMap; +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_db::models::ClusterId; + +use crate::Route; +use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const DIALOG_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 480.; +const FILE_ROW_H: f32 = 34.; +const FILE_LIST_MAX_ROWS: usize = 5; +const TYPE_DROPDOWN_W: f32 = 108.; + +/// Worlds are directories and modpacks/datapacks have no view so both are excluded +const IMPORTABLE: [ContentType; 3] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, +]; + +const SHADER_HINTS: [&str; 5] = ["shader", "bsl", "seus", "complementary", "sildur"]; + +fn content_label(content_type: ContentType) -> &'static str { + match content_type { + ContentType::ResourcePack => "Textures", + ContentType::Shader => "Shaders", + _ => "Mods", + } +} + +fn extension(path: &Path) -> Option { + Some(path.extension()?.to_str()?.to_ascii_lowercase()) +} + +fn file_name(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) +} + +/// Precedence `.jar` is always a mod then the current route then a name sniff +fn infer_content_type(path: &Path, route_type: Option) -> ContentType { + if extension(path).as_deref() == Some("jar") { + return ContentType::Mod; + } + if let Some(route_type) = route_type { + return route_type; + } + let name = file_name(path).to_lowercase(); + if SHADER_HINTS.iter().any(|hint| name.contains(hint)) { + ContentType::Shader + } else { + ContentType::ResourcePack + } +} + +fn route_target(route: &Route) -> Option<(ClusterId, ContentType)> { + match route { + Route::ClusterMods { cluster_id } => Some((*cluster_id, ContentType::Mod)), + Route::ClusterShaders { cluster_id } => Some((*cluster_id, ContentType::Shader)), + Route::ClusterTextures { cluster_id } => Some((*cluster_id, ContentType::ResourcePack)), + _ => None, + } +} + +/// State lives in the shell (owner of the bubbling `on_file_drop`) so it never re-renders on hover +#[derive(PartialEq)] +pub struct FileDropOverlay { + pub hovering: State, + pub pending: State>, +} + +impl Component for FileDropOverlay { + fn render(&self) -> impl IntoElement { + let files = self.pending.read().clone(); + + if !files.is_empty() { + return DropPrompt { + files, + pending: self.pending, + } + .into_element(); + } + + if !*self.hovering.read() { + return rect().into_element(); + } + + DropVeil.into_element() + } +} + +#[derive(PartialEq)] +struct DropVeil; + +impl Component for DropVeil { + fn render(&self) -> impl IntoElement { + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.) + .time(170) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let progress = intro.read().value(); + + rect() + .layer(Layer::Overlay) + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + // Purely decorative the drop must reach the shell handler beneath + .interactive(false) + .center() + .opacity(progress) + .background(Color::from_argb(190, 10, 14, 20)) + .child( + rect() + .vertical() + .center() + .spacing(10.) + .padding(Gaps::new_symmetric(22., 28.)) + .max_width(Size::px(360.)) + .offset_y((1. - progress) * 12.) + .corner_radius(CornerRadius::new_all(16.)) + .background(DIALOG_BG) + .border(border_all_color(1., colors::brand().with_a(120))) + .shadow(Shadow::from(( + 0., + 16., + 44., + 0., + Color::from_argb(160, 0, 0, 0), + ))) + .child(icon_badge(IconType::FolderDownload, 52., 22.)) + .child( + label() + .text("Drop files to add them") + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("You'll pick where they go next.") + .font_size(12.5) + .color(colors::fg_secondary()), + ), + ) + } +} + +fn icon_badge(icon: IconType, size: f32, icon_size: f32) -> impl IntoElement { + rect() + .width(Size::px(size)) + .height(Size::px(size)) + .center() + .corner_radius(CornerRadius::new_all(size / 2.)) + .background(colors::brand().with_a(46)) + .child(Icon::new(icon).size(icon_size).color(colors::brand())) +} + +#[derive(PartialEq)] +struct DropPrompt { + files: Vec, + pending: State>, +} + +impl Component for DropPrompt { + fn render(&self) -> impl IntoElement { + let route = use_route::(); + let active_cluster = use_active_cluster_id(); + let clusters_query = use_clusters(); + let dispatch = use_dispatch(); + + let files = self.files.clone(); + let mut pending = self.pending; + + let target = route_target(&route); + let initial_cluster = target.map(|(id, _)| id).or(*active_cluster.peek()); + let selected_cluster = use_state(move || initial_cluster); + // Absent entries fall back to the inferred type covering drops landing mid-prompt + let overrides = use_state(HashMap::::new); + + let clusters: Vec = settled_or_loading(&clusters_query).unwrap_or_default(); + + let body = if clusters.is_empty() { + no_clusters_body(pending) + } else { + prompt_body( + &files, + &clusters, + target.map(|(_, ct)| ct), + selected_cluster, + overrides, + dispatch, + pending, + ) + }; + + OverlayPopup::new() + .on_close(move |_| pending.set(Vec::new())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(DIALOG_W)) + .max_width(Size::window_percent(94.)) + .padding(Gaps::new_all(22.)) + .spacing(16.) + .corner_radius(CornerRadius::new_all(16.)) + .background(DIALOG_BG) + .border(border_all_color(1., colors::component_border())) + .shadow(Shadow::from(( + 0., + 18., + 52., + 0., + Color::from_argb(150, 0, 0, 0), + ))) + .child(body), + ), + ) + .into_element() + } +} + +fn dialog_header(title: String, subtitle: String) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + // Without Content::Flex the flexed text column doesn't resolve and the + // badge stops centring against it + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .child(icon_badge(IconType::FilePlus02, 38., 18.)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(title) + .font_size(17.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.5) + .max_lines(2) + .color(colors::fg_secondary()), + ), + ) +} + +fn no_clusters_body(mut pending: State>) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(dialog_header( + "Nowhere to put these".to_string(), + "Create a cluster first, then drop the files again.".to_string(), + )) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .child( + Button::new() + .primary() + .on_press(move |_| pending.set(Vec::new())) + .text("Close"), + ), + ) + .into_element() +} + +fn prompt_body( + files: &[PathBuf], + clusters: &[Cluster], + route_type: Option, + selected_cluster: State>, + overrides: State>, + dispatch: crate::Actions, + mut pending: State>, +) -> Element { + // The remembered cluster may have been deleted while the prompt was open + let cluster_id = selected_cluster + .read() + .filter(|id| clusters.iter().any(|c| c.id == *id)) + .unwrap_or(clusters[0].id); + let cluster_idx = clusters + .iter() + .position(|c| c.id == cluster_id) + .unwrap_or_default(); + + let resolved: Vec<(PathBuf, ContentType)> = files + .iter() + .map(|path| { + let content_type = overrides + .read() + .get(path) + .copied() + .unwrap_or_else(|| infer_content_type(path, route_type)); + (path.clone(), content_type) + }) + .collect(); + + 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()); + } + pending.set(Vec::new()); + }; + + rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(dialog_header( + format!( + "Add {} file{}", + files.len(), + if files.len() == 1 { "" } else { "s" } + ), + "Pick a cluster, then check what each one gets installed as.".to_string(), + )) + .child(cluster_field(clusters, cluster_idx, selected_cluster)) + .child(file_field(&resolved, overrides)) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| pending.set(Vec::new())) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(import) + .child(Icon::new(IconType::Plus).size(15.)) + .text(match files.len() { + 1 => "Add file".to_string(), + n => format!("Add {n} files"), + }), + ), + ) + .into_element() +} + +fn field(title: &'static str, control: impl IntoElement) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(7.) + .child( + label() + .text(title) + .font_size(11.5) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child(control) +} + +fn cluster_field( + clusters: &[Cluster], + selected_idx: usize, + mut selected: State>, +) -> impl IntoElement { + let labels: Vec = clusters + .iter() + .map(|c| format!("{} · {}", c.name, c.mc_version)) + .collect(); + let ids: Vec = clusters.iter().map(|c| c.id).collect(); + let current = labels[selected_idx].clone(); + + field( + "Destination cluster", + Dropdown::new(current, labels) + .width(Size::fill()) + .height(Size::px(32.)) + .on_select(move |idx: usize| { + if let Some(id) = ids.get(idx) { + selected.set(Some(*id)); + } + }), + ) +} + +fn file_field( + resolved: &[(PathBuf, ContentType)], + overrides: State>, +) -> impl IntoElement { + let visible = resolved.len().clamp(1, FILE_LIST_MAX_ROWS); + let rows = resolved + .iter() + .map(|(path, content_type)| file_row(path, *content_type, overrides).into_element()) + .collect::>(); + + field( + "Files", + rect() + .width(Size::fill()) + .padding(Gaps::new_all(6.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::page()) + .border(border_all_color(1., colors::component_border())) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::px(visible as f32 * FILE_ROW_H)) + .children(rows), + ), + ) +} + +fn file_row( + path: &Path, + content_type: ContentType, + mut overrides: State>, +) -> impl IntoElement { + let key = path.to_path_buf(); + let options: Vec = IMPORTABLE + .iter() + .map(|ct| content_label(*ct).to_string()) + .collect(); + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(FILE_ROW_H)) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(0., 4.)) + .child( + Icon::new(IconType::File02) + .size(13.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(file_name(path)) + .font_size(12.5) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .child( + Dropdown::new(content_label(content_type), options) + .width(Size::px(TYPE_DROPDOWN_W)) + .height(Size::px(24.)) + .on_select(move |idx: usize| { + if let Some(content_type) = IMPORTABLE.get(idx) { + overrides.write().insert(key.clone(), *content_type); + } + }), + ) +} diff --git a/packages/oneclient_app/src/components/generic_prompt.rs b/packages/oneclient_app/src/components/generic_prompt.rs new file mode 100644 index 00000000..fa5667d0 --- /dev/null +++ b/packages/oneclient_app/src/components/generic_prompt.rs @@ -0,0 +1,175 @@ +//! Fallback renderer for prompts no dedicated overlay claims +//! Without it an unrendered prompt leaves the core blocked on `ask()` blocking all later prompts + +use freya::prelude::*; +use oneclient_events::{Answer, ChoiceInput, ChoiceStyle}; +use oneclient_java::JAVA_CHOICE_DOWNLOAD; + +use crate::components::{Button, OverlayPopup}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::notifications::PendingPromptView; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::updater::UPDATE_CHOICE_INSTALL; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); + +fn claimed_elsewhere(prompt: &PendingPromptView) -> bool { + prompt.has_choice(JAVA_CHOICE_DOWNLOAD) || prompt.has_choice(UPDATE_CHOICE_INSTALL) +} + +#[derive(PartialEq)] +pub struct GenericPromptOverlay; + +impl Component for GenericPromptOverlay { + fn render(&self) -> impl IntoElement { + // Hooks run unconditionally before any early return to keep the hook count stable + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + + let Some(prompt) = snapshot.pending_prompt.clone() else { + return rect().into_element(); + }; + + if claimed_elsewhere(&prompt) { + return rect().into_element(); + } + + let close = dispatch.clone(); + + let mut buttons = rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.); + + if let Some(dismiss) = prompt.dismiss.clone() { + let cancel = dispatch.clone(); + buttons = buttons.child( + Button::new() + .secondary() + .text(dismiss) + .on_press(move |_| cancel.dismiss_prompt()), + ); + } + + for choice in &prompt.choices { + let dispatch = dispatch.clone(); + let id = choice.id; + let input = choice.input.clone(); + + let button = Button::new().text(choice.label.clone()); + let button = match choice.style { + ChoiceStyle::Primary => button.primary(), + ChoiceStyle::Danger => button.danger(), + ChoiceStyle::Secondary => button.secondary(), + }; + + buttons = buttons.child(button.on_press(move |_| match &input { + // Answered from the dialog callback not the press since no folder is picked yet + Some(ChoiceInput::Folder { title }) => { + let dispatch = dispatch.clone(); + let title = title.clone(); + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new() + .set_title(title) + .pick_folder() + .await + { + dispatch.answer_prompt(Answer::new(id).with_folder(handle.path())); + } + }); + } + // No picker here answering with `selection() == None` beats blocking the core + Some(ChoiceInput::Selection { hint }) => { + tracing::warn!( + choice = id, + hint, + "a prompt asked for a selection with no overlay to collect it" + ); + dispatch.answer_prompt(Answer::new(id)); + } + None => dispatch.answer_prompt(Answer::new(id)), + })); + } + + OverlayPopup::new() + .on_close(move |_| close.dismiss_prompt()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(prompt.title.clone()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(prompt.question.clone()) + .font_size(12.) + .max_lines(6) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child(buttons), + ), + ) + .into_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oneclient_events::Choice; + + fn prompt(choices: Vec) -> PendingPromptView { + PendingPromptView { + title: "Title".to_string(), + question: "Question".to_string(), + choices, + dismiss: Some("Cancel".to_string()), + } + } + + #[test] + fn the_two_rich_overlays_keep_their_own_prompts() { + assert!(claimed_elsewhere(&prompt(vec![Choice::primary( + JAVA_CHOICE_DOWNLOAD, + "Download", + )]))); + assert!(claimed_elsewhere(&prompt(vec![Choice::primary( + UPDATE_CHOICE_INSTALL, + "Download", + )]))); + } + + #[test] + fn every_other_prompt_falls_through_to_the_generic_overlay() { + for choices in [ + vec![Choice::new("launch", "Launch anyway")], + vec![Choice::primary("verify", "Verify and repair")], + vec![Choice::danger("delete", "Delete")], + Vec::new(), + ] { + assert!( + !claimed_elsewhere(&prompt(choices)), + "an unclaimed prompt must render somewhere" + ); + } + } +} diff --git a/packages/oneclient_app/src/components/icons.rs b/packages/oneclient_app/src/components/icons.rs new file mode 100644 index 00000000..4eab4960 --- /dev/null +++ b/packages/oneclient_app/src/components/icons.rs @@ -0,0 +1,152 @@ +use freya::prelude::*; +use oneclient_content::packages::ProviderId; + +use crate::{AppAssets, theme::colors}; + +#[derive(Clone, Copy, PartialEq)] +pub struct IconTint(pub State); + +#[derive(PartialEq)] +pub struct Icon { + pub icon: IconType, + pub size_px: f32, + pub color: Option, + pub rotation: Option, +} + +impl Icon { + pub fn new(icon: impl Into) -> Self { + let icon = icon.into(); + let color = match icon { + IconType::Modrinth => Some(colors::MODRINTH_COLOR), + IconType::Curseforge => Some(colors::CURSEFORGE_COLOR), + _ => None, + }; + + Self { + icon, + size_px: 24., + color, + rotation: None, + } + } + + pub fn size(mut self, size_px: f32) -> Self { + self.size_px = size_px; + self + } + + pub fn color(mut self, color: Color) -> Self { + self.color = Some(color); + self + } + + /// Degrees clockwise + pub fn rotate(mut self, degrees: f32) -> Self { + self.rotation = Some(degrees); + self + } +} + +impl Component for Icon { + fn render(&self) -> impl IntoElement { + let path = self.icon.path(); + let bytes = use_memo(move || AppAssets::get_bytes(path).unwrap_or_default()); + let tint = use_hook(try_consume_context::); + + let color = self + .color + .or_else(|| tint.map(|tint| *tint.0.read())) + .unwrap_or_else(colors::fg_primary); + + SvgViewer::new((path, bytes.read().cloned())) + .show_loader(false) + .width(Size::px(self.size_px)) + .height(Size::px(self.size_px)) + .color(color) + .map(self.rotation, |svg, degrees| svg.rotation(degrees)) + } +} + +impl From for IconType { + fn from(val: ProviderId) -> Self { + match val { + ProviderId::Modrinth => IconType::Modrinth, + ProviderId::CurseForge => IconType::Curseforge, + ProviderId::Local => IconType::Folder, + } + } +} + +#[derive(oneclient_macro::IconNamed, Debug, PartialEq, Clone, Copy)] +#[allow(dead_code)] +pub enum IconType { + AlertCircle, + AlertTriangle, + Announcement01, + ArrowLeft, + ArrowRight, + Brush01, + Bell01, + Calendar, + CheckCircle, + Check, + ChevronDown, + ChevronRight, + ChevronsLeft, + ChevronsRight, + ClipboardCheck, + ClockRewind, + CodeSnippet02, + Colors, + Copy01, + Curseforge, + Database01, + DotsGrid, + DotsVertical, + Download01, + DownloadCloud02, + Eye, + File02, + FilePlus02, + FileX02, + Folder, + FolderCheck, + FolderDownload, + Globe01, + HelpCircle, + InfoCircle, + Key01, + LayoutTop, + Link03, + LinkExternal01, + Loading02, + Maximize01, + MessageTextSquare01, + Minus, + Modrinth, + OnboardingAccount, + OnboardingComplete, + OnboardingLanguage, + OnboardingPreferences, + OnboardingWelcome, + PaintPour, + ParagraphWrap, + Pencil01, + Play, + Plus, + RefreshCcw02, + RefreshCw01, + Rocket02, + SearchMd, + Settings01, + Settings02, + Settings04, + Sliders04, + Square, + Terminal, + Trash01, + Users01, + XClose, + X, +} diff --git a/packages/oneclient_app/src/components/java_install_manager.rs b/packages/oneclient_app/src/components/java_install_manager.rs new file mode 100644 index 00000000..16e5987e --- /dev/null +++ b/packages/oneclient_app/src/components/java_install_manager.rs @@ -0,0 +1,275 @@ +use freya::prelude::*; +use oneclient_java::{AvailableJava, JavaVendor}; + +use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::hooks::{provider_versions, use_provider_versions}; +use crate::theme::colors; +use crate::ui::border_all_color; + +fn providers() -> Vec<(JavaVendor, &'static str)> { + vec![ + (JavaVendor::Zulu, "Azul Zulu"), + (JavaVendor::Adoptium, "Eclipse Temurin"), + (JavaVendor::Corretto, "Amazon Corretto"), + (JavaVendor::Liberica, "BellSoft Liberica"), + ] +} + +/// Calls `on_install` with the chosen `(vendor, major)` +#[derive(PartialEq)] +pub struct JavaInstallManager { + suggested: Option, + on_install: Option>, + on_close: Option>, +} + +impl Default for JavaInstallManager { + fn default() -> Self { + Self::new() + } +} + +impl JavaInstallManager { + pub fn new() -> Self { + Self { + suggested: None, + on_install: None, + on_close: None, + } + } + + pub fn suggested(mut self, major: Option) -> Self { + self.suggested = major; + self + } + + pub fn on_install(mut self, handler: impl Into>) -> Self { + self.on_install = Some(handler.into()); + self + } + + pub fn on_close(mut self, handler: impl Into>) -> Self { + self.on_close = Some(handler.into()); + self + } +} + +impl Component for JavaInstallManager { + fn render(&self) -> impl IntoElement { + let providers = providers(); + let mut selected = use_state(|| 0usize); + let suggested = self.suggested; + + let idx = (*selected.read()).min(providers.len() - 1); + let (vendor, _) = providers[idx].clone(); + + let versions_query = use_provider_versions(vendor.clone()); + let (mut versions, loading) = provider_versions(&versions_query); + + if let Some(major) = suggested { + versions.sort_by_key(|v| v.major != major); + } + + let provider_labels: Vec = + providers.iter().map(|(_, name)| name.to_string()).collect(); + let current_label = provider_labels[idx].clone(); + + let inner: Element = if loading { + status_text("Loading available versions...") + } else if versions.is_empty() { + status_text("No versions available from this provider.") + } else { + let mut area = ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .spacing(8.); + for available in versions { + let highlight = suggested == Some(available.major); + area = area.child( + VersionRow { + vendor: vendor.clone(), + available, + suggested: highlight, + on_install: self.on_install.clone(), + } + .into_element(), + ); + } + area.into_element() + }; + + let list = rect() + .width(Size::fill()) + .height(Size::px(280.)) + .child(inner) + .into_element(); + + let close_scrim = self.on_close.clone(); + let close_button = self.on_close.clone(); + + OverlayPopup::new() + .on_close(move |()| { + if let Some(handler) = &close_scrim { + handler.call(()); + } + }) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(16.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text("Install Java") + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text("Provider") + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child( + Dropdown::new(current_label, provider_labels) + .width(Size::fill()) + .height(Size::px(34.)) + .on_select(move |i: usize| selected.set(i)), + ), + ) + .child(list) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .child( + Button::new() + .ghost() + .on_press(move |_| { + if let Some(handler) = &close_button { + handler.call(()); + } + }) + .text("Close"), + ), + ), + ), + ) + .into_element() + } +} + +fn status_text(text: &'static str) -> Element { + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(16., 4.)) + .child( + label() + .text(text) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +#[derive(PartialEq)] +struct VersionRow { + vendor: JavaVendor, + available: AvailableJava, + suggested: bool, + on_install: Option>, +} + +impl Component for VersionRow { + fn render(&self) -> impl IntoElement { + let vendor = self.vendor.clone(); + let major = self.available.major; + let version = self.available.package.name.clone(); + let on_install = self.on_install.clone(); + + let mut title_row = rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + label() + .text(format!("Java {major}")) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ); + + if self.suggested { + title_row = title_row.child( + rect() + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::brand()) + .child( + label() + .text("Suggested") + .font_size(10.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ); + } + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_symmetric(10., 12.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::component_bg()) + .maybe(self.suggested, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child(title_row) + .child( + label() + .text(version) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .primary() + .small() + .on_press(move |_| { + if let Some(handler) = &on_install { + handler.call((vendor.clone(), major)); + } + }) + .child(Icon::new(IconType::Download01).size(13.)) + .text("Install"), + ) + } +} diff --git a/packages/oneclient_app/src/components/java_prompt.rs b/packages/oneclient_app/src/components/java_prompt.rs new file mode 100644 index 00000000..995e0edd --- /dev/null +++ b/packages/oneclient_app/src/components/java_prompt.rs @@ -0,0 +1,148 @@ +use freya::prelude::*; +use oneclient_events::Answer; +use oneclient_java::{JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JavaVendor}; + +use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); + +#[derive(PartialEq)] +pub struct JavaPromptOverlay; + +impl Component for JavaPromptOverlay { + fn render(&self) -> impl IntoElement { + // Hooks must run unconditionally before any early return to keep the hook count stable + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + let mut show_manager = use_state(|| false); + + let Some(prompt) = snapshot.pending_prompt.clone() else { + return rect().into_element(); + }; + + // Identified by the choices it offers rather than a prompt-kind enum + if !prompt.has_choice(JAVA_CHOICE_DOWNLOAD) { + return rect().into_element(); + } + + // The prompt carries no major so recover it from the folder choice's dialog title + let major = prompt + .choice(JAVA_CHOICE_FOLDER) + .and_then(|choice| match &choice.input { + Some(oneclient_events::ChoiceInput::Folder { title }) => title + .split_whitespace() + .find_map(|word| word.parse::().ok()), + _ => None, + }) + .unwrap_or(0); + + if *show_manager.read() { + let install = dispatch.clone(); + return JavaInstallManager::new() + .suggested(Some(major)) + .on_install(move |(vendor, _row_major): (JavaVendor, u32)| { + install.answer_prompt( + Answer::new(JAVA_CHOICE_DOWNLOAD).with_selection(vendor.to_string()), + ); + show_manager.set(false); + }) + .on_close(move |()| show_manager.set(false)) + .into_element(); + } + + let close = dispatch.clone(); + let cancel = dispatch.clone(); + let folder = dispatch.clone(); + + let pick_folder = move |_| { + let dispatch = folder.clone(); + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new() + .set_title(format!("Select a Java {major} installation folder")) + .pick_folder() + .await + { + dispatch + .answer_prompt(Answer::new(JAVA_CHOICE_FOLDER).with_folder(handle.path())); + } + }); + }; + + OverlayPopup::new() + .on_close(move |_| close.dismiss_prompt()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::FolderDownload) + .size(20.) + .color(colors::brand()), + ) + .child( + label() + .text(prompt.title.clone()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(prompt.question.clone()) + .font_size(12.) + .max_lines(4) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| cancel.dismiss_prompt()) + .text("Cancel"), + ) + .child( + Button::new() + .secondary() + .on_press(pick_folder) + .child(Icon::new(IconType::Folder).size(14.)) + .text("Choose folder"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| show_manager.set(true)) + .child(Icon::new(IconType::DownloadCloud02).size(14.)) + .text("Download"), + ), + ), + ), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/link_confirm.rs b/packages/oneclient_app/src/components/link_confirm.rs new file mode 100644 index 00000000..df537a08 --- /dev/null +++ b/packages/oneclient_app/src/components/link_confirm.rs @@ -0,0 +1,106 @@ +use freya::prelude::*; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::use_link_confirm; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); + +#[derive(PartialEq)] +pub struct ConfirmLinkOverlay; + +impl Component for ConfirmLinkOverlay { + fn render(&self) -> impl IntoElement { + let mut pending = use_link_confirm(); + let Some(url) = pending.read().clone() else { + return rect().into_element(); + }; + + let open_url = url.clone(); + + OverlayPopup::new() + .on_close(move |_| pending.set(None)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::AlertTriangle) + .size(20.) + .color(colors::code_warn()), + ) + .child( + label() + .text("Open external link?") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text("This link was provided by a third party. Only open it if you trust the source.") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::fill()) + .padding(Gaps::new_all(10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(url.clone()) + .font_size(12.) + .max_lines(4) + .width(Size::fill()) + .color(colors::code_info()), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| pending.set(None)) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + crate::platform::open_url(&open_url); + pending.set(None); + }) + .child(Icon::new(IconType::LinkExternal01).size(14.)) + .text("Open link"), + ), + ), + ), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/local_image.rs b/packages/oneclient_app/src/components/local_image.rs new file mode 100644 index 00000000..8d39fa8a --- /dev/null +++ b/packages/oneclient_app/src/components/local_image.rs @@ -0,0 +1,129 @@ +use std::path::PathBuf; + +use bytes::Bytes; +use freya::animation::{ + AnimNum, Ease, Function, OnChange, OnCreation, OnFinish, use_animation_with_dependencies, +}; +use freya::elements::image::{AspectRatio, ImageCover, ImageHandle, image}; +use freya::engine::prelude::{SkData, SkImage}; +use freya::prelude::*; + +use crate::hooks::{settled_or_loading, use_local_image}; +use crate::theme::colors; + +#[derive(PartialEq)] +pub struct LocalImage { + path: PathBuf, + max_edge: u32, + cover: bool, + skeleton: bool, +} + +impl LocalImage { + pub fn new(path: PathBuf, max_edge: u32, cover: bool) -> Self { + Self { + path, + max_edge, + cover, + skeleton: false, + } + } + + pub fn skeleton(mut self, skeleton: bool) -> Self { + self.skeleton = skeleton; + self + } +} + +impl Component for LocalImage { + fn render(&self) -> impl IntoElement { + let query = use_local_image(self.path.clone(), self.max_edge); + + let bytes: Option = settled_or_loading(&query); + + let mut cache = use_state(|| None::<(usize, ImageHandle)>); + let holder = bytes.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) + }); + + let aspect = if self.cover { + AspectRatio::Max + } else { + AspectRatio::Min + }; + + let loaded = holder.is_some(); + let show_skeleton = self.skeleton && !loaded; + + let pulse = use_animation_with_dependencies(&show_skeleton, |conf, show| { + conf.on_change(OnChange::Rerun); + + if *show { + conf.on_creation(OnCreation::Run); + conf.on_finish(OnFinish::reverse()); + } + + AnimNum::new(0.4, 0.9) + .time(800) + .ease(Ease::InOut) + .function(Function::Sine) + }); + + let pulse_v = pulse.read().value(); + + let fade = use_animation_with_dependencies(&loaded, |conf, loaded| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + let to = if *loaded { 1.0 } else { 0.0 }; + AnimNum::new(0., to) + .time(260) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let fade_v = fade.read().value(); + + let root = rect().width(Size::fill()).height(Size::fill()); + + match holder { + Some(holder) => root.child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .opacity(fade_v) + .child( + image(holder) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(aspect) + .image_cover(ImageCover::Center), + ), + ), + None if self.skeleton => root.child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .background(colors::component_bg()) + .opacity(pulse_v), + ), + None => root, + } + } +} + +fn decode(bytes: &Bytes) -> Option { + let data = unsafe { SkData::new_bytes(bytes) }; + let img = SkImage::from_encoded(data)?; + Some(ImageHandle::new(img, bytes.clone())) +} diff --git a/packages/oneclient_app/src/components/log_viewer.rs b/packages/oneclient_app/src/components/log_viewer.rs new file mode 100644 index 00000000..9cad37fc --- /dev/null +++ b/packages/oneclient_app/src/components/log_viewer.rs @@ -0,0 +1,557 @@ +use std::sync::{Arc, LazyLock}; + +use freya::prelude::*; +use freya::text_edit::Clipboard; +use regex::Regex; + +use crate::components::{Icon, IconType, ScrollArea, ScrollAreaCtx, corrected_scroll}; +use crate::theme::{self, colors}; + +const LINE_H: f32 = 16.; +const BODY_PAD_TOP: f32 = 10.; +const BODY_PAD_BOTTOM: f32 = 20.; +const LINE_FONT_SIZE: f32 = 12.; +const NUM_GAP: f32 = 12.; +const CHAR_W: f32 = LINE_FONT_SIZE * 0.6; +const ROW_PAD_X: f32 = 10.; +const TAB_WIDTH: usize = 4; +const OVERSCAN: i64 = 4; + +static LOG_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"^\[((?:\S+ )?\d+:\d+:\d+(?:\.\d+)?)\] \[([^/\n\r]*)/(\w+)\]:? (?:\[(CHAT)\])?") + .expect("valid log regex") +}); + +fn level_color(level: &str) -> Color { + match level.to_ascii_uppercase().as_str() { + "ERROR" | "FATAL" | "SEVERE" => colors::code_error(), + "WARN" | "WARNING" => colors::code_warn(), + "DEBUG" | "TRACE" => colors::code_debug(), + _ => colors::code_info(), + } +} + +#[derive(Clone)] +struct ParsedLine { + spans: Vec<(String, Color)>, +} + +#[derive(Default)] +struct WidthCache { + len: usize, + last_ptr: usize, + max: usize, +} + +fn line_ptr(line: &Arc) -> usize { + Arc::as_ptr(line) as *const u8 as usize +} + +impl WidthCache { + fn max_cols(&mut self, lines: &Arc>>) -> usize { + let len = lines.len(); + let appended = len >= self.len + && self.len > 0 + && lines.get(self.len - 1).map(line_ptr) == Some(self.last_ptr); + + let start = if appended { + if len == self.len { + return self.max; + } + self.len + } else { + self.max = 0; + 0 + }; + + for line in &lines[start..] { + self.max = self.max.max(visual_len(line)); + } + self.len = len; + self.last_ptr = lines.last().map(line_ptr).unwrap_or(0); + self.max + } +} + +fn clean_line(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut chars = raw.chars(); + let mut col = 0usize; + while let Some(c) = chars.next() { + match c { + '§' => { + chars.next(); + } + '\t' => { + let spaces = TAB_WIDTH - (col % TAB_WIDTH); + for _ in 0..spaces { + out.push(' '); + } + col += spaces; + } + '\r' => {} + _ => { + out.push(c); + col += 1; + } + } + } + out +} + +fn visual_len(raw: &str) -> usize { + let mut chars = raw.chars(); + let mut col = 0usize; + while let Some(c) = chars.next() { + match c { + '§' => { + chars.next(); + } + '\t' => col += TAB_WIDTH - (col % TAB_WIDTH), + '\r' => {} + _ => col += 1, + } + } + col +} + +fn parse_line(raw: &str) -> ParsedLine { + let cleaned = clean_line(raw); + + let Some(caps) = LOG_RE.captures(&cleaned) else { + return ParsedLine { + spans: vec![(cleaned, colors::fg_primary())], + }; + }; + + let matched = caps.get(0).map(|m| m.end()).unwrap_or(0); + let time = caps.get(1).map(|m| m.as_str()).unwrap_or_default(); + let thread = caps.get(2).map(|m| m.as_str()).unwrap_or_default(); + let level = caps.get(3).map(|m| m.as_str()).unwrap_or_default(); + let is_chat = caps.get(4).is_some(); + let lvl_color = level_color(level); + + let mut spans = vec![ + (format!("[{time}] "), colors::code_muted()), + (format!("[{thread}/{level}]"), lvl_color), + ]; + if is_chat { + spans.push((" [CHAT]".to_string(), colors::code_chat())); + } + let msg_color = if is_chat { + colors::code_chat() + } else { + colors::fg_primary() + }; + spans.push((format!(" {}", &cleaned[matched..]), msg_color)); + + ParsedLine { spans } +} + +#[derive(PartialEq)] +pub struct LogViewer { + title: String, + lines: Arc>>, + streaming: bool, + header: Option, +} + +impl LogViewer { + pub fn new(title: impl Into, lines: impl Into>>>) -> Self { + Self { + title: title.into(), + lines: lines.into(), + streaming: false, + header: None, + } + } + + pub fn streaming(mut self, streaming: bool) -> Self { + self.streaming = streaming; + self + } + + pub fn header(mut self, header: impl IntoElement) -> Self { + self.header = Some(header.into_element()); + self + } +} + +impl Component for LogViewer { + fn render(&self) -> impl IntoElement { + let streaming = self.streaming; + let lines: Arc>> = self.lines.clone(); + let count = lines.len(); + + let content_h = count as f32 * LINE_H + BODY_PAD_TOP + BODY_PAD_BOTTOM; + let width_cache = + use_hook(|| std::rc::Rc::new(std::cell::RefCell::new(WidthCache::default()))); + + let max_chars = width_cache.borrow_mut().max_cols(&lines); + let max_digits = (count.max(1) as f32).log10().floor() as usize + 1; + let num_col_w = max_digits as f32 * (LINE_FONT_SIZE * 0.62) + 6.; + let content_w = ROW_PAD_X + num_col_w + NUM_GAP + max_chars as f32 * CHAR_W + ROW_PAD_X; + + let scroll = use_scroll_controller(ScrollConfig::default); + let mut auto_scroll = use_state(|| streaming); + + let sel_anchor = use_state::>(|| None); + let sel_focus = use_state::>(|| None); + let selecting = use_state(|| false); + + let stick_bottom = streaming && *auto_scroll.read(); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .background(colors::page_elevated()) + .corner_radius(CornerRadius::new_all(12.)) + .maybe_child( + (!self.title.is_empty() || streaming) + .then(|| toolbar(self.title.clone(), streaming, auto_scroll)), + ) + .maybe_child(self.header.clone()) + .child( + ScrollArea::new() + .scroll_controller(scroll) + .horizontal(content_w) + .stick_bottom(stick_bottom) + .on_user_scroll(move |_| { + if streaming { + auto_scroll.set_if_modified(false); + } + }) + .content(move |ctx: ScrollAreaCtx| { + scroll_body(BodyArgs { + ctx, + scroll, + lines: lines.clone(), + content_h, + content_w, + num_col_w, + count, + sel_anchor, + sel_focus, + selecting, + }) + }), + ) + } +} + +fn toolbar(title: String, streaming: bool, auto_scroll: State) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .padding(Gaps::new(12., 20., 0., 20.)) + .spacing(12.) + .child( + label() + .text(title) + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .horizontal() + .width(Size::flex(1.0)) + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .maybe_child(streaming.then(|| auto_scroll_toggle(auto_scroll))), + ) + .into_element() +} + +fn auto_scroll_toggle(mut auto_scroll: State) -> impl IntoElement { + let on = *auto_scroll.read(); + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .padding(Gaps::new_symmetric(5., 10.)) + .corner_radius(CornerRadius::new_all(7.)) + .background(if on { + colors::brand().with_a(40) + } else { + colors::component_bg() + }) + .cursor(CursorIcon::Pointer) + .on_press(move |_| auto_scroll.toggle()) + .child(Icon::new(IconType::ChevronDown).size(13.).color(if on { + colors::brand() + } else { + colors::fg_secondary() + })) + .child(label().text("Auto-scroll").font_size(11.).color(if on { + colors::brand() + } else { + colors::fg_secondary() + })) + .into_element() +} + +#[derive(Clone, Copy)] +struct PosGeom { + count: usize, + num_col_w: f32, + viewport_top_px: f32, + viewport_left_px: f32, + corrected_x: f32, + corrected_y: f32, +} + +fn pos_at_screen( + lines: &[Arc], + geom: PosGeom, + screen_x: f32, + screen_y: f32, +) -> (usize, usize) { + let region_y = screen_y - geom.viewport_top_px; + let band_y = region_y - geom.corrected_y - BODY_PAD_TOP; + let line = ((band_y / LINE_H).floor() as i64).clamp(0, (geom.count as i64 - 1).max(0)) as usize; + + let text_origin_x = ROW_PAD_X + geom.num_col_w + NUM_GAP; + let local_x = screen_x - geom.viewport_left_px - geom.corrected_x - text_origin_x; + let raw_col = (local_x / CHAR_W).round().max(0.) as usize; + let len = lines.get(line).map(|l| visual_len(l)).unwrap_or(0); + (line, raw_col.min(len)) +} + +struct BodyArgs { + ctx: ScrollAreaCtx, + scroll: ScrollController, + lines: Arc>>, + content_h: f32, + content_w: f32, + num_col_w: f32, + count: usize, + sel_anchor: State>, + sel_focus: State>, + selecting: State, +} + +fn scroll_body(args: BodyArgs) -> Element { + let BodyArgs { + ctx, + mut scroll, + lines, + content_h, + content_w, + num_col_w, + count, + mut sel_anchor, + mut sel_focus, + mut selecting, + } = args; + + let corrected_y = ctx.corrected_y; + let corrected_x = ctx.corrected_x; + let viewport_h = ctx.viewport_h; + let viewport_top_px = ctx.viewport_top; + let viewport_left_px = ctx.viewport_left; + let body_w = content_w.max(ctx.viewport_w); + + let selection = match (*sel_anchor.read(), *sel_focus.read()) { + (Some(a), Some(f)) => Some((a.min(f), a.max(f))), + _ => None, + }; + + let geom = PosGeom { + count, + num_col_w, + viewport_top_px, + viewport_left_px, + corrected_x, + corrected_y, + }; + + let lines_down = lines.clone(); + let on_body_down = move |e: Event| { + let pos = pos_at_screen( + &lines_down, + geom, + e.global_location().x as f32, + e.global_location().y as f32, + ); + sel_anchor.set(Some(pos)); + sel_focus.set(Some(pos)); + selecting.set(true); + }; + + let lines_move = lines.clone(); + let on_global_move = move |e: Event| { + if !*selecting.read() { + return; + } + let cursor_y = e.global_location().y as f32; + let pos = pos_at_screen(&lines_move, geom, e.global_location().x as f32, cursor_y); + sel_focus.set(Some(pos)); + + const EDGE: f32 = 24.; + let region_y = cursor_y - viewport_top_px; + let (_, cur_scroll) = scroll.into(); + let delta = if region_y < EDGE { + (EDGE - region_y).min(EDGE) + } else if region_y > viewport_h - EDGE { + -((region_y - (viewport_h - EDGE)).min(EDGE)) + } else { + 0. + }; + if delta != 0. { + let target = cur_scroll as f32 + delta; + let clamped = corrected_scroll(content_h, viewport_h, target) as i32; + scroll.scroll_to_y(clamped); + } + }; + + let on_global_release = move |_: Event| { + if *selecting.read() { + selecting.set(false); + } + }; + + let lines_for_copy = lines.clone(); + let on_key_down = move |e: Event| { + let copy = matches!(&e.key, Key::Character(c) if c.as_str().eq_ignore_ascii_case("c")) + && (e.modifiers.contains(Modifiers::CONTROL) || e.modifiers.contains(Modifiers::META)); + if !copy { + return; + } + if let (Some(a), Some(f)) = (*sel_anchor.read(), *sel_focus.read()) { + let ((s_line, s_col), (e_line, e_col)) = (a.min(f), a.max(f)); + let mut parts: Vec = Vec::with_capacity(e_line - s_line + 1); + for line in s_line..=e_line { + let Some(raw) = lines_for_copy.get(line) else { + continue; + }; + let cleaned = clean_line(raw); + let from = if line == s_line { s_col } else { 0 }; + let to = if line == e_line { + e_col + } else { + cleaned.chars().count() + }; + let slice: String = cleaned + .chars() + .skip(from) + .take(to.saturating_sub(from)) + .collect(); + parts.push(slice); + } + let text = parts.join("\n"); + if let Err(err) = Clipboard::set(text) { + tracing::warn!("clipboard copy failed: {err:?}"); + } + } + }; + + rect() + .width(Size::px(body_w)) + .height(Size::px(content_h)) + .on_pointer_down(on_body_down) + .on_capture_global_pointer_move(on_global_move) + .on_capture_global_pointer_press(on_global_release) + .on_global_key_down(on_key_down) + .child(log_body( + &lines[..], + content_h, + corrected_y, + viewport_h, + body_w, + num_col_w, + selection, + )) + .into_element() +} + +fn log_body( + lines: &[Arc], + content_h: f32, + corrected_y: f32, + viewport_h: f32, + body_w: f32, + num_col_w: f32, + selection: Option<((usize, usize), (usize, usize))>, +) -> impl IntoElement { + let count = lines.len(); + let scrolled = -corrected_y; + let first = (((scrolled - BODY_PAD_TOP) / LINE_H).floor() as i64 - OVERSCAN).max(0) as usize; + let visible = (viewport_h / LINE_H).ceil() as usize + OVERSCAN as usize * 2 + 1; + let last = (first + visible).min(count); + + let mut body = rect().width(Size::px(body_w)).height(Size::px(content_h)); + for (i, raw) in lines.iter().enumerate().take(last).skip(first) { + let y = BODY_PAD_TOP + i as f32 * LINE_H; + let sel_cols = selection.and_then(|((sl, sc), (el, ec))| { + if i < sl || i > el { + return None; + } + let len = visual_len(raw); + let c0 = if i == sl { sc } else { 0 }; + let c1 = if i == el { ec } else { len }; + (c1 > c0).then_some((c0, c1)) + }); + let parsed = parse_line(raw); + body = body.child(log_line_row(i, &parsed, y, body_w, num_col_w, sel_cols)); + } + body +} + +fn log_line_row( + index: usize, + parsed: &ParsedLine, + y: f32, + body_w: f32, + num_col_w: f32, + sel_cols: Option<(usize, usize)>, +) -> impl IntoElement { + let mut message = paragraph() + .max_lines(1) + .width(Size::fill()) + .height(Size::px(LINE_H)) + .font_family(theme::MONO_FONT) + .font_size(LINE_FONT_SIZE); + for (text, color) in &parsed.spans { + message = message.span(Span::new(text.clone()).color(*color)); + } + + let highlight = sel_cols.map(|(c0, c1)| { + rect() + .position( + Position::new_absolute() + .top(0.) + .left(num_col_w + NUM_GAP + c0 as f32 * CHAR_W), + ) + .width(Size::px((c1 - c0) as f32 * CHAR_W)) + .height(Size::px(LINE_H)) + .corner_radius(CornerRadius::new_all(2.)) + .background(colors::selection_bg()) + .into_element() + }); + + rect() + .horizontal() + .position(Position::new_absolute().top(y).left(ROW_PAD_X)) + .width(Size::px((body_w - ROW_PAD_X * 2.).max(0.))) + .height(Size::px(LINE_H)) + .cross_align(Alignment::Center) + .spacing(NUM_GAP) + .maybe_child(highlight) + .child( + rect().width(Size::px(num_col_w)).child( + label() + .text(format!("{}", index + 1)) + .font_family(theme::MONO_FONT) + .font_size(LINE_FONT_SIZE) + .text_align(TextAlign::Right) + .width(Size::fill()) + .color(colors::code_muted()), + ), + ) + .child(message) +} diff --git a/packages/oneclient_app/src/components/memory_field.rs b/packages/oneclient_app/src/components/memory_field.rs new file mode 100644 index 00000000..76176220 --- /dev/null +++ b/packages/oneclient_app/src/components/memory_field.rs @@ -0,0 +1,51 @@ +use freya::prelude::*; + +use crate::components::{Dropdown, TextInput, validate_memory}; +use crate::theme::colors; +use crate::utils::{format_memory_gb, memory_presets_mb}; + +const UNSET_LABEL: &str = "Default"; +const CUSTOM_LABEL: &str = "Custom"; + +/// Presets picker with an input for memory allocation +pub fn memory_field(mut memory: State) -> impl IntoElement { + let presets = memory_presets_mb(); + let selected = match memory.read().trim() { + "" => UNSET_LABEL.to_string(), + value => value + .parse::() + .ok() + .filter(|mb| presets.contains(mb)) + .map(format_memory_gb) + .unwrap_or_else(|| CUSTOM_LABEL.to_string()), + }; + + let options: Vec = presets.iter().copied().map(format_memory_gb).collect(); + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + Dropdown::new(selected, options) + .width(Size::px(100.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| { + if let Some(mb) = presets.get(idx).copied() { + memory.set(mb.to_string()); + } + }), + ) + .child( + TextInput::new(memory) + .width(Size::px(90.)) + .placeholder("4096") + .on_validate(validate_memory) + .trailing( + label() + .text("MB") + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) +} diff --git a/packages/oneclient_app/src/components/microsoft_login.rs b/packages/oneclient_app/src/components/microsoft_login.rs new file mode 100644 index 00000000..f9ce07dd --- /dev/null +++ b/packages/oneclient_app/src/components/microsoft_login.rs @@ -0,0 +1,477 @@ +use crate::state::LoginProgress; +use freya::prelude::*; +use freya::query::{MutationCapability, MutationStateData, UseMutation}; +use freya::text_edit::Clipboard; +use oneclient_auth::{AuthErrorGuidance, MicrosoftLoginSession}; +use oneclient_core::LauncherError; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{ + CancelMicrosoftLoginKeys, login_code_already_handled, reset_login_code_dedup, + use_begin_microsoft_login, use_cancel_microsoft_login, use_finish_microsoft_login, + use_microsoft_login_status, +}; +use crate::platform; +use crate::theme::colors; +use crate::ui::border_all_color; + +#[derive(Clone)] +pub struct MicrosoftLogin { + begin: UseMutation, + cancel: UseMutation, + pending_login: State>, + cancelled: State, + status: Option, + locked: bool, + pub pending: bool, + pub error: Option, + error_guidance: Option, +} + +impl MicrosoftLogin { + pub fn start(&self) { + let mut cancelled = self.cancelled; + cancelled.set(false); + self.begin.mutate(()); + } + + fn cancel(&self) { + if self.locked { + return; + } + if let Some(session) = self.pending_login.peek().clone() { + self.cancel.mutate(CancelMicrosoftLoginKeys { + state_token: session.dedupe_key().to_string(), + }); + } + + reset_login_code_dedup(); + + let mut cancelled = self.cancelled; + let mut pending_login = self.pending_login; + cancelled.set(true); + pending_login.set(None); + } + + pub fn popup(&self) -> Option { + let handle = self.clone(); + self.pending_login.read().clone().map(move |login| { + microsoft_dialog( + login, + handle.clone(), + handle.status.clone(), + handle.error.clone(), + handle.error_guidance.clone(), + ) + }) + } +} + +pub fn use_microsoft_login() -> MicrosoftLogin { + let begin = use_begin_microsoft_login(); + let finish = use_finish_microsoft_login(); + let cancel = use_cancel_microsoft_login(); + let mut pending_login = use_state(|| None::); + let cancelled = use_state(|| false); + let status = use_microsoft_login_status(); + + use_side_effect(move || { + let session = match &*begin.read().state() { + MutationStateData::Settled { + res: Ok(session), .. + } => Some(session.clone()), + _ => None, + }; + let Some(session) = session else { return }; + if login_code_already_handled(session.dedupe_key()) { + return; + } + + platform::open_url(session.auth_url()); + finish.mutate(session.clone()); + pending_login.set(Some(session)); + }); + + use_side_effect(move || { + if matches!( + &*finish.read().state(), + MutationStateData::Settled { res: Ok(_), .. } + ) && pending_login.peek().is_some() + { + pending_login.set(None); + } + }); + + // True once the flow reports progress ensuring the user has seen the popup + let locked = status.as_ref().is_some_and(|s| s.current > 0); + let in_flight = begin.read().state().is_loading() || finish.read().state().is_loading(); + let pending = in_flight && !*cancelled.read(); + let error_info = mutation_err_info(&finish).or_else(|| mutation_err_info(&begin)); + let (error, error_guidance) = match error_info { + Some(info) => (Some(info.message), info.guidance), + None => (None, None), + }; + + MicrosoftLogin { + begin, + cancel, + pending_login, + cancelled, + status, + locked, + pending, + error, + error_guidance, + } +} + +struct AuthErrorInfo { + message: String, + guidance: Option, +} + +fn mutation_err_info(mutation: &UseMutation) -> Option +where + M: MutationCapability, +{ + let read = mutation.read(); + match &*read.state() { + MutationStateData::Settled { res: Err(err), .. } + | MutationStateData::Loading { + res: Some(Err(err)), + } => { + // Cancellation arrives as an error surfacing it would persist into the next sign-in + if err.is_login_cancelled() { + return None; + } + Some(AuthErrorInfo { + message: err.to_string(), + guidance: err.auth_guidance(), + }) + } + _ => None, + } +} + +fn microsoft_dialog( + login: MicrosoftLoginSession, + handle: MicrosoftLogin, + status: Option, + error: Option, + guidance: Option, +) -> impl IntoElement { + let close = handle.clone(); + login_dialog( + login.auth_url().to_string(), + login.user_code().to_string(), + login.verification_uri().to_string(), + handle.locked, + status, + error, + guidance, + move || close.cancel(), + ) +} + +#[expect(clippy::too_many_arguments)] +pub(crate) fn login_dialog( + auth_url: String, + user_code: String, + verification_uri: String, + locked: bool, + status: Option, + error: Option, + guidance: Option, + on_close: impl FnMut() + Clone + 'static, +) -> impl IntoElement { + let mut close_scrim = on_close.clone(); + let mut close_cancel = on_close.clone(); + OverlayPopup::new() + .on_close(move |()| close_scrim()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(420.)) + .max_width(Size::window_percent(90.)) + .max_height(Size::window_percent(90.)) + .cross_align(Alignment::Center) + .spacing(18.) + .padding(Gaps::new_all(28.)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text("Sign in to Microsoft") + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + ScrollView::new() + .width(Size::fill()) + .height(Size::auto()) + .max_height(Size::window_percent(70.)) + .child( + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(18.) + .child(browser_dialog_body(auth_url)) + .child(dialog_divider()) + .child(device_code_dialog_body(user_code, verification_uri)) + .child(dialog_divider()) + .child(offline_mode_dialog_body(on_close.clone())) + .child(status_row(status, error)) + .maybe_child(guidance.map(guidance_block)), + ), + ) + .maybe_child((!locked).then(|| { + Button::new() + .ghost() + .on_press(move |_| close_cancel()) + .text("Cancel") + .into_element() + })), + ), + ) + .into_element() +} + +fn dialog_divider() -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(1.)) + .background(colors::component_border()), + ) + .child( + label() + .text("or") + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(1.)) + .background(colors::component_border()), + ) + .into_element() +} + +fn status_row(status: Option, error: Option) -> impl IntoElement { + if let Some(error) = error { + return rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + Icon::new(IconType::AlertTriangle) + .size(13.) + .color(colors::danger()), + ) + .child(label().text(error).font_size(12.).color(colors::danger())) + .into_element(); + } + + let text = match &status { + Some(status) if status.total > 0 && status.current > 0 => { + format!("{} ({}/{})", status.label, status.current, status.total) + } + _ => "Waiting for you to finish signing in...".to_string(), + }; + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + Icon::new(IconType::Loading02) + .size(13.) + .color(colors::brand()), + ) + .child( + label() + .text(text) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn guidance_block(guidance: AuthErrorGuidance) -> impl IntoElement { + let mut steps = rect().vertical().width(Size::fill()).spacing(6.).child( + label() + .text("What you can do:") + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ); + + for (index, step) in guidance.steps_to_fix.into_iter().enumerate() { + steps = steps.child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text(format!("{}.", index + 1)) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(step) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .padding(Gaps::new_all(14.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(guidance.what_happened) + .font_size(12.) + .color(colors::fg_primary()), + ) + .child(steps) + .into_element() +} + +fn browser_dialog_body(auth_url: String) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + label() + .text("We opened the Microsoft sign-in page in your browser. Finish there and you'll be brought back automatically.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + Button::new() + .primary() + .on_press(move |_| platform::open_url(&auth_url)) + .child(Icon::new(IconType::LinkExternal01).size(16.)) + .text("Open in browser again"), + ) + .into_element() +} + +fn device_code_dialog_body(code: String, verification_uri: String) -> impl IntoElement { + let copy_code = code.clone(); + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + label() + .text("Or enter this code at the Microsoft sign-in page:") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::fill()) + .center() + .padding(Gaps::new_symmetric(20., 16.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::brand())) + .child( + label() + .text(code) + .font_size(48.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::Center) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| { + if let Err(err) = Clipboard::set(copy_code.clone()) { + tracing::warn!("clipboard copy failed: {err:?}"); + } + }) + .child(Icon::new(IconType::Copy01).size(16.)) + .text("Copy code"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| platform::open_url(&verification_uri)) + .child(Icon::new(IconType::LinkExternal01).size(16.)) + .text("Open in browser"), + ), + ) + .into_element() +} + +fn offline_mode_dialog_body(on_close: impl FnMut() + Clone + 'static) -> impl IntoElement { + let on_close_btn = on_close; + + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(10.) + .child( + label() + .text("Or Play Offline (LAN / Offline Servers)") + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + let mut close_cb = on_close_btn.clone(); + spawn(async move { + if let Ok(state) = crate::launcher::state() { + if let Ok(acc) = + state.auth.add_offline_account("Player".to_string()).await + { + let _ = state.auth.set_default_account(Some(acc.id)).await; + } + } + close_cb(); + }); + }) + .text("Play Offline as Player") + .into_element(), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs new file mode 100644 index 00000000..0b0eff07 --- /dev/null +++ b/packages/oneclient_app/src/components/mod.rs @@ -0,0 +1,85 @@ +mod account_switcher; +mod active_cluster_panel; +mod avatar; +mod button; +mod charts; +mod cluster_landscape_art; +mod cluster_update_popup; +mod context_menu; +mod dropdown; +mod dynamic_art; +mod file_drop; +mod generic_prompt; +mod icons; +mod java_install_manager; +mod java_prompt; +mod link_confirm; +mod local_image; +mod log_viewer; +mod memory_field; +mod microsoft_login; +mod navbar; +mod notifications; +mod overlay_popup; +mod package_row; +mod package_update_popup; +mod pagination; +mod player_model; +mod progress_track; +mod recents_row; +mod screenshot_viewer; +mod scrollview; +mod segmented_control; +mod splash_curtain; +mod status_bar; +mod tab_bar; +mod text_input; +mod toasts; +mod toggle; +mod update_prompt; +pub mod upload_mclogs; +mod version_card; + +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 cluster_landscape_art::ClusterLandscapeArt; +pub use cluster_update_popup::ClusterUpdatePopup; +pub use context_menu::ContextMenu; +pub use dropdown::Dropdown; +pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt}; +pub use file_drop::FileDropOverlay; +pub use generic_prompt::GenericPromptOverlay; +pub use icons::{Icon, IconTint, IconType}; +pub use java_install_manager::JavaInstallManager; +pub use java_prompt::JavaPromptOverlay; +pub use link_confirm::ConfirmLinkOverlay; +pub use local_image::LocalImage; +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 navbar::window_controls; +pub use navbar::{AppNavbar, OnboardingNavbar}; +pub use notifications::NotificationCenter; +pub use overlay_popup::OverlayPopup; +pub use package_row::{CardLayout, PackageEntry, PackageRow, provider_badge}; +pub use package_update_popup::PackageUpdatePopup; +pub use pagination::Pagination; +pub use player_model::PlayerModel; +pub use progress_track::progress_track; +pub use recents_row::RecentsRow; +pub use screenshot_viewer::ScreenshotViewer; +pub(crate) use scrollview::corrected_scroll; +pub use scrollview::{ScrollArea, ScrollAreaCtx}; +pub use segmented_control::{Segment, SegmentedControl}; +pub use splash_curtain::SplashCurtain; +pub use status_bar::StatusBar; +pub use tab_bar::{TabBar, TabItem}; +pub use text_input::{TextInput, validate_memory, validate_number}; +pub use toasts::Toasts; +pub use toggle::{toggle, toggle_controlled}; +pub use update_prompt::UpdatePromptOverlay; +pub use version_card::VersionCard; diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs new file mode 100644 index 00000000..76a148a7 --- /dev/null +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -0,0 +1,312 @@ +use freya::{ + animation::*, + prelude::*, + router::{RouterContext, use_route}, +}; + +use crate::{ + Route, + components::{Avatar, Icon, IconType}, + hooks::{ + 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; + +#[derive(PartialEq)] +pub struct Navbar; + +impl Component for Navbar { + fn render(&self) -> impl IntoElement { + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.) + .time(NAVBAR_INTRO_MS) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let eased = intro.get().value(); + let slide = (1.0 - eased) * -theme::NAVBAR_HEIGHT_PX; + + rect() + .width(Size::fill()) + .height(Size::px(theme::NAVBAR_HEIGHT_PX)) + .position(Position::new_absolute().top(0.).left(0.)) + .layer(Layer::OverlayLevel(2)) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .horizontal() + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(0.0, 40.0)) + .offset_y(slide) + .opacity(eased) + .child(navbar_left()) + .child(navbar_center()) + .child(NavbarRight), + ) + .child( + rect() + .window_drag() + .width(Size::window_percent(100.)) + .height(Size::px(theme::NAVBAR_HEIGHT_PX)) + .position(Position::new_absolute().top(0.).left(0.).right(0.)), + ) + } +} + +fn navbar_left() -> impl IntoElement { + rect() + .horizontal() + .width(Size::flex(1.0)) + .cross_align(Alignment::Center) + .child(navbar_logo()) +} + +fn navbar_logo() -> impl IntoElement { + let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default()); + + SvgViewer::new(("logo.svg", bytes.read().cloned())) + .show_loader(false) + .height(Size::px(44.)) + .width(Size::px(214.)) + .color(theme::colors::fg_primary()) +} + +fn navbar_center() -> impl IntoElement { + let route = use_route::(); + let browse_target = browse_target(); + + rect() + .horizontal() + .width(Size::flex(1.0)) + .main_align(Alignment::Center) + .cross_align(Alignment::Center) + .spacing(36.) + .child(NavLink { + active: route == Route::Home {}, + target: NavTarget::Route(Route::Home {}), + nav_label: "Home", + }) + .child(NavLink { + active: route == Route::Clusters {}, + target: NavTarget::Route(Route::Clusters {}), + nav_label: "Versions", + }) + .child(NavLink { + active: false, + target: NavTarget::External("https://store.polyfrost.org"), + nav_label: "Cosmetics", + }) + .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 = 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(|| sort_clusters_for_home(clusters).first().map(|c| c.id)); + + match cluster_id { + Some(cluster_id) => Route::Browser { + cluster_id, + package_type: "mod".to_string(), + pick_cluster: true, + }, + None => Route::Clusters {}, + } +} + +#[derive(PartialEq, Clone)] +enum NavTarget { + Route(Route), + External(&'static str), +} + +#[derive(PartialEq)] +struct NavLink { + active: bool, + target: NavTarget, + nav_label: &'static str, +} + +impl Component for NavLink { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + let a11y_id = use_a11y(); + let focused = use_focus(a11y_id); + + let active = self.active; + let target = self.target.clone(); + let nav_label = self.nav_label; + + let color = if active || hovering() || focused().is_focused() { + theme::colors::fg_primary() + } else { + theme::colors::fg_secondary() + }; + + let underline_width = if active { + 27. + } else if hovering() || focused().is_focused() { + 18. + } else { + 0. + }; + + rect() + .vertical() + .cross_align(Alignment::Center) + .spacing(2.) + .width(Size::px(nav_label.len() as f32 * 10. + 10.)) + // TODO workaround for a Freya measurement bug a fully transparent background + // measures wrongly so give it alpha 0 red to keep pointer events working + .background(Color::RED.with_a(0)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_all_press(move |e: Event| { + e.prevent_default(); + match &target { + NavTarget::Route(route) => { + let _ = RouterContext::get().push(route.clone()); + } + NavTarget::External(url) => crate::platform::open_url(url), + } + }) + .on_pointer_over(move |_| hovering.set(true)) + .on_pointer_out(move |_| hovering.set(false)) + .cursor(CursorIcon::Pointer) + .child( + label() + .text(nav_label) + .font_size(16.) + .font_weight(if active { + FontWeight::MEDIUM + } else { + FontWeight::NORMAL + }) + .color(color), + ) + .child( + rect() + .height(Size::px(2.)) + .width(Size::px(underline_width)) + .corner_radius(CornerRadius::new_all(2.)) + .background(if active { + theme::colors::fg_primary() + } else { + theme::colors::fg_secondary() + }), + ) + } +} + +#[derive(PartialEq)] +struct NavbarRight; + +impl Component for NavbarRight { + fn render(&self) -> impl IntoElement { + let current_account = use_current_account(); + let dispatch = use_dispatch(); + let unread = use_notifications_snapshot().unread_count(); + + let account_uuid = try_default_account(¤t_account) + .map(|account| account.id.to_string()) + .unwrap_or_else(|| uuid::Uuid::nil().to_string()); + + let notif_dispatch = dispatch.clone(); + let open_notifications = move |_| { + notif_dispatch.toggle_notification_center(); + }; + + let open_account_switcher = move |_| { + dispatch.toggle_account_switcher(); + }; + + let open_settings = |_| { + let _ = RouterContext::get().push(Route::SettingsLauncher {}); + }; + + rect() + .horizontal() + .width(Size::flex(1.0)) + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .spacing(8.) + .child( + super::navbar_button() + .child(notification_bell(unread)) + .on_press(open_notifications), + ) + .child( + super::navbar_button() + .child(Icon::new(IconType::Settings02).size(20.)) + .on_press(open_settings), + ) + .child( + super::navbar_button() + .padding(0.0) + .on_press(open_account_switcher) + .child( + Avatar::new(account_uuid) + .width(Size::px(24.)) + .height(Size::px(24.)), + ), + ) + .child(super::window_controls()) + } +} + +fn notification_bell(unread: usize) -> impl IntoElement { + rect() + .width(Size::px(20.)) + .height(Size::px(20.)) + .child(Icon::new(IconType::Bell01).size(20.)) + .maybe_child((unread > 0).then(|| { + rect() + .position(Position::new_absolute().top(-4.).right(-4.)) + .width(Size::px(16.)) + .height(Size::px(16.)) + .corner_radius(CornerRadius::from(8.)) + .background(theme::colors::danger()) + .layer(Layer::Relative(3)) + .center() + .child( + label() + .text(if unread > 9 { + "9+".to_string() + } else { + unread.to_string() + }) + .font_size(10.) + .font_weight(FontWeight::SEMI_BOLD) + .color(theme::colors::fg_primary()), + ) + .into_element() + })) +} diff --git a/packages/oneclient_app/src/components/navbar/mod.rs b/packages/oneclient_app/src/components/navbar/mod.rs new file mode 100644 index 00000000..150c3832 --- /dev/null +++ b/packages/oneclient_app/src/components/navbar/mod.rs @@ -0,0 +1,60 @@ +mod app_navbar; +mod onboarding_navbar; + +pub use app_navbar::Navbar as AppNavbar; +pub use onboarding_navbar::OnboardingNavbar; + +use freya::prelude::*; + +use crate::components::{Button, Icon, IconType}; + +pub(super) fn navbar_button() -> Button { + Button::new() + .ghost() + .icon() + .width(Size::px(36.)) + .height(Size::px(36.)) +} + +pub fn window_controls() -> impl IntoElement { + let minimize = |_| { + Platform::get().with_window(None, |win| { + win.set_minimized(true); + }); + }; + + let maximize = |_| { + Platform::get().with_window(None, |win| { + win.set_maximized(!win.is_maximized()); + }); + }; + + let close = |_| { + let platform = Platform::get(); + Platform::get().with_window(None, move |window| { + platform.close_window(window.id()); + }); + }; + + rect() + .horizontal() + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .spacing(8.) + .layer(Layer::OverlayLevel(15)) + .child( + navbar_button() + .child(Icon::new(IconType::Minus)) + .on_press(minimize), + ) + .child( + navbar_button() + .child(Icon::new(IconType::Maximize01).size(20.)) + .on_press(maximize), + ) + .child( + navbar_button() + .child(Icon::new(IconType::X)) + .on_press(close), + ) +} diff --git a/packages/oneclient_app/src/components/navbar/onboarding_navbar.rs b/packages/oneclient_app/src/components/navbar/onboarding_navbar.rs new file mode 100644 index 00000000..d9c66235 --- /dev/null +++ b/packages/oneclient_app/src/components/navbar/onboarding_navbar.rs @@ -0,0 +1,44 @@ +use freya::prelude::*; + +use crate::components::window_controls; +use crate::theme::{self, colors}; + +#[derive(PartialEq)] +pub struct OnboardingNavbar; + +impl Component for OnboardingNavbar { + fn render(&self) -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::px(theme::NAVBAR_HEIGHT_PX)) + .child( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(0., 24.)) + .child(logo()) + .child(rect().width(Size::flex(1.0)).height(Size::fill())) + .child(window_controls()), + ) + .child( + rect() + .window_drag() + .width(Size::window_percent(100.)) + .height(Size::px(theme::NAVBAR_HEIGHT_PX)) + .position(Position::new_absolute().top(0.).left(0.).right(0.)), + ) + } +} + +fn logo() -> impl IntoElement { + let bytes = use_memo(|| crate::AppAssets::get_bytes("logo.svg").unwrap_or_default()); + + SvgViewer::new(("logo.svg", bytes.read().cloned())) + .show_loader(false) + .height(Size::px(36.)) + .width(Size::px(170.)) + .color(colors::fg_primary()) +} diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs new file mode 100644 index 00000000..2924ce7f --- /dev/null +++ b/packages/oneclient_app/src/components/notifications.rs @@ -0,0 +1,529 @@ +use freya::{ + animation::{AnimNum, Ease, OnCreation, use_animation}, + prelude::*, +}; +use oneclient_events::Level; + +use crate::{ + 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}, +}; + +#[derive(PartialEq)] +pub struct NotificationCenter; + +impl Component for NotificationCenter { + fn render(&self) -> impl IntoElement { + let open = use_notifications_snapshot().center_open; + let dispatch = use_dispatch(); + + if !open { + return rect().into_element(); + } + + OverlayPopup::new() + .position(Position::new_global().top(72.).right(40.)) + .on_close(move |()| dispatch.close_notification_center()) + .child(NotificationPanel) + .into_element() + } +} + +#[derive(PartialEq)] +struct NotificationPanel; + +impl Component for NotificationPanel { + fn render(&self) -> impl IntoElement { + let inbox = use_notifications_snapshot().inbox; + + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(200).ease(Ease::Out) + }); + + let progress = intro.read().value(); + + let entries = inbox.len(); + let mut rows: Vec = Vec::new(); + if entries == 0 { + rows.push( + label() + .text("No notifications") + .font_size(13.) + .color(colors::fg_secondary()) + .into_element(), + ); + } else { + let last = entries - 1; + for (i, entry) in inbox.into_iter().enumerate() { + let id = entry.id; + rows.push(NotifEntryRow::new(entry, i != last).key(id).into_element()); + } + } + + let content = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(8.) + .children(rows); + + rect() + .vertical() + .content(Content::Flex) + .width(Size::px(368.)) + .height(Size::px(480.)) + .padding(Gaps::new_all(12.)) + .spacing(8.) + .opacity(progress) + .margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.)) + .background(colors::page_elevated().with_a(220)) + .blur(12.) + .corner_radius(CornerRadius::new_all(12.)) + .border( + Border::new() + .fill(colors::component_border()) + .width(1.) + .alignment(BorderAlignment::Inner), + ) + .shadow(Shadow::from(( + 0., + 8., + 32., + 0., + Color::from_argb(120, 0, 0, 0), + ))) + .child( + label() + .text("Notifications") + .font_size(18.) + .font_weight(FontWeight::MEDIUM) + .a11y_role(AccessibilityRole::TitleBar), + ) + .child(divider()) + .child(content) + .child(divider()) + .child(Footer) + } +} + +#[derive(PartialEq)] +struct NotifEntryRow { + entry: InboxEntry, + show_divider: bool, + key: DiffKey, +} + +impl KeyExt for NotifEntryRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl NotifEntryRow { + fn new(entry: InboxEntry, show_divider: bool) -> Self { + Self { + entry, + show_divider, + key: DiffKey::None, + } + } +} + +impl Component for NotifEntryRow { + fn render(&self) -> impl IntoElement { + let entry = self.entry.clone(); + let id = entry.id; + let dispatch = use_dispatch(); + let enter_dispatch = dispatch.clone(); + let body_dispatch = dispatch.clone(); + let click_dismissable = entry.click_dismissable(); + let has_actions = !entry.actions.is_empty(); + + let mut hovered = use_state(|| false); + let expanded = use_state(|| false); + + let content = rect() + .horizontal() + .width(Size::fill()) + .spacing(13.) + .cross_align(Alignment::Start) + .padding(Gaps::new_all(8.)) + .corner_radius(CornerRadius::new_all(8.)) + .maybe(*hovered.read() && !has_actions, |el| { + el.background(colors::ghost_overlay_hover()) + }) + .on_pointer_enter(move |_| { + hovered.set(true); + enter_dispatch.mark_notification_read(id); + }) + .on_pointer_leave(move |_| hovered.set(false)) + .maybe(click_dismissable, |el| { + el.on_press(move |_| dispatch.dismiss_notification(id)) + }) + .child( + rect() + .width(Size::px(32.)) + .height(Size::px(32.)) + .corner_radius(CornerRadius::new_all(6.)) + .center() + .child( + Icon::new(icon_for(&entry)) + .size(24.) + .color(level_color(&entry.level)), + ), + ) + .child(row_body(&entry, body_dispatch)); + + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child(content) + .maybe(!entry.tasks.is_empty(), |el| { + el.child(tasks_section(&entry, expanded)) + }) + .maybe(self.show_divider, |el| el.child(divider())) + } +} + +const TASK_LIMIT: usize = 12; + +fn tasks_section(entry: &InboxEntry, mut expanded: State) -> impl IntoElement { + let is_expanded = *expanded.read(); + let count = entry.tasks.len(); + + let mut section = rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .margin(Gaps::new(2., 0., 0., 44.)) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .cursor(CursorIcon::Pointer) + .on_press(move |_| { + let now = !*expanded.peek(); + expanded.set(now); + }) + .child(ChevronToggle { + expanded: is_expanded, + }) + .child( + label() + .text(if is_expanded { + "Hide tasks".to_string() + } else { + format!("Show {count} tasks") + }) + .font_size(11.) + .color(colors::fg_secondary()), + ), + ); + + if is_expanded { + let mut list = rect().vertical().width(Size::fill()).spacing(4.); + for task in entry.tasks.iter().take(TASK_LIMIT) { + list = list.child(task_row(task)); + } + if count > TASK_LIMIT { + list = list.child( + label() + .text(format!("+{} more", count - TASK_LIMIT)) + .font_size(10.) + .color(colors::fg_secondary()), + ); + } + section = section.child(list); + } + + section.into_element() +} + +fn task_row(task: &crate::notifications::TaskView) -> impl IntoElement { + let percent = ((task.current as f32 / task.total.max(1) as f32) * 100.0).round() as u64; + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .content(Content::Flex) + .child(phase_badge(task.phase)) + .child( + label() + .text(task.label.clone()) + .font_size(11.) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .maybe(task.total_count > 1, |el| { + el.child( + label() + .text(format!("{}/{}", task.done_count, task.total_count)) + .font_size(10.) + .color(colors::fg_secondary()), + ) + }) + .child( + label() + .text(format!("{percent}%")) + .font_size(10.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +#[derive(PartialEq)] +struct ChevronToggle { + expanded: bool, +} + +impl Component for ChevronToggle { + fn render(&self) -> impl IntoElement { + let expanded = self.expanded; + let anim = use_animation(|_| AnimNum::new(0., 90.).time(160).ease(Ease::Out)); + + use_side_effect_with_deps(&expanded, move |&expanded| { + let mut anim = anim; + if expanded { + anim.start(); + } else { + anim.reverse(); + } + }); + + let deg = anim.get().value(); + rect().rotate(deg).child( + Icon::new(IconType::ChevronRight) + .size(14.) + .color(colors::fg_secondary()), + ) + } +} + +fn phase_badge(phase: &str) -> impl IntoElement { + let color = match phase { + "Verifying" => colors::code_warn(), + "Extracting" => colors::success(), + "Installing" => colors::brand(), + _ => colors::brand(), + }; + rect() + .center() + .padding(Gaps::new_symmetric(1., 7.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(color.with_a(40)) + .child( + label() + .text(phase.to_string()) + .font_size(9.) + .font_weight(FontWeight::MEDIUM) + .color(color), + ) + .into_element() +} + +fn row_body(entry: &InboxEntry, dispatch: crate::Actions) -> impl IntoElement { + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Start) + .child( + label() + .text(entry.title.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(level_color(&entry.level)) + .width(Size::flex(1.0)), + ) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + label() + .text(relative_time(entry.created_at)) + .font_size(10.) + .color(colors::fg_secondary()), + ) + .maybe(!entry.read, |el| { + el.child( + rect() + .width(Size::px(6.)) + .height(Size::px(6.)) + .corner_radius(CornerRadius::new_all(3.)) + .background(colors::brand()), + ) + }), + ), + ) + .child( + label() + .text(entry.body.clone()) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child(row_extra(entry, dispatch)) +} + +fn row_extra(entry: &InboxEntry, dispatch: crate::Actions) -> impl IntoElement { + if let Some((current, total)) = entry.progress.filter(|_| entry.is_loading) { + let frac = (current as f32 / total.max(1) as f32).clamp(0.0, 1.0); + let bar = progress_track( + frac * 100.0, + 6., + colors::brand(), + colors::brand().with_a(128), + ); + + return rect() + .vertical() + .width(Size::fill()) + .spacing(5.) + .margin(Gaps::new(8., 0., 0., 0.)) + .child(bar) + .maybe_child(entry.transfer.map(transfer_footer)) + .into_element(); + } + + if !entry.actions.is_empty() { + let id = entry.id; + let dismiss_dispatch = dispatch.clone(); + + let mut row = rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .margin(Gaps::new(8., 0., 0., 0.)) + .child( + Button::new() + .small() + .secondary() + .width(Size::flex(1.0)) + .text("Dismiss") + .on_press(move |_| dismiss_dispatch.dismiss_notification(id)), + ); + + for (i, action) in entry.actions.iter().enumerate() { + let action_dispatch = dispatch.clone(); + let kind = action.kind.clone(); + row = row.child( + Button::new() + .small() + .width(Size::flex(1.0)) + .text(action.label.clone()) + .variant(if i == 0 { + ButtonVariant::Primary + } else { + ButtonVariant::Secondary + }) + .on_press(move |_| run_action(&action_dispatch, &kind)), + ); + } + + return row.into_element(); + } + + rect().into_element() +} + +fn transfer_footer(stats: TransferStats) -> Element { + let speed = format!("{}/s", format_size(stats.speed_bps as u64)); + let eta = stats + .eta_secs + .map(|secs| format!("{} left", format_duration_hms(secs as i64))); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .child( + label() + .text(speed) + .font_size(10.) + .color(colors::fg_secondary()), + ) + .maybe_child(eta.map(|eta| { + label() + .text(eta) + .font_size(10.) + .color(colors::fg_secondary()) + .into_element() + })) + .into_element() +} + +fn run_action(dispatch: &crate::Actions, kind: &NotificationActionKind) { + match kind { + NotificationActionKind::OpenClusterUpdate(summaries) => { + dispatch.open_cluster_update(summaries.clone()); + } + } +} + +#[derive(PartialEq)] +struct Footer; + +impl Component for Footer { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let is_empty = use_notifications_snapshot().inbox.is_empty(); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .child( + Button::new() + .ghost() + .small() + .disabled(is_empty) + .on_press(move |_| dispatch.clear_notification_inbox()) + .child(Icon::new(IconType::Trash01).size(16.).color(if is_empty { + colors::fg_primary_disabled() + } else { + colors::fg_primary() + })) + .text("Clear all"), + ) + } +} + +fn icon_for(entry: &InboxEntry) -> IconType { + if let Some(icon) = entry.icon { + return icon; + } + if entry.is_loading && entry.progress.is_some() { + return IconType::DownloadCloud02; + } + match entry.level { + Level::Error => IconType::AlertTriangle, + Level::Info => IconType::InfoCircle, + } +} + +fn level_color(level: &Level) -> Color { + match level { + Level::Info => colors::fg_primary(), + Level::Error => colors::danger(), + } +} diff --git a/packages/oneclient_app/src/components/overlay_popup.rs b/packages/oneclient_app/src/components/overlay_popup.rs new file mode 100644 index 00000000..3b2f776c --- /dev/null +++ b/packages/oneclient_app/src/components/overlay_popup.rs @@ -0,0 +1,112 @@ +use freya::{ + animation::{AnimNum, Ease, OnCreation, use_animation}, + prelude::*, +}; + +const SCRIM_ALPHA: f32 = 90.; + +#[derive(PartialEq)] +pub struct OverlayPopup { + children: Vec, + on_close: Option>, + position: Position, + backdrop: bool, + key: DiffKey, +} + +impl Default for OverlayPopup { + fn default() -> Self { + Self::new() + } +} + +impl OverlayPopup { + pub fn new() -> Self { + Self { + children: Vec::new(), + on_close: None, + position: Position::new_global().top(0.).left(0.), + backdrop: true, + key: DiffKey::None, + } + } + + pub fn position(mut self, position: Position) -> Self { + self.position = position; + self + } + + pub fn backdrop(mut self, backdrop: bool) -> Self { + self.backdrop = backdrop; + self + } + + pub fn on_close(mut self, on_close: impl Into>) -> Self { + self.on_close = Some(on_close.into()); + self + } +} + +impl ChildrenExt for OverlayPopup { + fn get_children(&mut self) -> &mut Vec { + &mut self.children + } +} + +impl KeyExt for OverlayPopup { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for OverlayPopup { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let scrim_close = self.on_close.clone(); + let key_close = self.on_close.clone(); + + let fade = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(180).ease(Ease::Out) + }); + + let scrim_alpha = if self.backdrop { SCRIM_ALPHA } else { 0. }; + let alpha = (fade.read().value() * scrim_alpha) as u8; + + rect() + .layer(Layer::Overlay) + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .child( + rect() + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .layer(Layer::OverlayLevel(10)) + .background(Color::from_argb(alpha, 0, 0, 0)) + .on_press(move |_| { + if let Some(on_close) = scrim_close.as_ref() { + on_close.call(()); + } + }), + ) + .child( + rect() + .position(self.position.clone()) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_auto_focus(true) + .a11y_role(AccessibilityRole::Dialog) + .layer(Layer::OverlayLevel(12)) + .on_global_key_down(move |e: Event| { + if e.key == Key::Named(NamedKey::Escape) + && let Some(on_close) = key_close.as_ref() + { + on_close.call(()); + } + }) + .children(self.children.clone()), + ) + } +} diff --git a/packages/oneclient_app/src/components/package_row.rs b/packages/oneclient_app/src/components/package_row.rs new file mode 100644 index 00000000..00b316d4 --- /dev/null +++ b/packages/oneclient_app/src/components/package_row.rs @@ -0,0 +1,534 @@ +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_content::packages::ProviderId; + +use crate::components::{Icon, IconType, toggle_controlled}; +use crate::hooks::{ClusterAction, loaded_image, use_cached_image, use_cluster_mutation}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +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.; + +#[derive(Clone, Copy, PartialEq)] +pub enum CardLayout { + List, + Grid, +} + +impl From for CardLayout { + fn from(layout: oneclient_core::settings::ViewLayout) -> Self { + match layout { + oneclient_core::settings::ViewLayout::Grid => CardLayout::Grid, + oneclient_core::settings::ViewLayout::List => CardLayout::List, + } + } +} + +#[derive(Clone, PartialEq)] +pub struct PackageEntry { + pub package_id: String, + pub bundle_name: Option, + pub provider: ProviderId, + pub name: String, + pub file_name: String, + pub author: String, + pub description: String, + pub icon_url: Option, + pub size: u64, + pub categories: Vec, + pub enabled: bool, + pub installed: bool, + pub hash: Option, + pub manifest_default: bool, + /// Private bundle dependency only set for bundle rows + pub hidden: bool, + /// Only set for browser-installed content bundle packages use the bundle update flow + pub update_available: bool, +} + +impl PackageEntry { + pub fn is_remote(&self) -> bool { + self.provider != ProviderId::Local + } + + pub fn in_bundle(&self) -> bool { + self.bundle_name.is_some() + } + + /// Bundle packages are never marked here so the two update flows cannot contradict + pub fn is_outdated(&self) -> bool { + self.update_available && self.is_remote() && !self.in_bundle() + } +} + +#[derive(PartialEq)] +pub struct PackageRow { + item: PackageEntry, + cluster_id: i64, + package_type: &'static str, + layout: CardLayout, + key: DiffKey, +} + +impl PackageRow { + pub fn new(item: PackageEntry, cluster_id: i64, package_type: &'static str) -> Self { + Self { + item, + cluster_id, + package_type, + layout: CardLayout::List, + key: DiffKey::None, + } + } + + pub fn layout(mut self, layout: CardLayout) -> Self { + self.layout = layout; + self + } +} + +impl KeyExt for PackageRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for PackageRow { + fn render(&self) -> impl IntoElement { + let item = self.item.clone(); + let cluster_id = self.cluster_id; + let package_type = self.package_type; + let layout = self.layout; + let cluster = use_cluster_mutation(); + let remove_hover = use_state(|| false); + + let icon_size = match layout { + CardLayout::List => 44., + 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 on_toggle: EventHandler<()> = { + let hash = item.hash.clone(); + let bundle_name = item.bundle_name.clone(); + let package_id = item.package_id.clone(); + let enabled_now = item.enabled; + let manifest_default = item.manifest_default; + (move |()| { + if let Some(h) = &hash { + cluster.mutate(ClusterAction::ToggleArtifact { + cluster_id, + hash: h.clone(), + }); + } else if let Some(bundle) = &bundle_name { + cluster.mutate(ClusterAction::SetBundlePackageEnabled { + cluster_id, + bundle_name: bundle.clone(), + package_id: package_id.clone(), + enabled: !enabled_now, + manifest_default, + }); + } + }) + .into() + }; + + match layout { + CardLayout::List => { + let removable = !item.in_bundle(); + let can_remove = removable && item.installed; + let rm_hash = item.hash.clone(); + let on_remove = move || { + if let Some(h) = &rm_hash { + cluster.mutate(ClusterAction::RemoveArtifact { + cluster_id, + hash: h.clone(), + }); + } + }; + list_card( + &item, + package_type, + cluster_id, + icon, + on_toggle, + can_remove, + on_remove, + remove_hover, + ) + } + CardLayout::Grid => grid_card(&item, package_type, cluster_id, icon, on_toggle), + } + } +} + +#[allow(clippy::too_many_arguments)] +fn list_card( + item: &PackageEntry, + package_type: &'static str, + cluster_id: i64, + icon: impl IntoElement, + on_toggle: EventHandler<()>, + can_remove: bool, + on_remove: impl FnMut() + 'static, + hovering: State, +) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(CARD_H)) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_all(10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(CARD_BG) + .content(Content::Flex) + .child(package_info(item, package_type, cluster_id, icon)) + .child(meta_size(item.size)) + .child(toggle_controlled(item.enabled, on_toggle)) + .child(remove_button(can_remove, on_remove, hovering)) + .into_element() +} + +fn grid_card( + item: &PackageEntry, + package_type: &'static str, + cluster_id: i64, + icon: impl IntoElement, + on_toggle: EventHandler<()>, +) -> Element { + let remote = item.is_remote(); + let enabled = item.enabled; + let title = if remote { + item.name.clone() + } else { + item.file_name.clone() + }; + + let (bg, border, content_alpha) = if enabled { + (colors::brand().with_a(38), colors::brand(), 255) + } else { + (CARD_BG, colors::component_border(), 140) + }; + + let badge = if remote { + let provider = item.provider; + let package_id = item.package_id.clone(); + let package_type_owned = package_type.to_string(); + rect() + .cursor(CursorIcon::Pointer) + .on_press(move |e: Event| { + e.stop_propagation(); + let _ = RouterContext::get().push(Route::BrowserPackage { + cluster_id, + package_type: package_type_owned.clone(), + package_id: format!("{}:{}", provider as u8, package_id), + }); + }) + .child(provider_badge(item.provider)) + .into_element() + } else { + local_badge() + }; + + let header = rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(10.) + .content(Content::Flex) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(title) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .width(Size::fill()) + .color(CARD_NAME.with_a(content_alpha)), + ) + .maybe(!item.author.is_empty(), |el| { + el.child( + label() + .text(format!("by {}", item.author)) + .font_size(10.) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_secondary().with_a(content_alpha)), + ) + }) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child(badge) + .maybe_child(item.is_outdated().then(outdated_badge)), + ), + ) + .into_element(); + + let description = if item.description.is_empty() { + None + } else { + Some( + label() + .text(item.description.clone()) + .font_size(11.) + .max_lines(3) + .width(Size::fill()) + .color(colors::fg_secondary().with_a(content_alpha)) + .into_element(), + ) + }; + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .spacing(8.) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(bg) + .border(border_all_color(1.5, border)) + .content(Content::Flex) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_toggle.call(())) + .child(header) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .maybe_child(description), + ) + .into_element() +} + +fn package_info( + item: &PackageEntry, + package_type: &'static str, + cluster_id: i64, + icon: impl IntoElement, +) -> impl IntoElement { + let remote = item.is_remote(); + 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() + }; + + rect() + .horizontal() + .width(Size::flex(1.0)) + .cross_align(Alignment::Center) + .spacing(12.) + .content(Content::Flex) + .maybe(remote, |el| { + el.cursor(CursorIcon::Pointer).on_press(move |_| { + let _ = RouterContext::get().push(Route::BrowserPackage { + cluster_id, + package_type: package_type.clone(), + package_id: format!("{}:{}", provider as u8, package_id), + }); + }) + }) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + label() + .text(title) + .font_size(15.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(CARD_NAME), + ) + .child(if remote { + provider_badge(item.provider) + } else { + local_badge() + }) + .maybe_child(item.is_outdated().then(outdated_badge)), + ) + .maybe(!item.author.is_empty(), |el| { + el.child( + label() + .text(format!("by {}", item.author)) + .font_size(10.) + .color(colors::fg_secondary()), + ) + }) + .maybe(!item.description.is_empty(), |el| { + el.child( + label() + .text(item.description.clone()) + .font_size(11.) + .max_lines(2) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + }), + ) + .into_element() +} + +fn remote_icon( + icon_url: &Option, + icon_query: &freya::query::UseQuery, + size: f32, +) -> Element { + let loaded = loaded_image(icon_url.as_deref(), icon_query); + + match loaded { + Some((url, bytes)) => ImageViewer::new((url, bytes)) + .width(Size::px(size)) + .height(Size::px(size)) + .aspect_ratio(AspectRatio::Min) + .corner_radius(CornerRadius::new_all(8.)) + .into_element(), + + None => icon_box(IconType::DotsGrid, size), + } +} + +fn local_icon(size: f32) -> Element { + icon_box(IconType::HelpCircle, size) +} + +fn icon_box(icon: IconType, size: f32) -> Element { + rect() + .center() + .width(Size::px(size)) + .height(Size::px(size)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .child( + Icon::new(icon) + .size(size * 0.4) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn meta_size(size: u64) -> impl IntoElement { + rect() + .maybe_child((size > 0).then(|| { + label() + .text(format_size(size)) + .font_size(11.) + .color(colors::fg_secondary()) + })) + .into_element() +} + +pub fn provider_badge(provider: ProviderId) -> Element { + badge( + Icon::new(provider).size(12.).into_element(), + provider.to_string(), + ) +} + +fn outdated_badge() -> Element { + accent_badge( + Icon::new(IconType::RefreshCw01) + .size(12.) + .color(colors::brand()) + .into_element(), + "Update available".to_string(), + colors::brand(), + ) +} + +fn local_badge() -> Element { + badge( + Icon::new(IconType::File02) + .size(12.) + .color(colors::fg_secondary()) + .into_element(), + "Local file".to_string(), + ) +} + +fn badge(icon: impl IntoElement, text: String) -> Element { + accent_badge(icon, text, colors::fg_secondary()) +} + +fn accent_badge(icon: impl IntoElement, text: String, accent: Color) -> Element { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .border(border_all_color(1., colors::component_border())) + .background(colors::component_bg()) + .child(icon) + .child( + label() + .text(text) + .font_size(10.) + .font_weight(FontWeight::MEDIUM) + .color(accent), + ) + .into_element() +} + +fn remove_button( + enabled: bool, + mut on_remove: impl FnMut() + 'static, + mut hovering: State, +) -> impl IntoElement { + let hot = enabled && *hovering.read(); + let color = if !enabled { + colors::fg_secondary().with_a(90) + } else if hot { + colors::danger() + } else { + colors::fg_secondary() + }; + + rect() + .center() + .width(Size::px(30.)) + .height(Size::px(30.)) + .corner_radius(CornerRadius::new_all(7.)) + .background(if hot { + colors::danger().with_a(30) + } else { + Color::TRANSPARENT + }) + .maybe(enabled, |el| { + el.cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_press(move |_| on_remove()) + }) + .child(Icon::new(IconType::Trash01).size(14.).color(color)) + .into_element() +} diff --git a/packages/oneclient_app/src/components/package_update_popup.rs b/packages/oneclient_app/src/components/package_update_popup.rs new file mode 100644 index 00000000..ec39435a --- /dev/null +++ b/packages/oneclient_app/src/components/package_update_popup.rs @@ -0,0 +1,388 @@ +//! Only lists packages installed from the browser bundle content has its own flow + +use std::collections::HashMap; + +use freya::prelude::*; +use oneclient_content::packages::{CachedPackageMeta, ProviderId}; +use oneclient_core::BrowserPackageUpdate; +use oneclient_db::models::ClusterId; + +use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea}; +use crate::hooks::{ + package_meta_batch, use_dispatch, use_notifications_snapshot, use_package_meta_batch, +}; +use crate::notifications::PackageUpdateGroup; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const DIALOG_W: f32 = 520.; +const DIALOG_H: f32 = 420.; +const CHOICE_DROPDOWN_W: f32 = 150.; + +type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; + +/// `Skip` is re-offered next launch `SkipVersion` persists the decline until a newer version +#[derive(Clone, Copy, Default, PartialEq, Eq)] +enum RowChoice { + #[default] + Update, + Skip, + SkipVersion, +} + +impl RowChoice { + const ALL: [Self; 3] = [Self::Update, Self::Skip, Self::SkipVersion]; + + fn label(self) -> &'static str { + match self { + Self::Update => "Update", + Self::Skip => "Skip for now", + Self::SkipVersion => "Skip this version", + } + } + + fn options() -> Vec { + Self::ALL.iter().map(|c| c.label().to_string()).collect() + } +} + +type RowKey = (ClusterId, String); + +fn row_key(update: &BrowserPackageUpdate) -> RowKey { + (update.cluster_id, update.hash.clone()) +} + +#[derive(PartialEq)] +pub struct PackageUpdatePopup; + +impl Component for PackageUpdatePopup { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + + // Absent entries default to Update so groups arriving mid-modal need no reconciliation + let choices = use_state(HashMap::::new); + + let groups = snapshot.package_updates.clone(); + + // Hooks run unconditionally so this must precede the early return below + let mut meta = MetaMap::new(); + for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { + let ids: Vec = groups + .iter() + .flatten() + .flat_map(|group| &group.packages) + .filter(|update| update.provider == provider) + .map(|update| update.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(); + + OverlayPopup::new() + .on_close(move |_| close.close_package_updates()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(dialog(&groups, &meta, dispatch, choices)), + ) + .into_element() + } +} + +fn resolve_name(update: &BrowserPackageUpdate, meta: &MetaMap) -> String { + meta.get(&(update.provider, update.project_id.clone())) + .map(|cached| cached.name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| update.display_name.clone()) +} + +fn dialog( + groups: &[PackageUpdateGroup], + meta: &MetaMap, + dispatch: crate::Actions, + choices: State>, +) -> impl IntoElement { + rect() + .vertical() + .width(Size::px(DIALOG_W)) + .height(Size::px(DIALOG_H)) + .max_width(Size::window_percent(95.)) + .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, dispatch, choices)) +} + +fn content( + groups: &[PackageUpdateGroup], + meta: &MetaMap, + dispatch: crate::Actions, + choices: State>, +) -> impl IntoElement { + let dismiss = dispatch.clone(); + let proceed_dispatch = dispatch.clone(); + let total: usize = groups.iter().map(|group| group.packages.len()).sum(); + + let subtitle = match groups { + [only] => format!( + "{total} package{} in {} can be updated. Pick what happens to each, then Proceed.", + if total == 1 { "" } else { "s" }, + only.cluster_name + ), + _ => format!( + "{total} package{} across {} clusters can be updated. Pick what happens to each, then Proceed.", + if total == 1 { "" } else { "s" }, + groups.len() + ), + }; + + let all: Vec = groups + .iter() + .flat_map(|group| group.packages.iter().cloned()) + .collect(); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new_all(22.)) + .spacing(14.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(3.) + .child( + label() + .text("Updates 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(update_list(groups, meta, groups.len() > 1, choices)) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| dismiss.close_package_updates()) + .text("Cancel"), + ) + .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()); + } + 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"), + ), + ) +} + +fn update_list( + groups: &[PackageUpdateGroup], + meta: &MetaMap, + show_headers: bool, + choices: State>, +) -> impl IntoElement { + let mut scroll = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(4.); + + for (index, group) in groups.iter().enumerate() { + if show_headers { + scroll = scroll.child(cluster_header(&group.cluster_name, index == 0)); + } + + for update in &group.packages { + let key = format!("{}:{}", group.cluster_id, update.hash); + let choice = choices + .read() + .get(&row_key(update)) + .copied() + .unwrap_or_default(); + scroll = scroll.child( + UpdateRow::new(resolve_name(update, meta), update.clone(), choice, choices) + .key(key) + .into_element(), + ); + } + } + + scroll.into_element() +} + +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()), + ) +} + +struct UpdateRow { + name: String, + update: BrowserPackageUpdate, + choice: RowChoice, + choices: State>, + key: DiffKey, +} + +impl UpdateRow { + fn new( + name: String, + update: BrowserPackageUpdate, + choice: RowChoice, + choices: State>, + ) -> Self { + Self { + name, + update, + choice, + choices, + key: DiffKey::None, + } + } +} + +impl PartialEq for UpdateRow { + fn eq(&self, other: &Self) -> bool { + self.name == other.name && self.update == other.update && self.choice == other.choice + } +} + +impl KeyExt for UpdateRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for UpdateRow { + fn render(&self) -> impl IntoElement { + let update = self.update.clone(); + let choice = self.choice; + let mut choices = self.choices; + let key = row_key(&update); + + let versions = match ( + update.installed_version_name.is_empty(), + update.latest_version_name.is_empty(), + ) { + (false, false) => format!( + "{} to {}", + update.installed_version_name, update.latest_version_name + ), + (true, false) => format!("New version {}", update.latest_version_name), + _ => "A newer version is available".to_string(), + }; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_symmetric(8., 10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .content(Content::Flex) + .child( + Icon::new(IconType::RefreshCw01) + .size(14.) + .color(colors::brand()), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(self.name.clone()) + .font_size(12.5) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_primary()), + ) + .child( + label() + .text(versions) + .font_size(11.) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ) + .child( + Dropdown::new(choice.label(), RowChoice::options()) + .width(Size::px(CHOICE_DROPDOWN_W)) + .height(Size::px(26.)) + .on_select(move |idx: usize| { + if let Some(choice) = RowChoice::ALL.get(idx) { + choices.write().insert(key.clone(), *choice); + } + }), + ) + } +} diff --git a/packages/oneclient_app/src/components/pagination.rs b/packages/oneclient_app/src/components/pagination.rs new file mode 100644 index 00000000..ef5a7640 --- /dev/null +++ b/packages/oneclient_app/src/components/pagination.rs @@ -0,0 +1,161 @@ +use freya::prelude::*; + +use crate::components::{Button, Icon, IconType}; +use crate::theme::colors; +use crate::ui::border_all_color; + +fn page_window(current: usize, total: usize, window: usize) -> (usize, usize) { + if total <= window { + return (0, total); + } + let end = (current + window / 2 + 1).min(total); + let start = end.saturating_sub(window); + (start, end) +} + +#[derive(PartialEq)] +pub struct Pagination { + page: State, + total_pages: usize, + window: usize, + enabled: bool, +} + +impl Pagination { + pub fn new(page: State, total_pages: usize) -> Self { + Self { + page, + total_pages, + window: 5, + enabled: true, + } + } + + #[allow(dead_code)] + pub fn window(mut self, window: usize) -> Self { + self.window = window.max(1); + self + } + + /// Greys out controls without unmounting the pager + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } +} + +impl Component for Pagination { + fn render(&self) -> impl IntoElement { + let page = self.page; + let total_pages = self.total_pages; + let interactive = self.enabled; + let current = *page.read(); + let last = total_pages.saturating_sub(1); + + let icon_btn = move |target: usize, available: bool, icon: IconType| { + let mut page = page; + let enabled = available && interactive; + Button::new() + .secondary() + .icon() + .enabled(enabled) + .on_press(move |_| page.set(target)) + .child(Icon::new(icon).size(14.).color(if enabled { + colors::fg_primary() + } else { + colors::fg_secondary().with_a(90) + })) + .into_element() + }; + + let num_btn = move |target: usize| { + let mut page = page; + let is_current = target == current; + + if is_current { + return rect() + .center() + .width(Size::px(32.)) + .height(Size::px(32.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg_pressed()) + .border(border_all_color(1., colors::brand())) + .child( + label() + .text(format!("{}", target + 1)) + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .into_element(); + } + + Button::new() + .secondary() + .width(Size::px(32.)) + .height(Size::px(32.)) + .padding(Gaps::new_symmetric(6., 0.)) + .enabled(interactive) + .on_press(move |_| page.set(target)) + .child( + label() + .text(format!("{}", target + 1)) + .font_size(12.) + .font_weight(FontWeight::NORMAL) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .into_element() + }; + + let ellipsis = || { + rect() + .center() + .width(Size::px(20.)) + .height(Size::px(32.)) + .child( + label() + .text("...") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element() + }; + + let (start, end) = page_window(current, total_pages, self.window); + + let mut row = rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::Center) + .cross_align(Alignment::Center) + .spacing(6.) + .child(icon_btn(0, current > 0, IconType::ChevronsLeft)) + .child(icon_btn( + current.saturating_sub(1), + current > 0, + IconType::ArrowLeft, + )); + + if start > 0 { + row = row.child(num_btn(0)); + if start > 1 { + row = row.child(ellipsis()); + } + } + + for p in start..end { + row = row.child(num_btn(p)); + } + + if end < total_pages { + if end < last { + row = row.child(ellipsis()); + } + row = row.child(num_btn(last)); + } + + row.child(icon_btn(current + 1, current < last, IconType::ArrowRight)) + .child(icon_btn(last, current < last, IconType::ChevronsRight)) + } +} diff --git a/packages/oneclient_app/src/components/player_model.rs b/packages/oneclient_app/src/components/player_model.rs new file mode 100644 index 00000000..23524feb --- /dev/null +++ b/packages/oneclient_app/src/components/player_model.rs @@ -0,0 +1,325 @@ +use std::cell::RefCell; + +use bytes::Bytes; +use freya::engine::prelude::{ + Data, Paint, SamplingOptions, Shader, SkData, SkImage, SkRect, TileMode, +}; +use freya::prelude::*; +use skia_safe::RuntimeEffect; +use skia_safe::runtime_effect::ChildPtr; + +use crate::hooks::use_player_skin; + +const CAM_DIST: f32 = 80.0; +const CAM_FOCAL: f32 = 1.85; +const DRAG_SENSITIVITY: f32 = 0.01; +const PITCH_LIMIT: f32 = 1.3; + +thread_local! { + static EFFECT: RefCell> = const { RefCell::new(None) }; +} + +fn player_effect() -> Option { + EFFECT.with(|cell| { + let mut slot = cell.borrow_mut(); + + if slot.is_none() { + match RuntimeEffect::make_for_shader(PLAYER_SKSL, None) { + Ok(effect) => *slot = Some(effect), + Err(err) => tracing::error!("player model SkSL failed to compile: {err}"), + } + } + + slot.clone() + }) +} + +#[derive(PartialEq, Clone)] +pub struct PlayerModel { + uuid: String, + width: Size, + height: Size, + yaw: f32, + pitch: f32, +} + +impl PlayerModel { + pub fn new(uuid: impl Into) -> Self { + Self { + uuid: uuid.into(), + width: Size::fill(), + height: Size::fill(), + yaw: 0.5, + pitch: -0.1, + } + } + + pub fn width(mut self, width: Size) -> Self { + self.width = width; + self + } + + pub fn height(mut self, height: Size) -> Self { + self.height = height; + self + } + + #[allow(dead_code)] + pub fn yaw(mut self, yaw: f32) -> Self { + self.yaw = yaw; + self + } + + #[allow(dead_code)] + pub fn pitch(mut self, pitch: f32) -> Self { + self.pitch = pitch; + self + } +} + +impl Component for PlayerModel { + fn render(&self) -> impl IntoElement { + let (skin_bytes, is_slim) = use_player_skin(self.uuid.clone()); + + let mut cache = use_state(|| None::<(usize, Shader)>); + let src_ptr = skin_bytes.as_ptr() as usize; + let cache_copy = cache.peek().cloned(); + let skin_shader = match cache_copy { + Some((ptr, shader)) if ptr == src_ptr => Some(shader), + _ => { + let shader = decode_skin_shader(&skin_bytes); + if let Some(shader) = &shader { + cache.set(Some((src_ptr, shader.clone()))); + } + shader + } + }; + + let mut yaw = use_state({ + let v = self.yaw; + move || v + }); + + let mut pitch = use_state({ + let v = self.pitch; + move || v + }); + + let mut drag = use_state(|| None::<(f32, f32, f32, f32)>); + + let mut last_uuid = use_state({ + let u = self.uuid.clone(); + move || u + }); + + if *last_uuid.peek() != self.uuid { + last_uuid.set(self.uuid.clone()); + yaw.set(self.yaw); + pitch.set(self.pitch); + drag.set(None); + } + + let effect = player_effect(); + + let render_cb = RenderCallback::new(move |ctx: &mut CanvasContext| { + let (Some(effect), Some(skin)) = (&effect, &skin_shader) else { + return; + }; + + let (w, h) = (ctx.size.width, ctx.size.height); + + if w <= 0.0 || h <= 0.0 { + return; + } + + let (cur_yaw, cur_pitch) = (yaw(), pitch()); + + let slim = if is_slim { 1.0 } else { 0.0 }; + + let mut uniforms = Vec::with_capacity(28); + for value in [w, h, cur_yaw, cur_pitch, CAM_DIST, CAM_FOCAL, slim] { + uniforms.extend_from_slice(&value.to_le_bytes()); + } + + let Some(shader) = effect.make_shader( + Data::new_copy(&uniforms), + &[ChildPtr::from(skin.clone())], + None, + ) else { + return; + }; + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_shader(shader); + ctx.canvas.draw_rect(SkRect::new(0.0, 0.0, w, h), &paint); + }); + + // `canvas` carries no style data, so the cursor rides on a wrapper `rect` that + // covers exactly the same area + rect() + .width(self.width.clone()) + .height(self.height.clone()) + .cursor(CursorIcon::Grab) + .child( + canvas(render_cb) + .key(src_ptr as u64) + .width(Size::fill()) + .height(Size::fill()), + ) + .on_pointer_down(move |e: Event| { + let loc = e.global_location(); + drag.set(Some((loc.x as f32, loc.y as f32, yaw(), pitch()))); + }) + .on_global_pointer_move(move |e: Event| { + let Some((sx, sy, yaw0, pitch0)) = *drag.read() else { + return; + }; + let loc = e.global_location(); + if loc.to_tuple() == (-1.0, -1.0) { + return; + } + + let dx = loc.x as f32 - sx; + let dy = loc.y as f32 - sy; + + yaw.set(yaw0 - dx * DRAG_SENSITIVITY); + pitch.set((pitch0 + dy * DRAG_SENSITIVITY).clamp(-PITCH_LIMIT, PITCH_LIMIT)); + + Platform::get().send(UserEvent::RequestRedraw); + }) + // Global so a release outside the widget still ends the drag + .on_global_pointer_press(move |_: Event| { + if drag.peek().is_some() { + drag.set(None); + } + }) + } +} + +fn decode_skin_shader(skin_bytes: &Bytes) -> Option { + let data = unsafe { SkData::new_bytes(skin_bytes) }; + let skin = SkImage::from_encoded(data)?; + let skin = skin.make_raster_image(None, None).unwrap_or(skin); + + skin.to_shader( + (TileMode::Clamp, TileMode::Clamp), + SamplingOptions::default(), + None, + ) +} + +const PLAYER_SKSL: &str = r#" +uniform float uResX; +uniform float uResY; +uniform float uYaw; +uniform float uPitch; +uniform float uDist; +uniform float uFocal; +uniform float uSlim; +uniform shader uSkin; + +void hitBox(float3 ro, float3 rd, float3 cmin, float3 cmax, float3 dims, float3 td, + float2 base, inout float best, inout half4 col) { + float3 t1 = (cmin - ro) / rd; + float3 t2 = (cmax - ro) / rd; + float3 tmin = min(t1, t2); + float3 tmax = max(t1, t2); + float tN = max(max(tmin.x, tmin.y), tmin.z); + float tF = min(min(tmax.x, tmax.y), tmax.z); + if (tN > tF || tF < 0.0) { return; } + float t = tN; + if (t < 0.0 || t >= best) { return; } + + float3 p = ro + rd * t; + float3 n = clamp((p - cmin) / dims, 0.0, 1.0); // 0..1 inside the box + float w = td.x, h = td.y, d = td.z; // atlas region sizes + + float2 uv; + float3 nrm; + if (tN == tmin.x) { + if (rd.x > 0.0) { // -X face (player right) + uv = float2(base.x + n.z * d, base.y + d + (1.0 - n.y) * h); + nrm = float3(-1.0, 0.0, 0.0); + } else { // +X face (player left) + uv = float2(base.x + d + w + (1.0 - n.z) * d, base.y + d + (1.0 - n.y) * h); + nrm = float3(1.0, 0.0, 0.0); + } + } else if (tN == tmin.y) { + if (rd.y > 0.0) { // -Y face (bottom): same orientation as the top, shifted by w + uv = float2(base.x + d + w + n.x * w, base.y + n.z * d); + nrm = float3(0.0, -1.0, 0.0); + } else { // +Y face (top) + uv = float2(base.x + d + n.x * w, base.y + n.z * d); + nrm = float3(0.0, 1.0, 0.0); + } + } else { + if (rd.z > 0.0) { // -Z face (back) + uv = float2(base.x + 2.0 * d + w + (1.0 - n.x) * w, base.y + d + (1.0 - n.y) * h); + nrm = float3(0.0, 0.0, -1.0); + } else { // +Z face (front) + uv = float2(base.x + d + n.x * w, base.y + d + (1.0 - n.y) * h); + nrm = float3(0.0, 0.0, 1.0); + } + } + + half4 s = uSkin.eval(uv); + + if (s.a < 0.5) { + return; + } + + float3 L = normalize(float3(0.4, 0.8, 0.6)); + float diff = 0.65 + 0.35 * clamp(dot(nrm, L), 0.0, 1.0); + col = half4(s.rgb * half(diff), 1.0); + best = t; +} + +half4 main(float2 fragCoord) { + float2 res = float2(uResX, uResY); + float2 uv = (fragCoord - 0.5 * res) / res.y; + uv.y = -uv.y; // screen y is down; world y is up + + float cy = cos(uYaw), sy = sin(uYaw); + float cp = cos(uPitch), sp = sin(uPitch); + float3 ro = float3(sy * cp, sp, cy * cp) * uDist; + float3 fwd = normalize(-ro); + float3 right = normalize(cross(fwd, float3(0.0, 1.0, 0.0))); + float3 up = cross(right, fwd); + float3 rd = normalize(fwd * uFocal + right * uv.x + up * uv.y); + + float best = 1e9; + half4 col = half4(0.0); + + float aw = uSlim > 0.5 ? 3.0 : 4.0; // arm width: slim = 3px, classic = 4px + float sw = aw + 0.5; // arm overlay (sleeve) width + + hitBox(ro, rd, float3(-4.0, 8.0, -4.0), float3(4.0, 16.0, 4.0), + float3(8.0, 8.0, 8.0), float3(8.0, 8.0, 8.0), float2(0.0, 0.0), best, col); // head + hitBox(ro, rd, float3(-4.0, -4.0, -2.0), float3(4.0, 8.0, 2.0), + float3(8.0, 12.0, 4.0), float3(8.0, 12.0, 4.0), float2(16.0, 16.0), best, col); // body + hitBox(ro, rd, float3(-4.0 - aw, -4.0, -2.0), float3(-4.0, 8.0, 2.0), + float3(aw, 12.0, 4.0), float3(aw, 12.0, 4.0), float2(40.0, 16.0), best, col); // right arm + hitBox(ro, rd, float3(4.0, -4.0, -2.0), float3(4.0 + aw, 8.0, 2.0), + float3(aw, 12.0, 4.0), float3(aw, 12.0, 4.0), float2(32.0, 48.0), best, col); // left arm + hitBox(ro, rd, float3(-4.0, -16.0, -2.0), float3(0.0, -4.0, 2.0), + float3(4.0, 12.0, 4.0), float3(4.0, 12.0, 4.0), float2(0.0, 16.0), best, col); // right leg + hitBox(ro, rd, float3(0.0, -16.0, -2.0), float3(4.0, -4.0, 2.0), + float3(4.0, 12.0, 4.0), float3(4.0, 12.0, 4.0), float2(16.0, 48.0), best, col); // left leg + + hitBox(ro, rd, float3(-4.5, 7.5, -4.5), float3(4.5, 16.5, 4.5), + float3(9.0, 9.0, 9.0), float3(8.0, 8.0, 8.0), float2(32.0, 0.0), best, col); // hat + hitBox(ro, rd, float3(-4.25, -4.25, -2.25), float3(4.25, 8.25, 2.25), + float3(8.5, 12.5, 4.5), float3(8.0, 12.0, 4.0), float2(16.0, 32.0), best, col); // jacket + hitBox(ro, rd, float3(-3.75 - sw, -4.25, -2.25), float3(-3.75, 8.25, 2.25), + float3(sw, 12.5, 4.5), float3(aw, 12.0, 4.0), float2(40.0, 32.0), best, col); // right sleeve + hitBox(ro, rd, float3(3.75, -4.25, -2.25), float3(3.75 + sw, 8.25, 2.25), + float3(sw, 12.5, 4.5), float3(aw, 12.0, 4.0), float2(48.0, 48.0), best, col); // left sleeve + hitBox(ro, rd, float3(-4.25, -16.25, -2.25), float3(0.25, -3.75, 2.25), + float3(4.5, 12.5, 4.5), float3(4.0, 12.0, 4.0), float2(0.0, 32.0), best, col); // right pant + hitBox(ro, rd, float3(-0.25, -16.25, -2.25), float3(4.25, -3.75, 2.25), + float3(4.5, 12.5, 4.5), float3(4.0, 12.0, 4.0), float2(0.0, 48.0), best, col); // left pant + + return col; +} +"#; diff --git a/packages/oneclient_app/src/components/progress_track.rs b/packages/oneclient_app/src/components/progress_track.rs new file mode 100644 index 00000000..742596d0 --- /dev/null +++ b/packages/oneclient_app/src/components/progress_track.rs @@ -0,0 +1,20 @@ +use freya::prelude::*; + +/// The app's one progress meter: a pill-shaped track with a filled portion. +/// +/// `pct` is 0-100 and is clamped here, so callers can hand over a raw ratio +/// without guarding against a denominator that grew mid-flight. +pub fn progress_track(pct: f32, height: f32, fill: Color, bg: Color) -> Rect { + rect() + .width(Size::fill()) + .height(Size::px(height)) + .corner_radius(CornerRadius::new_all(height / 2.)) + .background(bg) + .child( + rect() + .width(Size::percent(pct.clamp(0.0, 100.0))) + .height(Size::fill()) + .corner_radius(CornerRadius::new_all(height / 2.)) + .background(fill), + ) +} diff --git a/packages/oneclient_app/src/components/recents_row.rs b/packages/oneclient_app/src/components/recents_row.rs new file mode 100644 index 00000000..b30fcbb2 --- /dev/null +++ b/packages/oneclient_app/src/components/recents_row.rs @@ -0,0 +1,318 @@ +use freya::animation::*; +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::clusters::Cluster; + +use crate::components::{ART_PREVIEW_EDGE, DynamicArt, Icon, IconType}; +use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters}; +use crate::routes::Route; +use crate::theme; +use crate::theme::colors; +use crate::ui::{border_all, border_all_color}; +use crate::utils::sort_clusters_for_home; + +const ROW_HEIGHT_PX: f32 = 208.0; +const CARD_GAP_PX: f32 = 24.0; +const MORE_TILE_WIDTH_PX: f32 = 96.0; +const MIN_CARD_WIDTH_PX: f32 = 300.0; +const MAX_CARD_WIDTH_PX: f32 = 480.0; +const CARD_MS: u64 = 460; +const STAGGER_MS: u64 = 85; +const CARD_RISE_PX: f32 = 48.0; +const INTRO_ITEMS: usize = 6; + +#[derive(PartialEq)] +pub struct RecentsRow; + +impl Component for RecentsRow { + fn render(&self) -> impl IntoElement { + let clusters_query = use_clusters(); + let platform = Platform::get(); + let root_size = platform.root_size; + let scale_factor = platform.scale_factor; + + let clusters = settled_or_loading(&clusters_query).unwrap_or_default(); + + let sorted: Vec = sort_clusters_for_home(clusters); + + 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 + let items = intro_items(sorted.len()); + + let display = sorted.into_iter().take(slots).collect::>(); + + let intro = use_animation_with_dependencies(&items, |conf, items| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + AnimNum::new(0., 1.) + .time(intro_ms(*items).max(1)) + .function(Function::Linear) + }); + let progress = intro.get().value(); + + rect() + .width(Size::fill()) + .height(Size::px(ROW_HEIGHT_PX)) + .content(Content::Flex) + .horizontal() + .spacing(CARD_GAP_PX) + .children(display.iter().enumerate().map(|(index, cluster)| { + ClusterCard { + cluster: cluster.clone(), + index, + items, + progress, + } + .into_element() + })) + .child(OtherVersionsTile { + index: items.saturating_sub(1), + items, + progress, + }) + } +} + +fn intro_items(clusters: usize) -> usize { + (clusters + 1).min(INTRO_ITEMS) +} + +fn intro_ms(items: usize) -> u64 { + CARD_MS + (items.saturating_sub(1) as u64) * STAGGER_MS +} + +fn stagger_eased(progress: f32, index: usize, items: usize) -> f32 { + let elapsed = progress * intro_ms(items) as f32; + // Items past the budget share the last slot so every one still lands on a finished 1.0 + let start = index.min(items.saturating_sub(1)) as f32 * STAGGER_MS as f32; + let local = ((elapsed - start) / CARD_MS as f32).clamp(0., 1.); + 1.0 - (1.0 - local).powi(3) +} + +struct ClusterCard { + cluster: Cluster, + index: usize, + items: usize, + progress: f32, +} + +impl PartialEq for ClusterCard { + fn eq(&self, other: &Self) -> bool { + self.cluster.id == other.cluster.id + && self.index == other.index + && self.items == other.items + && self.progress == other.progress + } +} + +impl Component for ClusterCard { + fn render(&self) -> impl IntoElement { + let mut active_id = use_active_cluster_id(); + let active = *active_id.read() == Some(self.cluster.id); + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let eased = stagger_eased(self.progress, self.index, self.items); + let rise = (1.0 - eased) * CARD_RISE_PX; + + let title = format!("{} {}", self.cluster.mc_version, self.cluster.mc_loader); + + let cluster_id = self.cluster.id; + let on_press = move |_| { + *active_id.write() = Some(cluster_id); + }; + + rect() + .key(self.cluster.id) + .width(Size::flex(1.0)) + .max_width(Size::px(MAX_CARD_WIDTH_PX)) + .height(Size::fill()) + .offset_y(rise) + .opacity(eased) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .corner_radius(CornerRadius::new_all(12.)) + .overflow(Overflow::Clip) + .border( + if active || focused { + border_all_color(2., colors::brand()) + } else if *hovering.read() { + border_all_color(1., colors::component_border_hover()) + } else { + border_all(1.) + } + .alignment(BorderAlignment::Outer), + ) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_pointer_over(move |_| { + if !*hovering.peek() { + *hovering.write() = true; + } + }) + .on_pointer_out(move |_| { + *hovering.write() = false; + }) + .shadow( + Shadow::new() + .blur(24.) + .spread(12.) + .x(0.) + .y(0.) + .color(Color::from_af32rgb(0.3, 0, 0, 0)), + ) + .on_all_press(on_press) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .layer(Layer::Relative(1)) + .child( + DynamicArt::for_cluster(&self.cluster) + .max_edge(ART_PREVIEW_EDGE), + ), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .padding(Gaps::new_symmetric(12., 24.)) + .main_align(Alignment::End) + .cross_align(Alignment::Start) + .layer(Layer::Relative(4)) + .background( + LinearGradient::new() + .angle(0.0) + .stop((Color::from_argb(0, 25, 25, 25), 24.519)) + .stop((Color::from_af32rgb(0.75, 17, 17, 21), 65.)), + ) + .child( + label() + .text(title) + .font_size(32.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .layer(Layer::Relative(7)) + .background(if *hovering.read() { + Color::from_af32rgb(0.2, 0, 0, 0) + } else { + Color::TRANSPARENT + }), + ), + ), + ) + } +} + +#[derive(PartialEq)] +struct OtherVersionsTile { + index: usize, + items: usize, + progress: f32, +} + +impl Component for OtherVersionsTile { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let eased = stagger_eased(self.progress, self.index, self.items); + let rise = (1.0 - eased) * CARD_RISE_PX; + + rect() + .width(Size::px(MORE_TILE_WIDTH_PX)) + .height(Size::fill()) + .offset_y(rise) + .opacity(eased) + .corner_radius(CornerRadius::new_all(12.)) + .border( + if focused { + border_all_color(2., colors::brand()) + } else { + border_all(1.) + } + .alignment(BorderAlignment::Outer), + ) + .background(if *hovering.read() { + colors::ghost_overlay_pressed() + } else { + colors::ghost_overlay_hover() + }) + .blur(32.) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(move |_| { + *hovering.write() = true; + }) + .on_pointer_leave(move |_| { + *hovering.write() = false; + }) + .shadow( + Shadow::new() + .blur(24.) + .spread(12.) + .x(0.) + .y(0.) + .color(Color::from_af32rgb(0.3, 0, 0, 0)), + ) + .on_all_press(|_| { + let _ = RouterContext::get().push(Route::Clusters {}); + }) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .child(Icon::new(IconType::DotsGrid).size(50.)), + ) + } +} + +fn recent_card_slots_for_window(window_width_px: f32, scale_factor: f32) -> usize { + let scale = if scale_factor > 0. && scale_factor.is_finite() { + scale_factor + } else { + 1. + }; + recent_card_slots_for_width(window_width_px / scale - theme::HOME_PADDING_PX * 2.) +} + +/// `n` cards occupy `n` gaps (n-1 between cards one before the tile) so `n * (MIN + GAP) + MORE` +/// must fit +/// Always returns at least 1 the card carries no `min_width` and just renders narrower +fn recent_card_slots_for_width(row_width_px: f32) -> usize { + if !row_width_px.is_finite() { + return 1; + } + + let available = row_width_px - MORE_TILE_WIDTH_PX; + let slot = MIN_CARD_WIDTH_PX + CARD_GAP_PX; + (available / slot).floor().max(1.0) as usize +} diff --git a/packages/oneclient_app/src/components/screenshot_viewer.rs b/packages/oneclient_app/src/components/screenshot_viewer.rs new file mode 100644 index 00000000..2826d06d --- /dev/null +++ b/packages/oneclient_app/src/components/screenshot_viewer.rs @@ -0,0 +1,252 @@ +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::theme::colors; +use crate::ui::fmt_date; +use crate::utils::{format_res, format_size}; + +#[derive(PartialEq)] +pub struct ScreenshotViewer { + shots: Vec, + start: usize, + on_close: EventHandler<()>, +} + +impl ScreenshotViewer { + pub fn new( + shots: Vec, + start: usize, + on_close: impl Into>, + ) -> Self { + Self { + shots, + start, + on_close: on_close.into(), + } + } +} + +impl Component for ScreenshotViewer { + fn render(&self) -> impl IntoElement { + let shots = self.shots.clone(); + let len = shots.len(); + + let mut index = use_state({ + let start = self.start.min(len.saturating_sub(1)); + move || start + }); + + if len == 0 { + return rect().into_element(); + } + let idx = (*index.read()).min(len - 1); + let info = shots[idx].clone(); + + let action = use_screenshot_action(); + let dispatch = use_dispatch(); + + let close = self.on_close.clone(); + let scrim_close = self.on_close.clone(); + let delete_close = self.on_close.clone(); + + let has_prev = idx > 0; + let has_next = idx + 1 < len; + + let preview = LocalImage::new(info.path.clone(), 0, false).skeleton(true); + + let open_path = info.path.clone(); + let copy_path = info.path.clone(); + let copy_dispatch = dispatch.clone(); + let delete_path = info.path.clone(); + + OverlayPopup::new() + .on_close(move |_| scrim_close.call(())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .on_global_key_down(move |e: Event| match &e.key { + Key::Named(NamedKey::ArrowLeft) if idx > 0 => index.set(idx - 1), + Key::Named(NamedKey::ArrowRight) if idx + 1 < len => index.set(idx + 1), + _ => {} + }) + .child( + rect() + .vertical() + .width(Size::window_percent(88.)) + .height(Size::window_percent(90.)) + .spacing(12.) + .padding(Gaps::new_all(16.)) + .content(Content::Flex) + .child(header_row(&info, idx, len, move |_| close.call(()))) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .horizontal() + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(12.) + .overflow(Overflow::Clip) + .child(chevron_btn(IconType::ArrowLeft, has_prev, move |_| { + if idx > 0 { + index.set(idx - 1); + } + })) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .child(preview), + ) + .child(chevron_btn( + IconType::ArrowRight, + has_next, + move |_| { + if idx + 1 < len { + index.set(idx + 1); + } + }, + )), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| { + if let Some(dir) = open_path.parent() { + crate::platform::open_url( + &dir.to_string_lossy(), + ); + } + }) + .child(Icon::new(IconType::Folder).size(14.)) + .text("Open in folder"), + ) + .child( + Button::new() + .secondary() + .on_press(move |_| { + crate::platform::copy_image_to_clipboard( + copy_path.clone(), + ); + copy_dispatch + .notify("Copied to clipboard") + .body("Screenshot copied to your clipboard.") + .info() + .icon(IconType::ClipboardCheck) + .send(); + }) + .child(Icon::new(IconType::Copy01).size(14.)) + .text("Copy"), + ) + .child( + Button::new() + .danger() + .on_press(move |_| { + action.mutate(ScreenshotAction::Delete { + path: delete_path.clone(), + }); + delete_close.call(()); + }) + .child(Icon::new(IconType::Trash01).size(14.)) + .text("Delete"), + ), + ), + ), + ) + .into_element() + } +} + +fn chevron_btn( + icon: IconType, + enabled: bool, + on_press: impl Into>>, +) -> impl IntoElement { + let base = rect() + .width(Size::px(44.)) + .height(Size::px(44.)) + .center() + .corner_radius(CornerRadius::new_all(22.)); + + if !enabled { + return base + .background(Color::from_argb(70, 0, 0, 0)) + .on_press(|_| {}) + .child( + Icon::new(icon) + .size(26.) + .color(colors::fg_secondary().with_a(90)), + ) + .into_element(); + } + + base.background(Color::from_argb(140, 0, 0, 0)) + .cursor(CursorIcon::Pointer) + .on_press(on_press) + .child(Icon::new(icon).size(26.).color(colors::fg_primary())) + .into_element() +} + +fn header_row( + info: &ScreenshotInfo, + idx: usize, + len: usize, + on_close: impl Into>>, +) -> impl IntoElement { + let mut meta = format!( + "{} • {}", + format_size(info.size_bytes), + fmt_date(info.created) + ); + + if let Some(res) = info.resolution { + meta = format!("{} • {}", format_res(res), meta); + } + + if len > 1 { + meta = format!("{} of {} • {meta}", idx + 1, len); + } + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(info.name.clone()) + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(meta) + .font_size(11.) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .ghost() + .icon() + .on_press(on_close) + .child(Icon::new(IconType::XClose).size(18.)), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/components/scrollview.rs b/packages/oneclient_app/src/components/scrollview.rs new file mode 100644 index 00000000..6d6377cc --- /dev/null +++ b/packages/oneclient_app/src/components/scrollview.rs @@ -0,0 +1,617 @@ +#![allow(dead_code)] +use freya::prelude::*; + +use crate::theme::colors; + +const LAZY_OVERSCAN: i64 = 3; + +pub(crate) fn normalize_wheel_delta(delta: f64, scale_factor: f64) -> f32 { + if delta == 0.0 { + 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; + + let lines = (delta / FREYA_LINE_SPEED).round(); + if lines != 0.0 && (delta - lines * FREYA_LINE_SPEED).abs() <= LINE_DELTA_TOLERANCE { + return (lines * LINE_SCROLL) as f32; + } + + let scale_factor = if scale_factor > 0.0 { + scale_factor + } else { + 1.0 + }; + (delta / (FREYA_PIXEL_SPEED * scale_factor) * PIXEL_SCROLL) as f32 +} + +pub(crate) fn scroll_pos_from_wheel(wheel: f32, inner: f32, viewport: f32, current: f32) -> i32 { + if viewport >= inner { + return 0; + } + let new_pos = current + wheel; + if new_pos >= 0.0 && wheel > 0.0 { + return 0; + } + if new_pos <= -(inner - viewport) && wheel < 0.0 { + return -(inner - viewport) as i32; + } + new_pos as i32 +} + +pub(crate) fn corrected_scroll(inner: f32, viewport: f32, pos: f32) -> f32 { + if pos > 0.0 { + return 0.0; + } + if (-pos + viewport) > inner { + return if viewport < inner { + -(inner - viewport) + } else { + 0.0 + }; + } + pos +} + +pub(crate) fn scrollbar_pos_and_size(inner: f32, viewport: f32, scroll: f32) -> (f32, f32) { + if viewport >= inner { + return (0.0, inner); + } + let ratio = viewport / inner; + let thumb = (viewport * ratio).max(50.0); + let scroll_range = inner - viewport; + let thumb_range = viewport - thumb; + let normalized = -scroll / scroll_range; + + (normalized * thumb_range, thumb) +} + +#[derive(Clone, Copy)] +pub struct ScrollAreaCtx { + pub corrected_x: f32, + pub corrected_y: f32, + pub viewport_w: f32, + pub viewport_h: f32, + pub viewport_top: f32, + pub viewport_left: f32, +} + +pub struct ScrollArea { + width: Size, + height: Size, + padding: Gaps, + spacing: f32, + show_scrollbar: bool, + horizontal: bool, + content_width: f32, + stick_bottom: bool, + reset_key: Option, + controller: Option, + on_user_scroll: Option>, + on_ctx: Option>, + children: Vec, + builder: Option Element>>, +} + +impl Default for ScrollArea { + fn default() -> Self { + Self::new() + } +} + +impl ScrollArea { + pub fn new() -> Self { + Self { + width: Size::fill(), + height: Size::flex(1.0), + padding: Gaps::default(), + spacing: 0., + show_scrollbar: true, + horizontal: false, + content_width: 0., + stick_bottom: false, + reset_key: None, + controller: None, + on_user_scroll: None, + on_ctx: None, + children: Vec::new(), + builder: None, + } + } + + pub fn width(mut self, width: Size) -> Self { + self.width = width; + self + } + + pub fn height(mut self, height: Size) -> Self { + self.height = height; + self + } + + pub fn padding(mut self, padding: Gaps) -> Self { + self.padding = padding; + self + } + + pub fn spacing(mut self, spacing: f32) -> Self { + self.spacing = spacing; + self + } + + pub fn show_scrollbar(mut self, show: bool) -> Self { + self.show_scrollbar = show; + self + } + + pub fn horizontal(mut self, content_width: f32) -> Self { + self.horizontal = true; + self.content_width = content_width; + self + } + + pub fn stick_bottom(mut self, stick: bool) -> Self { + self.stick_bottom = stick; + self + } + + pub fn reset_key(mut self, key: u64) -> Self { + self.reset_key = Some(key); + self + } + + pub fn scroll_controller(mut self, controller: ScrollController) -> Self { + self.controller = Some(controller); + self + } + + pub fn on_user_scroll(mut self, handler: impl FnMut(()) + 'static) -> Self { + self.on_user_scroll = Some(EventHandler::new(handler)); + self + } + + pub fn on_ctx(mut self, handler: impl FnMut(ScrollAreaCtx) + 'static) -> Self { + self.on_ctx = Some(EventHandler::new(handler)); + self + } + + pub fn children(mut self, children: impl IntoIterator) -> Self { + self.children = children.into_iter().collect(); + self + } + + pub fn child(mut self, child: impl IntoElement) -> Self { + self.children.push(child.into_element()); + self + } + + pub fn content(mut self, builder: impl Fn(ScrollAreaCtx) -> Element + 'static) -> Self { + self.builder = Some(Box::new(builder)); + self + } + + pub fn lazy( + mut self, + count: usize, + item_height: f32, + spacing: f32, + render: impl Fn(usize) -> Element + 'static, + ) -> Self { + let slot = (item_height + spacing).max(1.); + self.builder = Some(Box::new(move |ctx: ScrollAreaCtx| { + let first = + (((-ctx.corrected_y) / slot).floor() as i64 - LAZY_OVERSCAN).max(0) as usize; + let span = ((ctx.viewport_h / slot).ceil() as i64 + 2 * LAZY_OVERSCAN).max(0) as usize; + let last = (first + span).min(count); + + let top_pad = first as f32 * slot; + let bottom_pad = count.saturating_sub(last) as f32 * slot; + + let mut container = rect().vertical().width(Size::fill()); + if top_pad > 0. { + container = container.child(rect().width(Size::fill()).height(Size::px(top_pad))); + } + for i in first..last { + container = container.child( + rect() + .key(i) + .width(Size::fill()) + .height(Size::px(slot)) + .child( + rect() + .width(Size::fill()) + .height(Size::px(item_height)) + .child(render(i)), + ), + ); + } + if bottom_pad > 0. { + container = + container.child(rect().width(Size::fill()).height(Size::px(bottom_pad))); + } + container.into_element() + })); + self + } + + fn view(&self) -> impl IntoElement { + let horizontal = self.horizontal; + let content_w = self.content_width; + let spacing = self.spacing; + let padding = self.padding; + let show_scrollbar = self.show_scrollbar; + let stick_bottom = self.stick_bottom; + let on_user_scroll = self.on_user_scroll.clone(); + + let internal = use_scroll_controller(ScrollConfig::default); + let mut controller = self.controller.unwrap_or(internal); + + let scale_factor = Platform::get().scale_factor; + + let mut viewport_h = use_state(|| 0f32); + let mut viewport_w = use_state(|| 0f32); + let mut viewport_top = use_state(|| 0f32); + let mut viewport_left = use_state(|| 0f32); + let mut content_h = use_state(|| 0f32); + let mut scroll_x = use_state(|| 0f32); + let mut drag_start = use_state::>(|| None); + let mut drag_start_x = use_state::>(|| None); + let mut shift_held = use_state(|| false); + + let reset_key = self.reset_key; + let mut last_reset = use_state(|| reset_key); + if reset_key.is_some() && *last_reset.peek() != reset_key { + last_reset.set(reset_key); + controller.scroll_to_y(0); + scroll_x.set_if_modified(0.); + } + + let (_, scrolled_y) = controller.into(); + let vp_h = *viewport_h.read(); + let vp_w = *viewport_w.read(); + let vp_top = *viewport_top.read(); + let vp_left = *viewport_left.read(); + let ct_h = *content_h.read(); + + let corrected_y = corrected_scroll(ct_h, vp_h, scrolled_y as f32); + let corrected_x = if horizontal { + corrected_scroll(content_w, vp_w, *scroll_x.read()) + } else { + 0. + }; + + let (scrollbar_y, scrollbar_height) = scrollbar_pos_and_size(ct_h, vp_h, corrected_y); + let show_v = show_scrollbar && vp_h > 0. && vp_h < ct_h; + + let (scrollbar_x, scrollbar_width) = scrollbar_pos_and_size(content_w, vp_w, corrected_x); + let show_h = horizontal && vp_w > 0. && vp_w < content_w; + + let pressing_v = use_memo(move || drag_start.read().is_some()); + let pressing_h = use_memo(move || drag_start_x.read().is_some()); + + let ctx = ScrollAreaCtx { + corrected_x, + corrected_y, + viewport_w: vp_w, + viewport_h: vp_h, + viewport_top: vp_top, + viewport_left: vp_left, + }; + + if let Some(cb) = &self.on_ctx { + cb.call(ctx); + } + + let content_el = if let Some(builder) = &self.builder { + let inner = builder(ctx); + if padding != Gaps::default() { + rect() + .width(Size::fill()) + .padding(padding) + .child(inner) + .into_element() + } else { + inner + } + } else { + rect() + .vertical() + .width(Size::fill()) + .spacing(spacing) + .padding(padding) + .children(self.children.clone()) + .into_element() + }; + + let us_wheel = on_user_scroll.clone(); + let us_thumb = on_user_scroll.clone(); + let us_h_thumb = on_user_scroll; + + let can_scroll_v = vp_h > 0. && vp_h < ct_h; + let can_scroll_h = horizontal && vp_w > 0. && vp_w < content_w; + + let on_wheel = move |e: Event| { + let shift = *shift_held.read(); + let scale = *scale_factor.peek(); + + let delta_x = normalize_wheel_delta(e.delta_x, scale); + let delta_y = normalize_wheel_delta(e.delta_y, scale); + + let h_delta = if horizontal { + if shift && delta_x == 0.0 { + delta_y + } else { + delta_x + } + } else { + 0.0 + }; + let v_delta = if horizontal && shift { 0.0 } else { delta_y }; + + let do_h = h_delta != 0.0 && can_scroll_h; + let do_v = v_delta != 0.0 && can_scroll_v; + + if !do_h && !do_v { + return; + } + + e.stop_propagation(); + + if let Some(cb) = &us_wheel { + cb.call(()); + } + + if do_h { + let cur_x = corrected_scroll(content_w, vp_w, *scroll_x.read()); + let new_x = scroll_pos_from_wheel(h_delta, content_w, vp_w, cur_x); + scroll_x.set(new_x as f32); + } + + if do_v { + let (_, cur_y) = controller.into(); + let current = corrected_scroll(ct_h, vp_h, cur_y as f32); + let new_y = scroll_pos_from_wheel(v_delta, ct_h, vp_h, current); + if new_y != cur_y { + controller.scroll_to_y(new_y); + } + } + }; + + let on_thumb_down = move |e: Event| { + if let Some(cb) = &us_thumb { + cb.call(()); + } + let (_, y) = controller.into(); + drag_start.set(Some((e.global_location().y as f32, y))); + }; + + let on_h_thumb_down = move |e: Event| { + if let Some(cb) = &us_h_thumb { + cb.call(()); + } + drag_start_x.set(Some((e.global_location().x as f32, *scroll_x.read()))); + }; + + let on_global_move = move |e: Event| { + if let Some((grab_y, scroll_at_grab)) = *drag_start.read() { + e.stop_propagation(); + + let thumb_range = (vp_h - scrollbar_height).max(1.); + let scroll_range = (ct_h - vp_h).max(0.); + + let cursor_dy = e.global_location().y as f32 - grab_y; + let scroll_dy = -(cursor_dy / thumb_range) * scroll_range; + let target = scroll_at_grab + scroll_dy as i32; + + let clamped = corrected_scroll(ct_h, vp_h, target as f32) as i32; + + let (_, cur_y) = controller.into(); + + if clamped != cur_y { + controller.scroll_to_y(clamped); + } + + return; + } + if let Some((grab_x, scroll_at_grab)) = *drag_start_x.read() { + e.stop_propagation(); + let thumb_range = (vp_w - scrollbar_width).max(1.); + let scroll_range = (content_w - vp_w).max(0.); + let cursor_dx = e.global_location().x as f32 - grab_x; + let scroll_dx = -(cursor_dx / thumb_range) * scroll_range; + let target = scroll_at_grab + scroll_dx; + scroll_x.set_if_modified(corrected_scroll(content_w, vp_w, target)); + } + }; + + let on_global_release = move |_: Event| { + if drag_start.read().is_some() { + drag_start.set(None); + } + if drag_start_x.read().is_some() { + drag_start_x.set(None); + } + }; + + rect() + .width(self.width.clone()) + .height(self.height.clone()) + .scrollable(true) + .a11y_builder(move |node| { + node.set_scroll_x(corrected_x as f64); + node.set_scroll_y(corrected_y as f64); + }) + .on_wheel(on_wheel) + .on_capture_global_pointer_move(on_global_move) + .on_capture_global_pointer_press(on_global_release) + .on_global_key_down(move |e: Event| { + let held = e.modifiers.contains(Modifiers::SHIFT) + || matches!(e.code, Code::ShiftLeft | Code::ShiftRight); + shift_held.set_if_modified(held); + }) + .on_global_key_up(move |e: Event| { + let held = if matches!(e.code, Code::ShiftLeft | Code::ShiftRight) { + false + } else { + e.modifiers.contains(Modifiers::SHIFT) + }; + shift_held.set_if_modified(held); + }) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .overflow(Overflow::Clip) + .on_sized(move |e: Event| { + let h = e.area.height(); + let w = e.area.width(); + let top = e.area.min_y(); + let left = e.area.min_x(); + if (*viewport_h.read() - h).abs() > 0.5 { + viewport_h.set(h); + } + if (*viewport_w.read() - w).abs() > 0.5 { + viewport_w.set(w); + } + if (*viewport_top.read() - top).abs() > 0.5 { + viewport_top.set(top); + } + if (*viewport_left.read() - left).abs() > 0.5 { + viewport_left.set(left); + } + }) + .child( + rect() + .width(Size::fill()) + .offset_x(corrected_x) + .offset_y(corrected_y) + .on_sized(move |e: Event| { + let h = e.area.height(); + if (*content_h.read() - h).abs() > 0.5 { + content_h.set(h); + if stick_bottom { + let vp = *viewport_h.read(); + let target = -((h - vp).max(0.)); + controller.scroll_to_y(target as i32); + } + } + }) + .child(content_el), + ) + .maybe_child(show_h.then(|| { + ScrollThumb { + vertical: false, + main_pos: scrollbar_x, + length: scrollbar_width, + pressing: pressing_h.into_readable(), + on_down: on_h_thumb_down.into(), + } + .into_element() + })) + .maybe_child(show_v.then(|| { + ScrollThumb { + vertical: true, + main_pos: scrollbar_y, + length: scrollbar_height, + pressing: pressing_v.into_readable(), + on_down: on_thumb_down.into(), + } + .into_element() + })), + ) + } +} + +impl PartialEq for ScrollArea { + fn eq(&self, other: &Self) -> bool { + if self.builder.is_some() || other.builder.is_some() { + return false; + } + self.width == other.width + && self.height == other.height + && self.padding == other.padding + && self.spacing == other.spacing + && self.show_scrollbar == other.show_scrollbar + && self.horizontal == other.horizontal + && self.content_width == other.content_width + && self.stick_bottom == other.stick_bottom + && self.reset_key == other.reset_key + && self.children == other.children + } +} + +impl Component for ScrollArea { + fn render(&self) -> impl IntoElement { + self.view() + } +} + +#[derive(PartialEq)] +struct ScrollThumb { + vertical: bool, + main_pos: f32, + length: f32, + pressing: Readable, + on_down: EventHandler>, +} + +impl Component for ScrollThumb { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + let on_down = self.on_down.clone(); + let pressing = *self.pressing.read(); + let hovered = *hovering.read(); + + let color = if pressing { + colors::fg_secondary_pressed() + } else if hovered { + colors::fg_secondary_hover() + } else { + colors::fg_secondary() + }; + let thickness = if pressing || hovered { 8. } else { 6. }; + + let (position, width, height, layer) = if self.vertical { + ( + Position::new_absolute().right(0.).top(self.main_pos), + Size::px(14.), + Size::px(self.length), + Layer::Relative(999), + ) + } else { + ( + Position::new_absolute().left(self.main_pos).bottom(2.), + Size::px(self.length), + Size::px(8.), + Layer::Relative(2), + ) + }; + + let (bar_w, bar_h) = if self.vertical { + (Size::px(thickness), Size::fill()) + } else { + (Size::fill(), Size::px(thickness)) + }; + + rect() + .position(position) + .width(width) + .height(height) + .center() + .layer(layer) + .on_pointer_down(move |e| on_down.call(e)) + .on_pointer_over(move |_| hovering.set(true)) + .on_pointer_out(move |_| hovering.set(false)) + .child( + rect() + .width(bar_w) + .height(bar_h) + .corner_radius(CornerRadius::new_all(12.)) + .background(color.with_a(160)), + ) + } +} diff --git a/packages/oneclient_app/src/components/segmented_control.rs b/packages/oneclient_app/src/components/segmented_control.rs new file mode 100644 index 00000000..917beb31 --- /dev/null +++ b/packages/oneclient_app/src/components/segmented_control.rs @@ -0,0 +1,213 @@ +use freya::prelude::*; + +use crate::components::{Icon, IconType}; +use crate::theme::colors; +use crate::ui::border_all_color; + +#[derive(PartialEq, Clone)] +pub struct Segment { + value: T, + icon: Option, + label: Option, +} + +impl Segment { + pub fn new(value: T) -> Self { + Self { + value, + icon: None, + label: None, + } + } + + pub fn icon(mut self, icon: IconType) -> Self { + self.icon = Some(icon); + self + } + + pub fn label(mut self, text: impl Into) -> Self { + self.label = Some(text.into()); + self + } +} + +#[derive(PartialEq)] +pub struct SegmentedControl { + selected: State, + segments: Vec>, + height: f32, + icon_size: Option, + tint_icons: bool, + equal_width: Option, + disabled: bool, +} + +impl SegmentedControl { + pub fn new(selected: State) -> Self { + Self { + selected, + segments: Vec::new(), + height: 36., + icon_size: None, + tint_icons: true, + equal_width: None, + disabled: false, + } + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn segment(mut self, segment: Segment) -> Self { + self.segments.push(segment); + self + } + + pub fn segments(mut self, segments: impl IntoIterator>) -> Self { + self.segments.extend(segments); + self + } + + #[allow(dead_code)] + pub fn height(mut self, height: f32) -> Self { + self.height = height; + self + } + + pub fn icon_size(mut self, size: f32) -> Self { + self.icon_size = Some(size); + self + } + + pub fn no_tint(mut self) -> Self { + self.tint_icons = false; + self + } + + pub fn equal_width(mut self, width: f32) -> Self { + self.equal_width = Some(width); + self + } +} + +impl Component for SegmentedControl { + fn render(&self) -> impl IntoElement { + let active = *self.selected.read(); + let selected = self.selected; + let icon_size = self.icon_size; + let tint = self.tint_icons; + let equal_width = self.equal_width; + let disabled = self.disabled; + + rect() + .horizontal() + .height(Size::px(self.height)) + .cross_align(Alignment::Center) + .spacing(2.) + .padding(Gaps::new_all(3.)) + .corner_radius(CornerRadius::new_all(9.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .children(self.segments.iter().map(move |seg| { + SegmentButton { + selected, + value: seg.value, + is_selected: seg.value == active, + icon: seg.icon, + label: seg.label.clone(), + icon_size, + tint, + equal_width, + disabled, + } + .into_element() + })) + } +} + +#[derive(PartialEq)] +struct SegmentButton { + selected: State, + value: T, + is_selected: bool, + icon: Option, + label: Option, + icon_size: Option, + tint: bool, + equal_width: Option, + disabled: bool, +} + +impl Component for SegmentButton { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let mut selected = self.selected; + let value = self.value; + let is_selected = self.is_selected; + let disabled = self.disabled; + let equal_width = self.equal_width; + let focused = focus().is_focused(); + + let content_color = if disabled { + colors::fg_secondary().with_a(110) + } else if is_selected { + colors::fg_primary() + } else { + colors::fg_secondary() + }; + + rect() + .horizontal() + .height(Size::fill()) + .cross_align(Alignment::Center) + .spacing(6.) + .corner_radius(CornerRadius::new_all(6.)) + .a11y_id(a11y_id) + .a11y_focusable(!disabled) + .a11y_role(AccessibilityRole::Button) + .map(equal_width, |el, w| { + el.width(Size::px(w)).main_align(Alignment::Center) + }) + .maybe(equal_width.is_none(), |el| { + el.padding(Gaps::new_symmetric(0., 12.)) + }) + .background(if is_selected { + colors::component_bg_pressed() + } else { + Color::TRANSPARENT + }) + .maybe(focused && !disabled, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .maybe(!disabled, |el| { + el.cursor(CursorIcon::Pointer) + .on_all_press(move |_| *selected.write() = value) + }) + .maybe_child(self.icon.map(|icon| { + let mut ic = Icon::new(icon); + if let Some(size) = self.icon_size { + ic = ic.size(size); + } + if self.tint { + ic = ic.color(content_color); + } + ic.into_element() + })) + .maybe_child(self.label.clone().map(|text| { + label() + .text(text) + .font_size(13.) + .font_weight(if is_selected { + FontWeight::MEDIUM + } else { + FontWeight::NORMAL + }) + .color(content_color) + .into_element() + })) + } +} diff --git a/packages/oneclient_app/src/components/splash_curtain.rs b/packages/oneclient_app/src/components/splash_curtain.rs new file mode 100644 index 00000000..7f2ee599 --- /dev/null +++ b/packages/oneclient_app/src/components/splash_curtain.rs @@ -0,0 +1,80 @@ +use std::time::Duration; + +use freya::animation::{AnimNum, Ease, Function, use_animation}; +use freya::prelude::*; + +use crate::hooks::use_splash; +use crate::theme::colors; + +const FADE_MS: u64 = 460; +/// Safety net reveal the app even if the "ready" signal never arrives +const FALLBACK_MS: u64 = 8000; + +#[derive(PartialEq)] +pub struct SplashCurtain; + +impl Component for SplashCurtain { + fn render(&self) -> impl IntoElement { + let splash = use_splash(); + + if !*splash.active.read() { + return rect().into_element(); + } + + // Fresh mount so `CurtainFade`'s animation and timers restart each time + CurtainFade.into_element() + } +} + +#[derive(PartialEq)] +struct CurtainFade; + +impl Component for CurtainFade { + fn render(&self) -> impl IntoElement { + let splash = use_splash(); + let ready = *splash.home_ready.read(); + + let fade = use_animation(|_| { + AnimNum::new(1.0, 0.0) + .time(FADE_MS) + .ease(Ease::Out) + .function(Function::Cubic) + }); + + // Force-reveal after a while if home never reports ready + use_hook(move || { + let mut home_ready = splash.home_ready; + spawn(async move { + tokio::time::sleep(Duration::from_millis(FALLBACK_MS)).await; + if !*home_ready.peek() { + home_ready.set(true); + } + }); + }); + + use_side_effect_with_deps(&ready, move |&ready| { + if !ready { + return; + } + let mut fade = fade; + fade.start(); + let mut active = splash.active; + spawn(async move { + tokio::time::sleep(Duration::from_millis(FADE_MS + 40)).await; + active.set(false); + }); + }); + + let opacity = fade.get().value(); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_global().top(0.).left(0.)) + .layer(Layer::Overlay) + .opacity(opacity) + .background(colors::page()) + .window_drag() + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/status_bar.rs b/packages/oneclient_app/src/components/status_bar.rs new file mode 100644 index 00000000..58dd86c4 --- /dev/null +++ b/packages/oneclient_app/src/components/status_bar.rs @@ -0,0 +1,197 @@ +use std::collections::HashSet; + +use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation}; +use freya::prelude::*; +use oneclient_net::status::{self, ServiceStatus}; + +use crate::components::{Icon, IconType}; +use crate::theme::colors; + +const BAR_HEIGHT: f32 = 34.; +const AMBER: Color = Color::from_rgb(191, 122, 26); + +/// `Layer::RelativeOverlay(n)` saturates at `n = 16` putting children on the same +/// (unordered) layer as the bar background +/// 14 leaves headroom above every popup +const BAR_LAYER: u8 = 14; + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Issue { + NoInternet, + McAuthDown, + PolyfrostDown, +} + +impl Issue { + fn is_active(self, s: &ServiceStatus) -> bool { + match self { + Self::NoInternet => !s.online, + Self::McAuthDown => s.online && !s.mc_auth_up, + Self::PolyfrostDown => s.online && !s.polyfrost_up, + } + } + + fn message(self) -> &'static str { + match self { + Self::NoInternet => "No internet connection.", + Self::McAuthDown => { + "Minecraft authentication servers are unreachable. Logging in may fail." + } + Self::PolyfrostDown => "Polyfrost services are experiencing issues.", + } + } + + fn icon(self) -> IconType { + match self { + Self::NoInternet => IconType::Globe01, + Self::McAuthDown => IconType::AlertTriangle, + Self::PolyfrostDown => IconType::AlertCircle, + } + } + + fn background(self) -> Color { + match self { + Self::NoInternet => colors::danger(), + Self::McAuthDown | Self::PolyfrostDown => AMBER, + } + } + + fn closeable(self) -> bool { + !matches!(self, Self::NoInternet) + } +} + +fn active_issues(s: &ServiceStatus) -> Vec { + [Issue::NoInternet, Issue::McAuthDown, Issue::PolyfrostDown] + .into_iter() + .filter(|i| i.is_active(s)) + .collect() +} + +#[derive(PartialEq)] +pub struct StatusBar; + +impl Component for StatusBar { + fn render(&self) -> impl IntoElement { + let mut status = use_state(status::current); + let mut dismissed = use_state(HashSet::::new); + + use_hook(move || { + status::request_recheck(); + let mut rx = status::subscribe(); + spawn(async move { + while rx.changed().await.is_ok() { + status.set(*rx.borrow()); + } + }); + }); + + use_side_effect(move || { + let s = *status.read(); + let cur = dismissed.peek().clone(); + let next: HashSet = cur.iter().copied().filter(|i| i.is_active(&s)).collect(); + if next != cur { + dismissed.set(next); + } + }); + + let s = *status.read(); + let dset = dismissed.read(); + let visible = active_issues(&s).into_iter().find(|i| !dset.contains(i)); + + match visible { + Some(issue) => StatusBanner { issue, dismissed }.into_element(), + None => rect().into_element(), + } + } +} + +#[derive(PartialEq)] +struct StatusBanner { + issue: Issue, + dismissed: State>, +} + +impl Component for StatusBanner { + fn render(&self) -> impl IntoElement { + let issue = self.issue; + let mut dismissed = self.dismissed; + + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.) + .time(260) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let p = intro.get().value(); + + let mut close_hover = use_state(|| false); + + let close = issue.closeable().then(|| { + rect() + .center() + .width(Size::px(22.)) + .height(Size::px(22.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if *close_hover.read() { + Color::WHITE.with_a(46) + } else { + Color::TRANSPARENT + }) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| close_hover.set(true)) + .on_pointer_leave(move |_| close_hover.set(false)) + .on_press(move |_| { + dismissed.write().insert(issue); + }) + .child(Icon::new(IconType::XClose).size(14.).color(Color::WHITE)) + .into_element() + }); + + // Flex spacers centre the message absolute positioning pinned the close button + // to the top of the content box inside the bar's padding + let leading = rect().width(Size::flex(1.0)).height(Size::fill()); + + let message = rect() + .horizontal() + .height(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .layer(Layer::OverlayLevel(u8::MAX - 20)) + .child(Icon::new(issue.icon()).size(15.).color(Color::WHITE)) + .child( + label() + .text(issue.message()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(Color::WHITE), + ); + + let trailing = rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .horizontal() + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .layer(Layer::Relative(1)) + .maybe_child(close); + + rect() + .width(Size::window_percent(100.)) + .height(Size::px(BAR_HEIGHT)) + .position(Position::new_global().bottom(0.).left(0.)) + .layer(Layer::OverlayLevel(BAR_LAYER)) + .background(issue.background()) + .opacity(p) + .horizontal() + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(0., 10.)) + .child(leading) + .child(message) + .child(trailing) + } +} diff --git a/packages/oneclient_app/src/components/tab_bar.rs b/packages/oneclient_app/src/components/tab_bar.rs new file mode 100644 index 00000000..636748fe --- /dev/null +++ b/packages/oneclient_app/src/components/tab_bar.rs @@ -0,0 +1,224 @@ +use freya::prelude::*; + +use crate::theme::colors; + +pub struct TabItem { + label: String, + active: bool, + count: Option, + on_press: Option>>, +} + +impl TabItem { + pub fn new(label: impl Into, active: bool) -> Self { + Self { + label: label.into(), + active, + count: None, + on_press: None, + } + } + + #[allow(dead_code)] + pub fn count_text(mut self, text: impl Into) -> Self { + self.count = Some(text.into()); + self + } + + pub fn on_press(mut self, handler: impl Into>>) -> Self { + self.on_press = Some(handler.into()); + self + } +} + +pub struct TabBar { + tabs: Vec, + width: Size, + height: Size, + spacing: f32, + font_size: f32, +} + +impl Default for TabBar { + fn default() -> Self { + Self::new() + } +} + +impl TabBar { + pub fn new() -> Self { + Self { + tabs: Vec::new(), + width: Size::fill(), + height: Size::fill(), + spacing: 24., + font_size: 12., + } + } + + pub fn tabs(mut self, tabs: impl IntoIterator) -> Self { + self.tabs = tabs.into_iter().collect(); + self + } + + pub fn width(mut self, width: Size) -> Self { + self.width = width; + self + } + + pub fn height(mut self, height: Size) -> Self { + self.height = height; + self + } + + pub fn spacing(mut self, spacing: f32) -> Self { + self.spacing = spacing; + self + } + + pub fn font_size(mut self, font_size: f32) -> Self { + self.font_size = font_size; + self + } +} + +fn tab_label(text: &str, font_size: f32, active: bool) -> impl IntoElement { + const ACTIVE_WEIGHT: FontWeight = FontWeight::MEDIUM; + + rect() + .center() + .content(Content::Fit) + .child( + label() + .text(text.to_string()) + .font_size(font_size) + .font_weight(ACTIVE_WEIGHT) + .color(Color::TRANSPARENT) + .text_align(TextAlign::Center), + ) + .child( + rect() + .position( + Position::new_absolute() + .top(0.) + .left(0.) + .right(0.) + .bottom(0.), + ) + .center() + .child( + label() + .text(text.to_string()) + .font_size(font_size) + .font_weight(if active { + ACTIVE_WEIGHT + } else { + FontWeight::NORMAL + }) + .color(colors::fg_primary()) + .text_align(TextAlign::Center), + ), + ) +} + +fn count_pill(count: &str, font_size: f32) -> impl IntoElement { + rect() + .center() + .padding(Gaps::new_symmetric(1., 6.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::component_bg()) + .child( + label() + .text(count.to_string()) + .font_size((font_size - 3.).max(8.)) + .color(colors::fg_secondary()), + ) +} + +impl IntoElement for TabBar { + fn into_element(self) -> Element { + let font_size = self.font_size; + + rect() + .horizontal() + .width(self.width) + .height(self.height) + .spacing(self.spacing) + .cross_align(Alignment::Center) + .content(Content::Fit) + .children(self.tabs.into_iter().map(|tab| { + TabButton { + label: tab.label, + active: tab.active, + count: tab.count, + font_size, + on_press: tab.on_press, + } + .into_element() + })) + .into_element() + } +} + +#[derive(PartialEq)] +struct TabButton { + label: String, + active: bool, + count: Option, + font_size: f32, + on_press: Option>>, +} + +impl Component for TabButton { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let active = self.active; + let font_size = self.font_size; + let underline_on = active || focus().is_focused(); + + let mut el = rect() + .vertical() + .content(Content::Fit) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer); + + if let Some(handler) = self.on_press.clone() { + el = el.on_all_press(move |e| handler.call(e)); + } + + el.child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .content(Content::Fit) + .child( + rect() + .vertical() + .content(Content::Fit) + .child(tab_label(&self.label, font_size, active)) + .child( + rect() + .height(Size::px(1.5)) + .width(Size::fill_minimum()) + .margin(Gaps::new_symmetric(0., 4.0)) + .corner_radius(CornerRadius::new_all(2.)) + .background(if underline_on { + colors::fg_primary() + } else { + Color::TRANSPARENT + }), + ), + ) + .maybe_child( + self.count + .as_ref() + .map(|c| count_pill(c, font_size).into_element()), + ), + ) + } +} diff --git a/packages/oneclient_app/src/components/text_input.rs b/packages/oneclient_app/src/components/text_input.rs new file mode 100644 index 00000000..6fad7509 --- /dev/null +++ b/packages/oneclient_app/src/components/text_input.rs @@ -0,0 +1,178 @@ +use freya::prelude::*; + +use crate::theme::colors; + +const GAP_VERT: f32 = 8.0; +const GAP_HORI: f32 = 12.0; + +fn text_input(value: impl Into>) -> Input { + Input::new(value) + .inner_margin(Gaps::new_symmetric(GAP_VERT, GAP_HORI)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .focus_background(colors::component_bg_pressed()) + .border_fill(colors::component_border()) + .focus_border_fill(colors::component_border_pressed()) + .color(colors::fg_primary()) + .placeholder_color(colors::fg_secondary()) +} + +pub fn validate_number(validator: InputValidator) { + let text = validator.text(); + + let valid = text.is_empty() || text.parse::().is_ok(); + + validator.set_valid(valid); +} + +/// Validates the input if user has entered more MB of memory than his machine has +pub fn validate_memory(validator: InputValidator) { + let text = validator.text(); + + let valid = text.is_empty() + || text + .parse::() + .is_ok_and(|mb| mb <= crate::utils::total_ram_mb()); + + validator.set_valid(valid); +} + +#[derive(Clone, PartialEq)] +pub struct TextInput { + input: Input, + layout: LayoutData, + style: StyleState, + text_style: TextStyleData, + elements: Vec, + key: DiffKey, +} + +#[allow(dead_code)] +impl TextInput { + pub fn new(value: impl Into>) -> Self { + Self::from_input(text_input(value)) + } + + pub fn from_input(input: Input) -> Self { + Self { + input, + layout: LayoutData::default(), + style: StyleState::default(), + text_style: TextStyleData::default(), + elements: Vec::new(), + key: DiffKey::None, + } + .font_size(14.) + } + + pub fn enabled(mut self, enabled: impl Into) -> Self { + self.input = self.input.enabled(enabled); + self + } + + pub fn placeholder(mut self, placeholder: impl Into>) -> Self { + self.input = self.input.placeholder(placeholder); + self + } + + pub fn on_validate(mut self, on_validate: impl Into>) -> Self { + self.input = self.input.on_validate(on_validate); + self + } + + pub fn on_submit(mut self, on_submit: impl Into>) -> Self { + self.input = self.input.on_submit(on_submit); + self + } + + pub fn mode(mut self, mode: InputMode) -> Self { + self.input = self.input.mode(mode); + self + } + + pub fn auto_focus(mut self, auto_focus: impl Into) -> Self { + self.input = self.input.auto_focus(auto_focus); + self + } + + pub fn leading(mut self, leading: impl Into) -> Self { + self.input = self.input.leading(leading); + self + } + + pub fn trailing(mut self, trailing: impl Into) -> Self { + self.input = self.input.trailing(trailing); + self + } + + pub fn on_pre_key_down( + mut self, + on_pre_key_down: impl Into, bool>>, + ) -> Self { + self.input = self.input.on_pre_key_down(on_pre_key_down); + self + } +} + +impl ChildrenExt for TextInput { + fn get_children(&mut self) -> &mut Vec { + &mut self.elements + } +} + +impl KeyExt for TextInput { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl LayoutExt for TextInput { + fn get_layout(&mut self) -> &mut LayoutData { + &mut self.layout + } +} + +impl TextStyleExt for TextInput { + fn get_text_style_data(&mut self) -> &mut TextStyleData { + &mut self.text_style + } +} + +impl ContainerExt for TextInput {} +impl ContainerWithContentExt for TextInput {} + +impl StyleExt for TextInput { + fn get_style(&mut self) -> &mut StyleState { + &mut self.style + } +} + +impl Component for TextInput { + fn render(&self) -> impl IntoElement { + let mut card = rect() + .layout(self.layout.clone()) + .text_style(self.text_style.clone()) + .children(self.elements.clone()); + + card.get_style().clone_from(&self.style); + + card.child( + self.input + .clone() + .map(self.text_style.text_align, |mut el, text_align| { + el = el.text_align(text_align); + + if text_align == TextAlign::Center { + el = el.inner_margin(Gaps::new_symmetric(GAP_VERT, 0.)); + } + + el + }) + .width(Size::fill()), + ) + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) + } +} diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs new file mode 100644 index 00000000..b7d98eeb --- /dev/null +++ b/packages/oneclient_app/src/components/toasts.rs @@ -0,0 +1,477 @@ +use std::{cell::Cell, rc::Rc, time::Instant}; + +use freya::{ + animation::{ + AnimNum, Ease, Function, OnChange, OnCreation, use_animation, + use_animation_with_dependencies, + }, + prelude::*, +}; +use oneclient_events::Level; + +use crate::{ + 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.; +const TRAVEL: f32 = 320.; +const TTL_MS: f32 = crate::notifications::MESSAGE_TOAST_TTL.as_millis() as f32; +const TTL_STEP: std::time::Duration = std::time::Duration::from_millis(100); +/// Below the navbar a smaller offset let the startup progress toast cover the nav links +const TOP_PX: f32 = crate::theme::NAVBAR_HEIGHT_PX + 12.; + +#[derive(Clone, Copy, PartialEq)] +enum Variant { + Progress, + Action, + Message, +} + +impl Variant { + fn of(entry: &InboxEntry) -> Self { + if entry.is_loading && entry.progress.is_some() { + Self::Progress + } else if !entry.actions.is_empty() { + Self::Action + } else { + Self::Message + } + } +} + +fn entry_icon(entry: &InboxEntry) -> (IconType, Color) { + if entry.is_loading && entry.progress.is_some() { + return (IconType::FolderDownload, colors::fg_primary()); + } + + match entry.level { + Level::Error => (IconType::AlertTriangle, colors::danger()), + Level::Info => (IconType::DownloadCloud02, colors::fg_primary()), + } +} + +#[derive(PartialEq)] +pub struct Toasts; + +impl Component for Toasts { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + // While any toast is hovered every toast's timer pauses + let hovering = use_state(|| 0usize); + + let entries: Vec = snapshot + .active_toast_entry_ids + .iter() + .filter_map(|id| snapshot.inbox.iter().find(|e| e.id == *id).cloned()) + .collect(); + + if entries.is_empty() { + return rect().into_element(); + } + + let mut stack = rect() + .vertical() + .spacing(12.) + .position(Position::new_global().top(TOP_PX).right(24.)) + .layer(Layer::OverlayLevel(3)); + + for entry in entries { + let id = entry.id; + stack = stack.child( + ToastCard { + entry, + hovering, + key: DiffKey::None, + } + .key(id), + ); + } + + stack.into_element() + } +} + +#[derive(PartialEq)] +struct ToastCard { + entry: InboxEntry, + hovering: State, + key: DiffKey, +} + +impl KeyExt for ToastCard { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for ToastCard { + fn render(&self) -> impl IntoElement { + let entry = self.entry.clone(); + let id = entry.id; + let variant = Variant::of(&entry); + let dispatch = use_dispatch(); + + let slide = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(TRAVEL, 0.) + .time(320) + .function(Function::Cubic) + .ease(Ease::Out) + }); + let fade = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(260).ease(Ease::Out) + }); + + let animations = use_animations_enabled(); + let paused = *self.hovering.read() > 0; + let mut was_paused = use_state(|| false); + let mut from_pct = use_state(|| 100.); + let mut run_start = use_state(Instant::now); + let mut wall_pct = use_state(|| 100.); + + if paused != *was_paused.peek() { + if paused { + let remaining = remaining_pct(*from_pct.peek(), *run_start.peek()); + from_pct.set(remaining); + wall_pct.set(remaining); + } else { + run_start.set(Instant::now()); + } + was_paused.set(paused); + } + + let mut ticker = use_state(|| None::); + use_side_effect_with_deps(&(animations, paused), move |&(animations, paused)| { + if animations || paused { + ticker.set(None); + return; + } + let handle = spawn(async move { + let platform = Platform::get(); + loop { + wall_pct.set(remaining_pct(*from_pct.peek(), *run_start.peek())); + platform.send(UserEvent::RequestRedraw); + tokio::time::sleep(TTL_STEP).await; + } + }); + ticker.set(Some(handle.owned())); + }); + + let ttl_bar_anim = + use_animation_with_dependencies(&(paused, *from_pct.peek()), |conf, deps| { + let &(paused, from) = deps; + conf.on_creation(if paused { + OnCreation::Nothing + } else { + OnCreation::Run + }); + conf.on_change(if paused { + OnChange::Reset + } else { + OnChange::Rerun + }); + AnimNum::new(from, 0.).time(((TTL_MS * from / 100.) as u64).max(1)) + }); + + let offset = slide.read().value(); + let opacity = fade.read().value(); + let ttl_pct = if animations { + ttl_bar_anim.read().value() + } else { + *wall_pct.read() + }; + + let mut hovering = self.hovering; + let hover_flag = use_hook(|| Rc::new(Cell::new(false))); + let (over_flag, out_flag, drop_flag) = + (hover_flag.clone(), hover_flag.clone(), hover_flag.clone()); + let over_dispatch = dispatch.clone(); + let out_dispatch = dispatch.clone(); + let drop_dispatch = dispatch.clone(); + + use_drop(move || { + if drop_flag.replace(false) { + let count = hovering.peek().saturating_sub(1); + hovering.set(count); + if count == 0 { + drop_dispatch.resume_toasts(); + } + } + }); + + let card = match variant { + Variant::Progress => progress_card(&entry), + Variant::Action => action_card(&entry, dispatch.clone(), id, ttl_pct), + Variant::Message => message_card(&entry, dispatch, id, ttl_pct), + }; + + rect() + .width(Size::px(TOAST_W)) + .offset_x(offset) + .opacity(opacity) + // `enter`/`leave` are exclusive to the deepest node so the close button would + // read as leaving the toast + .on_pointer_over(move |_| { + if over_flag.replace(true) { + return; + } + let count = *hovering.peek() + 1; + hovering.set(count); + if count == 1 { + over_dispatch.pause_toasts(); + } + }) + .on_pointer_out(move |_| { + if !out_flag.replace(false) { + return; + } + let count = hovering.peek().saturating_sub(1); + hovering.set(count); + if count == 0 { + out_dispatch.resume_toasts(); + } + }) + .child(card) + } +} + +fn remaining_pct(from_pct: f32, run_start: Instant) -> f32 { + let elapsed_pct = run_start.elapsed().as_secs_f32() * 1000. / TTL_MS * 100.; + (from_pct - elapsed_pct).clamp(0., 100.) +} + +fn toast_shell(entry: &InboxEntry) -> Rect { + let border_color = match entry.level { + Level::Error => colors::danger(), + Level::Info => colors::component_border(), + }; + rect() + .width(Size::px(TOAST_W)) + .background(colors::component_bg()) + .corner_radius(CornerRadius::new_all(12.)) + .overflow(Overflow::Clip) + .border( + Border::new() + .fill(border_color) + .width(1.) + .alignment(BorderAlignment::Inner), + ) +} + +fn icon_badge(entry: &InboxEntry) -> impl IntoElement { + let (icon, color) = entry_icon(entry); + rect() + .width(Size::px(32.)) + .height(Size::px(32.)) + .corner_radius(CornerRadius::new_all(6.)) + .center() + .child(Icon::new(entry.icon.unwrap_or(icon)).size(24.).color(color)) +} + +fn header(entry: &InboxEntry) -> impl IntoElement { + let title_color = match entry.level { + Level::Error => colors::danger(), + Level::Info => colors::fg_primary(), + }; + let body_color = colors::fg_secondary(); + + rect() + .vertical() + .width(Size::flex(1.0)) + .main_align(Alignment::Center) + .spacing(2.) + .child( + label() + .text(entry.title.clone()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(title_color), + ) + .child( + label() + .text(entry.body.clone()) + .font_size(12.) + .color(body_color), + ) +} + +fn ttl_bar(entry: &InboxEntry, pct: f32) -> impl IntoElement { + let color = match entry.level { + Level::Error => colors::danger(), + Level::Info => colors::brand(), + }; + rect() + .width(Size::fill()) + .height(Size::px(4.)) + .background(color.with_a(60)) + .child( + rect() + .width(Size::percent(pct.clamp(0.0, 100.0))) + .height(Size::px(4.)) + .corner_radius(CornerRadius::new_all(60.)) + .background(color), + ) +} + +fn close_button(dispatch: crate::Actions, id: u64) -> impl IntoElement { + rect() + .position(Position::new_absolute().top(8.).right(8.)) + .child( + Button::new() + .ghost() + .size(ButtonSize::Icon) + .width(Size::px(28.)) + .height(Size::px(28.)) + .on_press(move |_| dispatch.dismiss_toast(id)) + .child( + Icon::new(IconType::XClose) + .size(16.) + .color(colors::fg_secondary()), + ), + ) +} + +fn message_card(entry: &InboxEntry, dispatch: crate::Actions, id: u64, ttl_pct: f32) -> Rect { + toast_shell(entry) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new(15., 40., 17., 15.)) + .spacing(16.) + .cross_align(Alignment::Center) + .child(icon_badge(entry)) + .child(header(entry)), + ) + .child(close_button(dispatch, id)) + .child(ttl_bar(entry, ttl_pct)) +} + +fn action_card(entry: &InboxEntry, dispatch: crate::Actions, id: u64, ttl_pct: f32) -> Rect { + let action = entry.actions.first().cloned(); + + toast_shell(entry) + .child( + rect() + .vertical() + .width(Size::fill()) + .padding(Gaps::new(15., 15., 12., 15.)) + .spacing(10.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .spacing(16.) + .cross_align(Alignment::Center) + .child(icon_badge(entry)) + .child(header(entry)), + ) + .child(divider()) + .child(action_row(action, dispatch, id)), + ) + .child(ttl_bar(entry, ttl_pct)) +} + +fn action_row( + action: Option, + dispatch: crate::Actions, + id: u64, +) -> impl IntoElement { + let dismiss = dispatch.clone(); + let review_label = action + .as_ref() + .map(|a| a.label.clone()) + .unwrap_or_else(|| "Review".to_string()); + let review_kind = action.map(|a| a.kind); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(8.) + .child( + Button::new() + .small() + .secondary() + .width(Size::flex(1.0)) + .on_press(move |_| dismiss.dismiss_toast(id)) + .child(Icon::new(IconType::XClose).size(16.)) + .text("Dismiss"), + ) + .child( + Button::new() + .small() + .primary() + .width(Size::flex(1.0)) + .on_press(move |_| match &review_kind { + Some(kind) => run_action(&dispatch, kind), + None => dispatch.mark_notification_read(id), + }) + .child(Icon::new(IconType::Eye).size(16.)) + .text(review_label), + ) +} + +fn run_action(dispatch: &crate::Actions, kind: &NotificationActionKind) { + match kind { + NotificationActionKind::OpenClusterUpdate(summaries) => { + dispatch.open_cluster_update(summaries.clone()); + } + } +} + +fn progress_card(entry: &InboxEntry) -> Rect { + let frac = entry + .progress + .map(|(cur, total)| cur as f32 / total.max(1) as f32) + .unwrap_or(0.0) + .clamp(0.0, 1.0); + + toast_shell(entry).child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new(12., 14., 12., 14.)) + .spacing(13.) + .cross_align(Alignment::Start) + .child(icon_badge(entry)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(entry.title.clone()) + .font_size(14.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(entry.body.clone()) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + progress_track( + frac * 100.0, + 6., + colors::brand(), + colors::brand().with_a(128), + ) + .margin(Gaps::new(8., 0., 0., 0.)), + ), + ), + ) +} diff --git a/packages/oneclient_app/src/components/toggle.rs b/packages/oneclient_app/src/components/toggle.rs new file mode 100644 index 00000000..30adb1d7 --- /dev/null +++ b/packages/oneclient_app/src/components/toggle.rs @@ -0,0 +1,147 @@ +use freya::{ + animation::{AnimColor, AnimNum, Ease, Function, use_animation_transition}, + prelude::*, +}; + +use crate::{theme::colors, ui}; + +const WIDTH: f32 = 40.; +const HANDLE_SIZE: f32 = 16.; +const TIME: u64 = 180; + +pub fn toggle(value: State) -> impl IntoElement { + let mut v = value; + Switch { + value, + on_press: (move |()| v.toggle()).into(), + disabled: false, + } +} + +#[allow(dead_code)] +pub fn toggle_disabled(value: State) -> impl IntoElement { + Switch { + value, + on_press: (|()| {}).into(), + disabled: true, + } +} + +pub fn toggle_controlled(on: bool, on_toggle: EventHandler<()>) -> ToggleControlled { + ToggleControlled { on, on_toggle } +} + +#[derive(PartialEq)] +pub struct ToggleControlled { + on: bool, + on_toggle: EventHandler<()>, +} + +impl Component for ToggleControlled { + fn render(&self) -> impl IntoElement { + let mut value = use_state(|| self.on); + value.set_if_modified(self.on); + Switch { + value, + on_press: self.on_toggle.clone(), + disabled: false, + } + } +} + +#[derive(PartialEq)] +struct Switch { + value: State, + on_press: EventHandler<()>, + disabled: bool, +} + +impl Component for Switch { + fn render(&self) -> impl IntoElement { + let value = self.value; + let on_press = self.on_press.clone(); + let on = *value.read(); + let disabled = self.disabled; + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let knob_align = if on { Alignment::End } else { Alignment::Start }; + + let track_border = if focus().is_focused() && !disabled { + colors::fg_primary() + } else { + colors::component_border() + }; + + let background = use_animation_transition(value, |_, on| { + if on { + AnimColor::new(colors::component_bg(), colors::brand()) + } else { + AnimColor::new(colors::brand(), colors::component_bg()) + } + .time(TIME) + .function(Function::Expo) + .ease(Ease::Out) + }); + + let right_offset = use_animation_transition(value, |_, on| { + if on { + AnimNum::new(WIDTH - HANDLE_SIZE - 6., 0.) + } else { + AnimNum::new(0., WIDTH - HANDLE_SIZE - 6.) + } + .time(TIME) + .function(Function::Expo) + .ease(Ease::Out) + }); + + let text = if on { "On" } else { "Off" }; + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .a11y_id(a11y_id) + .a11y_focusable(!disabled) + .a11y_role(AccessibilityRole::Button) + .maybe(!disabled, |el| { + el.cursor(CursorIcon::Pointer).on_all_press(move |_| { + on_press.call(()); + }) + }) + .child( + label() + .text(text) + .font_size(12.) + .color(Color::from_rgb(167, 176, 190)), + ) + .child( + rect() + .width(Size::px(WIDTH)) + .height(Size::px(22.)) + .corner_radius(CornerRadius::new_all(11.)) + .padding(Gaps::new_all(3.)) + .main_align(knob_align) + .cross_align(Alignment::Center) + .background(if disabled { + colors::component_bg_disabled() + } else { + background.read().value() + }) + .border(ui::border_all_color(1., track_border)) + .child( + rect() + .width(Size::px(HANDLE_SIZE)) + .height(Size::px(HANDLE_SIZE)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if disabled { + colors::fg_secondary() + } else { + Color::WHITE + }) + .position(Position::new_absolute().right(right_offset.read().value())), + ), + ) + } +} diff --git a/packages/oneclient_app/src/components/update_prompt.rs b/packages/oneclient_app/src/components/update_prompt.rs new file mode 100644 index 00000000..b07a42ae --- /dev/null +++ b/packages/oneclient_app/src/components/update_prompt.rs @@ -0,0 +1,114 @@ +use freya::prelude::*; +use oneclient_events::Answer; + +use crate::updater::UPDATE_CHOICE_INSTALL; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{use_dispatch, use_notifications_snapshot}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); + +#[derive(PartialEq)] +pub struct UpdatePromptOverlay; + +impl Component for UpdatePromptOverlay { + fn render(&self) -> impl IntoElement { + let snapshot = use_notifications_snapshot(); + let dispatch = use_dispatch(); + + let Some(prompt) = snapshot.pending_prompt.clone() else { + return rect().into_element(); + }; + + // Recognised by the choice it offers so the event layer needn't enumerate prompts + if !prompt.has_choice(UPDATE_CHOICE_INSTALL) { + return rect().into_element(); + } + + let dismiss_label = prompt + .dismiss + .clone() + .unwrap_or_else(|| "Not now".to_string()); + let install_label = prompt + .choice(UPDATE_CHOICE_INSTALL) + .map(|choice| choice.label.clone()) + .unwrap_or_else(|| "Download".to_string()); + + let close = dispatch.clone(); + let cancel = dispatch.clone(); + let accept = dispatch.clone(); + + OverlayPopup::new() + .on_close(move |_| close.dismiss_prompt()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::DownloadCloud02) + .size(20.) + .color(colors::brand()), + ) + .child( + label() + .text(prompt.title.clone()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(prompt.question.clone()) + .font_size(12.) + .max_lines(4) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| cancel.dismiss_prompt()) + .text(dismiss_label), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + accept.answer_prompt(Answer::new( + UPDATE_CHOICE_INSTALL, + )) + }) + .child(Icon::new(IconType::DownloadCloud02).size(14.)) + .text(install_label), + ), + ), + ), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/components/upload_mclogs.rs b/packages/oneclient_app/src/components/upload_mclogs.rs new file mode 100644 index 00000000..ac5d2ffa --- /dev/null +++ b/packages/oneclient_app/src/components/upload_mclogs.rs @@ -0,0 +1,67 @@ +use freya::prelude::*; +use freya::query::MutationStateData; +use freya::text_edit::Clipboard; + +use crate::components::{Button, Icon, IconType}; +use crate::hooks::{UseUploadLog, use_dispatch}; +use crate::view::app::cluster::logs::Confirm; + +#[derive(PartialEq)] +pub struct UploadToMclogs { + has_log: bool, + confirm: State>, +} + +impl UploadToMclogs { + pub fn new(has_log: bool, confirm: State>) -> Self { + Self { has_log, confirm } + } +} + +impl Component for UploadToMclogs { + fn render(&self) -> impl IntoElement { + let mut confirm = self.confirm; + + Button::new() + .secondary() + .enabled(self.has_log) + .on_press(move |_| confirm.set(Some(Confirm::Upload))) + .child(Icon::new(IconType::LinkExternal01).size(15.)) + .text("Upload to mclo.gs") + } +} + +pub fn use_mclogs_feedback(upload: UseUploadLog) { + let dispatch = use_dispatch(); + let mut handled = use_state(|| None::); + + use_side_effect(move || match &*upload.read().state() { + MutationStateData::Settled { + res: Ok(result), .. + } => { + if handled.peek().as_deref() == Some(result.url.as_str()) { + return; + } + + handled.set(Some(result.url.clone())); + let _ = Clipboard::set(result.url.clone()); + + dispatch + .notify("Uploaded to mclo.gs") + .body(format!("{} (copied to clipboard)", result.url)) + .info() + .icon(IconType::LinkExternal01) + .send(); + } + MutationStateData::Settled { res: Err(err), .. } => { + let msg = err.to_string(); + if handled.peek().as_deref() == Some(msg.as_str()) { + return; + } + + handled.set(Some(msg.clone())); + dispatch.notify("Upload failed").body(msg).error().send(); + } + _ => {} + }); +} diff --git a/packages/oneclient_app/src/components/version_card.rs b/packages/oneclient_app/src/components/version_card.rs new file mode 100644 index 00000000..8695627a --- /dev/null +++ b/packages/oneclient_app/src/components/version_card.rs @@ -0,0 +1,158 @@ +use freya::prelude::*; +use oneclient_common::VersionKey; +use oneclient_core::clusters::Cluster; + +use crate::components::ClusterLandscapeArt; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::{ReleaseLine, line_art_key, line_title, loader_tags}; + +const CARD_HEIGHT_PX: f32 = 240.; + +pub struct VersionCard { + pub line: ReleaseLine, + pub art_key: Option, + pub tags: Vec, + pub title: String, + pub selected: bool, + pub on_press: EventHandler>, +} + +impl VersionCard { + pub fn new( + line: ReleaseLine, + clusters: &[Cluster], + selected: bool, + on_press: impl Into>>, + ) -> Self { + Self { + line, + art_key: line_art_key(line, clusters), + tags: loader_tags(clusters), + title: line_title(line, clusters), + selected, + on_press: on_press.into(), + } + } +} + +impl PartialEq for VersionCard { + fn eq(&self, other: &Self) -> bool { + self.line == other.line + && self.art_key == other.art_key + && self.tags == other.tags + && self.title == other.title + && self.selected == other.selected + } +} + +impl Component for VersionCard { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let selected = self.selected; + let hovered = *hovering.read(); + let focused = focus().is_focused(); + let on_press = self.on_press.clone(); + + let opacity = if selected || hovered || focused { + if selected { 1.0 } else { 0.85 } + } else { + 0.6 + }; + + let border_color = if selected || focused { + colors::brand() + } else if hovered { + colors::component_border_hover() + } else { + colors::component_border() + }; + + rect() + .key(self.line) + .width(Size::flex(1.0)) + .height(Size::px(CARD_HEIGHT_PX)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .on_all_press(move |e| on_press.call(e)) + .on_pointer_enter(move |_| { + *hovering.write() = true; + }) + .on_pointer_leave(move |_| { + *hovering.write() = false; + }) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .corner_radius(CornerRadius::new_all(12.)) + .opacity(opacity) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .child(ClusterLandscapeArt::for_version( + self.line.major, + self.art_key, + None, + false, + )), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .padding(Gaps::new_symmetric(12., 16.)) + .main_align(Alignment::SpaceBetween) + .corner_radius(CornerRadius::new_all(12.)) + .cross_align(Alignment::Start) + .border( + border_all_color(1., border_color) + .alignment(BorderAlignment::Inner), + ) + .layer(Layer::Relative(3)) + .background( + LinearGradient::new() + .angle(0.) + .stop((Color::from_af32rgb(0.8, 0, 0, 0), 0.)) + .stop((Color::from_af32rgb(0.3, 0, 0, 0), 20.)) + .stop((Color::from_af32rgb(0.3, 0, 0, 0), 60.)) + .stop((Color::from_af32rgb(0.8, 0, 0, 0), 100.)), + ) + .child( + rect() + .horizontal() + .spacing(8.) + .children(self.tags.iter().map(|tag| { + rect() + .padding(Gaps::new_symmetric(4., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(colors::fg_primary()) + .child( + label() + .text(tag.clone()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::brand()), + ) + .into_element() + })), + ) + .child( + label() + .text(format!("Version {}", self.title)) + .font_size(32.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ), + ) + } +} diff --git a/packages/oneclient_app/src/constants.rs b/packages/oneclient_app/src/constants.rs new file mode 100644 index 00000000..0bfe6067 --- /dev/null +++ b/packages/oneclient_app/src/constants.rs @@ -0,0 +1,7 @@ +pub const WINDOW_APP_ID: &str = "oneclient_app"; +pub const WINDOW_TITLE: &str = "OneClient"; + +pub const UPDATER_PUBKEY: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFGODk3MkMyMjg0MjFDMDUKUldRRkhFSW93bktKSHpkWjNEMXNzaDVINVpCTU8xSnhuK2RnV0dTZ2FkcFJWbG1zUkhGYTNjaUkK"; +pub const UPDATER_ENDPOINT: &str = + "https://github.com/Polyfrost/OneLauncher/releases/latest/download/latest.json"; +pub const RELEASES_URL: &str = "https://github.com/Polyfrost/OneLauncher/releases/latest"; diff --git a/packages/oneclient_app/src/events.rs b/packages/oneclient_app/src/events.rs new file mode 100644 index 00000000..fe924da8 --- /dev/null +++ b/packages/oneclient_app/src/events.rs @@ -0,0 +1,379 @@ +//! Runs under freya's `spawn` not `tokio::spawn` radio state is `!Send` +//! Events are batched and folded because a radio write wakes every subscriber + +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use freya::prelude::spawn_forever; +use freya::radio::RadioStation; +use oneclient_events::{Event, EventReceiver, GameEvent, LaunchStage, ProgressEvent, Signal}; +use tokio::sync::mpsc; + +use crate::hooks::PumpSignal; +use crate::notifications::{MESSAGE_TOAST_TTL, PendingPromptView}; +use crate::state::{AppChannel, AppState, LoginProgress}; + +/// Quiet period before log lines are written without it every line wakes the log view +const GAME_LOG_FLUSH: Duration = Duration::from_millis(120); + +const COALESCE_BUDGET: usize = 1024; + +pub struct EventPump { + pub events: EventReceiver, + pub signals: mpsc::UnboundedReceiver, + pub station: RadioStation, +} + +impl EventPump { + pub async fn run(mut self) { + let mut armed: HashMap = HashMap::new(); + // Hovering any toast pauses every toast including ones arriving while hovering + let mut paused = false; + let mut game_flush: Option = None; + + loop { + let next_deadline = armed + .values() + .filter_map(ToastTimer::deadline) + .min() + .into_iter() + .chain(game_flush) + .min(); + + let timer = async { + match next_deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => std::future::pending::<()>().await, + } + }; + + tokio::select! { + () = timer => { + let now = tokio::time::Instant::now(); + let due: Vec = armed + .iter() + .filter(|(_, t)| t.deadline().is_some_and(|d| d <= now)) + .map(|(id, _)| *id) + .collect(); + + if !due.is_empty() { + { + let mut guard = self.station.write_channel(AppChannel::Notifications); + let AppState { + notifications, inbox, .. + } = &mut **guard; + for id in &due { + notifications.expire_toast(inbox, *id); + } + } + for id in due { + armed.remove(&id); + } + reconcile(&self.station, &mut armed, paused); + } + + if game_flush.is_some_and(|d| d <= now) { + game_flush = None; + // Lines were already folded in on arrival this only ends the quiet period + self.station.write_channel(AppChannel::Game); + } + } + + event = self.events.recv() => { + let Some(first) = event else { break }; + let mut batch = vec![first]; + while batch.len() < COALESCE_BUDGET { + match self.events.try_recv() { + Ok(next) => batch.push(next), + Err(_) => break, + } + } + + let folded = self.apply(batch); + if folded.touched_engine { + reconcile(&self.station, &mut armed, paused); + } + if folded.saw_game_log && game_flush.is_none() { + game_flush = Some(tokio::time::Instant::now() + GAME_LOG_FLUSH); + } + } + + signal = self.signals.recv() => { + let Some(signal) = signal else { break }; + match signal { + PumpSignal::Reconcile => reconcile(&self.station, &mut armed, paused), + PumpSignal::PauseToasts => { + paused = true; + for timer in armed.values_mut() { + timer.pause(); + } + } + PumpSignal::ResumeToasts => { + paused = false; + for timer in armed.values_mut() { + timer.resume(); + } + } + } + } + } + } + } + + fn apply(&mut self, batch: Vec) -> Folded { + let mut folded = Folded::default(); + let mut engine_events = Vec::new(); + let mut stages: Vec<(i64, LaunchStage)> = Vec::new(); + let mut logs: Vec<(i64, String)> = Vec::new(); + let mut failed: Option<(i64, String)> = None; + let mut login: Option> = None; + let mut sync_complete = false; + + for event in batch { + match event { + Event::Signal(Signal::ClustersChanged) => folded.clusters = true, + Event::Signal(Signal::JavaChanged) => folded.java = true, + Event::Signal(Signal::SyncComplete) => { + sync_complete = true; + folded.clusters = true; + } + Event::Game(GameEvent::Stage { cluster_id, stage }) => { + stages.push((cluster_id, stage)); + } + Event::Game(GameEvent::Log { cluster_id, line }) => { + folded.saw_game_log = true; + logs.push((cluster_id, line)); + } + Event::Game(GameEvent::Failed { + cluster_id, + message, + }) => failed = Some((cluster_id, message)), + // Lifted out so it never reaches the engine the sign-in modal renders it inline + Event::Progress(ProgressEvent::Update { + id, + ref label, + current, + total, + }) if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => { + login = Some((current < total).then(|| LoginProgress { + label: label.clone(), + current, + total, + })); + } + other => engine_events.push(other), + } + } + + if !stages.is_empty() || !logs.is_empty() || failed.is_some() { + let mut guard = self.station.write_channel(AppChannel::Game); + for (cluster_id, stage) in stages { + 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())); + } + } + for (cluster_id, line) in logs { + let buffer = guard.game.logs.entry(cluster_id).or_default(); + std::sync::Arc::make_mut(buffer).push(std::sync::Arc::from(line)); + } + if let Some((cluster_id, message)) = failed { + guard.game.stages.insert(cluster_id, LaunchStage::Exited); + guard.game.error = Some(message); + } + } + + if sync_complete { + self.station + .write_channel(AppChannel::Launcher) + .launcher + .fetching = false; + } + + if let Some(progress) = login { + self.station + .write_channel(AppChannel::MicrosoftLogin) + .microsoft_login = progress; + } + + if !engine_events.is_empty() { + folded.touched_engine = true; + let mut guard = self.station.write_channel(AppChannel::Notifications); + let AppState { + notifications, + inbox, + prompt, + .. + } = &mut **guard; + for event in engine_events { + if let (_timers, Some(pending)) = notifications.dispatch(inbox, event) { + *prompt = Some(pending); + } + } + } + + if folded.clusters { + spawn_forever(async move { crate::hooks::invalidate_cluster_queries().await }); + } + if folded.java { + spawn_forever(async move { crate::hooks::invalidate_java_queries().await }); + } + + folded + } +} + +#[derive(Default)] +struct Folded { + touched_engine: bool, + saw_game_log: bool, + clusters: bool, + java: bool, +} + +fn reconcile( + station: &RadioStation, + armed: &mut HashMap, + paused: bool, +) { + let state = station.peek(); + let want: HashSet = state + .notifications + .active_toast_entry_ids() + .into_iter() + .filter(|id| state.inbox.iter().any(|e| e.id == *id && !e.is_loading)) + .collect(); + + for id in &want { + armed + .entry(*id) + .or_insert_with(|| ToastTimer::armed(paused)); + } + armed.retain(|id, _| want.contains(id)); +} + +enum ToastTimer { + Running(tokio::time::Instant), + Paused(Duration), +} + +impl ToastTimer { + fn armed(paused: bool) -> Self { + if paused { + Self::Paused(MESSAGE_TOAST_TTL) + } else { + Self::Running(tokio::time::Instant::now() + MESSAGE_TOAST_TTL) + } + } + + /// `None` while paused + fn deadline(&self) -> Option { + match self { + Self::Running(deadline) => Some(*deadline), + Self::Paused(_) => None, + } + } + + fn pause(&mut self) { + if let Self::Running(deadline) = self { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + *self = Self::Paused(remaining); + } + } + + fn resume(&mut self) { + if let Self::Paused(remaining) = self { + let remaining = *remaining; + *self = Self::Running(tokio::time::Instant::now() + remaining); + } + } +} + +#[must_use] +pub fn prompt_view(state: &AppState) -> Option { + state.prompt.as_ref().map(|prompt| PendingPromptView { + title: prompt.title.clone(), + question: prompt.question.clone(), + choices: prompt.choices.clone(), + dismiss: prompt.dismiss.clone(), + }) +} + +pub async fn start_launcher( + station: RadioStation, + events: oneclient_events::EventBus, +) -> Result<(), anyhow::Error> { + let state = crate::launcher::install(oneclient_core::LauncherState::new(events).await?); + + oneclient_net::status::start(state.services.requester.clone()); + oneclient_polyplus::start(std::sync::Arc::clone(&state.auth)); + oneclient_core::run_startup_tasks(&state); + + let data_dir = oneclient_common::paths::launcher_dir() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + + let settings = state.settings.read().clone(); + let auto_update = settings.auto_update; + + let mut station = station; + { + let mut guard = station.write_channel(AppChannel::Launcher); + guard.launcher = crate::state::LauncherInit { + ready: true, + fetching: true, + syncing_bundles: true, + error: None, + snapshots: 0, + data_dir, + }; + } + { + let mut guard = station.write_channel(AppChannel::Settings); + guard.settings.settings = settings; + guard.settings.status = crate::state::AsyncStatus::Ready; + guard.settings.error = None; + } + + crate::hooks::invalidate_profile_queries().await; + crate::updater::spawn_update_check(auto_update, state.services.events.clone()); + Ok(()) +} + +pub fn report_startup_failure(station: &RadioStation, err: &anyhow::Error) { + let message = err.to_string(); + tracing::error!("launcher init failed: {err:#}"); + + let data_dir = oneclient_common::paths::launcher_dir() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + + let mut station = *station; + { + let mut guard = station.write_channel(AppChannel::Launcher); + guard.launcher.error = Some(message.clone()); + guard.launcher.data_dir = data_dir; + guard.launcher.snapshots = crate::recovery::snapshots().len(); + } + + let mut guard = station.write_channel(AppChannel::Notifications); + let AppState { + notifications, + inbox, + .. + } = &mut **guard; + notifications.dispatch( + inbox, + Event::Notification(oneclient_events::Notification::Message( + oneclient_events::Message { + title: "Launcher failed to start".into(), + body: message, + level: oneclient_events::Level::Error, + }, + )), + ); +} diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs new file mode 100644 index 00000000..2afc0127 --- /dev/null +++ b/packages/oneclient_app/src/hooks/actions.rs @@ -0,0 +1,1438 @@ +//! Core operations go onto freya's UI-thread executor radio state is `!Send` +//! +//! 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::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use freya::prelude::spawn_forever; +use freya::radio::RadioStation; +use oneclient_cluster::{ + ClusterStage, ClusterUpdate, GameSettingsProfile, PackageUpdateMode, ProfileUpdate, +}; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_core::settings::LauncherSettings; +use oneclient_core::settings::store::{save_global_profile, save_settings_and_apply}; +use oneclient_db::models::ClusterId; +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}; + +/// Over-disabling is the cheaper mistake a dead button beats a second game +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); + +#[derive(Debug, Clone, PartialEq)] +enum LaunchUpdatePlan { + Nothing, + Apply(Vec), + Prompt(Vec), +} + +/// Kept pure and separate from the work it describes so the three-way mode is +/// testable without a launcher database or provider +/// +/// `pending` has already had declined versions filtered out by +/// `BrowserUpdateCheck::pending` +fn plan_launch_updates( + mode: PackageUpdateMode, + pending: Vec, +) -> LaunchUpdatePlan { + if pending.is_empty() { + return LaunchUpdatePlan::Nothing; + } + + match mode { + // The cache was already written skipping only affects what is shown + PackageUpdateMode::Skip => LaunchUpdatePlan::Nothing, + PackageUpdateMode::Automatic => LaunchUpdatePlan::Apply(pending), + PackageUpdateMode::Prompt => LaunchUpdatePlan::Prompt(pending), + } +} + +/// The pump alone owns the toast timers so adding or removing a toast has to +/// tell it to re-arm hover-pause is a timer property not component state +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PumpSignal { + Reconcile, + PauseToasts, + ResumeToasts, +} + +#[derive(Clone)] +pub struct Actions { + station: RadioStation, + pump: mpsc::UnboundedSender, + events: oneclient_events::EventBus, +} + +impl Actions { + #[must_use] + pub fn station(&self) -> RadioStation { + self.station + } + + /// A channel handle, so a clone still feeds the pump set up at launch + #[must_use] + pub fn events(&self) -> oneclient_events::EventBus { + self.events.clone() + } + + #[must_use] + pub fn new( + station: RadioStation, + pump: mpsc::UnboundedSender, + events: oneclient_events::EventBus, + ) -> Self { + Self { + station, + pump, + events, + } + } + + fn nudge(&self, signal: PumpSignal) { + let _ = self.pump.send(signal); + } + + fn with_engine(&self, mutate: impl FnOnce(&mut AppState)) { + { + let mut guard = self + .station + .clone() + .write_channel(AppChannel::Notifications); + mutate(&mut guard); + } + self.nudge(PumpSignal::Reconcile); + } + + fn set_settings_error(&self, error: Option) { + self.station + .clone() + .write_channel(AppChannel::Settings) + .settings + .error = error; + } + + pub fn reload_settings(&self) { + let station = self.station; + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + { + let mut guard = station.clone().write_channel(AppChannel::Settings); + guard.settings.status = AsyncStatus::Loading; + guard.settings.error = None; + } + + 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); + + let mut guard = station.clone().write_channel(AppChannel::Settings); + guard.settings.settings = loaded; + guard.settings.status = AsyncStatus::Ready; + guard.settings.error = None; + }); + } + + pub fn save_settings(&self) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + { + let mut guard = actions.station.clone().write_channel(AppChannel::Settings); + guard.settings.saving = true; + guard.settings.error = None; + } + + let settings = state.settings.read().clone(); + let result = save_settings_and_apply(&state.services, &settings).await; + + let mut guard = actions.station.clone().write_channel(AppChannel::Settings); + guard.settings.error = result.err().map(|err| err.to_string()); + guard.settings.saving = false; + guard.settings.status = AsyncStatus::Ready; + }); + } + + fn mutate_settings( + &self, + mutate: impl FnOnce(&mut LauncherSettings), + ) -> Option { + let state = launcher::state().ok()?; + let updated = { + let mut lock = state.settings.write(); + mutate(&mut lock); + lock.clone() + }; + + let mut guard = self.station.clone().write_channel(AppChannel::Settings); + guard.settings.settings = updated.clone(); + guard.settings.status = AsyncStatus::Ready; + Some(updated) + } + + fn persist(&self, settings: LauncherSettings) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + if let Err(err) = save_settings_and_apply(&state.services, &settings).await { + actions.set_settings_error(Some(err.to_string())); + } + }); + } + + pub fn set_settings(&self, settings: LauncherSettings) { + let Some(updated) = self.mutate_settings(|s| *s = settings) else { + return; + }; + if let Ok(state) = launcher::state() { + state.discord.set_enabled(updated.discord_enabled); + } + self.persist(updated); + } + + /// Not persisted the next real save carries the seen versions along + pub fn record_seen_version(&self, version: impl Into) { + let version = version.into(); + self.mutate_settings(|settings| { + if !settings.seen_versions.iter().any(|v| v == &version) { + settings.seen_versions.push(version); + } + }); + } + + pub fn set_seen_changelog_version(&self, version: impl Into) { + let version = version.into(); + let mut changed = false; + let updated = self.mutate_settings(|settings| { + if settings.seen_changelog_version.as_deref() != Some(version.as_str()) { + settings.seen_changelog_version = Some(version); + changed = true; + } + }); + + if let (true, Some(updated)) = (changed, updated) { + self.persist(updated); + } + } + + pub fn mark_onboarding_seen(&self) { + if let Some(updated) = self.mutate_settings(|settings| settings.seen_onboarding = true) { + self.persist(updated); + } + } + + pub fn reset_onboarding(&self) { + if let Some(updated) = self.mutate_settings(|settings| { + settings.seen_onboarding = false; + settings.accepted_tos_version = 0; + settings.accepted_privacy_version = 0; + settings.seen_versions.clear(); + }) { + self.persist(updated); + } + } + + pub fn accept_tos(&self, terms_version: u32, privacy_version: u32) { + if let Some(updated) = self.mutate_settings(|settings| { + settings.accepted_tos_version = terms_version; + settings.accepted_privacy_version = privacy_version; + }) { + self.persist(updated); + } + } + + pub fn save_global_profile(&self, profile: GameSettingsProfile) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + if let Err(err) = save_global_profile(&state.settings, profile).await { + tracing::error!("failed to save the global profile: {err:#}"); + return; + } + actions.mutate_settings(|_| {}); + super::invalidate_profile_queries().await; + }); + } + + pub fn update_global_profile(&self, update: ProfileUpdate) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let mut global = state.settings.read().global_game_settings.clone(); + update.apply(&mut global); + if let Err(err) = save_global_profile(&state.settings, global).await { + tracing::error!("failed to update the global profile: {err:#}"); + return; + } + actions.mutate_settings(|_| {}); + super::invalidate_profile_queries().await; + }); + } + + pub fn create_settings_profile(&self, name: impl Into) { + let name = name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let global = state.settings.read().global_game_settings.clone(); + match oneclient_cluster::profiles::create_settings_profile( + &state.services.db, + &global, + &name, + ) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to create settings profile: {err:#}"), + } + }); + } + + pub fn create_profile_from_global( + &self, + name: impl Into, + mem_max: Option, + force_fullscreen: Option, + ) { + let name = name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let global = state.settings.read().global_game_settings.clone(); + match oneclient_cluster::profiles::create_profile_from_global( + &state.services.db, + &global, + &name, + mem_max, + force_fullscreen, + ) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to create profile: {err:#}"), + } + }); + } + + pub fn upsert_named_profile(&self, profile: GameSettingsProfile) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match oneclient_cluster::profiles::upsert_named_profile(&state.services.db, &profile) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to save profile: {err:#}"), + } + }); + } + + pub fn update_named_profile(&self, name: impl Into, update: ProfileUpdate) { + let name = name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match oneclient_cluster::profiles::update_named_profile( + &state.services.db, + &name, + update, + ) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to update profile: {err:#}"), + } + }); + } + + pub fn delete_named_profile(&self, name: impl Into) { + let name = name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match oneclient_cluster::profiles::delete_named_profile(&state.services.db, &name).await + { + Ok(()) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to delete profile: {err:#}"), + } + }); + } + + pub fn update_cluster_profile(&self, cluster_id: ClusterId, update: ProfileUpdate) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match state.clusters.update_profile(cluster_id, update).await { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to update cluster profile: {err:#}"), + } + }); + } + + pub fn create_and_assign_cluster_profile( + &self, + cluster_id: ClusterId, + profile_name: impl Into, + ) { + let profile_name = profile_name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let global = state.settings.read().global_game_settings.clone(); + match state + .clusters + .create_and_assign_profile(&global, cluster_id, &profile_name) + .await + { + Ok(_) => super::invalidate_profile_queries().await, + Err(err) => tracing::error!("failed to assign cluster profile: {err:#}"), + } + }); + } + + pub fn set_cluster_loader_version(&self, cluster_id: ClusterId, version: impl Into) { + let version = version.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + if let Err(err) = state + .clusters + .update(cluster_id, ClusterUpdate::default().loader_version(version)) + .await + { + tracing::error!("failed to set loader version: {err:#}"); + return; + } + // Best effort on failure the cluster just will not re-download + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::NotReady) + .await; + super::invalidate_cluster_queries().await; + }); + } + + pub fn import_launcher( + &self, + source: oneclient_core::MigrationSource, + folder_name: impl Into, + target: oneclient_core::ImportTarget, + ) { + let folder_name = folder_name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match oneclient_core::import_migration_game_dir(&state, source, &folder_name, target) + .await + { + Ok(()) => { + events + .notify("Import complete") + .body(format!("Copied your files from {}.", source.display_name())) + .send(); + events.signal(oneclient_events::Signal::ClustersChanged); + } + Err(err) => events + .notify("Import failed") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + pub fn install_java_runtime(&self, vendor: oneclient_java::JavaVendor, major: u32) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match state.java.install_runtime_from(&vendor, major).await { + Ok(_) => { + events.signal(oneclient_events::Signal::JavaChanged); + invalidate_java_queries().await + } + Err(err) => events + .notify("Java install failed") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + pub fn add_custom_java_runtime(&self, path: PathBuf) { + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match state.java.add_custom_runtime(path).await { + Ok(runtime) => { + events + .notify("Java added") + .body(format!("Java {} ({})", runtime.major, runtime.vendor)) + .send(); + events.signal(oneclient_events::Signal::JavaChanged); + } + Err(err) => events + .notify("Failed to add Java") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + pub fn remove_java_runtime(&self, path: impl Into) { + let path = path.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + match state.java.remove_runtime(&path).await { + Ok(()) => super::invalidate_java_queries().await, + Err(err) => tracing::error!("failed to remove java runtime: {err:#}"), + } + }); + } + + pub fn toggle_notification_center(&self) { + self.with_engine(|state| { + state.center_open = state + .notifications + .toggle_center(&mut state.inbox, state.center_open); + }); + } + + pub fn toggle_account_switcher(&self) { + let open = self.station.peek().account_switcher_open; + self.station + .clone() + .write_channel(AppChannel::AccountSwitcher) + .account_switcher_open = !open; + } + + pub fn close_account_switcher(&self) { + self.station + .clone() + .write_channel(AppChannel::AccountSwitcher) + .account_switcher_open = false; + } + + pub fn close_notification_center(&self) { + self.with_engine(|state| state.center_open = false); + } + + pub fn clear_notification_inbox(&self) { + self.with_engine(|state| { + state.inbox.clear(); + state.notifications.clear_inbox(); + }); + } + + pub fn dismiss_toast(&self, entry_id: u64) { + self.with_engine(|state| { + state + .notifications + .dismiss_toast(&mut state.inbox, entry_id) + }); + } + + pub fn mark_notification_read(&self, entry_id: u64) { + self.with_engine(|state| state.notifications.mark_read(&mut state.inbox, entry_id)); + } + + pub fn dismiss_notification(&self, entry_id: u64) { + self.with_engine(|state| { + state + .notifications + .dismiss_notification(&mut state.inbox, entry_id); + }); + } + + pub fn open_cluster_update(&self, summaries: Vec) { + self.with_engine(|state| { + state.notifications.open_cluster_update(summaries); + state.center_open = false; + }); + } + + pub fn close_cluster_update(&self) { + self.with_engine(|state| state.notifications.close_cluster_update()); + } + + pub fn close_package_updates(&self) { + self.with_engine(|state| state.notifications.close_package_updates()); + } + + /// Hovering any toast pauses every toast including ones arriving while + /// hovering hence a pump signal rather than a state flag + pub fn pause_toasts(&self) { + self.nudge(PumpSignal::PauseToasts); + } + + pub fn resume_toasts(&self) { + self.nudge(PumpSignal::ResumeToasts); + } + + pub fn answer_prompt(&self, answer: Answer) { + self.reply_to_prompt(Some(answer)); + } + + pub fn dismiss_prompt(&self) { + self.reply_to_prompt(None); + } + + fn reply_to_prompt(&self, answer: Option) { + let taken = self + .station + .clone() + .write_channel(AppChannel::Notifications) + .prompt + .take(); + + if let Some(PendingPrompt { + reply_tx: Some(reply_tx), + .. + }) = taken + { + let _ = reply_tx.send(answer); + } + } + + pub fn notify(&self, title: impl Into) -> NotificationBuilder { + NotificationBuilder { + actions: self.clone(), + spec: NotificationSpec { + title: title.into(), + body: String::new(), + level: Level::Info, + icon: None, + progress: None, + actions: Vec::new(), + }, + } + } + + fn push_notification(&self, spec: NotificationSpec) { + self.with_engine(|state| { + state.notifications.push_custom(&mut state.inbox, spec); + }); + } + + pub fn send_test_progress(&self, current: u64, total: u64) { + let Ok(state) = launcher::state() else { return }; + let id = uuid::Uuid::from_u128(0x0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0_0CE0); + state + .services + .events + .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 + .clone() + .write_channel(AppChannel::Game) + .game + .begin_launch(cluster_id); + if !claimed { + return; + } + + let station = self.station; + let actions = self.clone(); + spawn_forever(async move { + let started = Instant::now(); + launch(&actions, cluster_id).await; + + // An instant failure would hand the button back inside the same + // click burst so hold it for the floor either way + if let Some(remaining) = LAUNCH_HOLD.checked_sub(started.elapsed()) { + tokio::time::sleep(remaining).await; + } + station + .clone() + .write_channel(AppChannel::Game) + .game + .finish_launch(cluster_id); + }); + } + + pub fn kill_cluster(&self, cluster_id: ClusterId) { + let Ok(state) = launcher::state() else { return }; + if !state.games.kill(cluster_id) { + tracing::debug!(cluster_id, "kill requested but no tracked process"); + } + } + + pub fn dismiss_game_error(&self) { + self.station + .clone() + .write_channel(AppChannel::Game) + .game + .error = None; + } + + pub fn import_local_file( + &self, + cluster_id: ClusterId, + content_type: ContentType, + path: PathBuf, + ) { + 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, + cluster_id, + &state.services.content(), + ) + .await + { + Ok(row) => { + events + .notify("Imported") + .body(format!("Added {}", row.file_name)) + .send(); + super::invalidate_cluster_queries().await; + } + Err(err) => events + .notify("Import failed") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + pub fn install_package( + &self, + cluster_id: ClusterId, + provider: ProviderId, + project_id: impl Into, + version_id: impl Into, + ) { + let (project_id, version_id) = (project_id.into(), version_id.into()); + let actions = self.clone(); + + self.station + .clone() + .write_channel(AppChannel::Installs) + .installs + .begin(cluster_id, provider, project_id.clone()); + + spawn_forever(async move { + let finish = |actions: &Actions| { + actions + .station + .clone() + .write_channel(AppChannel::Installs) + .installs + .finish(cluster_id, provider, &project_id); + }; + + let Ok(state) = launcher::state() else { + finish(&actions); + return; + }; + + let install = crate::install::install_package( + &state, + provider, + &project_id, + &version_id, + cluster_id, + ) + .await; + + // Replaces the download's progress notification in place rather + // than arriving as a second one + let spec = match &install.result { + Ok(name) => NotificationSpec { + title: "Installed".to_string(), + body: crate::install::install_body( + name, + &install.dependencies, + &install.missing_dependencies, + ), + level: Level::Info, + icon: Some(IconType::Download01), + progress: None, + actions: Vec::new(), + }, + Err(err) => NotificationSpec { + title: "Install failed".to_string(), + body: err.to_string(), + level: Level::Error, + icon: None, + progress: None, + actions: Vec::new(), + }, + }; + + let session_id = install.session_id.unwrap_or_else(uuid::Uuid::nil); + actions.with_engine(|app| { + app.notifications + .finish_grouped_as_actions(&mut app.inbox, session_id, Some(spec)); + }); + + if install.result.is_ok() { + // Before the refresh below so the version list never renders + // the moment where both copies read as active + if let Err(err) = oneclient_content::bundles::reconcile_duplicate_activity( + cluster_id, + &state.services.content(), + ) + .await + { + tracing::warn!(%err, "failed to resolve duplicate package versions"); + } + + super::invalidate_cluster_content_queries().await; + + state + .services + .events + .signal(oneclient_events::Signal::ClustersChanged); + } + + finish(&actions); + }); + } + + /// Shares the installs channel with [`Self::install_package`] so every row's + /// button reads as busy while one is being acted on + /// + /// Recorded as a bundle override when a bundle owns the artifact otherwise + /// the next sync puts the version straight back + pub fn remove_package_version( + &self, + cluster_id: ClusterId, + provider: ProviderId, + project_id: impl Into, + hash: impl Into, + display_name: impl Into, + ) { + let (project_id, hash, display_name) = + (project_id.into(), hash.into(), display_name.into()); + let actions = self.clone(); + + self.station + .clone() + .write_channel(AppChannel::Installs) + .installs + .begin(cluster_id, provider, project_id.clone()); + + spawn_forever(async move { + let finish = |actions: &Actions| { + actions + .station + .clone() + .write_channel(AppChannel::Installs) + .installs + .finish(cluster_id, provider, &project_id); + }; + + let Ok(state) = launcher::state() else { + finish(&actions); + return; + }; + + let events = state.services.events.clone(); + match oneclient_core::remove_artifact_from_cluster( + cluster_id, + &hash, + true, + &state.services.content(), + ) + .await + { + Ok(()) => { + events.notify("Removed").body(display_name).send(); + + // The row's next state is read off the cluster's content + // so refresh that before the busy flag drops + super::invalidate_cluster_content_queries().await; + events.signal(oneclient_events::Signal::ClustersChanged); + } + Err(err) => events + .notify("Remove failed") + .body(err.to_string()) + .error() + .send(), + } + + finish(&actions); + }); + } + + pub fn install_bundle( + &self, + cluster_id: ClusterId, + bundle_name: impl Into, + skip_compatibility: bool, + ) { + let bundle_name = bundle_name.into(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + match oneclient_core::install_bundle( + cluster_id, + &bundle_name, + skip_compatibility, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + { + Ok(_) => { + events + .notify("Installed") + .body(format!("Added {bundle_name}")) + .send(); + events.signal(oneclient_events::Signal::ClustersChanged); + } + Err(err) => events + .notify("Install failed") + .body(err.to_string()) + .error() + .send(), + } + }); + } + + pub fn apply_bundle_updates(&self, cluster_id: ClusterId) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let result = match oneclient_core::apply_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + { + Ok(result) => result, + Err(err) => { + state + .services + .events + .notify("Update failed") + .body(err.to_string()) + .error() + .send(); + return; + } + }; + + super::invalidate_cluster_queries().await; + if let Some(spec) = + crate::install::cluster_update_notification(cluster_id, &result, &state.services) + .await + { + actions.push_notification(spec); + } + }); + } + + /// `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() { + Ok(state) => state, + Err(err) => { + tracing::error!("bundle sync skipped, launcher not ready: {err:#}"); + return; + } + }; + + let actions = self.clone(); + self.station + .clone() + .write_channel(AppChannel::Launcher) + .launcher + .syncing_bundles = true; + + spawn_forever(async move { + if let Err(err) = state.bundles.sync(&state.services.content()).await { + tracing::error!("bundle catalog sync failed: {err:#}"); + } + if let Err(err) = oneclient_core::clusters::apply_remote_migrations(&state).await { + tracing::error!("cluster migrations failed: {err:#}"); + } + if let Err(err) = oneclient_core::clusters::ensure_from_bundles(&state).await { + tracing::error!("bundle cluster provisioning failed: {err:#}"); + } + + // One grouped session for the batch so all downloads surface as a + // single notification `detach` lets it be converted in place + let session = oneclient_events::GroupedProgressSession::start( + &state.services.events, + "Updating mods", + ); + let changed = oneclient_content::bundles::sync_all_cluster_bundles( + state.bundles.as_ref(), + &state.services.content(), + Some(&session), + ) + .await; + let session_id = session.detach(); + + actions + .station + .clone() + .write_channel(AppChannel::Launcher) + .launcher + .syncing_bundles = false; + super::invalidate_cluster_queries().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); + }); + }); + } + + /// The check runs and the cache is written whatever the mode says keeping + /// the package manager's "Update available" markers honest + /// + /// Returns once the launch may proceed nothing is still downloading then + /// Never fails the launch every error logs and lets the game start + async fn resolve_package_updates_before_launch( + &self, + state: &Arc, + cluster_id: ClusterId, + ) { + let content = state.services.content(); + + // Bounded a hanging network would otherwise hold the launch for + // reqwest's much longer timeouts + let check = match tokio::time::timeout( + UPDATE_CHECK_BUDGET, + oneclient_core::refresh_browser_package_updates(cluster_id, &content), + ) + .await + { + Ok(Ok(check)) => check, + Ok(Err(err)) => { + tracing::warn!( + cluster_id, + error = %err, + "browser package update check failed, launching anyway" + ); + return; + } + Err(_elapsed) => { + tracing::debug!( + cluster_id, + "browser package update check exceeded its launch budget" + ); + return; + } + }; + + let pending = check.pending(); + let mode = self.cluster_update_mode(state, cluster_id).await; + + match plan_launch_updates(mode, pending) { + LaunchUpdatePlan::Nothing => {} + LaunchUpdatePlan::Apply(updates) => { + self.apply_updates_for_launch(state, &updates).await; + } + LaunchUpdatePlan::Prompt(updates) => { + let Some(group) = + crate::install::package_update_group(cluster_id, &updates, &state.services) + .await + else { + return; + }; + self.prompt_package_updates(group).await; + } + } + } + + /// Anything unreadable falls back to the default rather than blocking the + /// launch on a settings lookup + async fn cluster_update_mode( + &self, + state: &Arc, + cluster_id: ClusterId, + ) -> PackageUpdateMode { + let global = state.settings.read().global_game_settings.clone(); + + let Ok(cluster) = state.clusters.get(cluster_id).await else { + return PackageUpdateMode::default(); + }; + + state + .clusters + .resolve_settings(&global, &cluster) + .await + .ok() + .and_then(|profile| profile.browser_update_mode) + .unwrap_or_default() + } + + /// One grouped progress notification so the pre-launch pause shows something + async fn apply_updates_for_launch( + &self, + state: &Arc, + updates: &[oneclient_core::BrowserPackageUpdate], + ) { + let content = state.services.content(); + let session = oneclient_events::GroupedProgressSession::start( + &state.services.events, + "Updating packages", + ); + session.expect( + oneclient_events::TaskCategory::Packages, + updates.len() as u64, + updates.len() as u64, + ); + + let mut applied = 0usize; + for update in updates { + let child = session.child( + update.display_name.clone(), + 1, + oneclient_events::TaskCategory::Packages, + ); + match oneclient_core::apply_browser_package_update(update, Some(&child), &content).await + { + Ok(_) => applied += 1, + // Not a reason to hold the game back the cache row survives + // so the next launch offers it again + Err(err) => tracing::warn!( + package = %update.display_name, + error = %err, + "automatic package update failed" + ), + } + child.finish(); + } + + let session_id = session.detach(); + let spec = (applied > 0).then(|| NotificationSpec { + title: "Packages updated".to_string(), + body: format!( + "{applied} package{} updated before launch", + if applied == 1 { "" } else { "s" } + ), + level: Level::Info, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: Vec::new(), + }); + + self.with_engine(|app| { + app.notifications + .finish_grouped_as_actions(&mut app.inbox, session_id, spec); + }); + + if applied > 0 { + super::invalidate_cluster_queries().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.center_open = false; + }); + + // A replaced or torn-down modal is equally a reason to stop waiting + let _ = wait.await; + } + + pub fn apply_package_update(&self, update: oneclient_core::BrowserPackageUpdate) { + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + + let session = oneclient_events::GroupedProgressSession::start( + &events, + format!("Updating {}", update.display_name), + ); + let child = session.child( + update.display_name.clone(), + 1, + oneclient_events::TaskCategory::Packages, + ); + + let result = oneclient_core::apply_browser_package_update( + &update, + Some(&child), + &state.services.content(), + ) + .await; + + child.finish(); + let session_id = session.detach(); + + let spec = match &result { + Ok(_) => NotificationSpec { + title: "Updated".to_string(), + body: format!( + "{} is now on {}", + update.display_name, update.latest_version_name + ), + level: Level::Info, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: Vec::new(), + }, + Err(err) => NotificationSpec { + title: "Update failed".to_string(), + body: err.to_string(), + level: Level::Error, + icon: None, + progress: None, + actions: Vec::new(), + }, + }; + + actions.with_engine(|app| { + app.notifications + .finish_grouped_as_actions(&mut app.inbox, session_id, Some(spec)); + }); + + // A failed update stays in the list so the user can retry + if result.is_ok() { + actions.with_engine(|app| { + app.notifications + .resolve_package_update(update.cluster_id, &update.hash); + }); + super::invalidate_cluster_queries().await; + } + }); + } + + /// The package stays marked out of date only the modal stops asking and + /// only for this version + pub fn skip_package_update(&self, cluster_id: ClusterId, hash: impl Into) { + let hash = hash.into(); + let actions = self.clone(); + spawn_forever(async move { + let Ok(state) = launcher::state() else { return }; + if let Err(err) = oneclient_core::skip_browser_package_update( + cluster_id, + &hash, + &state.services.content(), + ) + .await + { + tracing::warn!(error = %err, "failed to record a skipped package update"); + } + + actions.with_engine(|app| { + app.notifications.resolve_package_update(cluster_id, &hash); + }); + super::invalidate_cluster_queries().await; + }); + } +} + +/// 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 +async fn launch(actions: &Actions, cluster_id: ClusterId) { + let Ok(state) = launcher::state() else { return }; + let events = state.services.events.clone(); + + events.game_stage(cluster_id, oneclient_events::LaunchStage::Checking); + + let account = match state.auth.default_account_for_launch().await { + Ok(account) => account, + Err(err) => { + events.game_failed(cluster_id, format!("{err:#}")); + return; + } + }; + + let Some(account) = account else { + events.game_failed( + cluster_id, + "Add a Minecraft account before launching.".to_string(), + ); + return; + }; + + // Before the game process never after Minecraft reads its mods once at + // startup + actions + .resolve_package_updates_before_launch(&state, cluster_id) + .await; + + if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await { + // A missing file is the one failure the launcher can fix itself and a + // path inside our metadata folder gives the user nothing to act on + if err.indicates_missing_files() { + repair_and_relaunch(&state, cluster_id, &account, err).await; + return; + } + + events.game_failed(cluster_id, format!("{err:#}")); + } +} + +/// Only ever one retry if the game still will not start after a full rehash and +/// repair the problem is not missing files +async fn repair_and_relaunch( + state: &std::sync::Arc, + cluster_id: ClusterId, + account: &oneclient_auth::MinecraftAccount, + original: oneclient_core::LauncherError, +) { + let events = state.services.events.clone(); + + tracing::warn!( + cluster_id, + "launch failed on a missing file; repairing: {original:#}" + ); + events + .notify("Repairing installation") + .body("Some game files are missing. Checking and re-downloading them.") + .send(); + + let report = match oneclient_core::verify_cluster_files(state, cluster_id).await { + Ok(report) => report, + Err(repair_err) => { + tracing::error!(cluster_id, "repair failed: {repair_err:#}"); + // Lead with the original failure the repair failure explains why + // it was not fixed + events.game_failed( + cluster_id, + format!("{original:#} (repair also failed: {repair_err:#})"), + ); + return; + } + }; + + events + .notify("Repair complete") + .body(report.summary()) + .send(); + + if let Err(err) = oneclient_core::launch_cluster(state, cluster_id, account, true).await { + tracing::error!(cluster_id, "launch failed again after repair: {err:#}"); + events.game_failed(cluster_id, format!("{err:#}")); + } +} + +#[must_use = "the notification is not raised until `.send()` is called"] +pub struct NotificationBuilder { + actions: Actions, + spec: NotificationSpec, +} + +impl NotificationBuilder { + pub fn body(mut self, body: impl Into) -> Self { + self.spec.body = body.into(); + self + } + + pub fn level(mut self, level: Level) -> Self { + self.spec.level = level; + self + } + + pub fn info(self) -> Self { + self.level(Level::Info) + } + + pub fn error(self) -> Self { + self.level(Level::Error) + } + + pub fn icon(mut self, icon: IconType) -> Self { + self.spec.icon = Some(icon); + self + } + + pub fn progress(mut self, current: u64, total: u64) -> Self { + self.spec.progress = Some((current, total)); + self + } + + pub fn action(mut self, action: NotificationAction) -> Self { + self.spec.actions.push(action); + self + } + + pub fn actions(mut self, actions: impl IntoIterator) -> Self { + self.spec.actions = actions.into_iter().collect(); + self + } + + pub fn send(self) { + self.actions.push_notification(self.spec); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oneclient_common::domain::ProviderId; + + fn update(hash: &str) -> oneclient_core::BrowserPackageUpdate { + oneclient_core::BrowserPackageUpdate { + cluster_id: 1, + hash: hash.into(), + provider: ProviderId::Modrinth, + project_id: "sodium".into(), + installed_version_id: "v1".into(), + installed_version_name: "1.0".into(), + latest_version_id: "v2".into(), + latest_version_name: "2.0".into(), + display_name: "Sodium".into(), + skipped: false, + } + } + + #[test] + fn skip_mode_launches_without_asking_or_installing() { + assert_eq!( + plan_launch_updates(PackageUpdateMode::Skip, vec![update("a")]), + LaunchUpdatePlan::Nothing, + ); + } + + #[test] + fn automatic_mode_installs_without_asking() { + assert_eq!( + plan_launch_updates(PackageUpdateMode::Automatic, vec![update("a")]), + LaunchUpdatePlan::Apply(vec![update("a")]), + ); + } + + #[test] + fn prompt_mode_is_the_default_and_asks() { + assert_eq!( + plan_launch_updates(PackageUpdateMode::default(), vec![update("a")]), + LaunchUpdatePlan::Prompt(vec![update("a")]), + ); + } + + #[test] + fn nothing_pending_never_opens_a_modal() { + for mode in PackageUpdateMode::ALL.iter().copied() { + assert_eq!( + plan_launch_updates(mode, Vec::new()), + LaunchUpdatePlan::Nothing, + "{mode:?} must not hold a launch up over an empty list", + ); + } + } +} diff --git a/packages/oneclient_app/src/hooks/active_cluster.rs b/packages/oneclient_app/src/hooks/active_cluster.rs new file mode 100644 index 00000000..54627eb8 --- /dev/null +++ b/packages/oneclient_app/src/hooks/active_cluster.rs @@ -0,0 +1,109 @@ +use std::collections::{HashMap, HashSet}; + +use freya::prelude::*; +use oneclient_content::packages::ProviderId; +use oneclient_db::models::ClusterId; + +#[derive(Clone)] +pub struct ActiveClusterState(pub State>); + +pub fn use_provide_active_cluster(active: ActiveClusterState) { + use_provide_root_context(move || active.clone()); +} + +pub fn use_active_cluster_id() -> State> { + consume_root_context::().0 +} + +/// Startup curtain stays up past the Startup route until home content settles +/// so the app never reveals a half-populated home +#[derive(Clone, Copy)] +pub struct SplashState { + pub active: State, + pub home_ready: State, +} + +pub fn use_provide_splash(state: SplashState) { + use_provide_root_context(move || state); +} + +pub fn use_splash() -> SplashState { + consume_root_context::() +} + +#[derive(Clone)] +pub struct BrowserCompatState(pub State); + +pub fn use_provide_browser_compat(state: BrowserCompatState) { + use_provide_root_context(move || state.clone()); +} + +pub fn use_browser_compat() -> State { + consume_root_context::().0 +} + +#[derive(Clone)] +pub struct LinkConfirmState(pub State>); + +pub fn use_provide_link_confirm(state: LinkConfirmState) { + use_provide_root_context(move || state.clone()); +} + +pub fn use_link_confirm() -> State> { + consume_root_context::().0 +} + +#[derive(Clone)] +pub struct BrowserUiState { + pub query: String, + pub provider: ProviderId, + pub categories: Vec, + pub page: usize, +} + +impl Default for BrowserUiState { + fn default() -> Self { + Self { + query: String::new(), + provider: ProviderId::Modrinth, + categories: Vec::new(), + page: 0, + } + } +} + +#[derive(Clone)] +pub struct BrowserStateStore(pub State>); + +pub fn use_provide_browser_state(store: BrowserStateStore) { + use_provide_root_context(move || store.clone()); +} + +pub fn use_browser_state_store() -> State> { + consume_root_context::().0 +} + +#[derive(Clone)] +pub struct OnboardingSelectionState { + pub selected: State>, + /// Until set the shell keeps re-deriving `selected` from the catalog so a + /// late-settling bundle list still gets the right defaults + pub user_touched: State, + /// `None` means "no migration decision yet" + pub migrated_categories: State>>, + pub language: State, + pub reduce_motion: State, + pub predownload: State, + pub setup_started: State, + /// `None` = don't import + pub import_folder: State>, + pub import_dedicated: State, +} + +pub fn use_provide_onboarding_selection(state: OnboardingSelectionState) { + use_provide_root_context(move || state.clone()); +} + +pub fn use_onboarding_selection() -> OnboardingSelectionState { + consume_root_context::() +} diff --git a/packages/oneclient_app/src/hooks/debounce.rs b/packages/oneclient_app/src/hooks/debounce.rs new file mode 100644 index 00000000..b0c71857 --- /dev/null +++ b/packages/oneclient_app/src/hooks/debounce.rs @@ -0,0 +1,28 @@ +use std::time::Duration; + +use freya::prelude::*; + +/// Trailing-edge debounce updates only after `delay` with no further change +pub fn use_debounced(value: T, delay: Duration) -> State +where + T: Clone + PartialEq + 'static, +{ + let debounced = use_state(|| value.clone()); + // Task commits only if its generation is still latest dropping superseded changes + let mut generation = use_state(|| 0u64); + + use_side_effect_with_deps(&value, move |value| { + let value = value.clone(); + let this_gen = *generation.peek() + 1; + generation.set(this_gen); + let mut debounced = debounced; + spawn(async move { + tokio::time::sleep(delay).await; + if *generation.peek() == this_gen { + debounced.set(value); + } + }); + }); + + debounced +} diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs new file mode 100644 index 00000000..efadde52 --- /dev/null +++ b/packages/oneclient_app/src/hooks/mod.rs @@ -0,0 +1,116 @@ +mod actions; +mod active_cluster; +mod debounce; +mod queries; +mod view_state; + +pub use debounce::use_debounced; +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, + 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, +}; + +pub use actions::{Actions, NotificationBuilder, PumpSignal}; +pub use queries::{ + AddOfflineAccountKeys, BROWSE_PAGE_SIZE, BeginMicrosoftLoginMutation, CachedImageQuery, + CancelMicrosoftLoginKeys, CancelMicrosoftLoginMutation, ClusterAction, ClusterBundles, + ClusterLogsQuery, FinishMicrosoftLoginMutation, LogAction, LogContentQuery, MigrationQuery, + OnboardingBundlesQuery, RefreshAccountKeys, RemoveAccountKeys, ScreenshotAction, + SetDefaultAccountKeys, StorageAction, StorageActionMutation, StorageReportQuery, TermsQuery, + UploadLogKeys, UploadLogMutation, UseLogAction, UseRefreshAccount, UseRemoveAccount, + UseScreenshotAction, UseSetDefaultAccount, UseStorageAction, UseUploadLog, VERSIONS_PAGE_SIZE, + accounts_have_microsoft, bundle_overrides_map, bundles_with_status_items, category_list, + changelog_error, changelog_groups, changelog_is_loading, cluster_content_items, + content_type_for_slug, has_migration_data, invalidate_cluster_content_queries, + invalidate_cluster_queries, invalidate_java_queries, invalidate_logs_queries, + invalidate_profile_queries, invalidate_screenshots_queries, invalidate_storage_queries, + 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, + 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, + 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_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, +}; + +use crate::notifications::NotificationSnapshot; +use crate::state::{ + AppChannel, GameState, InstallState, LauncherInit, LoginProgress, SettingsState, +}; +use freya::prelude::*; +use freya::radio::use_radio; + +pub fn use_provide_actions(actions: &Actions) { + let actions = actions.clone(); + use_provide_root_context(move || actions.clone()); +} + +pub fn use_dispatch() -> Actions { + consume_root_context::() +} + +/// Wakes its component only when that channel is written so a toast tick does +/// not re-render a component reading only `data_dir` +pub fn use_launcher() -> LauncherInit { + use_radio(AppChannel::Launcher).read().launcher.clone() +} + +pub fn use_settings_snapshot() -> SettingsState { + use_radio(AppChannel::Settings).read().settings.clone() +} + +/// Built on read not published on write cloning the inbox on every one of a +/// download's tens of thousands of engine changes was the channel's main cost +pub fn use_notifications_snapshot() -> NotificationSnapshot { + let radio = use_radio(AppChannel::Notifications); + let state = radio.read(); + state.notifications.snapshot( + &state.inbox, + state.center_open, + crate::events::prompt_view(&state), + ) +} + +pub fn use_account_switcher_open() -> bool { + use_radio(AppChannel::AccountSwitcher) + .read() + .account_switcher_open +} + +pub fn use_game_snapshot() -> GameState { + use_radio(AppChannel::Game).read().game.clone() +} + +pub fn use_installs_snapshot() -> InstallState { + use_radio(AppChannel::Installs).read().installs.clone() +} + +pub fn use_microsoft_login_status() -> Option { + use_radio(AppChannel::MicrosoftLogin) + .read() + .microsoft_login + .clone() +} + +pub(crate) use queries::invalidate_auth_queries; diff --git a/packages/oneclient_app/src/hooks/queries/analytics.rs b/packages/oneclient_app/src/hooks/queries/analytics.rs new file mode 100644 index 00000000..cd80fd77 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/analytics.rs @@ -0,0 +1,58 @@ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::LauncherError; +use oneclient_core::game::{Analytics, cluster_analytics, global_analytics}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GlobalAnalyticsKeys; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GlobalAnalyticsQuery; + +impl QueryCapability for GlobalAnalyticsQuery { + type Ok = Analytics; + type Err = LauncherError; + type Keys = GlobalAnalyticsKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + global_analytics(&state.services.db).await + } +} + +pub fn use_global_analytics() -> UseQuery { + use_query(Query::new(GlobalAnalyticsKeys, GlobalAnalyticsQuery)) +} + +pub fn try_global_analytics(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterAnalyticsKeys { + pub cluster_id: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterAnalyticsQuery; + +impl QueryCapability for ClusterAnalyticsQuery { + type Ok = Analytics; + type Err = LauncherError; + type Keys = ClusterAnalyticsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + cluster_analytics(&state.services.db, keys.cluster_id).await + } +} + +pub fn use_cluster_analytics(cluster_id: i64) -> UseQuery { + use_query(Query::new( + ClusterAnalyticsKeys { cluster_id }, + ClusterAnalyticsQuery, + )) +} + +pub fn try_cluster_analytics(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} diff --git a/packages/oneclient_app/src/hooks/queries/auth.rs b/packages/oneclient_app/src/hooks/queries/auth.rs new file mode 100644 index 00000000..4380b280 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/auth.rs @@ -0,0 +1,409 @@ +use std::sync::Mutex; + +use freya::query::{ + Mutation, MutationCapability, MutationStateData, QueriesStorage, Query, QueryCapability, + UseMutation, UseQuery, use_mutation, use_query, +}; +use oneclient_auth::{AccountKind, MicrosoftLoginSession, MinecraftAccount}; +use oneclient_core::LauncherError; +use uuid::Uuid; + +static HANDLED_LOGIN_CODE: Mutex> = Mutex::new(None); + +pub fn login_code_already_handled(user_code: &str) -> bool { + let mut handled = HANDLED_LOGIN_CODE.lock().unwrap(); + if handled.as_deref() == Some(user_code) { + return true; + } + *handled = Some(user_code.to_string()); + false +} + +pub fn reset_login_code_dedup() { + *HANDLED_LOGIN_CODE.lock().unwrap() = None; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListAccountsKeys; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct DefaultAccountKeys { + pub fallback: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AccountKeys { + pub id: Uuid, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListAccountsQuery; + +impl QueryCapability for ListAccountsQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = ListAccountsKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()?.auth.list_accounts().await) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct DefaultAccountQuery; + +impl QueryCapability for DefaultAccountQuery { + type Ok = Option; + type Err = LauncherError; + type Keys = DefaultAccountKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let account = state.auth.default_account().await?; + if account.is_some() || !keys.fallback { + return Ok(account); + } + + let accounts = state.auth.list_accounts().await; + Ok(accounts.into_iter().next()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AccountQuery; + +impl QueryCapability for AccountQuery { + type Ok = Option; + type Err = LauncherError; + type Keys = AccountKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()?.auth.get_account(keys.id).await) + } +} + +pub fn use_accounts() -> UseQuery { + use_query(Query::new(ListAccountsKeys, ListAccountsQuery)) +} + +pub fn use_default_account(fallback: bool) -> UseQuery { + use_query(Query::new( + DefaultAccountKeys { fallback }, + DefaultAccountQuery, + )) +} + +pub fn use_current_account() -> UseQuery { + use_default_account(true) +} + +pub fn use_account(id: Uuid) -> UseQuery { + use_query(Query::new(AccountKeys { id }, AccountQuery)) +} + +pub fn try_accounts(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} + +pub fn try_default_account(query: &UseQuery) -> Option { + super::state::settled_or_loading(query).flatten() +} + +pub fn try_account(query: &UseQuery) -> Option { + super::state::settled_or_loading(query).flatten() +} + +pub(crate) async fn invalidate_auth_queries(account_id: Option) { + QueriesStorage::::invalidate_matching(ListAccountsKeys).await; + for fallback in [false, true] { + QueriesStorage::::invalidate_matching(DefaultAccountKeys { fallback }) + .await; + } + if let Some(id) = account_id { + QueriesStorage::::invalidate_matching(AccountKeys { id }).await; + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BeginMicrosoftLoginMutation; + +impl MutationCapability for BeginMicrosoftLoginMutation { + type Ok = MicrosoftLoginSession; + type Err = LauncherError; + type Keys = (); + + async fn run(&self, _keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()? + .auth + .begin_microsoft_login() + .await?) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct FinishMicrosoftLoginMutation; + +impl MutationCapability for FinishMicrosoftLoginMutation { + type Ok = MinecraftAccount; + type Err = LauncherError; + type Keys = MicrosoftLoginSession; + + async fn run(&self, flow: &Self::Keys) -> Result { + Ok(crate::launcher::state()? + .auth + .finish_microsoft_login(flow.clone()) + .await?) + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if let Ok(account) = result { + invalidate_auth_queries(Some(account.id)).await; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CancelMicrosoftLoginMutation; + +/// Keyed by the session's CSRF state token +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct CancelMicrosoftLoginKeys { + pub state_token: String, +} + +impl MutationCapability for CancelMicrosoftLoginMutation { + type Ok = (); + type Err = LauncherError; + type Keys = CancelMicrosoftLoginKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + crate::launcher::state()? + .auth + .cancel_microsoft_login(&keys.state_token) + .await; + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AddMicrosoftAccountMutation; + +impl MutationCapability for AddMicrosoftAccountMutation { + type Ok = MinecraftAccount; + type Err = LauncherError; + type Keys = (); + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let flow = state.auth.begin_microsoft_login().await?; + Ok(state.auth.finish_microsoft_login(flow).await?) + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if let Ok(account) = result { + invalidate_auth_queries(Some(account.id)).await; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct AddOfflineAccountMutation; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct AddOfflineAccountKeys { + pub username: String, +} + +impl MutationCapability for AddOfflineAccountMutation { + type Ok = MinecraftAccount; + type Err = LauncherError; + type Keys = AddOfflineAccountKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()? + .auth + .add_offline_account(keys.username.clone()) + .await?) + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if let Ok(account) = result { + invalidate_auth_queries(Some(account.id)).await; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RemoveAccountMutation; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RemoveAccountKeys { + pub id: Uuid, +} + +impl MutationCapability for RemoveAccountMutation { + type Ok = (); + type Err = LauncherError; + type Keys = RemoveAccountKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()? + .auth + .remove_account(keys.id) + .await?) + } + + async fn on_settled(&self, keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_auth_queries(Some(keys.id)).await; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SetDefaultAccountMutation; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct SetDefaultAccountKeys { + pub id: Option, +} + +impl MutationCapability for SetDefaultAccountMutation { + type Ok = (); + type Err = LauncherError; + type Keys = SetDefaultAccountKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()? + .auth + .set_default_account(keys.id) + .await?) + } + + async fn on_settled(&self, keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_auth_queries(keys.id).await; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RefreshAccountMutation; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RefreshAccountKeys { + pub id: Uuid, +} + +impl MutationCapability for RefreshAccountMutation { + type Ok = MinecraftAccount; + type Err = LauncherError; + type Keys = RefreshAccountKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()? + .auth + .refresh_account(keys.id) + .await?) + } + + async fn on_settled(&self, keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_auth_queries(Some(keys.id)).await; + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RefreshAllAccountsMutation; + +impl MutationCapability for RefreshAllAccountsMutation { + type Ok = Vec; + type Err = LauncherError; + type Keys = (); + + async fn run(&self, _keys: &Self::Keys) -> Result { + Ok(crate::launcher::state()? + .auth + .refresh_all_accounts() + .await?) + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_auth_queries(None).await; + } + } +} + +pub type UseSetDefaultAccount = UseMutation; +pub type UseRemoveAccount = UseMutation; +pub type UseRefreshAccount = UseMutation; + +pub fn use_begin_microsoft_login() -> UseMutation { + use_mutation(Mutation::new(BeginMicrosoftLoginMutation)) +} + +pub fn use_finish_microsoft_login() -> UseMutation { + use_mutation(Mutation::new(FinishMicrosoftLoginMutation)) +} + +pub fn use_cancel_microsoft_login() -> UseMutation { + use_mutation(Mutation::new(CancelMicrosoftLoginMutation)) +} + +pub fn use_add_microsoft_account() -> UseMutation { + use_mutation(Mutation::new(AddMicrosoftAccountMutation)) +} + +pub fn use_add_offline_account() -> UseMutation { + use_mutation(Mutation::new(AddOfflineAccountMutation)) +} + +pub fn use_remove_account() -> UseMutation { + use_mutation(Mutation::new(RemoveAccountMutation)) +} + +pub fn use_set_default_account() -> UseMutation { + use_mutation(Mutation::new(SetDefaultAccountMutation)) +} + +pub fn use_refresh_account() -> UseMutation { + use_mutation(Mutation::new(RefreshAccountMutation)) +} + +pub fn use_refresh_all_accounts() -> UseMutation { + use_mutation(Mutation::new(RefreshAllAccountsMutation)) +} + +/// True before it has ever been run as well as while running For button +/// disabled state use [`mutation_is_running`] instead +pub fn mutation_is_pending(mutation: &UseMutation) -> bool { + matches!( + &*mutation.read().state(), + MutationStateData::Pending | MutationStateData::Loading { .. } + ) +} + +pub fn mutation_is_running(mutation: &UseMutation) -> bool { + mutation.read().state().is_loading() +} + +pub fn mutation_error(mutation: &UseMutation) -> Option +where + M: MutationCapability, + M::Err: Clone, +{ + let reader = mutation.read(); + match &*reader.state() { + MutationStateData::Settled { res: Err(err), .. } => Some(err.clone()), + MutationStateData::Loading { + res: Some(Err(err)), + } => Some(err.clone()), + _ => None, + } +} + +pub fn accounts_have_microsoft(accounts: &[MinecraftAccount]) -> bool { + accounts.iter().any(|a| a.kind == AccountKind::Microsoft) +} diff --git a/packages/oneclient_app/src/hooks/queries/bundles.rs b/packages/oneclient_app/src/hooks/queries/bundles.rs new file mode 100644 index 00000000..7e22aa88 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/bundles.rs @@ -0,0 +1,171 @@ +use std::collections::HashMap; + +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::clusters::Cluster; +use oneclient_core::{ + BundleArchive, BundleUpdateCheckResult, BundleWithUpdateStatus, LauncherError, + get_bundles_with_update_status, list_cluster_bundle_overrides, +}; +use oneclient_db::models::ClusterId; + +#[derive(Clone, Debug)] +pub struct ClusterBundles { + pub cluster: Cluster, + pub archives: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct OnboardingBundlesQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct OnboardingBundlesKeys; + +impl QueryCapability for OnboardingBundlesQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = OnboardingBundlesKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let clusters = state.clusters.list().await?; + + let mut out = Vec::with_capacity(clusters.len()); + for cluster in clusters { + let archives = state + .bundles + .archives_for( + &state.services.content(), + &cluster.mc_version, + cluster.mc_loader, + ) + .await + .unwrap_or_default(); + out.push(ClusterBundles { cluster, archives }); + } + Ok(out) + } +} + +pub fn use_onboarding_bundles() -> UseQuery { + use_query(Query::new(OnboardingBundlesKeys, OnboardingBundlesQuery)) +} + +pub fn onboarding_bundles_items( + query: &UseQuery, +) -> Option> { + super::state::settled_or_loading(query) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BundlesWithStatusQuery { + pub cluster_id: ClusterId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BundlesWithStatusKeys { + pub cluster_id: ClusterId, +} + +impl QueryCapability for BundlesWithStatusQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = BundlesWithStatusKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let _ = keys; + let state = crate::launcher::state()?; + Ok(get_bundles_with_update_status( + self.cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await?) + } +} + +pub fn bundles_with_status_items( + query: &UseQuery, +) -> Vec { + super::state::settled_or_loading(query).unwrap_or_default() +} + +pub fn use_bundles_with_status(cluster_id: ClusterId) -> UseQuery { + use_query(Query::new( + BundlesWithStatusKeys { cluster_id }, + BundlesWithStatusQuery { cluster_id }, + )) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BundleOverridesQuery { + pub cluster_id: ClusterId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BundleOverridesKeys { + pub cluster_id: ClusterId, +} + +impl QueryCapability for BundleOverridesQuery { + type Ok = HashMap<(String, String), String>; + type Err = LauncherError; + type Keys = BundleOverridesKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let _ = keys; + let state = crate::launcher::state()?; + let rows = + list_cluster_bundle_overrides(self.cluster_id, &state.services.content()).await?; + Ok(rows + .into_iter() + .map(|(bundle, pkg, ty)| ((bundle, pkg), ty)) + .collect()) + } +} + +pub fn use_bundle_overrides(cluster_id: ClusterId) -> UseQuery { + use_query(Query::new( + BundleOverridesKeys { cluster_id }, + BundleOverridesQuery { cluster_id }, + )) +} + +pub fn bundle_overrides_map( + query: &UseQuery, +) -> HashMap<(String, String), String> { + super::state::settled_or_loading(query).unwrap_or_default() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BundleUpdatesQuery { + pub cluster_id: ClusterId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct BundleUpdatesKeys { + pub cluster_id: ClusterId, +} + +impl QueryCapability for BundleUpdatesQuery { + type Ok = BundleUpdateCheckResult; + type Err = LauncherError; + type Keys = BundleUpdatesKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let _ = keys; + let state = crate::launcher::state()?; + Ok(oneclient_core::check_bundle_updates( + self.cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await?) + } +} + +pub fn use_bundle_updates(cluster_id: ClusterId) -> UseQuery { + use_query(Query::new( + BundleUpdatesKeys { cluster_id }, + BundleUpdatesQuery { cluster_id }, + )) +} diff --git a/packages/oneclient_app/src/hooks/queries/changelog.rs b/packages/oneclient_app/src/hooks/queries/changelog.rs new file mode 100644 index 00000000..3f4d0eab --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/changelog.rs @@ -0,0 +1,44 @@ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::{ChangelogGroup, LauncherError, fetch_changelog, parse_changelog}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ChangelogKeys { + pub meta_url_base: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ChangelogQuery; + +impl QueryCapability for ChangelogQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = ChangelogKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let markdown = fetch_changelog(&state.services.requester).await?; + Ok(parse_changelog(&markdown)) + } +} + +pub fn use_changelog() -> UseQuery { + let meta_url_base = super::use_meta_url_key(); + + use_query(Query::new(ChangelogKeys { meta_url_base }, ChangelogQuery)) +} + +pub fn changelog_groups(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} + +pub fn latest_changelog_version(query: &UseQuery) -> Option { + changelog_groups(query).and_then(|groups| groups.first().map(|group| group.version.clone())) +} + +pub fn changelog_error(query: &UseQuery) -> Option { + super::state::query_error(query) +} + +pub fn changelog_is_loading(query: &UseQuery) -> bool { + super::state::query_is_loading(query) +} diff --git a/packages/oneclient_app/src/hooks/queries/cluster_content.rs b/packages/oneclient_app/src/hooks/queries/cluster_content.rs new file mode 100644 index 00000000..196d57f5 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/cluster_content.rs @@ -0,0 +1,52 @@ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_content::packages::{ContentType, PackageStore}; +use oneclient_core::{LauncherError, LinkedArtifactInfo}; +use oneclient_db::models::ClusterId; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterContentQuery { + pub cluster_id: ClusterId, + pub content_type: ContentType, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterContentKeys { + pub cluster_id: ClusterId, + pub content_type: ContentType, +} + +impl QueryCapability for ClusterContentQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = ClusterContentKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let all = + PackageStore::list_linked_artifacts(self.cluster_id, &state.services.content()).await?; + Ok(all + .into_iter() + .filter(|item| item.content_type == self.content_type) + .collect()) + } +} + +pub fn use_cluster_content( + cluster_id: ClusterId, + content_type: ContentType, +) -> UseQuery { + use_query(Query::new( + ClusterContentKeys { + cluster_id, + content_type, + }, + ClusterContentQuery { + cluster_id, + content_type, + }, + )) +} + +pub fn cluster_content_items(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query).unwrap_or_default() +} diff --git a/packages/oneclient_app/src/hooks/queries/clusters.rs b/packages/oneclient_app/src/hooks/queries/clusters.rs new file mode 100644 index 00000000..a8bc5bea --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/clusters.rs @@ -0,0 +1,31 @@ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::LauncherError; +use oneclient_core::clusters::Cluster; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListClustersQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListClustersKeys; + +impl QueryCapability for ListClustersQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = ListClustersKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + Ok(state.clusters.list().await?) + } +} + +pub fn use_clusters() -> UseQuery { + use_query(Query::new(ListClustersKeys, ListClustersQuery)) +} + +/// A lookup not a fetch there is no per-cluster query the list is always +/// loaded and small A hook because it subscribes to the list +pub fn use_cluster(cluster_id: i64) -> Option { + let clusters = super::state::settled_or_loading(&use_clusters()).unwrap_or_default(); + clusters.into_iter().find(|c| c.id == cluster_id) +} diff --git a/packages/oneclient_app/src/hooks/queries/image.rs b/packages/oneclient_app/src/hooks/queries/image.rs new file mode 100644 index 00000000..e35c94e2 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/image.rs @@ -0,0 +1,58 @@ +use std::time::Duration; + +use bytes::Bytes; +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::LauncherError; + +const IMAGE_STALE: Duration = Duration::from_secs(60 * 60); +const IMAGE_CLEAN: Duration = Duration::from_secs(6 * 60 * 60); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CachedImageQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct CachedImageKeys { + pub url: String, + pub max_edge: u32, +} + +impl QueryCapability for CachedImageQuery { + type Ok = Bytes; + type Err = LauncherError; + type Keys = CachedImageKeys; + + #[tracing::instrument(name = "cached_image", level = "debug", skip(self, keys), fields(url = %keys.url, max_edge = keys.max_edge))] + async fn run(&self, keys: &Self::Keys) -> Result { + if keys.url.is_empty() { + return Err(LauncherError::Minecraft("no image url".to_string())); + } + + let state = crate::launcher::state()?; + state + .images + .get(&state.services.requester, &keys.url, keys.max_edge) + .await + } +} + +/// Returns the url alongside the bytes callers key their image handle on it +/// and a stale url with fresh bytes renders the wrong picture for a frame +pub fn loaded_image( + url: Option<&str>, + query: &UseQuery, +) -> Option<(String, Bytes)> { + let url = url?; + let bytes = super::state::settled_or_loading(query)?; + Some((url.to_string(), bytes)) +} + +pub fn use_cached_image(url: Option, max_edge: u32) -> UseQuery { + let url = url.unwrap_or_default(); + let keys = (!url.is_empty()).then_some(CachedImageKeys { url, max_edge }); + + use_query( + Query::new(keys, CachedImageQuery) + .stale_time(IMAGE_STALE) + .clean_time(IMAGE_CLEAN), + ) +} diff --git a/packages/oneclient_app/src/hooks/queries/java.rs b/packages/oneclient_app/src/hooks/queries/java.rs new file mode 100644 index 00000000..b65ec305 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/java.rs @@ -0,0 +1,68 @@ +use freya::query::{QueriesStorage, Query, QueryCapability, UseQuery, use_query}; +use oneclient_java::{AvailableJava, JavaRuntime, JavaVendor}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListJavaRuntimesQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListJavaRuntimesKeys; + +impl QueryCapability for ListJavaRuntimesQuery { + type Ok = Vec; + type Err = String; + type Keys = ListJavaRuntimesKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state().map_err(|e| e.to_string())?; + state.java.list_runtimes().await.map_err(|e| e.to_string()) + } +} + +pub fn use_java_runtimes() -> UseQuery { + use_query(Query::new(ListJavaRuntimesKeys, ListJavaRuntimesQuery)) +} + +pub fn java_runtimes(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query).unwrap_or_default() +} + +pub async fn invalidate_java_queries() { + QueriesStorage::::invalidate_all().await; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ProviderVersionsQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ProviderVersionsKeys { + pub vendor: JavaVendor, +} + +impl QueryCapability for ProviderVersionsQuery { + type Ok = Vec; + type Err = String; + type Keys = ProviderVersionsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state().map_err(|e| e.to_string())?; + state + .java + .available_versions(&keys.vendor) + .await + .map_err(|e| e.to_string()) + } +} + +pub fn use_provider_versions(vendor: JavaVendor) -> UseQuery { + use_query(Query::new( + ProviderVersionsKeys { vendor }, + ProviderVersionsQuery, + )) +} + +pub fn provider_versions(query: &UseQuery) -> (Vec, bool) { + ( + super::state::settled_or_loading(query).unwrap_or_default(), + super::state::query_is_busy(query), + ) +} diff --git a/packages/oneclient_app/src/hooks/queries/logs.rs b/packages/oneclient_app/src/hooks/queries/logs.rs new file mode 100644 index 00000000..c1718ffe --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/logs.rs @@ -0,0 +1,152 @@ +use std::path::PathBuf; + +use freya::query::{ + Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, + use_mutation, use_query, +}; +use oneclient_core::{ + LauncherError, LogFileInfo, LogLevel, LogLine, MclogsUploadResponse, ReadOptions, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterLogsKeys { + pub cluster_id: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterLogsQuery; + +impl QueryCapability for ClusterLogsQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = ClusterLogsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let cluster = state.clusters.get(keys.cluster_id).await?; + Ok(oneclient_core::list_cluster_logs(&cluster)?) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct LogContentKeys { + pub path: PathBuf, + pub level: Option, + pub search: Option, + pub max_lines: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LogContentQuery; + +impl QueryCapability for LogContentQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = LogContentKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + if keys.path.as_os_str().is_empty() { + return Ok(Vec::new()); + } + Ok(oneclient_core::read_log_at( + &keys.path, + &ReadOptions { + level_filter: keys.level, + search: keys.search.clone(), + max_lines: keys.max_lines, + }, + ) + .await?) + } +} + +pub fn use_cluster_logs(cluster_id: i64) -> UseQuery { + use_query(Query::new(ClusterLogsKeys { cluster_id }, ClusterLogsQuery)) +} + +pub fn use_log_content( + path: PathBuf, + level: Option, + search: Option, + max_lines: Option, +) -> UseQuery { + use_query(Query::new( + LogContentKeys { + path, + level, + search, + max_lines, + }, + LogContentQuery, + )) +} + +pub fn try_cluster_logs(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} + +pub fn try_log_content(query: &UseQuery) -> Option> { + super::state::settled_or_loading(query) +} + +pub async fn invalidate_logs_queries() { + QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_all().await; +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct UploadLogKeys { + pub path: PathBuf, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct UploadLogMutation; + +impl MutationCapability for UploadLogMutation { + type Ok = MclogsUploadResponse; + type Err = LauncherError; + type Keys = UploadLogKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + Ok(oneclient_core::upload_log_at(&state.services.requester, &keys.path).await?) + } +} + +pub type UseUploadLog = UseMutation; + +pub fn use_upload_log() -> UseUploadLog { + use_mutation(Mutation::new(UploadLogMutation)) +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum LogAction { + Delete { path: PathBuf }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LogActionMutation; + +impl MutationCapability for LogActionMutation { + type Ok = (); + type Err = LauncherError; + type Keys = LogAction; + + async fn run(&self, keys: &Self::Keys) -> Result { + match keys { + LogAction::Delete { path } => Ok(oneclient_core::delete_log_at(path).await?), + } + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_logs_queries().await; + } + } +} + +pub type UseLogAction = UseMutation; + +pub fn use_log_action() -> UseLogAction { + use_mutation(Mutation::new(LogActionMutation)) +} diff --git a/packages/oneclient_app/src/hooks/queries/migration.rs b/packages/oneclient_app/src/hooks/queries/migration.rs new file mode 100644 index 00000000..4a9d862c --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/migration.rs @@ -0,0 +1,52 @@ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::{ + LauncherError, MigrationDetection, detect_migration, resolve_migration_chain, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MigrationQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MigrationKeys; + +impl QueryCapability for MigrationQuery { + type Ok = Option; + type Err = LauncherError; + type Keys = MigrationKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let Some(mut detection) = detect_migration().await? else { + return Ok(None); + }; + + // Runs before launcher migrations walks the source instance up the + // migration chain so it can be imported into the correct cluster + let rules = crate::launcher::state()?.versions.migrations().await; + if !rules.is_empty() { + for instance in &mut detection.instances { + if instance.mc_version.is_empty() { + continue; + } + let resolved = + resolve_migration_chain(&instance.mc_version, instance.mc_loader, &rules); + if resolved != instance.mc_version { + instance.target_mc_version = Some(resolved); + } + } + } + + Ok(Some(detection)) + } +} + +pub fn use_migration() -> UseQuery { + use_query(Query::new(MigrationKeys, MigrationQuery)) +} + +pub fn migration_detection(query: &UseQuery) -> Option { + super::state::settled_or_loading(query).flatten() +} + +pub fn has_migration_data(query: &UseQuery) -> bool { + migration_detection(query).is_some_and(|d| !d.instances.is_empty()) +} diff --git a/packages/oneclient_app/src/hooks/queries/mod.rs b/packages/oneclient_app/src/hooks/queries/mod.rs new file mode 100644 index 00000000..85dd4317 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/mod.rs @@ -0,0 +1,101 @@ +use crate::hooks::use_settings_snapshot; + +fn use_meta_url_key() -> String { + use_settings_snapshot() + .settings + .custom_meta_url_base + .unwrap_or_default() + .trim() + .to_string() +} + +mod analytics; +mod auth; +mod bundles; +mod changelog; +mod cluster_content; +mod clusters; +mod image; +mod java; +mod logs; +mod migration; +mod mutations; +mod package_updates; +mod packages; +mod player_profile; +mod screenshots; +mod settings_profiles; +mod skin; +mod state; +mod storage; +mod tos; +mod version_metadata; +mod versions; + +pub use analytics::{ + try_cluster_analytics, try_global_analytics, use_cluster_analytics, use_global_analytics, +}; +pub use auth::{ + AddOfflineAccountKeys, BeginMicrosoftLoginMutation, CancelMicrosoftLoginKeys, + CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys, + RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount, + UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error, + mutation_is_pending, mutation_is_running, reset_login_code_dedup, try_account, try_accounts, + try_default_account, use_account, use_accounts, use_add_microsoft_account, + use_add_offline_account, use_begin_microsoft_login, use_cancel_microsoft_login, + use_current_account, use_default_account, use_finish_microsoft_login, use_refresh_account, + use_refresh_all_accounts, use_remove_account, use_set_default_account, +}; +pub use bundles::{ + ClusterBundles, OnboardingBundlesQuery, bundle_overrides_map, bundles_with_status_items, + onboarding_bundles_items, use_bundle_overrides, use_bundle_updates, use_bundles_with_status, + use_onboarding_bundles, +}; +pub 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}; +pub use image::{CachedImageQuery, loaded_image, use_cached_image}; +pub use java::{ + invalidate_java_queries, java_runtimes, provider_versions, use_java_runtimes, + use_provider_versions, +}; +pub use logs::{ + ClusterLogsQuery, LogAction, LogContentQuery, UploadLogKeys, UploadLogMutation, UseLogAction, + UseUploadLog, invalidate_logs_queries, try_cluster_logs, try_log_content, use_cluster_logs, + use_log_action, use_log_content, use_upload_log, +}; +pub use migration::{MigrationQuery, has_migration_data, migration_detection, use_migration}; +pub use mutations::{ + ClusterAction, invalidate_cluster_content_queries, invalidate_cluster_queries, + invalidate_profile_queries, use_cluster_mutation, +}; +pub use package_updates::{package_updates, stale_hashes, use_package_updates}; +pub use packages::{ + 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, +}; +pub use player_profile::use_player_profile; +pub use screenshots::{ + ScreenshotAction, UseScreenshotAction, invalidate_screenshots_queries, try_cluster_screenshots, + use_cluster_screenshots, use_local_image, use_screenshot_action, use_screenshot_folder_watch, +}; +pub use settings_profiles::{ + try_game_profile, use_cluster_profile, use_cluster_settings, use_game_profile, + use_named_profiles, +}; +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}; + +pub(crate) use auth::invalidate_auth_queries; diff --git a/packages/oneclient_app/src/hooks/queries/mutations.rs b/packages/oneclient_app/src/hooks/queries/mutations.rs new file mode 100644 index 00000000..89798f29 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/mutations.rs @@ -0,0 +1,231 @@ +use std::path::PathBuf; + +use freya::query::{Mutation, MutationCapability, QueriesStorage, UseMutation, use_mutation}; +use oneclient_content::packages::{ContentType, PackageStore}; +use oneclient_db::models::ClusterId; + +use super::bundles::{BundleOverridesQuery, BundleUpdatesQuery, BundlesWithStatusQuery}; +use super::cluster_content::ClusterContentQuery; +use super::clusters::ListClustersQuery; +use super::package_updates::PackageUpdatesQuery; +use super::settings_profiles::{ + ClusterProfileQuery, ClusterSettingsQuery, GameProfileQuery, ListNamedProfilesQuery, +}; + +async fn timed(step: &'static str, fut: impl std::future::Future) { + let started = std::time::Instant::now(); + fut.await; + tracing::debug!( + target: "oneclient_app::perf", + step, + ms = started.elapsed().as_millis() as u64, + "invalidate step" + ); +} + +pub async fn invalidate_cluster_queries() { + let started = std::time::Instant::now(); + timed( + "cluster_content", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundle_overrides", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundles_with_status", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "clusters", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "bundle_updates", + QueriesStorage::::invalidate_all(), + ) + .await; + timed( + "package_updates", + QueriesStorage::::invalidate_all(), + ) + .await; + tracing::debug!( + target: "oneclient_app::perf", + ms = started.elapsed().as_millis() as u64, + "cluster queries invalidated" + ); +} + +/// Split out of [`invalidate_cluster_queries`] so an install can wait for just +/// this before dropping its busy flag +pub async fn invalidate_cluster_content_queries() { + QueriesStorage::::invalidate_all().await; +} + +pub async fn invalidate_profile_queries() { + QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_all().await; +} + +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct ClusterMutation; + +#[derive(Clone, PartialEq, Eq, Hash)] +pub enum ClusterAction { + ToggleArtifact { + cluster_id: ClusterId, + hash: String, + }, + RemoveArtifact { + cluster_id: ClusterId, + hash: String, + }, + RemoveBundlePackageFromDisk { + cluster_id: ClusterId, + hash: String, + }, + SetBundlePackageEnabled { + cluster_id: ClusterId, + bundle_name: String, + package_id: String, + enabled: bool, + /// Manifest default matching it clears the override contradicting it + /// writes `Enabled` / `Disabled` + manifest_default: bool, + }, + ImportLocalFile { + cluster_id: ClusterId, + content_type: ContentType, + path: PathBuf, + }, + SetDedicatedDir { + cluster_id: ClusterId, + dedicated: bool, + }, + VerifyFiles { + cluster_id: ClusterId, + }, +} + +impl MutationCapability for ClusterMutation { + type Ok = (); + type Err = String; + type Keys = ClusterAction; + + async fn run(&self, keys: &ClusterAction) -> Result<(), String> { + let started = std::time::Instant::now(); + let state = crate::launcher::state().map_err(|e| e.to_string())?; + 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::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 + } + ClusterAction::SetBundlePackageEnabled { + cluster_id, + bundle_name, + package_id, + enabled, + manifest_default, + } => { + oneclient_core::set_bundle_package_enabled( + *cluster_id, + bundle_name, + package_id, + *enabled, + *manifest_default, + content, + ) + .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 + .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 + match oneclient_core::verify_cluster_files(&state, *cluster_id).await { + Ok(report) => { + let notify = services.events.notify("Verification complete"); + let notify = notify.body(report.summary()); + if report.unrepairable.is_empty() { + notify.send(); + } else { + notify.error().send(); + } + Ok(()) + } + Err(err) => Err(oneclient_content::ContentError::InvalidData { + reason: err.to_string(), + }), + } + } + }; + tracing::debug!( + target: "oneclient_app::perf", + ms = started.elapsed().as_millis() as u64, + ok = result.is_ok(), + "cluster action ran" + ); + result.map_err(|e| e.to_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(); + } + invalidate_cluster_queries().await; + } +} + +pub fn use_cluster_mutation() -> UseMutation { + use_mutation(Mutation::new(ClusterMutation)) +} diff --git a/packages/oneclient_app/src/hooks/queries/package_updates.rs b/packages/oneclient_app/src/hooks/queries/package_updates.rs new file mode 100644 index 00000000..8d88fd52 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/package_updates.rs @@ -0,0 +1,50 @@ +use std::collections::HashSet; + +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::{BrowserPackageUpdate, LauncherError}; +use oneclient_db::models::ClusterId; + +/// Reads the cache table only rendering a package list must never talk to a +/// provider The check itself runs on launch +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageUpdatesQuery { + pub cluster_id: ClusterId, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageUpdatesKeys { + pub cluster_id: ClusterId, +} + +impl QueryCapability for PackageUpdatesQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = PackageUpdatesKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + Ok(oneclient_core::cached_browser_package_updates( + self.cluster_id, + &state.services.content(), + ) + .await?) + } +} + +pub fn use_package_updates(cluster_id: ClusterId) -> UseQuery { + use_query(Query::new( + PackageUpdatesKeys { cluster_id }, + PackageUpdatesQuery { cluster_id }, + )) +} + +pub fn package_updates(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query).unwrap_or_default() +} + +pub fn stale_hashes(query: &UseQuery) -> HashSet { + package_updates(query) + .into_iter() + .map(|update| update.hash) + .collect() +} diff --git a/packages/oneclient_app/src/hooks/queries/packages.rs b/packages/oneclient_app/src/hooks/queries/packages.rs new file mode 100644 index 00000000..548276b9 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/packages.rs @@ -0,0 +1,313 @@ +use std::time::Duration; + +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_common::domain::GameLoader; +use oneclient_common::search::normalize_query; +use oneclient_content::packages::types::{ + DEFAULT_PAGE_SIZE, Page, ProjectDetail, ProjectSummary, SearchFilters, SearchSort, + VersionSummary, +}; +use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId}; +use oneclient_core::LauncherError; + +pub const BROWSE_PAGE_SIZE: usize = DEFAULT_PAGE_SIZE; +pub const VERSIONS_PAGE_SIZE: usize = 20; + +const VERSIONS_STALE: Duration = Duration::from_secs(5 * 60); + +pub fn content_type_for_slug(slug: &str) -> ContentType { + match slug { + "shader" => ContentType::Shader, + "texture" => ContentType::ResourcePack, + _ => ContentType::Mod, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageSearchQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PackageSearchKeys { + pub provider: ProviderId, + pub content_type: ContentType, + pub query: String, + pub game_versions: Vec, + pub loaders: Vec, + pub categories: Vec, + pub sort: SearchSort, + pub page: usize, +} + +impl QueryCapability for PackageSearchQuery { + type Ok = Page; + type Err = LauncherError; + type Keys = PackageSearchKeys; + + #[tracing::instrument(name = "package_search", level = "debug", skip(self, keys), fields(provider = ?keys.provider, page = keys.page))] + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let provider = state.services.packages.get(keys.provider)?; + Ok(provider + .search( + &SearchFilters { + query: (!keys.query.is_empty()).then(|| keys.query.clone()), + content_type: Some(keys.content_type), + game_versions: (!keys.game_versions.is_empty()) + .then(|| keys.game_versions.clone()), + loaders: (!keys.loaders.is_empty()).then(|| keys.loaders.clone()), + categories: (!keys.categories.is_empty()).then(|| keys.categories.clone()), + sort: Some(keys.sort), + offset: Some(keys.page * BROWSE_PAGE_SIZE), + limit: Some(BROWSE_PAGE_SIZE), + }, + &state.services.content(), + ) + .await?) + } +} + +#[allow(clippy::too_many_arguments)] +pub fn use_package_search( + provider: ProviderId, + content_type: ContentType, + query: String, + game_versions: Vec, + loaders: Vec, + categories: Vec, + sort: SearchSort, + page: usize, +) -> UseQuery { + use_query(Query::new( + PackageSearchKeys { + provider, + content_type, + // The key is also the cache key without normalizing "sodium" and + // "sodium " are two entries and a trailing space refetches for nothing + query: normalize_query(&query), + game_versions, + loaders, + categories, + sort, + page, + }, + PackageSearchQuery, + )) +} + +pub fn search_items(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query) + .map(|page| page.items) + .unwrap_or_default() +} + +pub fn search_total(query: &UseQuery) -> usize { + super::state::settled_or_loading(query) + .map(|page| page.total) + .unwrap_or(0) +} + +pub fn search_pending(query: &UseQuery) -> bool { + super::state::query_is_busy(query) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageProjectQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PackageProjectKeys { + pub provider: ProviderId, + pub project_id: String, +} + +impl QueryCapability for PackageProjectQuery { + type Ok = ProjectDetail; + type Err = LauncherError; + type Keys = PackageProjectKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let provider = state.services.packages.get(keys.provider)?; + Ok(provider + .get_project_with_body(&keys.project_id, &state.services.content()) + .await?) + } +} + +pub fn use_package_project( + provider: ProviderId, + project_id: String, +) -> UseQuery { + use_query(Query::new( + PackageProjectKeys { + provider, + project_id, + }, + PackageProjectQuery, + )) +} + +pub fn project_detail(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageMetaBatchQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PackageMetaBatchKeys { + pub provider: ProviderId, + pub project_ids: Vec, +} + +impl QueryCapability for PackageMetaBatchQuery { + type Ok = std::collections::HashMap; + type Err = LauncherError; + type Keys = PackageMetaBatchKeys; + + #[tracing::instrument(name = "package_meta_batch", level = "debug", skip(self, keys), fields(provider = ?keys.provider, ids = keys.project_ids.len()))] + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + Ok(oneclient_content::packages::fetch_package_meta( + &state.services.content(), + keys.provider, + &keys.project_ids, + ) + .await?) + } +} + +pub fn use_package_meta_batch( + provider: ProviderId, + mut project_ids: Vec, +) -> UseQuery { + project_ids.sort(); + project_ids.dedup(); + use_query(Query::new( + PackageMetaBatchKeys { + provider, + project_ids, + }, + PackageMetaBatchQuery, + )) +} + +pub fn package_meta_batch( + query: &UseQuery, +) -> std::collections::HashMap { + super::state::settled_or_loading(query).unwrap_or_default() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageVersionsQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PackageVersionsKeys { + pub provider: ProviderId, + pub project_id: String, + pub game_version: Option, + pub loader: Option, + pub page: usize, +} + +impl QueryCapability for PackageVersionsQuery { + type Ok = Page; + type Err = LauncherError; + type Keys = PackageVersionsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let provider = state.services.packages.get(keys.provider)?; + Ok(provider + .list_versions( + &keys.project_id, + keys.game_version.as_deref(), + keys.loader, + keys.page * VERSIONS_PAGE_SIZE, + VERSIONS_PAGE_SIZE, + &state.services.content(), + ) + .await?) + } +} + +pub fn use_package_versions( + provider: ProviderId, + project_id: String, + game_version: Option, + loader: Option, + page: usize, +) -> UseQuery { + use_package_versions_when(true, provider, project_id, game_version, loader, page) +} + +pub fn use_package_versions_when( + enabled: bool, + provider: ProviderId, + project_id: String, + game_version: Option, + loader: Option, + page: usize, +) -> UseQuery { + let keys = enabled.then_some(PackageVersionsKeys { + provider, + project_id, + game_version, + loader, + page, + }); + + use_query(Query::new(keys, PackageVersionsQuery).stale_time(VERSIONS_STALE)) +} + +pub fn version_list(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query) + .map(|page| page.items) + .unwrap_or_default() +} + +pub fn versions_total(query: &UseQuery) -> usize { + super::state::settled_or_loading(query) + .map(|page| page.total) + .unwrap_or(0) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageCategoriesQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct PackageCategoriesKeys { + pub provider: ProviderId, + pub content_type: ContentType, +} + +impl QueryCapability for PackageCategoriesQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = PackageCategoriesKeys; + + #[tracing::instrument(name = "package_categories", level = "debug", skip(self, keys), fields(provider = ?keys.provider))] + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let provider = state.services.packages.get(keys.provider)?; + Ok(provider + .list_categories(keys.content_type, &state.services.content()) + .await?) + } +} + +pub fn use_package_categories( + provider: ProviderId, + content_type: ContentType, +) -> UseQuery { + use_query(Query::new( + PackageCategoriesKeys { + provider, + content_type, + }, + PackageCategoriesQuery, + )) +} + +pub fn category_list(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query).unwrap_or_default() +} diff --git a/packages/oneclient_app/src/hooks/queries/player_profile.rs b/packages/oneclient_app/src/hooks/queries/player_profile.rs new file mode 100644 index 00000000..ec19930f --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/player_profile.rs @@ -0,0 +1,57 @@ +use std::time::Duration; + +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::LauncherError; +use oneclient_mc::{self as minecraft, PlayerProfileView}; + +const PROFILE_STALE: Duration = Duration::from_secs(30 * 60); +const PROFILE_CLEAN: Duration = Duration::from_secs(2 * 60 * 60); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct FetchPlayerProfileQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PlayerProfileQueryKeys { + pub uuid: String, + pub access_token: Option, +} + +impl QueryCapability for FetchPlayerProfileQuery { + type Ok = PlayerProfileView; + type Err = LauncherError; + type Keys = PlayerProfileQueryKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let access_token = keys.access_token.clone(); + + let state = crate::launcher::state()?; + let client = &state.services.requester; + + Ok( + minecraft::fetch_player_profile_view(client, &keys.uuid, access_token.as_deref()) + .await?, + ) + } + + // TODO Cache + // fn matches(&self, keys: &Self::Keys) -> bool { + // keys.uuid.as_deref().is_some_and(|id| !id.is_empty()) + // } +} + +pub fn use_player_profile( + uuid: impl Into, + access_token: Option>, +) -> UseQuery { + use_query( + Query::new( + PlayerProfileQueryKeys { + uuid: uuid.into(), + access_token: access_token.map(|s| s.into()), + }, + FetchPlayerProfileQuery, + ) + .stale_time(PROFILE_STALE) + .clean_time(PROFILE_CLEAN), + ) +} diff --git a/packages/oneclient_app/src/hooks/queries/screenshots.rs b/packages/oneclient_app/src/hooks/queries/screenshots.rs new file mode 100644 index 00000000..29625df6 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/screenshots.rs @@ -0,0 +1,215 @@ +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use bytes::Bytes; +use freya::prelude::{spawn, use_hook}; +use freya::query::{ + Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, + use_mutation, use_query, +}; +use notify::{EventKind, RecursiveMode, Watcher}; +use oneclient_core::{LauncherError, ScreenshotInfo}; +use tokio::sync::{Semaphore, mpsc}; + +static LOCAL_IMAGE_SEMAPHORE: OnceLock> = OnceLock::new(); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterScreenshotsKeys { + pub cluster_id: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterScreenshotsQuery; + +impl QueryCapability for ClusterScreenshotsQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = ClusterScreenshotsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let cluster = state.clusters.get(keys.cluster_id).await?; + Ok(oneclient_core::list_cluster_screenshots(&cluster)?) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct LocalImageKeys { + pub path: PathBuf, + pub max_edge: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LocalImageQuery; + +impl QueryCapability for LocalImageQuery { + type Ok = Bytes; + type Err = LauncherError; + type Keys = LocalImageKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let path = keys.path.clone(); + let max_edge = (keys.max_edge != 0).then_some(keys.max_edge); + + let _permit = if max_edge.is_some() { + let sem = LOCAL_IMAGE_SEMAPHORE + .get_or_init(|| Arc::new(Semaphore::new(1))) + .clone(); + Some( + sem.acquire_owned() + .await + .map_err(|_| LauncherError::Minecraft("local image semaphore closed".into()))?, + ) + } else { + None + }; + + Ok( + tokio::task::spawn_blocking(move || oneclient_core::load_screenshot(&path, max_edge)) + .await + .map_err(|e| LauncherError::Minecraft(e.to_string()))??, + ) + } +} + +pub fn use_cluster_screenshots(cluster_id: i64) -> UseQuery { + use_query(Query::new( + ClusterScreenshotsKeys { cluster_id }, + ClusterScreenshotsQuery, + )) +} + +pub fn use_local_image(path: PathBuf, max_edge: u32) -> UseQuery { + use_query(Query::new( + LocalImageKeys { path, max_edge }, + LocalImageQuery, + )) +} + +const WATCH_QUIET: Duration = Duration::from_millis(400); + +pub fn use_screenshot_folder_watch( + folder: Option, + query: UseQuery, +) { + use_hook(move || { + let Some(folder) = folder else { + return; + }; + + spawn(async move { + if let Err(err) = watch_folder(&folder, query).await { + tracing::warn!( + folder = %folder.display(), + error = %err, + "not watching the screenshot folder; the list will refresh on re-entry only" + ); + } + }); + }); +} + +async fn watch_folder( + folder: &Path, + query: UseQuery, +) -> notify::Result<()> { + // If there is no screenshots folder it creates it + std::fs::create_dir_all(folder)?; + + let (tx, mut rx) = mpsc::unbounded_channel(); + let mut watcher = + notify::recommended_watcher(move |event: notify::Result| match event { + Ok(event) if touches_image(&event) => { + let _ = tx.send(()); + } + Ok(_) => {} + Err(err) => tracing::debug!(error = %err, "screenshot watcher reported an error"), + })?; + watcher.watch(folder, RecursiveMode::NonRecursive)?; + + let mut pending = false; + loop { + tokio::select! { + biased; + + event = rx.recv() => { + if event.is_none() { + return Ok(()); + } + pending = true; + } + + () = quiet_period(pending) => { + pending = false; + query.invalidate(); + } + } + } +} + +async fn quiet_period(pending: bool) { + if pending { + tokio::time::sleep(WATCH_QUIET).await; + } else { + std::future::pending::<()>().await; + } +} + +fn touches_image(event: ¬ify::Event) -> bool { + if matches!(event.kind, EventKind::Access(_)) { + return false; + } + + event.paths.iter().any(|path| { + path.extension().and_then(OsStr::to_str).is_some_and(|ext| { + ["png", "jpg", "jpeg"] + .iter() + .any(|known| ext.eq_ignore_ascii_case(known)) + }) + }) +} + +pub fn try_cluster_screenshots( + query: &UseQuery, +) -> Option> { + super::state::settled_or_loading(query) +} + +pub async fn invalidate_screenshots_queries() { + QueriesStorage::::invalidate_all().await; + QueriesStorage::::invalidate_all().await; +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum ScreenshotAction { + Delete { path: PathBuf }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ScreenshotActionMutation; + +impl MutationCapability for ScreenshotActionMutation { + type Ok = (); + type Err = LauncherError; + type Keys = ScreenshotAction; + + async fn run(&self, keys: &Self::Keys) -> Result { + match keys { + ScreenshotAction::Delete { path } => Ok(oneclient_core::delete_screenshot(path)?), + } + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_screenshots_queries().await; + } + } +} + +pub type UseScreenshotAction = UseMutation; + +pub fn use_screenshot_action() -> UseScreenshotAction { + use_mutation(Mutation::new(ScreenshotActionMutation)) +} diff --git a/packages/oneclient_app/src/hooks/queries/settings_profiles.rs b/packages/oneclient_app/src/hooks/queries/settings_profiles.rs new file mode 100644 index 00000000..6977b829 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/settings_profiles.rs @@ -0,0 +1,136 @@ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +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)] +pub struct ListNamedProfilesQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ListNamedProfilesKeys; + +impl QueryCapability for ListNamedProfilesQuery { + type Ok = Vec; + type Err = String; + type Keys = ListNamedProfilesKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state().map_err(|e| e.to_string())?; + list_named_profiles(&state.services.db) + .await + .map_err(|e| e.to_string()) + } +} + +pub fn use_named_profiles() -> UseQuery { + use_query(Query::new(ListNamedProfilesKeys, ListNamedProfilesQuery)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct GameProfileQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct GameProfileKeys { + pub name: Option, +} + +impl QueryCapability for GameProfileQuery { + type Ok = GameSettingsProfile; + type Err = String; + type Keys = GameProfileKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let name = keys.name.clone(); + let state = crate::launcher::state().map_err(|e| e.to_string())?; + let global = state.settings.read().global_game_settings.clone(); + get_profile_or_default(&state.services.db, &global, name.as_deref()) + .await + .map_err(|e| e.to_string()) + } +} + +pub fn use_game_profile(name: Option>) -> UseQuery { + use_query(Query::new( + GameProfileKeys { + name: name.map(Into::into), + }, + GameProfileQuery, + )) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterProfileQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ClusterProfileKeys { + pub profile_name: Option, +} + +impl QueryCapability for ClusterProfileQuery { + type Ok = GameSettingsProfile; + type Err = String; + type Keys = ClusterProfileKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let profile_name = keys.profile_name.clone(); + let state = crate::launcher::state().map_err(|e| e.to_string())?; + let global = state.settings.read().global_game_settings.clone(); + resolve_cluster_profile(&state.services.db, &global, profile_name.as_deref()) + .await + .map_err(|e| e.to_string()) + } +} + +pub fn use_cluster_profile( + profile_name: Option>, +) -> UseQuery { + use_query(Query::new( + ClusterProfileKeys { + profile_name: profile_name.map(Into::into), + }, + ClusterProfileQuery, + )) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterSettingsQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ClusterSettingsKeys { + pub cluster_id: ClusterId, +} + +impl QueryCapability for ClusterSettingsQuery { + type Ok = GameSettingsProfile; + type Err = String; + type Keys = ClusterSettingsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let cluster_id = keys.cluster_id; + let state = crate::launcher::state().map_err(|e| e.to_string())?; + let cluster = state + .clusters + .get(cluster_id) + .await + .map_err(|e| e.to_string())?; + let global = state.settings.read().global_game_settings.clone(); + state + .clusters + .resolve_settings(&global, &cluster) + .await + .map_err(|e| e.to_string()) + } +} + +pub fn use_cluster_settings(cluster_id: ClusterId) -> UseQuery { + use_query(Query::new( + ClusterSettingsKeys { cluster_id }, + ClusterSettingsQuery, + )) +} + +pub fn try_game_profile(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} diff --git a/packages/oneclient_app/src/hooks/queries/skin.rs b/packages/oneclient_app/src/hooks/queries/skin.rs new file mode 100644 index 00000000..10ab6609 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/skin.rs @@ -0,0 +1,32 @@ +use bytes::Bytes; +use freya::prelude::*; + +use crate::AppAssets; + +pub fn use_player_skin(uuid: String) -> (Bytes, bool) { + let profile = super::use_player_profile(uuid.clone(), None::); + + let (skin_url, is_slim) = crate::hooks::settled_or_loading(&profile) + .map_or((None, false), |profile| (profile.skin_url, profile.is_slim)); + + let skin_query = super::use_cached_image(skin_url.clone(), 256); + + let steve = use_memo(|| AppAssets::get_bytes("steve.png").unwrap_or_default()); + let alex = use_memo(|| AppAssets::get_bytes("alex.png").unwrap_or_default()); + + let default_slim = (java_string_hash(&uuid) & 1) == 1; + + match crate::hooks::loaded_image(skin_url.as_deref(), &skin_query) { + Some((_, bytes)) => (bytes, is_slim), + None if default_slim => (alex.read().clone(), true), + None => (steve.read().clone(), false), + } +} + +fn java_string_hash(s: &str) -> i32 { + let mut h: i32 = 0; + for c in s.encode_utf16() { + h = h.wrapping_mul(31).wrapping_add(c as i32); + } + h +} diff --git a/packages/oneclient_app/src/hooks/queries/state.rs b/packages/oneclient_app/src/hooks/queries/state.rs new file mode 100644 index 00000000..63e5f3ac --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/state.rs @@ -0,0 +1,53 @@ +//! Query state readers The useful cases straddle two `QueryStateData` +//! variants a refetch still holds the previous value the UI wants to show + +use std::fmt::Display; + +use freya::query::{QueryCapability, QueryStateData, UseQuery}; + +/// Keeps the previous value visible across a refetch `None` means there has +/// never been a successful result (not finished yet or only errors so far) +pub fn settled_or_loading(query: &UseQuery) -> Option +where + Q: QueryCapability, + Q::Ok: Clone, +{ + let reader = query.read(); + let state = reader.state(); + state.ok().cloned() +} + +/// Not reported while loading a refetch in flight has not failed yet even if +/// the previous attempt did +pub fn query_error(query: &UseQuery) -> Option +where + Q: QueryCapability, + Q::Err: Display, +{ + let reader = query.read(); + let state = reader.state(); + match &*state { + QueryStateData::Settled { res: Err(err), .. } => Some(err.to_string()), + _ => None, + } +} + +/// A refetch still holding a previous value is *not* loading here Use +/// [`query_is_busy`] where the distinction matters +pub fn query_is_loading(query: &UseQuery) -> bool { + let reader = query.read(); + let state = reader.state(); + matches!( + &*state, + QueryStateData::Pending | QueryStateData::Loading { res: None } + ) +} + +pub fn query_is_busy(query: &UseQuery) -> bool { + let reader = query.read(); + let state = reader.state(); + matches!( + &*state, + QueryStateData::Pending | QueryStateData::Loading { .. } + ) +} diff --git a/packages/oneclient_app/src/hooks/queries/storage.rs b/packages/oneclient_app/src/hooks/queries/storage.rs new file mode 100644 index 00000000..4db9e814 --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/storage.rs @@ -0,0 +1,84 @@ +use std::time::Duration; + +use freya::query::{ + Mutation, MutationCapability, QueriesStorage, Query, QueryCapability, UseMutation, UseQuery, + use_mutation, use_query, +}; +use oneclient_core::LauncherError; +use oneclient_core::storage::{StorageReport, storage_report}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct StorageReportKeys; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct StorageReportQuery; + +impl QueryCapability for StorageReportQuery { + type Ok = StorageReport; + type Err = LauncherError; + type Keys = StorageReportKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + storage_report(&state).await + } +} + +const STORAGE_STALE_TIME: Duration = Duration::from_secs(120); + +pub fn use_storage_report() -> UseQuery { + use_query(Query::new(StorageReportKeys, StorageReportQuery).stale_time(STORAGE_STALE_TIME)) +} + +pub fn try_storage_report(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} + +pub async fn invalidate_storage_queries() { + QueriesStorage::::invalidate_all().await; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum StorageAction { + CleanUnreferencedCache, + CleanLegacyClusterContent, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct StorageActionMutation; + +impl MutationCapability for StorageActionMutation { + type Ok = (); + type Err = String; + type Keys = StorageAction; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state().map_err(|e| e.to_string())?; + + match keys { + StorageAction::CleanUnreferencedCache => { + oneclient_core::storage::clean_unreferenced_cache(&state) + .await + .map(|_| ()) + } + StorageAction::CleanLegacyClusterContent => { + oneclient_core::storage::clean_legacy_cluster_content(&state) + .await + .map(|_| ()) + } + } + .map_err(|e| e.to_string()) + } + + async fn on_settled(&self, _keys: &Self::Keys, result: &Result) { + if result.is_ok() { + invalidate_storage_queries().await; + } + } +} + +pub type UseStorageAction = UseMutation; + +pub fn use_storage_action() -> UseStorageAction { + use_mutation(Mutation::new(StorageActionMutation)) +} diff --git a/packages/oneclient_app/src/hooks/queries/tos.rs b/packages/oneclient_app/src/hooks/queries/tos.rs new file mode 100644 index 00000000..7add719c --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/tos.rs @@ -0,0 +1,39 @@ +use freya::query::{Query, QueryCapability, UseQuery, use_query}; +use oneclient_core::{LauncherError, TermsDocument, fetch_terms}; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct TermsKeys { + pub meta_url_base: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct TermsQuery; + +impl QueryCapability for TermsQuery { + type Ok = TermsDocument; + type Err = LauncherError; + type Keys = TermsKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + fetch_terms(&state.services.requester).await + } +} + +pub fn use_terms() -> UseQuery { + let meta_url_base = super::use_meta_url_key(); + + use_query(Query::new(TermsKeys { meta_url_base }, TermsQuery)) +} + +pub fn terms_document(query: &UseQuery) -> Option { + super::state::settled_or_loading(query) +} + +pub fn terms_error(query: &UseQuery) -> Option { + super::state::query_error(query) +} + +pub fn terms_is_loading(query: &UseQuery) -> bool { + super::state::query_is_loading(query) +} diff --git a/packages/oneclient_app/src/hooks/queries/version_metadata.rs b/packages/oneclient_app/src/hooks/queries/version_metadata.rs new file mode 100644 index 00000000..e2f5646c --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/version_metadata.rs @@ -0,0 +1,113 @@ +use oneclient_common::VersionKey; +use oneclient_common::domain::GameLoader; +use oneclient_core::VersionMetadata; + +use super::use_versions; + +pub fn pick_version_metadata( + list: &[VersionMetadata], + major: u32, + key: Option, + loader: Option, +) -> Option { + let loader_matches = |m: &VersionMetadata| match (&m.loader, loader) { + (Some(s), Some(l)) => l.to_string().eq_ignore_ascii_case(s), + _ => false, + }; + + if let Some(key) = key { + if let Some(hit) = list + .iter() + .find(|m| m.major_version == major && m.key() == Some(key) && loader_matches(m)) + { + return Some(hit.clone()); + } + if let Some(hit) = list + .iter() + .find(|m| m.major_version == major && m.key() == Some(key)) + { + return Some(hit.clone()); + } + let (minor, _) = key; + if let Some(hit) = list + .iter() + .find(|m| m.major_version == major && m.minor_version == Some(minor)) + { + return Some(hit.clone()); + } + } + + list.iter() + .find(|m| m.major_version == major && m.minor_version.is_none()) + .cloned() +} + +pub fn use_version_metadata( + major: Option, + key: Option, + loader: Option, +) -> Option { + let versions_query = use_versions(); + + let major = major?; + let reader = versions_query.read(); + let state = reader.state(); + let list = state.ok()?; + + pick_version_metadata(list, major, key, loader) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry( + major: u32, + minor: Option, + patch: Option, + loader: Option<&str>, + name: &str, + ) -> VersionMetadata { + VersionMetadata { + major_version: major, + minor_version: minor, + patch_version: patch, + loader: loader.map(str::to_string), + name: name.to_string(), + art_url: None, + long_description: None, + tags: Vec::new(), + predownload: false, + } + } + + fn list() -> Vec { + vec![ + entry(26, None, None, None, "major"), + entry(26, Some(1), None, Some("fabric"), "bare minor"), + entry(26, Some(1), Some(2), Some("fabric"), "patched"), + ] + } + + #[test] + fn patch_and_bare_minor_are_distinct() { + let bare = pick_version_metadata(&list(), 26, Some((1, None)), Some(GameLoader::Fabric)); + assert_eq!(bare.unwrap().name, "bare minor"); + + let patched = + pick_version_metadata(&list(), 26, Some((1, Some(2))), Some(GameLoader::Fabric)); + assert_eq!(patched.unwrap().name, "patched"); + } + + #[test] + fn unknown_patch_falls_back_to_its_minor_line() { + let hit = pick_version_metadata(&list(), 26, Some((1, Some(9))), Some(GameLoader::Fabric)); + assert_eq!(hit.unwrap().name, "bare minor"); + } + + #[test] + fn unknown_minor_falls_back_to_major() { + let hit = pick_version_metadata(&list(), 26, Some((7, None)), Some(GameLoader::Fabric)); + assert_eq!(hit.unwrap().name, "major"); + } +} diff --git a/packages/oneclient_app/src/hooks/queries/versions.rs b/packages/oneclient_app/src/hooks/queries/versions.rs new file mode 100644 index 00000000..d4ff81cf --- /dev/null +++ b/packages/oneclient_app/src/hooks/queries/versions.rs @@ -0,0 +1,87 @@ +use freya::query::{Query, QueryCapability, QueryStateData, UseQuery, use_query}; +use oneclient_common::domain::GameLoader; +use oneclient_core::{LauncherError, VersionMetadata}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct VersionsMetadataQuery; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct VersionsMetadataKeys; + +impl QueryCapability for VersionsMetadataQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = VersionsMetadataKeys; + + async fn run(&self, _keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let metadata = state + .versions + .metadata(&state.services.requester.config().meta_url_base) + .await; + if !metadata.is_empty() { + return Ok(metadata); + } + state.versions.sync(&state.services).await?; + Ok(state + .versions + .metadata(&state.services.requester.config().meta_url_base) + .await) + } +} + +pub fn use_versions() -> UseQuery { + use_query(Query::new(VersionsMetadataKeys, VersionsMetadataQuery)) +} + +/// A failed fetch reads as an empty list rather than `None` so callers that +/// block on this don't wait forever when the network is down +pub fn versions_metadata(query: &UseQuery) -> Option> { + let reader = query.read(); + let state = reader.state(); + match &*state { + QueryStateData::Settled { res: Err(_), .. } => Some(Vec::new()), + other => other.ok().cloned(), + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LoaderVersionsQuery; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct LoaderVersionsKeys { + pub mc_version: String, + pub loader: GameLoader, +} + +impl QueryCapability for LoaderVersionsQuery { + type Ok = Vec; + type Err = LauncherError; + type Keys = LoaderVersionsKeys; + + async fn run(&self, keys: &Self::Keys) -> Result { + let state = crate::launcher::state()?; + let mut metadata = state.metadata.lock().await; + Ok(oneclient_core::get_loader_versions( + &mut metadata, + &state.services.mc(), + &keys.mc_version, + keys.loader, + ) + .await?) + } +} + +pub fn use_loader_versions( + mc_version: String, + loader: GameLoader, +) -> UseQuery { + use_query(Query::new( + LoaderVersionsKeys { mc_version, loader }, + LoaderVersionsQuery, + )) +} + +pub fn loader_versions(query: &UseQuery) -> Vec { + super::state::settled_or_loading(query).unwrap_or_default() +} diff --git a/packages/oneclient_app/src/hooks/view_state.rs b/packages/oneclient_app/src/hooks/view_state.rs new file mode 100644 index 00000000..64696a34 --- /dev/null +++ b/packages/oneclient_app/src/hooks/view_state.rs @@ -0,0 +1,42 @@ +use freya::prelude::*; +use oneclient_core::settings::{ViewLayout, ViewState}; + +use super::{use_dispatch, use_settings_snapshot}; + +#[derive(Clone, Copy)] +pub struct PersistedView { + pub layout: State, + pub sort: State>, +} + +pub fn use_view_state(key: &str) -> PersistedView { + let key = key.to_string(); + let settings = use_settings_snapshot().settings; + let dispatch = use_dispatch(); + + let initial = settings.view_state(&key); + let init_layout = initial.layout; + let init_sort = initial.sort; + let layout = use_state(move || init_layout); + let sort = use_state(move || init_sort.clone()); + + let mut first = use_state(|| true); + use_side_effect(move || { + let next = ViewState { + layout: *layout.read(), + sort: sort.read().clone(), + }; + if *first.peek() { + first.set(false); + return; + } + if settings.view_state(&key) == next { + return; + } + let mut updated = settings.clone(); + updated.set_view_state(key.clone(), next); + dispatch.set_settings(updated); + }); + + PersistedView { layout, sort } +} diff --git a/packages/oneclient_app/src/install.rs b/packages/oneclient_app/src/install.rs new file mode 100644 index 00000000..62961989 --- /dev/null +++ b/packages/oneclient_app/src/install.rs @@ -0,0 +1,342 @@ +use std::sync::Arc; + +use oneclient_core::LauncherState; + +use oneclient_content::packages::PackageStore; +use oneclient_events::Level; + +use crate::components::IconType; +use crate::notifications::{ + ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind, + NotificationSpec, PackageUpdateGroup, +}; + +/// Falls back to a placeholder a missing row must not lose the whole message +pub async fn cluster_display_name( + cluster_id: i64, + services: &oneclient_core::LauncherServices, +) -> String { + PackageStore::get_cluster(cluster_id, &services.content()) + .await + .map(|cluster| cluster.name) + .unwrap_or_else(|_| "Cluster".to_string()) +} + +pub async fn package_update_group( + cluster_id: i64, + updates: &[oneclient_core::BrowserPackageUpdate], + services: &oneclient_core::LauncherServices, +) -> Option { + if updates.is_empty() { + return None; + } + + Some(PackageUpdateGroup { + cluster_id, + cluster_name: cluster_display_name(cluster_id, services).await, + packages: updates.to_vec(), + }) +} + +pub fn item_from_bundle_file(file: &oneclient_core::BundleFile) -> ClusterUpdateItem { + match &file.kind { + oneclient_core::BundleFileKind::Managed { + provider, + project_id, + .. + } => ClusterUpdateItem { + provider: *provider, + project_id: Some(project_id.clone()), + fallback: file.display_name(), + }, + oneclient_core::BundleFileKind::External(_) => ClusterUpdateItem { + provider: oneclient_content::packages::ProviderId::Local, + project_id: None, + fallback: file.display_name(), + }, + } +} + +async fn cluster_update_summary( + cluster_id: i64, + result: &oneclient_core::ApplyBundleUpdatesResult, + services: &oneclient_core::LauncherServices, +) -> Option { + let updated: Vec = result + .updates_applied + .iter() + .map(|u| item_from_bundle_file(&u.new_file)) + .collect(); + let added: Vec = result + .additions_applied + .iter() + .map(|a| item_from_bundle_file(&a.new_file)) + .collect(); + let removed: Vec = result + .removals_applied + .iter() + .map(|r| ClusterUpdateItem { + provider: r + .provider + .unwrap_or(oneclient_content::packages::ProviderId::Local), + project_id: r.project_id.clone(), + fallback: r + .display_name + .clone() + .unwrap_or_else(|| r.package_id.clone()), + }) + .collect(); + + if updated.is_empty() && added.is_empty() && removed.is_empty() { + return None; + } + + let cluster_name = + oneclient_content::packages::PackageStore::get_cluster(cluster_id, &services.content()) + .await + .map(|c| c.name) + .unwrap_or_else(|_| "Cluster".to_string()); + + Some(ClusterUpdateSummary { + cluster_id, + cluster_name, + updated, + added, + removed, + }) +} + +pub async fn cluster_update_notification( + cluster_id: i64, + result: &oneclient_core::ApplyBundleUpdatesResult, + services: &oneclient_core::LauncherServices, +) -> Option { + let summary = cluster_update_summary(cluster_id, result, services).await?; + let total = summary.total(); + let body = format!( + "{total} package{} changed in {}", + if total == 1 { "" } else { "s" }, + summary.cluster_name + ); + + Some(NotificationSpec { + title: "Cluster updated".to_string(), + body, + level: Level::Info, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: vec![NotificationAction { + label: "View changes".to_string(), + kind: NotificationActionKind::OpenClusterUpdate(vec![summary]), + }], + }) +} + +/// All clusters share one "View changes" action so the notification keeps exactly two buttons +pub async fn combined_cluster_update_spec( + changed: &[(i64, oneclient_core::ApplyBundleUpdatesResult)], + services: &oneclient_core::LauncherServices, +) -> Option { + let mut summaries = Vec::new(); + let mut total_changes = 0usize; + + for (cluster_id, result) in changed { + if let Some(summary) = cluster_update_summary(*cluster_id, result, services).await { + total_changes += summary.total(); + summaries.push(summary); + } + } + + if summaries.is_empty() { + return None; + } + + let cluster_count = summaries.len(); + let body = format!( + "{total_changes} package{} updated across {cluster_count} cluster{}", + if total_changes == 1 { "" } else { "s" }, + if cluster_count == 1 { "" } else { "s" } + ); + + Some(NotificationSpec { + title: "Mods updated".to_string(), + body, + level: Level::Info, + icon: Some(IconType::DownloadCloud02), + progress: None, + actions: vec![NotificationAction { + label: "View changes".to_string(), + kind: NotificationActionKind::OpenClusterUpdate(summaries), + }], + }) +} + +/// The session is detached not finished so the caller can reuse its notification +/// as the "Installed" / "Install failed" result +/// `session_id` is `None` if no download ran +pub struct PackageInstall { + pub session_id: Option, + pub result: anyhow::Result, + pub dependencies: Vec, + /// Unresolved or failed dependencies the package still installs + pub missing_dependencies: Vec, +} + +pub fn install_body(name: &str, dependencies: &[String], missing: &[String]) -> String { + let mut body = format!("Added {name}"); + + if !dependencies.is_empty() { + body.push_str(&format!( + " with {} dependenc{}", + dependencies.len(), + if dependencies.len() == 1 { "y" } else { "ies" } + )); + } + body.push('.'); + + if !missing.is_empty() { + body.push_str(&format!(" Could not add: {}.", missing.join(", "))); + } + + body +} + +impl PackageInstall { + fn failed(err: anyhow::Error) -> Self { + Self { + session_id: None, + result: Err(err), + dependencies: Vec::new(), + missing_dependencies: Vec::new(), + } + } +} + +pub async fn install_package( + state: &Arc, + provider: oneclient_content::packages::ProviderId, + project_id: &str, + version_id: &str, + cluster_id: i64, +) -> PackageInstall { + let lookup = async { + let provider_impl = state.services.packages.get(provider)?; + let project = provider_impl + .get_project(project_id, &state.services.content()) + .await?; + let version = provider_impl + .get_version(project_id, version_id, &state.services.content()) + .await?; + anyhow::Ok((project, version)) + } + .await; + + let (project, version) = match lookup { + Ok(found) => found, + Err(err) => return PackageInstall::failed(err), + }; + + // Resolved before the session starts so its children can be announced up front + let mut resolution = oneclient_content::packages::DependencyResolution::default(); + if oneclient_content::packages::resolves_dependencies(project.content_type) { + match oneclient_content::packages::resolve_required( + provider, + &version, + cluster_id, + &state.services.content(), + ) + .await + { + Ok(resolved) => resolution = resolved, + Err(err) => { + tracing::warn!(%err, "dependency resolution failed, installing package alone"); + } + } + } + + let session = oneclient_events::GroupedProgressSession::start( + &state.services.events, + format!("Installing {}", project.name), + ); + + let size = version.primary_file().map(|f| f.size).unwrap_or(0); + let dependency_bytes: u64 = resolution + .install + .iter() + .map(|dep| dep.version.primary_file().map(|f| f.size).unwrap_or(0)) + .sum(); + session.expect( + oneclient_events::TaskCategory::Packages, + 1 + resolution.install.len() as u64, + size + dependency_bytes, + ); + + let mut installed_dependencies = Vec::new(); + let mut missing_dependencies = resolution.unresolved; + + // Dependencies first so the package is never in a cluster without them however the run ends + for dependency in &resolution.install { + let child = session.child( + dependency.project.name.clone(), + dependency + .version + .primary_file() + .map(|f| f.size) + .unwrap_or(0), + oneclient_events::TaskCategory::Packages, + ); + + let result = PackageStore::install_to_cluster( + provider, + &dependency.project, + &dependency.version, + cluster_id, + false, + false, + Some(&child), + &state.services.content(), + ) + .await; + + child.finish(); + + match result { + Ok(_) => installed_dependencies.push(dependency.project.name.clone()), + Err(err) => { + tracing::warn!( + dependency = %dependency.project.name, + %err, + "failed to install dependency" + ); + missing_dependencies.push(dependency.project.name.clone()); + } + } + } + + let child = session.child( + project.name.clone(), + size, + oneclient_events::TaskCategory::Packages, + ); + + let result = PackageStore::install_to_cluster( + provider, + &project, + &version, + cluster_id, + false, + false, + Some(&child), + &state.services.content(), + ) + .await; + + child.finish(); + + PackageInstall { + session_id: Some(session.detach()), + result: result.map(|_| project.name).map_err(anyhow::Error::from), + dependencies: installed_dependencies, + missing_dependencies, + } +} diff --git a/packages/oneclient_app/src/launcher.rs b/packages/oneclient_app/src/launcher.rs new file mode 100644 index 00000000..8b7bcb4e --- /dev/null +++ b/packages/oneclient_app/src/launcher.rs @@ -0,0 +1,22 @@ +//! The global lives here not in the core so the library stays constructible in tests +//! It is needed because `freya::query`'s `run` methods take only keys with no context + +use std::sync::{Arc, OnceLock}; + +use oneclient_core::{LauncherError, LauncherResult, LauncherState}; + +static LAUNCHER: OnceLock> = OnceLock::new(); + +/// Returns the existing handle on a second call rather than panicking +pub fn install(state: Arc) -> Arc { + if let Err(existing) = LAUNCHER.set(Arc::clone(&state)) { + tracing::warn!("launcher state was already installed; keeping the first"); + return existing; + } + state +} + +/// Errors with [`LauncherError::NotInitialized`] before startup installs the handle +pub fn state() -> LauncherResult> { + LAUNCHER.get().cloned().ok_or(LauncherError::NotInitialized) +} diff --git a/packages/oneclient_app/src/layout/animated_outlet.rs b/packages/oneclient_app/src/layout/animated_outlet.rs new file mode 100644 index 00000000..4ea329e3 --- /dev/null +++ b/packages/oneclient_app/src/layout/animated_outlet.rs @@ -0,0 +1,176 @@ +use freya::animation::*; +use freya::prelude::*; +use freya::router::*; + +use super::app_shell::{appshell_overlay, back_button, hides_overlay}; +use crate::Route; +use crate::theme; +use crate::ui::entrance_motion_layer; + +#[derive(Clone, Copy, PartialEq)] +enum Enter { + None, + Up, + Fade, +} + +/// This outlet must stay still for these the shell runs its own content-only +/// transition else the sidebar animates along with the page +fn is_sidebar_route(route: &Route) -> bool { + matches!( + route, + Route::SettingsAppearance {} + | Route::SettingsMinecraft {} + | Route::SettingsAccounts {} + | Route::SettingsLauncher {} + | Route::SettingsJava {} + | Route::SettingsStorage {} + | Route::SettingsApis {} + | Route::SettingsLanguage {} + | Route::SettingsDeveloper {} + | Route::SettingsChangelog {} + ) +} + +fn is_cluster_route(route: &Route) -> bool { + matches!( + route, + Route::ClusterOverview { .. } + | Route::ClusterLogs { .. } + | Route::ProcessLogs { .. } + | Route::ClusterScreenshots { .. } + | Route::ClusterMods { .. } + | Route::ClusterShaders { .. } + | Route::ClusterTextures { .. } + | Route::ClusterSettings { .. } + ) +} + +fn enter_kind(from: &Route, to: &Route) -> Enter { + if (is_sidebar_route(from) && is_sidebar_route(to)) + || (is_cluster_route(from) && is_cluster_route(to)) + { + Enter::None + } else if hides_overlay(from) { + Enter::Up + } else { + Enter::Fade + } +} + +#[derive(PartialEq)] +pub struct AnimatedAppOutlet; + +impl Component for AnimatedAppOutlet { + fn render(&self) -> impl IntoElement { + let mut router = use_animated_router::(); + + let route = use_route::(); + let mut origin_route = use_state(|| route.clone()); + let mut last_route = use_state(|| route.clone()); + + use_side_effect_with_deps(&route, move |current| { + let prev = last_route.peek().clone(); + if !(is_sidebar_route(&prev) && is_sidebar_route(current)) { + origin_route.set(prev); + } + last_route.set(current.clone()); + }); + + let back_title = origin_route.read().title(); + + let anim = use_animation(|_conf| { + AnimNum::new(0., 1.) + .time(430) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let mut last_to = use_state(|| None::); + + let (_, to, is_transition) = match &*router.read() { + AnimatedRouterContext::FromTo(from, to) => (enter_kind(from, to), to.clone(), true), + AnimatedRouterContext::In(to) => (Enter::None, to.clone(), false), + }; + + if last_to.peek().as_ref() != Some(&to) { + last_to.set(Some(to.clone())); + if is_transition { + anim.run(AnimDirection::Forward); + } + } + + let anim_finished = *anim.has_run_yet().read() && !*anim.is_running().read(); + + use_side_effect_with_deps(&anim_finished, move |&finished| { + if finished { + Platform::get().send(UserEvent::RequestRedraw); + } + }); + + if anim_finished && matches!(&*router.peek(), AnimatedRouterContext::FromTo(_, _)) { + router.write().settle(); + } + + let kind = match &*router.read() { + AnimatedRouterContext::FromTo(from, to) => enter_kind(from, to), + AnimatedRouterContext::In(_) => Enter::None, + }; + + let is_home = matches!(to, Route::Home {}); + let show_overlay = !hides_overlay(&to); + + let p = if anim_finished { + 1.0 + } else { + anim.get().value() + }; + + let chrome_opacity = if kind == Enter::Up { p } else { 1.0 }; + + let (content_dy, content_opacity) = if anim_finished || matches!(kind, Enter::None) { + (0., 1.) + } else { + match kind { + Enter::None => (0., 1.), + Enter::Up => ((1. - p) * 48., p), + Enter::Fade => ((1. - p) * 22., p), + } + }; + + let overlay = show_overlay.then(|| appshell_overlay().opacity(chrome_opacity)); + + let back = (!is_home).then(|| { + rect() + .opacity(chrome_opacity) + .child(back_button(&back_title)) + }); + + let column = rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .margin(Gaps::new(theme::NAVBAR_HEIGHT_PX, 0., 0., 0.)) + .overflow(Overflow::Clip) + .layer(Layer::Relative(3)) + .maybe_child(back) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .overflow(Overflow::Clip) + .child(entrance_motion_layer( + 0., + content_dy, + content_opacity, + Outlet::::new(), + )), + ); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .maybe_child(overlay) + .child(column) + } +} diff --git a/packages/oneclient_app/src/layout/app_shell.rs b/packages/oneclient_app/src/layout/app_shell.rs new file mode 100644 index 00000000..91d1e0a0 --- /dev/null +++ b/packages/oneclient_app/src/layout/app_shell.rs @@ -0,0 +1,613 @@ +use bytes::Bytes; +use freya::animation::*; +use freya::engine::prelude::{Paint, SamplingOptions, SkData, SkImage, SkRect}; +use freya::prelude::*; +use freya::query::QueryStateData; +use freya::router::*; +use skia_safe::canvas::SrcRectConstraint; +use skia_safe::sampling_options::CubicResampler; + +use crate::Route; +use crate::components::Button; +use crate::components::{ + ART_PREVIEW_EDGE, AppNavbar, DynamicArt, FileDropOverlay, Icon, IconType, OverlayPopup, + ScrollArea, +}; +use crate::layout::AnimatedAppOutlet; +use crate::theme; +use crate::use_settings_snapshot; +use oneclient_core::clusters::Cluster; +use oneclient_db::models::ClusterId; + +use crate::hooks::{ + ActiveClusterState, BrowserCompatState, BrowserStateStore, use_active_cluster_id, use_clusters, + use_game_snapshot, use_launcher, use_provide_active_cluster, use_provide_browser_compat, + use_provide_browser_state, use_splash, +}; +use crate::theme::colors; +use oneclient_events::LaunchStage; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(PartialEq)] +pub struct AppShell; + +impl Component for AppShell { + fn render(&self) -> impl IntoElement { + let active_cluster = use_state(|| None::); + use_provide_active_cluster(ActiveClusterState(active_cluster)); + + let browser_compat = use_state(|| true); + use_provide_browser_compat(BrowserCompatState(browser_compat)); + + let browser_state = use_state(HashMap::new); + use_provide_browser_state(BrowserStateStore(browser_state)); + + // `FileDrop` bubbles so anything a drop zone doesn't `stop_propagation()` lands here + let mut drop_hovering = use_state(|| false); + let mut drop_pending = use_state(Vec::::new); + + let game = use_game_snapshot(); + + let running_cluster = game + .stages + .iter() + .find(|(_, s)| **s == LaunchStage::Running) + .map(|(id, _)| *id); + + use_side_effect_with_deps(&running_cluster, move |running| { + if let Some(cluster_id) = *running { + spawn(async move { + let _ = RouterContext::get().push(Route::ProcessLogs { cluster_id }); + }); + } + }); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .color(colors::fg_primary()) + .overflow(Overflow::Clip) + .on_global_file_hover(move |_| drop_hovering.set(true)) + .on_global_file_hover_cancelled(move |_| drop_hovering.set(false)) + .on_file_drop(move |e: Event| { + drop_hovering.set(false); + drop_pending.write().extend_from_slice(&e.file_paths); + }) + .child(AppNavbar) + .child(AppHomeBackground) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .child(AnimatedRouter::::new(AnimatedAppOutlet)), + ) + .child(FileDropOverlay { + hovering: drop_hovering, + pending: drop_pending, + }) + .maybe_child( + game.error + .clone() + .map(|message| LaunchErrorDialog { message }.into_element()), + ) + } +} + +pub fn hides_overlay(route: &Route) -> bool { + matches!(route, Route::Home {}) +} + +const DIALOG_BG: Color = Color::from_rgb(21, 28, 34); +const CODE_BG: Color = Color::from_rgb(13, 18, 22); + +#[derive(PartialEq)] +struct LaunchErrorDialog { + message: String, +} + +impl Component for LaunchErrorDialog { + fn render(&self) -> impl IntoElement { + let dispatch = crate::hooks::use_dispatch(); + let lines: Vec = self + .message + .lines() + .map(|line| { + label() + .text(line.to_string()) + .font_family(theme::MONO_FONT) + .font_size(12.) + .width(Size::fill()) + .color(colors::fg_primary()) + .into_element() + }) + .collect(); + + let close = dispatch.clone(); + let outside_close = dispatch.clone(); + let copy_dispatch = dispatch.clone(); + OverlayPopup::new() + .on_close(move |_| close.dismiss_game_error()) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .on_press(move |_| outside_close.dismiss_game_error()) + .child( + rect() + .vertical() + .width(Size::px(560.)) + .background(DIALOG_BG) + .corner_radius(CornerRadius::new_all(16.)) + .padding(Gaps::new_all(24.)) + .spacing(16.) + .border(crate::ui::border_all_color(1., colors::component_border())) + .on_press(|e: Event| e.stop_propagation()) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child( + Icon::new(IconType::AlertTriangle) + .size(20.) + .color(colors::danger()), + ) + .child( + label() + .text("Game failed to launch") + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text("Minecraft could not be launched. Error details:") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::px(260.)) + .background(CODE_BG) + .corner_radius(CornerRadius::new_all(8.)) + .padding(Gaps::new_all(12.)) + .overflow(Overflow::Clip) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .children(lines), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(10.) + .child(copy_error_button(&self.message, copy_dispatch)) + .child( + Button::new() + .primary() + .on_press(move |_| dispatch.dismiss_game_error()) + .text("Close"), + ), + ), + ), + ) + } +} + +fn copy_error_button(message: &str, dispatch: crate::Actions) -> impl IntoElement { + let message = message.to_string(); + Button::new() + .secondary() + .on_press(move |_| { + if let Err(err) = freya::text_edit::Clipboard::set(message.clone()) { + tracing::warn!("clipboard copy failed: {err:?}"); + dispatch + .notify("Copy failed") + .body("Could not copy the error to the clipboard.") + .error() + .send(); + } else { + dispatch + .notify("Copied to clipboard") + .body("Error message copied to your clipboard.") + .info() + .icon(IconType::ClipboardCheck) + .send(); + } + }) + .child(Icon::new(IconType::Copy01).size(14.)) + .text("Copy") + .into_element() +} + +pub(crate) fn appshell_overlay() -> Rect { + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .interactive(false) + .layer(Layer::Relative(2)) + .child( + rect() + .height(Size::px(200.)) + .width(Size::fill()) + .background( + LinearGradient::new() + .stop((Color::BLACK.with_a(100), 0.)) + .stop((colors::page().with_a(255), 95.0)), + ), + ) + .child( + rect() + .height(Size::fill()) + .width(Size::fill()) + .background(colors::page()), + ) +} + +pub const HOME_BACKGROUND_ASSET: &str = "backgrounds/CavesAndCliffs.jpg"; + +fn home_cluster(clusters: &[Cluster], active: Option) -> Option<&Cluster> { + active + .and_then(|id| clusters.iter().find(|c| c.id == id)) + .or_else(|| clusters.first()) +} + +fn home_art(cluster: Option<&Cluster>) -> DynamicArt { + cluster + .map_or_else(DynamicArt::fallback, DynamicArt::for_cluster) + .preview_edge(ART_PREVIEW_EDGE) +} + +/// Warms the home art at the root so its three async hops overlap startup behind the +/// splash left to Home the fallback lingers on screen after the curtain lifts +#[derive(PartialEq, Clone, Copy)] +pub struct HomeArtPrefetch; + +impl Component for HomeArtPrefetch { + fn render(&self) -> impl IntoElement { + // Before the launcher is ready every query errors and a settled error is not + // retried until a new subscriber mounts + if !use_launcher().ready { + return rect().into_element(); + } + HomeArtWarm.into_element() + } +} + +#[derive(PartialEq, Clone, Copy)] +struct HomeArtWarm; + +impl Component for HomeArtWarm { + fn render(&self) -> impl IntoElement { + let clusters_query = use_clusters(); + let art = { + let reader = clusters_query.read(); + let state = reader.state(); + // No active cluster this early so this picks the same one `AppHomeBackground` will + let clusters = state.ok().map_or(&[][..], Vec::as_slice); + home_art(home_cluster(clusters, None)) + }; + let _ = art.use_bytes(); + + rect().into_element() + } +} + +#[derive(PartialEq, Clone, Copy)] +pub struct AppHomeBackground; + +impl Component for AppHomeBackground { + fn render(&self) -> impl IntoElement { + let parallax_enabled = use_settings_snapshot().settings.dynamic_background_enabled; + let clusters_query = use_clusters(); + let active_id = use_active_cluster_id(); + let launcher = use_launcher(); + let splash = use_splash(); + + // The home view is populated once these settle which releases the splash curtain + let clusters_settled = matches!( + &*clusters_query.read().state(), + QueryStateData::Settled { .. } + ); + let home_ready = clusters_settled && !launcher.fetching; + use_side_effect_with_deps(&home_ready, move |&ready| { + if ready { + let mut flag = splash.home_ready; + if !*flag.peek() { + flag.set(true); + } + } + }); + + let (art, art_dep) = { + let reader = clusters_query.read(); + let state = reader.state(); + let clusters = state.ok().map_or(&[][..], Vec::as_slice); + let chosen = home_cluster(clusters, *active_id.read()); + let dep = chosen.map(|c| c.id); + (home_art(chosen), dep) + }; + + let (_, art_bytes) = art.use_bytes(); + + let fade = use_animation_with_dependencies(&art_dep, |conf, _| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + AnimNum::new(0., 1.) + .time(520) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let fade_opacity = fade.get().value(); + + ParallaxArt { + art_bytes, + fade_opacity, + parallax_enabled, + } + .into_element() + } +} + +#[derive(PartialEq, Clone)] +pub struct ParallaxArt { + pub art_bytes: Bytes, + pub fade_opacity: f32, + pub parallax_enabled: bool, +} + +impl Component for ParallaxArt { + fn render(&self) -> impl IntoElement { + let mut size = use_state(|| (0f32, 0f32)); + let mut target = use_state(|| (0f32, 0f32)); + let mut current = use_state(|| (0f32, 0f32)); + + let mut anim_task = use_state(|| None::); + use_side_effect_with_deps(&self.parallax_enabled, move |enabled| { + if *enabled { + let handle = spawn(async move { + let platform = Platform::get(); + + const FRAME: std::time::Duration = std::time::Duration::from_millis(33); + const STEP: f32 = 0.12; + const SETTLE: f32 = 0.002; + + loop { + let (tx, ty) = *target.peek(); + let (cx, cy) = *current.peek(); + + if (tx - cx).abs() > SETTLE || (ty - cy).abs() > SETTLE { + current.set((cx + (tx - cx) * STEP, cy + (ty - cy) * STEP)); + platform.send(UserEvent::RequestRedraw); + } + + tokio::time::sleep(FRAME).await; + } + }); + anim_task.set(Some(handle.owned())); + } else { + anim_task.set(None); + target.set((0.0, 0.0)); + current.set((0.0, 0.0)); + } + }); + + let mut img_cache = use_state(|| None::<(usize, SkImage)>); + let src_ptr = self.art_bytes.as_ptr() as usize; + let cached = img_cache.peek().clone(); + let image = match cached { + Some((ptr, img)) if ptr == src_ptr => Some(img), + _ => { + let decoded = decode_background_image(&self.art_bytes); + if let Some(img) = &decoded { + img_cache.set(Some((src_ptr, img.clone()))); + } + decoded + } + }; + + let parallax_enabled = self.parallax_enabled; + let fade_opacity = self.fade_opacity; + + let render_cb = RenderCallback::new(move |ctx: &mut CanvasContext| { + let Some(image) = &image else { + return; + }; + let (w, h) = (ctx.size.width, ctx.size.height); + if w <= 0.0 || h <= 0.0 { + return; + } + let iw = image.width() as f32; + let ih = image.height() as f32; + if iw <= 0.0 || ih <= 0.0 { + return; + } + + let (cx, cy) = if parallax_enabled { + current() + } else { + (0.0, 0.0) + }; + + let canvas_aspect = w / h; + let img_aspect = iw / ih; + let (mut crop_w, mut crop_h) = if img_aspect > canvas_aspect { + (ih * canvas_aspect, ih) + } else { + (iw, iw / canvas_aspect) + }; + + const SCALE: f32 = 1.10; + crop_w /= SCALE; + crop_h /= SCALE; + + const STRENGTH: f32 = 0.01; + let pan_x = cx * w * 0.5 * STRENGTH * (crop_w / w); + let pan_y = cy * h * 0.5 * STRENGTH * (crop_h / h); + let ccx = iw * 0.5 - pan_x; + let ccy = ih * 0.5 - pan_y; + + let src = SkRect::new( + ccx - crop_w * 0.5, + ccy - crop_h * 0.5, + ccx + crop_w * 0.5, + ccy + crop_h * 0.5, + ); + let dst = SkRect::new(0.0, 0.0, w, h); + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + let sampling = SamplingOptions::from(CubicResampler::mitchell()); + ctx.canvas.draw_image_rect_with_sampling_options( + image, + Some((&src, SrcRectConstraint::Strict)), + dst, + sampling, + &paint, + ); + }); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .position(Position::new_absolute()) + .interactive(false) + .on_sized(move |e: Event| { + let (sw, sh) = (e.area.width(), e.area.height()); + let (pw, ph) = *size.peek(); + if (pw - sw).abs() > 0.5 || (ph - sh).abs() > 0.5 { + size.set((sw, sh)); + } + }) + .maybe(parallax_enabled, |el| { + el.on_capture_global_pointer_move(move |e: Event| { + let (sw, sh) = *size.peek(); + if sw > 0.0 && sh > 0.0 { + let loc = e.global_location(); + target.set(( + (loc.x as f32 / sw) * 2.0 - 1.0, + (loc.y as f32 / sh) * 2.0 - 1.0, + )); + } + }) + }) + .child( + rect() + .expanded() + .position(Position::new_absolute()) + .layer(Layer::Relative(1)) + .child(gradient_overlay_radial()), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .layer(Layer::Relative(0)) + .opacity(fade_opacity) + .child( + canvas(render_cb) + .key(src_ptr as u64) + .width(Size::fill()) + .height(Size::fill()), + ), + ) + } +} + +fn decode_background_image(bytes: &Bytes) -> Option { + let data = unsafe { SkData::new_bytes(bytes) }; + let img = SkImage::from_encoded(data)?; + Some(img.make_raster_image(None, None).unwrap_or(img)) +} + +const VIGNETTE_SPOTLIGHT_SHADER: &str = r#" +uniform float2 iResolution; + +half4 main(float2 fragCoord) { + float2 uv = fragCoord / iResolution; + + float2 center = float2(0.80, 0.45); + float2 distVec = uv - center; + + float2 dir = normalize(distVec + float2(0.00001)); + + float leftScale = 1.8; + float rightScale = 4.5; + float scaleX = mix(leftScale, rightScale, smoothstep(-1.0, 1.0, dir.x)); + + float topScale = 2.2; + float bottomScale = 2.2; + float scaleY = mix(topScale, bottomScale, smoothstep(-1.0, 1.0, dir.y)); + + distVec.x *= scaleX; + distVec.y *= scaleY; + + float dist = length(distVec); + + float vignette = smoothstep(0.0, 1.2, dist); + vignette = pow(vignette, 1.3); + + float maxDarkness = 0.8; + float alpha = vignette * maxDarkness; + + return half4(0.0, 0.0, 0.0, alpha); +} +"#; + +pub(crate) fn gradient_overlay_radial() -> impl IntoElement { + let effect = use_hook(|| { + freya::engine::prelude::RuntimeEffect::make_for_shader(VIGNETTE_SPOTLIGHT_SHADER, None) + .expect("Failed to compile vignette shader") + }); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .background(ShaderFill::new( + VIGNETTE_SPOTLIGHT_SHADER, + effect, + move |effect, bounds| { + let w = bounds.width(); + let h = bounds.height(); + + #[rustfmt::skip] + let uniforms: [f32; 2] = [ + w, h, + ]; + + let bytes = unsafe { + std::slice::from_raw_parts( + uniforms.as_ptr() as *const u8, + std::mem::size_of_val(&uniforms), + ) + }; + + effect.make_shader(freya::engine::prelude::Data::new_copy(bytes), &[], None) + }, + )) +} + +pub(crate) fn back_button(destination: &str) -> impl IntoElement { + Button::new() + .ghost() + .small() + .on_press(|_| { + RouterContext::get().go_back(); + }) + .margin(Gaps::new(0., 0., 0., 32.)) + .child(Icon::new(IconType::ArrowLeft).size(12.)) + .text(format!("Back to {destination}")) +} diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs new file mode 100644 index 00000000..062d6ae8 --- /dev/null +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -0,0 +1,343 @@ +use freya::animation::*; +use freya::prelude::*; +use freya::router::*; +use oneclient_common::parse_mc_version; + +use crate::components::{Button, Icon, IconType, TabBar, TabItem}; +use crate::hooks::{ + use_cluster, use_dispatch, use_game_snapshot, use_launcher, use_version_metadata, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::entrance_motion_layer; +use crate::view::app::launch_button_state; + +const HEADER_HEIGHT: f32 = 64.; +const TABS_HEIGHT: f32 = 44.; +const BAR_SPACING: f32 = 16.; + +#[derive(PartialEq, Clone, Copy)] +pub enum ClusterViewShellTab { + Overview, + Logs, + GameLog, + Screenshots, + Mods, + Shaders, + Textures, + Settings, +} + +impl ClusterViewShellTab { + fn label(&self) -> &'static str { + match self { + Self::Overview => "Overview", + Self::Logs => "Logs", + Self::GameLog => "Live Log", + Self::Screenshots => "Screenshots", + Self::Mods => "Mods", + Self::Shaders => "Shaders", + Self::Textures => "Textures", + Self::Settings => "Settings", + } + } + + fn route(&self, cluster_id: i64) -> Option { + match self { + Self::Overview => Some(Route::ClusterOverview { cluster_id }), + Self::Logs => Some(Route::ClusterLogs { cluster_id }), + Self::GameLog => Some(Route::ProcessLogs { cluster_id }), + Self::Screenshots => Some(Route::ClusterScreenshots { cluster_id }), + Self::Mods => Some(Route::ClusterMods { cluster_id }), + Self::Shaders => Some(Route::ClusterShaders { cluster_id }), + Self::Textures => Some(Route::ClusterTextures { cluster_id }), + Self::Settings => Some(Route::ClusterSettings { cluster_id }), + } + } + + fn index(self) -> i32 { + match self { + Self::Overview => 0, + Self::Logs => 1, + Self::Screenshots => 2, + Self::Mods => 3, + Self::Shaders => 4, + Self::Textures => 5, + Self::Settings => 6, + Self::GameLog => 7, + } + } +} + +const SIDE_PADDING: f32 = 40.; + +pub fn cluster_content() -> Rect { + rect().vertical().width(Size::fill()).height(Size::fill()) +} + +fn route_cluster(route: &Route) -> Option<(i64, ClusterViewShellTab)> { + Some(match route { + Route::ClusterOverview { cluster_id } => (*cluster_id, ClusterViewShellTab::Overview), + Route::ClusterLogs { cluster_id } => (*cluster_id, ClusterViewShellTab::Logs), + Route::ProcessLogs { cluster_id } => (*cluster_id, ClusterViewShellTab::GameLog), + Route::ClusterScreenshots { cluster_id } => (*cluster_id, ClusterViewShellTab::Screenshots), + Route::ClusterMods { cluster_id } => (*cluster_id, ClusterViewShellTab::Mods), + Route::ClusterShaders { cluster_id } => (*cluster_id, ClusterViewShellTab::Shaders), + Route::ClusterTextures { cluster_id } => (*cluster_id, ClusterViewShellTab::Textures), + Route::ClusterSettings { cluster_id } => (*cluster_id, ClusterViewShellTab::Settings), + _ => return None, + }) +} + +#[derive(PartialEq)] +pub struct ClusterShell; + +impl Component for ClusterShell { + fn render(&self) -> impl IntoElement { + let route = use_route::(); + let (cluster_id, active_tab) = + route_cluster(&route).unwrap_or((0, ClusterViewShellTab::Overview)); + + let dispatch = use_dispatch(); + let game = use_game_snapshot(); + let launcher = use_launcher(); + let syncing = launcher.fetching || launcher.syncing_bundles; + let cluster = use_cluster(cluster_id); + + let show_game_log = game.is_active(cluster_id); + let launch_state = launch_button_state(&game, cluster_id, syncing); + + // Queried unconditionally the shell can mount before the cluster list settles + // and a conditional hook would change this component's hook count mid-life + let parsed = cluster + .as_ref() + .and_then(|c| parse_mc_version(&c.mc_version)); + let metadata = use_version_metadata( + parsed.as_ref().map(|p| p.major), + parsed.and_then(|p| p.key()), + cluster.as_ref().map(|c| c.mc_loader), + ); + + let header = cluster.as_ref().map(|cluster| { + let title = format!("{} {}", cluster.mc_loader, cluster.mc_version); + let subtitle = metadata + .and_then(|m| m.long_description) + .unwrap_or_else(|| cluster.name.clone()); + cluster_header( + title, + subtitle, + cluster_id, + dispatch, + launch_state, + game.is_running(cluster_id), + cluster.game_dir().ok(), + ) + .into_element() + }); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .child( + rect() + .vertical() + .width(Size::fill()) + .padding(Gaps::new( + BAR_SPACING, + SIDE_PADDING, + BAR_SPACING, + SIDE_PADDING, + )) + .spacing(BAR_SPACING) + .maybe_child(header) + .child(cluster_tabs(active_tab, cluster_id, show_game_log)), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .overflow(Overflow::Clip) + .padding(Gaps::new(0., SIDE_PADDING, SIDE_PADDING, SIDE_PADDING)) + .child(AnimatedRouter::::new(ClusterContentOutlet)), + ) + } +} + +fn tab_dir(from: &Route, to: &Route) -> f32 { + match (route_cluster(from), route_cluster(to)) { + (Some((_, a)), Some((_, b))) => (b.index() - a.index()).signum() as f32, + _ => 0., + } +} + +#[derive(PartialEq)] +struct ClusterContentOutlet; + +impl Component for ClusterContentOutlet { + fn render(&self) -> impl IntoElement { + let mut router = use_animated_router::(); + + let anim = use_animation(|_conf| { + AnimNum::new(0., 1.) + .time(300) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let mut last_to = use_state(|| None::); + + let (dir, to, is_transition) = match &*router.read() { + AnimatedRouterContext::FromTo(from, to) => (tab_dir(from, to), to.clone(), true), + AnimatedRouterContext::In(to) => (0., to.clone(), false), + }; + + if last_to.peek().as_ref() != Some(&to) { + last_to.set(Some(to.clone())); + if is_transition { + anim.run(AnimDirection::Forward); + } + } + + let anim_finished = *anim.has_run_yet().read() && !*anim.is_running().read(); + + use_side_effect_with_deps(&anim_finished, move |&finished| { + if finished { + Platform::get().send(UserEvent::RequestRedraw); + } + }); + + if anim_finished && matches!(&*router.peek(), AnimatedRouterContext::FromTo(_, _)) { + router.write().settle(); + } + + const CONTENT_DX: f32 = 44.; + let p = if anim_finished { + 1.0 + } else { + anim.get().value() + }; + let slide_x = if anim_finished || dir == 0. { + 0. + } else { + dir * (1.0 - p) * CONTENT_DX + }; + let opacity = if anim_finished || dir == 0. { 1. } else { p }; + + entrance_motion_layer(slide_x, 0., opacity, Outlet::::new()) + } +} + +fn cluster_header( + title: String, + subtitle: String, + cluster_id: i64, + dispatch: crate::Actions, + launch_state: (&'static str, bool), + running: bool, + folder: Option, +) -> impl IntoElement { + let (launch_label, launch_enabled) = launch_state; + let kill_dispatch = dispatch.clone(); + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .min_height(Size::px(HEADER_HEIGHT)) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .main_align(Alignment::Center) + .spacing(6.) + .child( + label() + .text(title) + .font_size(28.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ), + ) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .maybe_child(folder.map(crate::components::open_folder_button)) + .maybe(running, |el| { + el.child( + Button::new() + .danger() + .icon() + .on_press(move |_| kill_dispatch.kill_cluster(cluster_id)) + .child(Icon::new(IconType::Square).size(16.)), + ) + }) + .child( + Button::new() + .primary() + .large() + .enabled(launch_enabled) + .on_press(move |_| { + if launch_enabled { + dispatch.launch_cluster(cluster_id); + } + }) + .text(launch_label), + ), + ) +} + +fn cluster_tabs( + active: ClusterViewShellTab, + cluster_id: i64, + show_game_log: bool, +) -> impl IntoElement { + let tabs = [ + Some(ClusterViewShellTab::Overview), + Some(ClusterViewShellTab::Logs), + Some(ClusterViewShellTab::Screenshots), + Some(ClusterViewShellTab::Mods), + Some(ClusterViewShellTab::Shaders), + Some(ClusterViewShellTab::Textures), + Some(ClusterViewShellTab::Settings), + show_game_log.then_some(ClusterViewShellTab::GameLog), + ]; + + let tab_items = tabs.into_iter().flatten().map(|tab| { + let route = tab.route(cluster_id); + let mut item = TabItem::new(tab.label(), tab == active); + if let Some(route) = route { + item = item.on_press(move |_| { + let _ = RouterContext::get().push(route.clone()); + }); + } + item + }); + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(TABS_HEIGHT)) + .cross_align(Alignment::Center) + .padding(Gaps::new(0., 24., 0., 24.)) + .background(colors::page_elevated()) + .corner_radius(CornerRadius::new_all(12.)) + .child( + TabBar::new() + .width(Size::fill()) + .height(Size::fill()) + .spacing(24.) + .font_size(12.) + .tabs(tab_items), + ) +} diff --git a/packages/oneclient_app/src/layout/mod.rs b/packages/oneclient_app/src/layout/mod.rs new file mode 100644 index 00000000..ac882c8b --- /dev/null +++ b/packages/oneclient_app/src/layout/mod.rs @@ -0,0 +1,14 @@ +mod animated_outlet; +mod app_shell; +mod cluster_shell; +mod onboarding_shell; +mod root_layout; +mod settings_shell; + +pub use animated_outlet::AnimatedAppOutlet; +pub(crate) use app_shell::gradient_overlay_radial; +pub use app_shell::{AppShell, HOME_BACKGROUND_ASSET, HomeArtPrefetch}; +pub use cluster_shell::{ClusterShell, cluster_content}; +pub use onboarding_shell::OnboardingShell; +pub use root_layout::RootLayout; +pub use settings_shell::SettingsShell; diff --git a/packages/oneclient_app/src/layout/onboarding_shell.rs b/packages/oneclient_app/src/layout/onboarding_shell.rs new file mode 100644 index 00000000..0f6c489a --- /dev/null +++ b/packages/oneclient_app/src/layout/onboarding_shell.rs @@ -0,0 +1,130 @@ +use std::collections::HashSet; + +use freya::prelude::*; +use freya::router::{Outlet, use_route}; + +use crate::Route; +use crate::components::OnboardingNavbar; +use crate::hooks::{ + OnboardingSelectionState, has_migration_data, onboarding_bundles_items, use_migration, + use_onboarding_bundles, use_provide_onboarding_selection, +}; +use crate::theme::colors; +use crate::view::onboarding::{ + LoadingBackdrop, default_selection, onboarding_step_index, onboarding_total, +}; + +#[derive(PartialEq)] +pub struct OnboardingShell; + +impl Component for OnboardingShell { + fn render(&self) -> impl IntoElement { + let mut selected = use_state(HashSet::new); + let user_touched = use_state(|| false); + let migrated_categories = use_state(|| None::>); + let language = use_state(|| "English".to_string()); + let reduce_motion = use_state(|| false); + let predownload = use_state(|| true); + let setup_started = use_state(|| false); + let import_folder = use_state(|| None::); + let import_dedicated = use_state(|| false); + use_provide_onboarding_selection(OnboardingSelectionState { + selected, + user_touched, + migrated_categories, + language, + reduce_motion, + predownload, + setup_started, + import_folder, + import_dedicated, + }); + + let migration_query = use_migration(); + let detected_migration = has_migration_data(&migration_query); + + let route = use_route::(); + let step_index = onboarding_step_index(&route, detected_migration); + + let bundles = use_onboarding_bundles(); + + use_side_effect(move || { + if *user_touched.peek() { + return; + } + let Some(items) = onboarding_bundles_items(&bundles) else { + return; + }; + let categories = migrated_categories.read().clone(); + let defaults = default_selection(&items, categories.as_deref()); + if *selected.peek() != defaults { + selected.set(defaults); + } + }); + let show_backdrop = + matches!(&route, Route::OnboardingDownloading {}) && *setup_started.read(); + let backdrop = show_backdrop.then(|| { + let clusters = onboarding_bundles_items(&bundles) + .map(|items| items.into_iter().map(|cb| cb.cluster).collect()) + .unwrap_or_default(); + LoadingBackdrop { clusters }.into_element() + }); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .background(colors::page()) + .color(colors::fg_primary()) + .overflow(Overflow::Clip) + .maybe_child(backdrop) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .layer(Layer::Relative(30)) + .child(progress_bar( + step_index, + onboarding_total(detected_migration), + )) + .child(OnboardingNavbar) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .overflow(Overflow::Clip) + .child(Outlet::::new()) + .child(copyright()), + ), + ) + } +} + +fn progress_bar(step_index: usize, total: usize) -> impl IntoElement { + let filled = ((step_index + 1) as f32 / total as f32) * 100.; + + rect() + .width(Size::fill()) + .height(Size::px(3.)) + .background(Color::WHITE.with_a(28)) + .child( + rect() + .width(Size::percent(filled)) + .height(Size::fill()) + .background(colors::brand()), + ) +} + +fn copyright() -> impl IntoElement { + rect() + .position(Position::new_absolute().bottom(16.).left(24.)) + .interactive(false) + .child( + label() + .text("Design © 2026 Polyfrost. All rights reserved.") + .font_size(11.) + .color(colors::fg_secondary()), + ) +} diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs new file mode 100644 index 00000000..e88433ec --- /dev/null +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -0,0 +1,81 @@ +use freya::prelude::*; +use freya::router::*; + +use crate::components::{ + AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, + NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, UpdatePromptOverlay, +}; +use crate::hooks::{SplashState, use_provide_splash}; +use crate::layout::HomeArtPrefetch; +use crate::motion::AnimationClockDriver; +use crate::routes::Route; +use crate::theme; +use crate::theme::colors; + +#[derive(PartialEq)] +pub struct RootLayout; + +impl Component for RootLayout { + fn render(&self) -> impl IntoElement { + let active = use_state(|| false); + let home_ready = use_state(|| false); + use_provide_splash(SplashState { active, home_ready }); + + // macOS rounds the window natively so Freya must not round on top of it + #[cfg(target_os = "macos")] + let corner = 0.; + + // Elsewhere the window is borderless and squared when maximized + // No reactive maximized signal exists so root_size changes stand in as the trigger + #[cfg(not(target_os = "macos"))] + let corner = { + let root_size = Platform::get().root_size; + let mut maximized = use_state(|| false); + let size = *root_size.read(); + let dep = (size.width as i32, size.height as i32); + use_side_effect_with_deps(&dep, move |_| { + spawn(async move { + let is_max = Platform::get() + .post_callback(|id, ctx| { + ctx.windows.get(&id).map(|w| w.window().is_maximized()) + }) + .await; + if let Ok(Some(is_max)) = is_max { + if *maximized.peek() != is_max { + maximized.set(is_max); + } + } + }); + }); + if *maximized.read() { 0. } else { 12. } + }; + + rect() + .width(Size::fill()) + .height(Size::fill()) + .background(colors::page()) + .color(colors::fg_primary()) + .font_family(theme::DEFAULT_FONT) + .corner_radius(CornerRadius::new_all(corner)) + .overflow(Overflow::Clip) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .child(Outlet::::new()), + ) + .child(NotificationCenter) + .child(AccountSwitcher) + .child(Toasts) + .child(UpdatePromptOverlay) + .child(JavaPromptOverlay) + // Must stay last it renders whatever the overlays above did not claim + .child(GenericPromptOverlay) + .child(ClusterUpdatePopup) + .child(PackageUpdatePopup) + .child(StatusBar) + .child(SplashCurtain) + .child(AnimationClockDriver) + .child(HomeArtPrefetch) + } +} diff --git a/packages/oneclient_app/src/layout/settings_shell.rs b/packages/oneclient_app/src/layout/settings_shell.rs new file mode 100644 index 00000000..0700c104 --- /dev/null +++ b/packages/oneclient_app/src/layout/settings_shell.rs @@ -0,0 +1,835 @@ +use std::borrow::Cow; +use std::cell::RefCell; + +use freya::animation::*; +use freya::prelude::*; +use freya::router::*; +use freya::text_edit::Clipboard; +use oneclient_common::search::{MatchScore, SearchQuery}; +use sysinfo::CpuRefreshKind; +use sysinfo::MemoryRefreshKind; +use sysinfo::RefreshKind; +use sysinfo::System; + +use crate::components::{Icon, IconType, ScrollArea, ScrollAreaCtx, TextInput}; +use crate::hooks::{latest_changelog_version, use_changelog, use_settings_snapshot}; +use crate::routes::Route; +use crate::theme::colors; +use crate::use_dispatch; + +const SIDEBAR_WIDTH_PX: f32 = 225.; +const ITEM_HEIGHT_PX: f32 = 33.; +const SEARCH_WIDTH_PX: f32 = 256.; +const SEARCH_RESULTS_MAX: usize = 12; + +thread_local! { + static SETTINGS_SCROLL_CTX: RefCell> = const { RefCell::new(None) }; + static SETTINGS_SCROLL_CONTROLLER: RefCell> = const { RefCell::new(None) }; + static PENDING_SETTING_FOCUS: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) fn set_pending_setting_focus(id: &'static str) { + PENDING_SETTING_FOCUS.with(|cell| *cell.borrow_mut() = Some(id)); + + spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + PENDING_SETTING_FOCUS.with(|cell| { + if *cell.borrow() == Some(id) { + *cell.borrow_mut() = None; + } + }); + }); +} + +#[derive(Clone)] +struct SearchItem { + id: &'static str, + icon: IconType, + title: &'static str, + description: &'static str, + keywords: &'static [&'static str], + route: Route, +} + +// TODO Replace this with inventory crate or something +const SEARCH_INDEX: &[SearchItem] = &[ + SearchItem { + id: "launcher.discord_rpc", + icon: IconType::Link03, + title: "Discord RPC", + description: "Enable Discord Rich Presence.", + keywords: &["discord", "rpc", "rich presence"], + route: Route::SettingsLauncher {}, + }, + SearchItem { + id: "launcher.folder", + icon: IconType::Folder, + title: "Launcher Folder", + description: "Open the launcher data directory.", + keywords: &["data dir", "directory", "folder", "open"], + route: Route::SettingsLauncher {}, + }, + SearchItem { + id: "appearance.accent_color", + icon: IconType::PaintPour, + title: "Accent color", + description: "The main color used across the launcher.", + keywords: &["theme", "accent", "color", "brand"], + route: Route::SettingsAppearance {}, + }, + SearchItem { + id: "appearance.custom_theme", + icon: IconType::Colors, + title: "Custom theme", + description: "Create, edit, and import launcher themes.", + keywords: &["theme", "custom", "import", "export"], + route: Route::SettingsAppearance {}, + }, + SearchItem { + id: "appearance.parallax_background", + icon: IconType::Eye, + title: "Parallax background", + description: "Make the home screen background drift with your cursor.", + keywords: &["background", "parallax", "motion"], + route: Route::SettingsAppearance {}, + }, + SearchItem { + id: "appearance.animations", + icon: IconType::Play, + title: "Animations", + description: "Toggle all animations in the launcher.", + keywords: &["reduce motion", "animations", "effects"], + route: Route::SettingsAppearance {}, + }, + SearchItem { + id: "apis.modrinth_key", + icon: IconType::Key01, + title: "Modrinth API key", + description: "Personal access token used for Modrinth requests.", + keywords: &["modrinth", "token", "api key", "pat"], + route: Route::SettingsApis {}, + }, + SearchItem { + id: "apis.curseforge_key", + icon: IconType::Key01, + title: "CurseForge API key", + description: "API key used for CurseForge requests.", + keywords: &["curseforge", "api key"], + route: Route::SettingsApis {}, + }, + SearchItem { + id: "apis.custom_endpoint", + icon: IconType::Globe01, + title: "Custom API Endpoint", + description: "Override the default OneClient backend endpoint.", + keywords: &["endpoint", "api", "backend", "url"], + route: Route::SettingsApis {}, + }, + SearchItem { + id: "minecraft.force_fullscreen", + icon: IconType::Maximize01, + title: "Force Fullscreen", + description: "Force Minecraft to start in fullscreen mode.", + keywords: &["fullscreen", "window"], + route: Route::SettingsMinecraft {}, + }, + SearchItem { + id: "minecraft.resolution", + icon: IconType::LayoutTop, + title: "Resolution", + description: "The game window resolution in pixels.", + keywords: &["width", "height", "window size", "resolution"], + route: Route::SettingsMinecraft {}, + }, + SearchItem { + id: "minecraft.memory", + icon: IconType::Database01, + title: "Memory", + description: "The amount of memory allocated for the game.", + keywords: &["ram", "memory", "heap", "xmx"], + route: Route::SettingsMinecraft {}, + }, + SearchItem { + id: "minecraft.pre_launch", + icon: IconType::FilePlus02, + title: "Pre-Launch Command", + description: "Command to run before launching the game.", + keywords: &["hook", "command", "pre launch", "prelaunch"], + route: Route::SettingsMinecraft {}, + }, + SearchItem { + id: "minecraft.wrapper", + icon: IconType::ParagraphWrap, + title: "Wrapper Command", + description: "Command to run when launching the game.", + keywords: &["hook", "command", "wrapper", "gamescope"], + route: Route::SettingsMinecraft {}, + }, + SearchItem { + id: "minecraft.post_exit", + icon: IconType::FileX02, + title: "Post-Exit Command", + description: "Command to run after exiting the game.", + keywords: &["hook", "command", "post exit", "postexit"], + route: Route::SettingsMinecraft {}, + }, + // Java routing only the page is not built from settings_row_focus yet + SearchItem { + id: "java.install", + icon: IconType::Download01, + title: "Install Java", + description: "Install a Java runtime from a provider.", + keywords: &["java", "runtime", "temurin", "zulu", "install manager"], + route: Route::SettingsJava {}, + }, + SearchItem { + id: "java.add_folder", + icon: IconType::Folder, + title: "Add Java from folder", + description: "Add an existing Java installation.", + keywords: &["java", "runtime", "custom", "folder", "path"], + route: Route::SettingsJava {}, + }, + SearchItem { + id: "storage.usage", + icon: IconType::Database01, + title: "Disk usage", + description: "See what the launcher is storing and where.", + keywords: &["storage", "disk", "space", "size", "usage", "cache"], + route: Route::SettingsStorage {}, + }, + SearchItem { + id: "storage.cleanup", + icon: IconType::Trash01, + title: "Free up space", + description: "Remove cached packages and leftovers nothing is using.", + keywords: &[ + "storage", "clean", "cleanup", "free", "space", "cache", "unused", "orphan", "leftover", + ], + route: Route::SettingsStorage {}, + }, + SearchItem { + id: "language.select", + icon: IconType::Globe01, + title: "Language", + description: "Choose the launcher language.", + keywords: &[ + "language", "locale", "english", "spanish", "french", "german", "russian", "japanese", + "chinese", + ], + route: Route::SettingsLanguage {}, + }, + SearchItem { + id: "developer.compat_only", + icon: IconType::SearchMd, + title: "Compatible content only", + description: "Filter the content browser to the active cluster.", + keywords: &["browser", "compat", "filter"], + route: Route::SettingsDeveloper {}, + }, + SearchItem { + id: "developer.log_debug", + icon: IconType::Terminal, + title: "Log Debug Info", + description: "Enable extra debug logging (requires restart).", + keywords: &["debug", "logs", "logging"], + route: Route::SettingsDeveloper {}, + }, + SearchItem { + id: "developer.show_dev", + icon: IconType::CodeSnippet02, + title: "Show Dev stuff", + description: "Enable dev tools and show the debug page.", + keywords: &["devtools", "developer", "debug"], + route: Route::SettingsDeveloper {}, + }, + SearchItem { + id: "developer.debug_page", + icon: IconType::CodeSnippet02, + title: "Debug Page", + description: "Open the debug page.", + keywords: &["debug", "page"], + route: Route::SettingsDeveloper {}, + }, + SearchItem { + id: "nav.accounts", + icon: IconType::Users01, + title: "Accounts", + description: "Manage Minecraft / Microsoft accounts.", + keywords: &["account", "login", "microsoft", "msa"], + route: Route::SettingsAccounts {}, + }, + SearchItem { + id: "nav.changelog", + icon: IconType::RefreshCcw02, + title: "Changelog", + description: "View what's new in OneClient.", + keywords: &["changelog", "release notes", "updates"], + route: Route::SettingsChangelog {}, + }, +]; + +#[derive(PartialEq, Clone, Copy)] +pub enum SettingsTab { + MinecraftSettings, + Accounts, + LauncherSettings, + Java, + Storage, + Appearance, + Apis, + Language, + DeveloperOptions, + Changelog, +} + +impl SettingsTab { + fn label(self) -> &'static str { + match self { + Self::MinecraftSettings => "Minecraft settings", + Self::Accounts => "Accounts", + Self::LauncherSettings => "Launcher settings", + Self::Java => "Java", + Self::Storage => "Storage", + Self::Appearance => "Appearance", + Self::Apis => "APIs", + Self::Language => "Language", + Self::DeveloperOptions => "Developer options", + Self::Changelog => "Changelog", + } + } + + fn icon(self) -> IconType { + match self { + Self::MinecraftSettings => IconType::Sliders04, + Self::Accounts => IconType::Users01, + Self::LauncherSettings => IconType::Rocket02, + Self::Java => IconType::CodeSnippet02, + Self::Storage => IconType::Database01, + Self::Appearance => IconType::Brush01, + Self::Apis => IconType::Key01, + Self::Language => IconType::Globe01, + Self::DeveloperOptions => IconType::CodeSnippet02, + Self::Changelog => IconType::RefreshCcw02, + } + } + + fn route(self) -> Option { + match self { + Self::MinecraftSettings => Some(Route::SettingsMinecraft {}), + Self::Accounts => Some(Route::SettingsAccounts {}), + Self::LauncherSettings => Some(Route::SettingsLauncher {}), + Self::Java => Some(Route::SettingsJava {}), + Self::Storage => Some(Route::SettingsStorage {}), + Self::Appearance => Some(Route::SettingsAppearance {}), + Self::Apis => Some(Route::SettingsApis {}), + Self::Language => Some(Route::SettingsLanguage {}), + Self::DeveloperOptions => Some(Route::SettingsDeveloper {}), + Self::Changelog => Some(Route::SettingsChangelog {}), + } + } +} + +struct SettingsGroup { + label: &'static str, + tabs: &'static [SettingsTab], +} + +const GROUPS: &[SettingsGroup] = &[ + SettingsGroup { + label: "LAUNCHER SETTINGS", + tabs: &[ + SettingsTab::LauncherSettings, + SettingsTab::Java, + SettingsTab::Storage, + SettingsTab::Appearance, + SettingsTab::Apis, + SettingsTab::Language, + ], + }, + SettingsGroup { + label: "GAME SETTINGS", + tabs: &[SettingsTab::MinecraftSettings, SettingsTab::Accounts], + }, + SettingsGroup { + label: "ABOUT", + tabs: &[SettingsTab::DeveloperOptions, SettingsTab::Changelog], + }, +]; + +#[derive(PartialEq)] +pub struct SettingsShell; + +fn route_tab(route: &Route) -> SettingsTab { + match route { + Route::SettingsMinecraft {} => SettingsTab::MinecraftSettings, + Route::SettingsAccounts {} => SettingsTab::Accounts, + Route::SettingsLauncher {} => SettingsTab::LauncherSettings, + Route::SettingsJava {} => SettingsTab::Java, + Route::SettingsStorage {} => SettingsTab::Storage, + Route::SettingsApis {} => SettingsTab::Apis, + Route::SettingsLanguage {} => SettingsTab::Language, + Route::SettingsDeveloper {} => SettingsTab::DeveloperOptions, + Route::SettingsChangelog {} => SettingsTab::Changelog, + _ => SettingsTab::Appearance, + } +} + +impl Component for SettingsShell { + fn render(&self) -> impl IntoElement { + let route = use_route::(); + let active = route_tab(&route); + + let changelog_unread = has_unread_changelog(); + + let search = use_state(String::new); + let query = SearchQuery::new(&search.read()); + let scroll = use_scroll_controller(ScrollConfig::default); + SETTINGS_SCROLL_CONTROLLER.with(|cell| *cell.borrow_mut() = Some(scroll)); + + let anim = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.) + .time(340) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let mut last_route = use_state(|| route.clone()); + if *last_route.peek() != route { + last_route.set(route.clone()); + anim.run(AnimDirection::Forward); + } + let p = anim.get().value(); + + let children: Vec = if query.is_empty() { + const SHIFT_PX: f32 = 46.; + vec![ + rect() + .width(Size::fill()) + .offset_x(-(1.0 - p) * SHIFT_PX) + .opacity(p) + .child(Outlet::::new()) + .into_element(), + ] + } else { + search_results(&query, search) + }; + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(Gaps::new(0., 40., 40., 32.)) + .spacing(40.) + .child(sidebar(active, changelog_unread)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .overflow(Overflow::Clip) + .spacing(24.) + .child(content_header(active.label().to_string(), search)) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .show_scrollbar(true) + .spacing(4.) + .scroll_controller(scroll) + .on_ctx(|ctx| { + SETTINGS_SCROLL_CTX.with(|cell| *cell.borrow_mut() = Some(ctx)); + }) + .children(children), + ), + ) + } +} + +fn content_header(title: String, search: State) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .child( + label() + .text(title) + .font_size(30.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child(search_box(search)) +} + +fn search_box(mut search: State) -> impl IntoElement { + let has_query = !search.read().trim().is_empty(); + let base = TextInput::new(search) + .placeholder("Search settings...") + .width(Size::px(SEARCH_WIDTH_PX)) + .leading( + Icon::new(IconType::SearchMd) + .size(16.) + .color(colors::fg_secondary()) + .into_element(), + ); + + if !has_query { + return base; + } + + base.trailing( + rect() + .padding(Gaps::new_symmetric(2., 2.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(Color::TRANSPARENT) + .cursor(CursorIcon::Pointer) + .on_press(move |_| search.set(String::new())) + .child( + Icon::new(IconType::XClose) + .size(16.) + .color(colors::fg_secondary()), + ) + .into_element(), + ) +} + +/// A title hit outranks a keyword or description hit however well those scored +fn settings_score(item: &SearchItem, query: &SearchQuery) -> Option<(u8, MatchScore)> { + if let Some(score) = query.score(item.title) { + return Some((2, score)); + } + if let Some(score) = query.best_score(item.keywords.iter().copied()) { + return Some((1, score)); + } + query.score(item.description).map(|score| (0, score)) +} + +fn search_results(query: &SearchQuery, mut search: State) -> Vec { + let mut scored: Vec<(u8, MatchScore, SearchItem)> = SEARCH_INDEX + .iter() + .filter_map(|item| settings_score(item, query).map(|(f, s)| (f, s, item.clone()))) + .collect(); + + // Shortest title last it is the entry closest to being just the query + scored.sort_by_key(|(field, score, item)| { + ( + std::cmp::Reverse(*field), + std::cmp::Reverse(*score), + item.title.len(), + ) + }); + let mut matches: Vec = scored.into_iter().map(|(_, _, item)| item).collect(); + matches.truncate(SEARCH_RESULTS_MAX); + + if matches.is_empty() { + return vec![ + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(10., 14.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + label() + .text("No settings matched your search.") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element(), + ]; + } + + let mut out: Vec = Vec::with_capacity(matches.len() + 1); + out.push( + rect() + .padding(Gaps::new(16., 0., 8., 2.)) + .child( + label() + .text("SEARCH RESULTS") + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .into_element(), + ); + + for item in matches { + let route = item.route; + let id = item.id; + let title = item.title; + let desc = item.description; + out.push( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .cursor(CursorIcon::Pointer) + .on_press(move |_| { + set_pending_setting_focus(id); + search.set(String::new()); + let _ = RouterContext::get().replace(route.clone()); + }) + .child(Icon::new(item.icon)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text(desc) + .font_size(11.) + .max_lines(2) + .color(colors::fg_secondary()), + ), + ) + .child( + Icon::new(IconType::ChevronRight) + .size(18.) + .color(colors::fg_secondary()), + ) + .into_element(), + ); + } + out +} + +/// False while the changelog is loading or failed rather than guessing +fn has_unread_changelog() -> bool { + let settings = use_settings_snapshot().settings; + let query = use_changelog(); + + match latest_changelog_version(&query) { + Some(latest) => settings.seen_changelog_version.as_deref() != Some(latest.as_str()), + None => false, + } +} + +fn sidebar(active: SettingsTab, changelog_unread: bool) -> impl IntoElement { + rect() + .vertical() + .width(Size::px(SIDEBAR_WIDTH_PX)) + .min_width(Size::px(SIDEBAR_WIDTH_PX)) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(Gaps::new(16., 0., 0., 0.)) + .content(Content::Flex) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(16.) + .children(GROUPS.iter().map(|group| { + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + rect().padding(Gaps::new(0., 0., 2., 6.)).child( + label() + .text(group.label) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ), + ) + .children(group.tabs.iter().map(|tab| { + SidebarItem { + tab: *tab, + active: *tab == active, + has_dot: matches!(tab, SettingsTab::Changelog) && changelog_unread, + } + .into_element() + })) + .into_element() + })), + ) + .child(SidebarInfo::new()) +} + +#[derive(PartialEq)] +struct SidebarInfo { + kernel_version: String, + ram_mb: u64, + cpu_kind: String, +} + +impl SidebarInfo { + pub fn new() -> Self { + let system = System::new_with_specifics( + RefreshKind::nothing() + .with_memory(MemoryRefreshKind::nothing().with_ram()) + .with_cpu(CpuRefreshKind::nothing()), + ); + + let kernel_version = System::kernel_long_version(); + let ram_mb = system.total_memory() / 1024 / 1024; + let cpu_kind = system + .cpus() + .first() + .map(|cpu| cpu.brand()) + .unwrap_or_default(); + + Self { + kernel_version, + ram_mb, + cpu_kind: cpu_kind.to_string(), + } + } +} + +impl Component for SidebarInfo { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + + let items: [Cow<'static, str>; 4] = [ + Cow::Borrowed(concat!( + "OneClient v", + env!("CARGO_PKG_VERSION"), + cfg_select! { + debug_assertions => " (debug)", + _ => " (release)" + } + )), + Cow::Owned(self.kernel_version.clone()), + Cow::Owned(self.cpu_kind.clone()), + Cow::Owned(format!("RAM Total of {} MB", self.ram_mb)), + ]; + + let text = items + .iter() + .map(|s| s.as_ref()) + .collect::>() + .join("\n"); + + let copy_to_clipboard = move |_| { + if let Err(err) = Clipboard::set(text.clone()) { + tracing::warn!("clipboard copy failed: {err:?}"); + dispatch + .notify("Copy failed") + .body("Could not copy system information to the clipboard.") + .error() + .send(); + } else { + dispatch + .notify("Copied to clipboard") + .body("System information copied to your clipboard.") + .info() + .icon(IconType::ClipboardCheck) + .send(); + } + }; + + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .font_size(12.) + .color(colors::fg_secondary()) + .children( + items + .into_iter() + .map(|item| label().text(item).into_element()), + ) + .cursor(CursorIcon::Pointer) + .on_press(copy_to_clipboard) + } +} + +#[derive(PartialEq)] +struct SidebarItem { + tab: SettingsTab, + active: bool, + has_dot: bool, +} + +impl Component for SidebarItem { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let tab = self.tab; + let active = self.active; + let route = tab.route(); + + let background = if active { + colors::page_elevated() + } else if *hovering.read() { + colors::ghost_overlay() + } else { + Color::TRANSPARENT + }; + + let has_dot = self.has_dot; + + let mut el = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .height(Size::px(ITEM_HEIGHT_PX)) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_symmetric(0., 12.)) + .corner_radius(CornerRadius::new_all(7.)) + .background(background) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| *hovering.write() = true) + .on_pointer_leave(move |_| *hovering.write() = false) + .map(route, |el, route| { + el.on_all_press(move |_| { + let _ = RouterContext::get().replace(route.clone()); + }) + }); + + if focus().is_focused() { + el = el.border( + Border::new() + .fill(colors::brand()) + .width(1.) + .alignment(BorderAlignment::Inner), + ); + } + + el.child(Icon::new(tab.icon()).size(18.).color(colors::fg_primary())) + .child( + rect() + .horizontal() + .width(Size::flex(1.0)) + .cross_align(Alignment::Center) + .spacing(6.) + .child( + label() + .text(tab.label()) + .font_size(14.) + .color(colors::fg_primary()), + ) + .maybe_child(has_dot.then(notification_dot)), + ) + } +} + +fn notification_dot() -> impl IntoElement { + rect() + .width(Size::px(4.)) + .height(Size::px(4.)) + .corner_radius(CornerRadius::new_all(2.)) + .background(colors::brand()) +} diff --git a/packages/oneclient_app/src/lib.rs b/packages/oneclient_app/src/lib.rs new file mode 100644 index 00000000..f5b016d1 --- /dev/null +++ b/packages/oneclient_app/src/lib.rs @@ -0,0 +1,33 @@ +// TODO Remove this once freya has some workaround for this +#![allow(float_literal_f32_fallback)] +// Instrumented oneclient_core async call chains exceed the default limit +#![recursion_limit = "256"] + +mod assets; +mod components; +pub mod events; +pub mod hooks; +mod install; +mod launcher; +mod layout; +mod motion; +mod notifications; +pub mod platform; +pub mod recovery; +mod routes; +pub mod state; +pub mod theme; +mod transfer; +mod ui; +pub mod updater; +pub(crate) mod utils; +mod view; + +pub mod constants; + +pub use assets::AppAssets; +pub use components::ConfirmLinkOverlay; +pub use events::EventPump; +pub use hooks::*; +pub use routes::{Route, router}; +pub use state::{AppChannel, AppState}; diff --git a/packages/oneclient_app/src/main.rs b/packages/oneclient_app/src/main.rs new file mode 100644 index 00000000..d555468d --- /dev/null +++ b/packages/oneclient_app/src/main.rs @@ -0,0 +1,135 @@ +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] +#![recursion_limit = "256"] + +use freya::prelude::*; +use freya::radio::use_init_radio_station; +use oneclient_app::state::{AppChannel, AppState}; +use oneclient_app::{ + Actions, ConfirmLinkOverlay, EventPump, LinkConfirmState, constants, events, router, theme, + use_provide_actions, use_provide_link_confirm, +}; +use tokio::runtime::Builder; + +struct OneClientApp; + +impl App for OneClientApp { + fn render(&self) -> impl IntoElement { + // Radio state is `!Send` so every writer runs on the UI thread `spawn_forever` + let station = use_init_radio_station::(AppState::default); + + let actions = use_hook(move || { + let (signals_tx, signals_rx) = tokio::sync::mpsc::unbounded_channel(); + let (events_bus, events_rx) = oneclient_events::EventBus::channel(); + let actions = Actions::new(station, signals_tx, events_bus.clone()); + + // Started first so nothing emitted during startup waits on a consumer + spawn_forever( + EventPump { + events: events_rx, + signals: signals_rx, + station, + } + .run(), + ); + + let startup = actions.clone(); + let rescue_bus = events_bus.clone(); + spawn_forever(async move { + match events::start_launcher(station, events_bus).await { + // Must follow startup `sync_bundles` needs the launcher handle and firing + // it early leaves `syncing_bundles` stuck disabling every launch button + Ok(()) => startup.sync_bundles(), + Err(err) => { + events::report_startup_failure(&station, &err); + oneclient_app::updater::spawn_update_check(false, rescue_bus); + } + } + }); + + actions + }); + + use_provide_actions(&actions); + + let link_confirm = use_state(|| None::); + use_provide_link_confirm(LinkConfirmState(link_confirm)); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .child(ConfirmLinkOverlay) + .child(router()) + } +} + +fn main() { + let mut builder = Builder::new_multi_thread(); + builder.enable_all().max_blocking_threads(64); + + // Debug builds emit unoptimized async code that can overflow the default 2MB stack + #[cfg(debug_assertions)] + builder.thread_stack_size(3 * 1024 * 1024); + + let rt = builder.build().unwrap(); + let _tokio_guard = rt.enter(); + + let settings = rt.block_on(oneclient_core::settings::store::load_settings(None)); + + if settings.log_debug { + oneclient_core::logger::init_debug() + } else { + oneclient_core::logger::init() + } + .expect("Failed to initialize logger"); + + let _sentry_guard = oneclient_core::reporting::init(settings.crash_reporting); + + #[cfg(target_os = "macos")] + oneclient_app::platform::macos::loop_memory_collector(); + + let window_config = WindowConfig::new_app(OneClientApp) + .with_title(constants::WINDOW_TITLE) + .with_app_id(constants::WINDOW_APP_ID) + .with_icon(LaunchConfig::window_icon(include_bytes!( + "../icons/128x128.png" + ))) + .with_size(1200., 800.) + .with_min_size(800., 600.) + .with_transparency(true) + .with_background(Color::TRANSPARENT); + + #[cfg(target_os = "macos")] + let window_config = window_config + .with_decorations(true) + .with_window_attributes(|attrs, _| { + use freya::winit::platform::macos::WindowAttributesExtMacOS; + attrs + .with_title_hidden(true) + .with_titlebar_transparent(true) + .with_titlebar_buttons_hidden(true) + .with_fullsize_content_view(true) + .with_has_shadow(true) + }); + + #[cfg(not(target_os = "macos"))] + let window_config = window_config.with_decorations(false); + + let mut launch_config = LaunchConfig::new() + .with_window(window_config) + .with_gpu_resource_cache_limit( + std::env::var("ONECLIENT_GPU_CACHE_BYTES") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(512 * 1024 * 1024), + ) + .with_default_font(theme::DEFAULT_FONT); + + for (font, bytes) in theme::load_fonts() { + launch_config = launch_config.with_font(font, bytes); + } + + launch(launch_config); +} diff --git a/packages/oneclient_app/src/motion.rs b/packages/oneclient_app/src/motion.rs new file mode 100644 index 00000000..85b76547 --- /dev/null +++ b/packages/oneclient_app/src/motion.rs @@ -0,0 +1,29 @@ +use freya::prelude::*; + +use crate::hooks::use_settings_snapshot; + +#[derive(PartialEq)] +pub struct AnimationClockDriver; + +impl Component for AnimationClockDriver { + fn render(&self) -> impl IntoElement { + let enabled = use_settings_snapshot().settings.animations_enabled; + let clock = AnimationClock::get(); + + use_side_effect_with_deps(&enabled, move |&enabled| { + if enabled { + clock.enable(); + } else { + clock.disable(); + } + }); + + rect().into_element() + } +} + +/// Read from the setting rather than the clock so a component re-renders when the preference is +/// toggled, and so it never reads the one frame of stale speed before the driver above applies it +pub fn use_animations_enabled() -> bool { + use_settings_snapshot().settings.animations_enabled +} diff --git a/packages/oneclient_app/src/notifications.rs b/packages/oneclient_app/src/notifications.rs new file mode 100644 index 00000000..386d927f --- /dev/null +++ b/packages/oneclient_app/src/notifications.rs @@ -0,0 +1,1009 @@ +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use oneclient_content::packages::ProviderId; +use oneclient_core::BrowserPackageUpdate; +use oneclient_db::models::ClusterId; +use oneclient_events::{ + Answer, Choice, Event, GroupedProgressEvent, Level, Notification, ProgressEvent, TaskCategory, +}; +use tokio::sync::oneshot; +use uuid::Uuid; + +use crate::components::IconType; +use crate::transfer::{TransferMeter, TransferStats}; + +pub const MESSAGE_TOAST_TTL: Duration = Duration::from_secs(5); + +#[derive(Clone, Debug, PartialEq)] +pub struct NotificationAction { + pub label: String, + pub kind: NotificationActionKind, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum NotificationActionKind { + /// A list so a batch sync stays a single "View changes" action not one button per cluster + OpenClusterUpdate(Vec), +} + +/// Carries core updates whole not a display projection the modal's Update button +/// hands one straight back to the apply path +#[derive(Clone, Debug, PartialEq)] +pub struct PackageUpdateGroup { + pub cluster_id: ClusterId, + pub cluster_name: String, + pub packages: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ClusterUpdateItem { + pub provider: ProviderId, + pub project_id: Option, + /// Used when the meta cache has no entry (file name / package id) + pub fallback: String, +} + +impl ClusterUpdateItem { + pub fn from_name(name: impl Into) -> Self { + Self { + provider: ProviderId::Local, + project_id: None, + fallback: name.into(), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ClusterUpdateSummary { + pub cluster_id: ClusterId, + pub cluster_name: String, + pub updated: Vec, + pub added: Vec, + pub removed: Vec, +} + +impl ClusterUpdateSummary { + pub fn total(&self) -> usize { + self.updated.len() + self.added.len() + self.removed.len() + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct InboxEntry { + pub level: Level, + pub id: u64, + pub title: String, + pub body: String, + pub icon: Option, + pub progress: Option<(u64, u64)>, + pub is_loading: bool, + pub read: bool, + pub created_at: Instant, + pub actions: Vec, + pub tasks: Vec, + /// Bytes/sec and seconds remaining for grouped downloads + pub transfer: Option, +} + +/// One aggregate row per [`TaskCategory`] (all libraries collapse into one "Libraries" row) +#[derive(Clone, Debug, PartialEq)] +pub struct TaskView { + pub label: String, + pub phase: &'static str, + pub current: u64, + pub total: u64, + pub done_count: u64, + pub total_count: u64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct NotificationSpec { + pub title: String, + pub body: String, + pub level: Level, + pub icon: Option, + pub progress: Option<(u64, u64)>, + pub actions: Vec, +} + +impl InboxEntry { + pub fn dismissable(&self) -> bool { + !self.is_loading + } + + pub fn click_dismissable(&self) -> bool { + self.dismissable() && self.actions.is_empty() + } +} + +#[derive(Clone, Debug)] +pub struct ActiveToast { + #[allow(dead_code)] + pub toast_id: u64, + pub entry_id: u64, +} + +#[derive(Debug)] +pub struct PendingPrompt { + pub title: String, + pub question: String, + pub choices: Vec, + pub dismiss: Option, + /// Taken when answered so a second answer cannot fire the oneshot twice + pub reply_tx: Option>>, +} + +#[derive(Clone, Debug)] +pub struct PendingPromptView { + pub title: String, + pub question: String, + pub choices: Vec, + pub dismiss: Option, +} + +impl PendingPromptView { + /// Lets a specialised overlay recognise a prompt without the event layer naming it + pub fn has_choice(&self, id: &str) -> bool { + self.choices.iter().any(|choice| choice.id == id) + } + + pub fn choice(&self, id: &str) -> Option<&Choice> { + self.choices.iter().find(|choice| choice.id == id) + } +} + +#[derive(Clone, Debug)] +#[allow(dead_code)] +pub struct ToastDismissTimer { + pub toast_id: u64, + pub entry_id: u64, + pub after: Duration, +} + +#[derive(Clone, Debug, Default)] +pub struct NotificationSnapshot { + pub inbox: Vec, + pub center_open: bool, + pub pending_prompt: Option, + pub cluster_update: Option>, + pub package_updates: Option>, + pub active_toast_entry_ids: Vec, +} + +impl NotificationSnapshot { + pub fn unread_count(&self) -> usize { + NotificationState::unread_count(&self.inbox) + } +} + +#[derive(Default)] +pub struct NotificationState { + next_id: u64, + active_toasts: Vec, + progress_entries: HashMap, + grouped_entries: HashMap, + grouped_tasks: HashMap, + pending_timers: Vec, + cluster_update: Option>, + package_updates: Option>, + /// Resumes the launch that opened the update modal + /// Held here not by the launch task + /// because every way the modal can end goes through this state + package_updates_done: Option>, +} + +const CATEGORY_ORDER: [TaskCategory; 7] = [ + TaskCategory::Java, + TaskCategory::Client, + TaskCategory::Libraries, + TaskCategory::Natives, + TaskCategory::Assets, + TaskCategory::Metadata, + TaskCategory::Packages, +]; + +struct ChildRec { + category: TaskCategory, + phase: &'static str, + current: u64, + total: u64, +} + +#[derive(Default)] +struct GroupedTasks { + title: String, + children: HashMap, + done_units: HashMap, + done_count: HashMap, + seen_count: HashMap, + /// Expected counts/bytes announced up-front so totals don't climb mid-download + reserved_count: HashMap, + reserved_units: HashMap, + meter: TransferMeter, +} + +impl GroupedTasks { + fn task_list(&self) -> Vec { + CATEGORY_ORDER + .iter() + .filter_map(|&cat| { + let seen_count = self.seen_count.get(&cat).copied().unwrap_or(0); + let reserved_count = self.reserved_count.get(&cat).copied().unwrap_or(0); + let total_count = seen_count.max(reserved_count); + if total_count == 0 { + return None; + } + let done_units = self.done_units.get(&cat).copied().unwrap_or(0); + let done_count = self.done_count.get(&cat).copied().unwrap_or(0); + let reserved_units = self.reserved_units.get(&cat).copied().unwrap_or(0); + + let live: Vec<&ChildRec> = self + .children + .values() + .filter(|c| c.category == cat) + .collect(); + let live_current: u64 = live.iter().map(|c| c.current.min(c.total)).sum(); + let live_total: u64 = live.iter().map(|c| c.total).sum(); + + let phase = live + .iter() + .map(|c| c.phase) + .find(|p| *p != "Downloading") + .unwrap_or("Downloading"); + + Some(TaskView { + label: cat.label().to_string(), + phase, + current: done_units + live_current, + // Reserved total keeps the denominator stable while children are still added + total: reserved_units.max(done_units + live_total), + done_count, + total_count, + }) + }) + .collect() + } + + fn active_body(&self) -> Option<&'static str> { + let mut minecraft = false; + let mut packages = false; + for child in self.children.values() { + if child.category.is_minecraft() { + minecraft = true; + } else { + packages = true; + } + } + if minecraft { + Some("Downloading Minecraft") + } else if packages { + Some("Downloading Packages") + } else { + None + } + } +} + +impl NotificationState { + pub fn snapshot( + &self, + inbox: &[InboxEntry], + center_open: bool, + pending_prompt: Option, + ) -> NotificationSnapshot { + NotificationSnapshot { + inbox: inbox.to_vec(), + center_open, + pending_prompt, + cluster_update: self.cluster_update.clone(), + package_updates: self.package_updates.clone(), + active_toast_entry_ids: self.active_toasts.iter().map(|t| t.entry_id).collect(), + } + } + + #[must_use] + pub fn active_toast_entry_ids(&self) -> Vec { + self.active_toasts.iter().map(|t| t.entry_id).collect() + } + + pub fn open_cluster_update(&mut self, summaries: Vec) { + if summaries.is_empty() { + return; + } + self.cluster_update = Some(summaries); + } + + pub fn close_cluster_update(&mut self) { + self.cluster_update = None; + } + + /// `done` is the launch's continuation it fires immediately when there is nothing + /// to show so a launch never waits on a modal that was never drawn + pub fn open_package_updates( + &mut self, + groups: Vec, + done: Option>, + ) { + let groups: Vec = groups + .into_iter() + .filter(|group| !group.packages.is_empty()) + .collect(); + + if groups.is_empty() { + if let Some(done) = done { + let _ = done.send(()); + } + return; + } + + // A second modal would strand the first launch so release the old continuation first + self.finish_package_updates(); + self.package_updates = Some(groups); + self.package_updates_done = done; + } + + pub fn close_package_updates(&mut self) { + self.finish_package_updates(); + } + + /// Applying and skipping are both answers either drops the row and can close the modal + pub fn resolve_package_update(&mut self, cluster_id: ClusterId, hash: &str) { + let Some(groups) = self.package_updates.as_mut() else { + return; + }; + + for group in groups.iter_mut() { + if group.cluster_id == cluster_id { + group.packages.retain(|p| p.hash != hash); + } + } + groups.retain(|group| !group.packages.is_empty()); + + if groups.is_empty() { + self.finish_package_updates(); + } + } + + /// Safe to call when nothing is open so every exit path can call it unconditionally + fn finish_package_updates(&mut self) { + self.package_updates = None; + if let Some(done) = self.package_updates_done.take() { + // A send failure means the launch task is gone nothing left to resume + let _ = done.send(()); + } + } + + pub fn unread_count(inbox: &[InboxEntry]) -> usize { + inbox.iter().filter(|entry| !entry.read).count() + } + + /// Deliberately does not snapshot this runs tens of thousands of times per download + /// and each snapshot deep-clones the inbox + /// Callers snapshot once after draining + pub fn dispatch( + &mut self, + inbox: &mut Vec, + event: Event, + ) -> (Vec, Option) { + self.pending_timers.clear(); + + match event { + Event::Notification(Notification::Message(message)) => { + let entry_id = self.push_inbox( + inbox, + message.title, + message.body, + message.level, + None, + false, + ); + self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); + } + // The sign-in modal renders this progress itself it must not also become a toast + Event::Progress(ProgressEvent::Update { id, .. }) + if id == oneclient_auth::MICROSOFT_LOGIN_PROGRESS => {} + Event::Progress(ProgressEvent::Update { + id, + label, + current, + total, + }) => { + self.handle_progress(inbox, id, label, current, total); + } + Event::Progress(ProgressEvent::Complete { id, title, body }) => { + self.handle_progress_complete(inbox, id, title, body); + } + Event::Progress(ProgressEvent::Grouped(event)) => { + self.handle_grouped_progress(inbox, event); + } + // Handled directly by the runtime loop they never become inbox entries + Event::Signal(_) | Event::Game(_) => {} + Event::Notification(Notification::Prompt(request)) => { + let pending_prompt = Some(PendingPrompt { + title: request.title, + question: request.body, + choices: request.choices, + dismiss: request.dismiss, + reply_tx: Some(request.reply), + }); + let timers = std::mem::take(&mut self.pending_timers); + return (timers, pending_prompt); + } + } + + let timers = std::mem::take(&mut self.pending_timers); + (timers, None) + } + + pub fn toggle_center(&mut self, _inbox: &mut [InboxEntry], center_open: bool) -> bool { + let next = !center_open; + if next { + self.active_toasts.clear(); + self.pending_timers.clear(); + } + next + } + + pub fn clear_inbox(&mut self) { + self.progress_entries.clear(); + self.grouped_entries.clear(); + self.grouped_tasks.clear(); + self.active_toasts.clear(); + self.pending_timers.clear(); + } + + pub fn dismiss_toast(&mut self, inbox: &mut Vec, entry_id: u64) { + let Some(pos) = self + .active_toasts + .iter() + .position(|t| t.entry_id == entry_id) + else { + return; + }; + let entry = inbox.iter().find(|e| e.id == entry_id); + if entry.is_some_and(|e| !e.dismissable()) { + return; + } + let has_progress = entry.is_some_and(|e| e.progress.is_some()); + self.active_toasts.remove(pos); + if !has_progress { + self.forget_entry(inbox, entry_id); + } + } + + #[allow(dead_code)] + pub fn dismiss_toast_timer(&mut self, _inbox: &[InboxEntry], toast_id: u64, _entry_id: u64) { + self.active_toasts + .retain(|toast| toast.toast_id != toast_id); + } + + pub fn expire_toast(&mut self, _inbox: &[InboxEntry], entry_id: u64) { + self.active_toasts + .retain(|toast| toast.entry_id != entry_id); + } + + pub fn mark_read(&mut self, inbox: &mut [InboxEntry], entry_id: u64) { + if let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) { + entry.read = true; + } + } + + pub fn dismiss_notification(&mut self, inbox: &mut Vec, entry_id: u64) { + if inbox + .iter() + .find(|e| e.id == entry_id) + .is_some_and(|e| e.dismissable()) + { + self.forget_entry(inbox, entry_id); + } + } + + fn forget_entry(&mut self, inbox: &mut Vec, entry_id: u64) { + inbox.retain(|e| e.id != entry_id); + self.active_toasts.retain(|t| t.entry_id != entry_id); + self.progress_entries.retain(|_, &mut v| v != entry_id); + self.grouped_entries.retain(|_, &mut v| v != entry_id); + } + + fn push_inbox( + &mut self, + inbox: &mut Vec, + title: String, + body: String, + level: Level, + progress: Option<(u64, u64)>, + is_loading: bool, + ) -> u64 { + let id = self.next_id; + self.next_id += 1; + inbox.insert( + 0, + InboxEntry { + id, + title, + body, + level, + icon: None, + progress, + is_loading, + read: false, + created_at: Instant::now(), + actions: Vec::new(), + tasks: Vec::new(), + transfer: None, + }, + ); + id + } + + pub fn push_custom(&mut self, inbox: &mut Vec, spec: NotificationSpec) -> u64 { + let NotificationSpec { + title, + body, + level, + icon, + progress, + actions, + } = spec; + + let is_loading = progress.is_some_and(|(current, total)| total == 0 || current < total); + + let id = self.next_id; + self.next_id += 1; + inbox.insert( + 0, + InboxEntry { + id, + title, + body, + level, + icon, + progress: progress.map(|(current, total)| (current, total.max(1))), + is_loading, + read: false, + created_at: Instant::now(), + actions, + tasks: Vec::new(), + transfer: None, + }, + ); + + if progress.is_some() { + self.ensure_progress_toast(id); + } else { + self.push_ephemeral_toast(id, MESSAGE_TOAST_TTL); + } + + id + } + + fn update_inbox_entry( + &self, + inbox: &mut [InboxEntry], + entry_id: u64, + title: impl Into, + body: impl Into, + progress: Option<(u64, u64)>, + is_loading: bool, + ) { + if let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) { + entry.title = title.into(); + entry.body = body.into(); + entry.progress = progress; + entry.is_loading = is_loading; + entry.read = false; + } + } + + fn push_ephemeral_toast(&mut self, entry_id: u64, ttl: Duration) { + let toast_id = self.next_id; + self.next_id += 1; + self.active_toasts.push(ActiveToast { toast_id, entry_id }); + self.pending_timers.push(ToastDismissTimer { + toast_id, + entry_id, + after: ttl, + }); + } + + fn ensure_progress_toast(&mut self, entry_id: u64) { + if self + .active_toasts + .iter() + .any(|toast| toast.entry_id == entry_id) + { + return; + } + + let toast_id = self.next_id; + self.next_id += 1; + self.active_toasts.push(ActiveToast { toast_id, entry_id }); + } + + fn handle_progress( + &mut self, + inbox: &mut Vec, + id: Uuid, + label: String, + current: u64, + total: u64, + ) { + let done = total > 0 && current >= total; + let body = progress_body(&label, current, total); + let progress = Some((current, total.max(1))); + + let entry_id = if let Some(&entry_id) = self.progress_entries.get(&id) { + entry_id + } else { + let entry_id = self.push_inbox( + inbox, + label.clone(), + body.clone(), + Level::Info, + progress, + !done, + ); + self.progress_entries.insert(id, entry_id); + entry_id + }; + + self.update_inbox_entry(inbox, entry_id, label, body, progress, !done); + self.ensure_progress_toast(entry_id); + + // The id->entry mapping is kept even once `done` so a later `ProgressComplete` + // can convert this same card into its finished state + } + + /// Converts the in-flight card in place so an operation and its "done" notice stay one + /// notification falls back to a fresh message if the entry was dismissed mid-flight + fn handle_progress_complete( + &mut self, + inbox: &mut Vec, + id: Uuid, + title: String, + body: String, + ) { + if let Some(entry_id) = self.progress_entries.remove(&id) { + self.update_inbox_entry(inbox, entry_id, title, body, None, false); + self.ensure_progress_toast(entry_id); + } else { + let entry_id = self.push_inbox(inbox, title, body, Level::Info, None, false); + self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL); + } + } + + fn handle_grouped_progress( + &mut self, + inbox: &mut Vec, + event: GroupedProgressEvent, + ) { + match event { + GroupedProgressEvent::Start { session_id, title } => { + let entry_id = self.push_inbox( + inbox, + title.clone(), + "Preparing...".to_string(), + Level::Info, + None, + true, + ); + self.grouped_entries.insert(session_id, entry_id); + self.grouped_tasks.insert( + session_id, + GroupedTasks { + title, + ..Default::default() + }, + ); + } + GroupedProgressEvent::Expect { + session_id, + category, + count, + total, + } => { + if let Some(group) = self.grouped_tasks.get_mut(&session_id) { + *group.reserved_count.entry(category).or_default() += count; + *group.reserved_units.entry(category).or_default() += total; + } + self.refresh_grouped_entry(inbox, session_id); + if let Some(&entry_id) = self.grouped_entries.get(&session_id) { + self.ensure_progress_toast(entry_id); + } + } + GroupedProgressEvent::AddChild { + session_id, + child_id, + label: _, + total, + category, + } => { + if let Some(group) = self.grouped_tasks.get_mut(&session_id) { + group.children.insert( + child_id, + ChildRec { + category, + phase: "Downloading", + current: 0, + total: total.max(1), + }, + ); + *group.seen_count.entry(category).or_default() += 1; + } + self.refresh_grouped_entry(inbox, session_id); + if let Some(&entry_id) = self.grouped_entries.get(&session_id) { + self.ensure_progress_toast(entry_id); + } + } + GroupedProgressEvent::UpdateChild { + session_id, + child_id, + current, + total, + } => { + if let Some(group) = self.grouped_tasks.get_mut(&session_id) + && let Some(task) = group.children.get_mut(&child_id) + { + task.current = current; + task.total = total.max(1); + } + self.refresh_grouped_entry(inbox, session_id); + } + GroupedProgressEvent::SetChildPhase { + session_id, + child_id, + phase, + } => { + if let Some(group) = self.grouped_tasks.get_mut(&session_id) + && let Some(task) = group.children.get_mut(&child_id) + { + task.phase = phase.label(); + } + self.refresh_grouped_entry(inbox, session_id); + } + GroupedProgressEvent::FinishChild { + session_id, + child_id, + } => { + if let Some(group) = self.grouped_tasks.get_mut(&session_id) + && let Some(task) = group.children.remove(&child_id) + { + *group.done_units.entry(task.category).or_default() += task.total; + *group.done_count.entry(task.category).or_default() += 1; + } + self.refresh_grouped_entry(inbox, session_id); + } + GroupedProgressEvent::End { session_id } => { + self.grouped_tasks.remove(&session_id); + if let Some(entry_id) = self.grouped_entries.remove(&session_id) { + let had_progress = inbox + .iter() + .find(|e| e.id == entry_id) + .is_some_and(|e| e.progress.is_some()); + if !had_progress { + self.forget_entry(inbox, entry_id); + } else if let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) { + entry.body = "Complete".to_string(); + entry.progress = Some((1, 1)); + entry.is_loading = false; + entry.tasks = Vec::new(); + entry.transfer = None; + } + } + } + } + } + + /// Reuses the same inbox id so a progress card morphs into its result instead of + /// becoming a second entry + /// `spec: None` forgets the entry entirely + pub fn finish_grouped_as_actions( + &mut self, + inbox: &mut Vec, + session_id: Uuid, + spec: Option, + ) { + self.grouped_tasks.remove(&session_id); + let entry_id = self.grouped_entries.remove(&session_id); + + let Some(spec) = spec else { + if let Some(entry_id) = entry_id { + self.forget_entry(inbox, entry_id); + } + return; + }; + + let NotificationSpec { + title, + body, + level, + icon, + progress: _, + actions, + } = spec; + + match entry_id.and_then(|id| inbox.iter_mut().find(|e| e.id == id)) { + Some(entry) => { + entry.title = title; + entry.body = body; + entry.level = level; + entry.icon = icon; + entry.progress = None; + entry.is_loading = false; + entry.read = false; + entry.created_at = Instant::now(); + entry.actions = actions; + entry.tasks = Vec::new(); + entry.transfer = None; + // Keeping it in `active_toasts` lets the loop arm a dismiss timer + // now that the entry is no longer loading + self.ensure_progress_toast(entry.id); + } + None => { + self.push_custom( + inbox, + NotificationSpec { + title, + body, + level, + icon, + progress: None, + actions, + }, + ); + } + } + } + + fn refresh_grouped_entry(&mut self, inbox: &mut [InboxEntry], session_id: Uuid) { + let Some(&entry_id) = self.grouped_entries.get(&session_id) else { + return; + }; + let Some(group) = self.grouped_tasks.get_mut(&session_id) else { + return; + }; + + let tasks = group.task_list(); + let body = group + .active_body() + .map(str::to_string) + .unwrap_or_else(|| "Preparing...".to_string()); + let title = group.title.clone(); + + let completed: u64 = tasks.iter().map(|t| t.current.min(t.total)).sum(); + let total: u64 = tasks.iter().map(|t| t.total).sum::().max(1); + + // Below 1 B/s the estimate is noise + let transfer = group + .meter + .sample(completed, total) + .filter(|stats| stats.speed_bps >= 1.0); + + let Some(entry) = inbox.iter_mut().find(|e| e.id == entry_id) else { + return; + }; + entry.title = title; + entry.body = body; + entry.progress = Some((completed, total)); + entry.is_loading = true; + entry.read = false; + entry.tasks = tasks; + entry.transfer = transfer; + } +} + +fn progress_body(label: &str, current: u64, total: u64) -> String { + if total == 0 { + return label.to_string(); + } + + let percent = ((current as f64 / total as f64) * 100.0).round() as u64; + format!("{label} - {percent}%") +} + +#[cfg(test)] +mod package_update_tests { + use super::*; + use oneclient_common::domain::ProviderId; + + fn update(hash: &str) -> BrowserPackageUpdate { + BrowserPackageUpdate { + cluster_id: 1, + hash: hash.into(), + provider: ProviderId::Modrinth, + project_id: "sodium".into(), + installed_version_id: "v1".into(), + installed_version_name: "1.0".into(), + latest_version_id: "v2".into(), + latest_version_name: "2.0".into(), + display_name: "Sodium".into(), + skipped: false, + } + } + + fn group(hashes: &[&str]) -> PackageUpdateGroup { + PackageUpdateGroup { + cluster_id: 1, + cluster_name: "Test".into(), + packages: hashes.iter().map(|hash| update(hash)).collect(), + } + } + + #[test] + fn the_launch_resumes_once_every_row_is_answered() { + let mut state = NotificationState::default(); + let (done, mut wait) = oneshot::channel(); + state.open_package_updates(vec![group(&["a", "b"])], Some(done)); + + state.resolve_package_update(1, "a"); + assert!( + wait.try_recv().is_err(), + "a launch must wait while rows are still unanswered" + ); + + state.resolve_package_update(1, "b"); + assert!( + wait.try_recv().is_ok(), + "the last answer releases the launch" + ); + assert!(state.package_updates.is_none()); + } + + #[test] + fn dismissing_the_modal_still_releases_the_launch() { + let mut state = NotificationState::default(); + let (done, mut wait) = oneshot::channel(); + state.open_package_updates(vec![group(&["a"])], Some(done)); + + state.close_package_updates(); + + assert!( + wait.try_recv().is_ok(), + "\"Not now\" declines the updates, not the launch" + ); + } + + #[test] + fn nothing_to_show_releases_the_launch_immediately() { + let mut state = NotificationState::default(); + let (done, mut wait) = oneshot::channel(); + + state.open_package_updates(vec![group(&[])], Some(done)); + + assert!( + wait.try_recv().is_ok(), + "a modal that never opens cannot be answered" + ); + assert!(state.package_updates.is_none()); + } + + #[test] + fn a_second_modal_does_not_strand_the_first_launch() { + let mut state = NotificationState::default(); + let (first, mut first_wait) = oneshot::channel(); + let (second, mut second_wait) = oneshot::channel(); + + state.open_package_updates(vec![group(&["a"])], Some(first)); + state.open_package_updates(vec![group(&["b"])], Some(second)); + + assert!( + first_wait.try_recv().is_ok(), + "the replaced launch is let go" + ); + assert!(second_wait.try_recv().is_err(), "the new one still waits"); + } + + #[test] + fn resolving_an_unknown_package_is_a_no_op() { + let mut state = NotificationState::default(); + let (done, mut wait) = oneshot::channel(); + state.open_package_updates(vec![group(&["a"])], Some(done)); + + state.resolve_package_update(1, "not-here"); + state.resolve_package_update(99, "a"); + + assert!(wait.try_recv().is_err()); + assert!(state.package_updates.is_some()); + } +} diff --git a/packages/oneclient_app/src/platform.rs b/packages/oneclient_app/src/platform.rs new file mode 100644 index 00000000..d6cc40a1 --- /dev/null +++ b/packages/oneclient_app/src/platform.rs @@ -0,0 +1,56 @@ +pub fn open_url(url: &str) { + if let Err(err) = open::that_detached(url) { + tracing::warn!("failed to open url {url}: {err}"); + } +} + +pub fn copy_image_to_clipboard(path: std::path::PathBuf) { + std::thread::spawn(move || { + let img = match image::open(&path) { + Ok(img) => img.into_rgba8(), + Err(err) => { + tracing::warn!("failed to decode {} for clipboard: {err}", path.display()); + return; + } + }; + let (width, height) = (img.width() as usize, img.height() as usize); + let data = arboard::ImageData { + width, + height, + bytes: std::borrow::Cow::Owned(img.into_raw()), + }; + match arboard::Clipboard::new() { + Ok(mut clip) => { + if let Err(err) = clip.set_image(data) { + tracing::warn!("failed to copy image to clipboard: {err}"); + } + } + Err(err) => tracing::warn!("failed to open clipboard: {err}"), + } + }); +} + +#[cfg(target_os = "macos")] +pub mod macos { + use std::time::Duration; + + pub fn loop_memory_collector() { + tokio::spawn(async { + tokio::time::sleep(Duration::from_secs(8)).await; + + loop { + release_unused_memory(); + tokio::time::sleep(Duration::from_secs(60)).await; + } + }); + } + + fn release_unused_memory() { + unsafe { + unsafe extern "C" { + fn malloc_zone_pressure_relief(zone: *mut core::ffi::c_void, goal: usize) -> usize; + } + malloc_zone_pressure_relief(core::ptr::null_mut(), 0); + } + } +} diff --git a/packages/oneclient_app/src/recovery.rs b/packages/oneclient_app/src/recovery.rs new file mode 100644 index 00000000..9478d4e9 --- /dev/null +++ b/packages/oneclient_app/src/recovery.rs @@ -0,0 +1,72 @@ +use std::path::PathBuf; + +use freya::prelude::spawn_forever; + +use crate::hooks::Actions; + +pub fn database_path() -> Option { + oneclient_common::paths::database_file().ok() +} + +pub fn snapshots() -> Vec { + database_path() + .map(|path| oneclient_db::backup::list(&path)) + .unwrap_or_default() +} + +pub fn open_data_folder() { + let Ok(dir) = oneclient_common::paths::launcher_dir() else { + return; + }; + + crate::platform::open_url(&dir.to_string_lossy()); +} + +pub fn restore_latest(actions: &Actions) { + let Some(path) = database_path() else { return }; + let Some(snapshot) = snapshots().into_iter().next() else { + set_error(actions, "There are no snapshots to restore.".to_owned()); + return; + }; + + match oneclient_db::backup::restore(&path, &snapshot) { + Ok(_) => retry(actions), + Err(err) => set_error(actions, format!("Couldn't restore the snapshot: {err}")), + } +} + +pub fn reset(actions: &Actions) { + let Some(path) = database_path() else { return }; + + match oneclient_db::backup::reset(&path) { + Ok(_) => retry(actions), + Err(err) => set_error(actions, format!("Couldn't reset the database: {err}")), + } +} + +fn retry(actions: &Actions) { + let station = actions.station(); + let events = actions.events(); + + { + let mut guard = station + .clone() + .write_channel(crate::state::AppChannel::Launcher); + guard.launcher.error = None; + guard.launcher.snapshots = snapshots().len(); + } + + spawn_forever(async move { + if let Err(err) = crate::events::start_launcher(station, events).await { + crate::events::report_startup_failure(&station, &err); + } + }); +} + +fn set_error(actions: &Actions, message: String) { + let mut guard = actions + .station() + .write_channel(crate::state::AppChannel::Launcher); + guard.launcher.error = Some(message); + guard.launcher.snapshots = snapshots().len(); +} diff --git a/packages/oneclient_app/src/routes.rs b/packages/oneclient_app/src/routes.rs new file mode 100644 index 00000000..de586dc8 --- /dev/null +++ b/packages/oneclient_app/src/routes.rs @@ -0,0 +1,168 @@ +use freya::prelude::*; +use freya::router::*; + +use crate::layout::AppShell; +use crate::layout::ClusterShell; +use crate::layout::OnboardingShell; +use crate::layout::RootLayout; +use crate::layout::SettingsShell; + +use crate::view::{ + NotFound, Startup, + app::{ + AccountSkins, Clusters, Debug, Home, Stats, + browser::{Browser, BrowserPackage}, + cluster::{ + ClusterLogs, ClusterMods, ClusterOverview, ClusterScreenshots, ClusterSettings, + ClusterShaders, ClusterTextures, ProcessLogs, + }, + settings::{ + SettingsAccounts, SettingsApis, SettingsAppearance, SettingsChangelog, + SettingsDeveloper, SettingsJava, SettingsLanguage, SettingsLauncher, SettingsMinecraft, + SettingsStorage, + }, + }, + onboarding::{ + OnboardingAccount, OnboardingBundles, OnboardingDownloading, OnboardingLanguage, + OnboardingMigration, OnboardingPreferences, OnboardingTerms, OnboardingWelcome, + }, +}; + +#[derive(Routable, Debug, Clone, PartialEq)] +#[rustfmt::skip] +pub enum Route { + #[layout(RootLayout)] + #[route("/")] + Startup {}, + + #[layout(OnboardingShell)] + #[route("/onboarding")] + OnboardingWelcome {}, + #[route("/onboarding/terms")] + OnboardingTerms {}, + #[route("/onboarding/migration")] + OnboardingMigration {}, + #[route("/onboarding/language")] + OnboardingLanguage {}, + #[route("/onboarding/account")] + OnboardingAccount {}, + #[route("/onboarding/preferences")] + OnboardingPreferences {}, + #[route("/onboarding/bundles")] + OnboardingBundles {}, + #[route("/onboarding/downloading")] + OnboardingDownloading {}, + #[end_layout] + + #[layout(AppShell)] + #[route("/app")] + Home {}, + #[route("/app/clusters")] + Clusters {}, + + #[layout(ClusterShell)] + #[route("/app/clusters/:cluster_id")] + ClusterOverview { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/logs")] + ClusterLogs { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/process")] + ProcessLogs { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/screenshots")] + ClusterScreenshots { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/mods")] + ClusterMods { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/shaders")] + ClusterShaders { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/textures")] + ClusterTextures { cluster_id: i64 }, + #[route("/app/clusters/:cluster_id/settings")] + ClusterSettings { cluster_id: i64 }, + #[end_layout] + + #[route("/app/browser/:cluster_id/:package_type?:pick_cluster")] + Browser { cluster_id: i64, package_type: String, pick_cluster: bool }, + #[route("/app/browser/:cluster_id/:package_type/package/:package_id")] + BrowserPackage { cluster_id: i64, package_type: String, package_id: String }, + #[route("/app/account/skins")] + AccountSkins {}, + #[route("/app/stats")] + Stats {}, + #[route("/app/debug")] + Debug {}, + + #[layout(SettingsShell)] + #[route("/app/settings/appearance")] + SettingsAppearance {}, + #[route("/app/settings/minecraft")] + SettingsMinecraft {}, + #[route("/app/settings/accounts")] + SettingsAccounts {}, + #[route("/app/settings/launcher")] + SettingsLauncher {}, + #[route("/app/settings/java")] + SettingsJava {}, + #[route("/app/settings/storage")] + SettingsStorage {}, + #[route("/app/settings/apis")] + SettingsApis {}, + #[route("/app/settings/language")] + SettingsLanguage {}, + #[route("/app/settings/developer")] + SettingsDeveloper {}, + #[route("/app/settings/changelog")] + SettingsChangelog {}, + // #[route("/app/settings/feedback")] + // SettingsFeedback {}, + #[end_layout] + #[end_layout] + + #[route("/:..path")] + NotFound { path: Vec }, +} + +impl Route { + pub fn title(&self) -> String { + match self { + Route::Startup { .. } => "Startup".to_string(), + Route::OnboardingWelcome { .. } => "Welcome".to_string(), + Route::OnboardingTerms { .. } => "Terms & Privacy".to_string(), + Route::OnboardingMigration { .. } => "Migration".to_string(), + Route::OnboardingLanguage { .. } => "Language".to_string(), + Route::OnboardingAccount { .. } => "Account".to_string(), + Route::OnboardingPreferences { .. } => "Accessibility".to_string(), + Route::OnboardingBundles { .. } => "Mods".to_string(), + Route::OnboardingDownloading { .. } => "Finishing".to_string(), + Route::Home { .. } => "Home".to_string(), + Route::Clusters { .. } => "Versions".to_string(), + Route::ClusterOverview { .. } => "Cluster".to_string(), + Route::ClusterLogs { .. } => "Cluster Logs".to_string(), + Route::ProcessLogs { .. } => "Process".to_string(), + Route::ClusterScreenshots { .. } => "Cluster Screenshots".to_string(), + Route::ClusterMods { .. } => "Cluster Mods".to_string(), + Route::ClusterShaders { .. } => "Cluster Shaders".to_string(), + Route::ClusterTextures { .. } => "Cluster Textures".to_string(), + Route::ClusterSettings { .. } => "Cluster Settings".to_string(), + Route::Browser { .. } => "Browser".to_string(), + Route::BrowserPackage { .. } => "Browser".to_string(), + Route::AccountSkins { .. } => "Skins".to_string(), + Route::Stats { .. } => "Statistics".to_string(), + Route::Debug { .. } => "Debug".to_string(), + Route::SettingsAppearance { .. } => "Settings".to_string(), + Route::SettingsMinecraft { .. } => "Minecraft Settings".to_string(), + Route::SettingsAccounts { .. } => "Accounts".to_string(), + Route::SettingsLauncher { .. } => "Launcher Settings".to_string(), + Route::SettingsJava { .. } => "Java".to_string(), + Route::SettingsStorage { .. } => "Storage".to_string(), + Route::SettingsApis { .. } => "APIs".to_string(), + Route::SettingsLanguage { .. } => "Language".to_string(), + Route::SettingsDeveloper { .. } => "Developer Options".to_string(), + Route::SettingsChangelog { .. } => "Changelog".to_string(), + // Route::SettingsFeedback { .. } => "Feedback".to_string(), + Route::NotFound { .. } => "Not Found".to_string(), + } + } +} + +pub fn router() -> impl IntoElement { + Router::::new(|| RouterConfig::default().with_initial_path(Route::Startup {})) +} diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs new file mode 100644 index 00000000..eae4ac29 --- /dev/null +++ b/packages/oneclient_app/src/state.rs @@ -0,0 +1,207 @@ +//! [`AppChannel`] gives per-concern subscription a single `watch` channel woke every +//! consumer and deep-cloned the inbox on every event + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use freya::radio::RadioChannel; +use oneclient_common::domain::ProviderId; +use oneclient_core::settings::LauncherSettings; +use oneclient_events::LaunchStage; + +use crate::notifications::{InboxEntry, NotificationState, PendingPrompt}; + +/// Writing through a channel wakes only the components that subscribed to it +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum AppChannel { + Launcher, + Settings, + Notifications, + Game, + AccountSwitcher, + MicrosoftLogin, + Installs, +} + +impl RadioChannel for AppChannel {} + +#[derive(Default)] +pub struct AppState { + pub launcher: LauncherInit, + pub settings: SettingsState, + /// The engine itself not a snapshot both the pump and UI actions fold into it + pub notifications: NotificationState, + /// Held beside the engine which folds events into it by `&mut` so the engine + /// stays a plain state machine with no channels of its own + pub inbox: Vec, + pub prompt: Option, + pub center_open: bool, + pub game: GameState, + pub account_switcher_open: bool, + pub microsoft_login: Option, + pub installs: InstallState, +} + +/// In-flight installs so the button that started one stays disabled until it lands +#[derive(Clone, Debug, Default, PartialEq)] +pub struct InstallState { + pending: HashSet<(i64, ProviderId, String)>, +} + +impl InstallState { + pub fn begin(&mut self, cluster_id: i64, provider: ProviderId, project_id: String) { + self.pending.insert((cluster_id, provider, project_id)); + } + + pub fn finish(&mut self, cluster_id: i64, provider: ProviderId, project_id: &str) { + self.pending + .remove(&(cluster_id, provider, project_id.to_string())); + } + + #[must_use] + pub fn is_installing(&self, cluster_id: i64, provider: ProviderId, project_id: &str) -> bool { + self.pending + .contains(&(cluster_id, provider, project_id.to_string())) + } +} + +#[derive(Clone, Debug, Default)] +pub struct LauncherInit { + pub ready: bool, + pub fetching: bool, + /// Launch is disabled until this clears + pub syncing_bundles: bool, + pub error: Option, + pub data_dir: String, + /// Restorable database snapshots, for the startup failure screen + pub snapshots: usize, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AsyncStatus { + #[default] + Idle, + Loading, + Ready, + Error, +} + +#[derive(Clone, Debug, Default)] +pub struct SettingsState { + pub settings: LauncherSettings, + pub status: AsyncStatus, + pub saving: bool, + pub error: Option, +} + +/// Rendered inside the sign-in modal rather than as a toast the core reports it +/// as ordinary progress and has no opinion about where it is shown +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LoginProgress { + pub label: String, + pub current: u64, + pub total: u64, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GameState { + pub stages: HashMap, + pub error: Option, + pub logs: HashMap>>>, + /// Launches started from the UI but not yet answered by core which takes a few + /// hundred ms every click in that window otherwise spawns its own game + pending: HashSet, +} + +impl GameState { + #[must_use] + pub fn stage(&self, cluster_id: i64) -> Option { + self.stages.get(&cluster_id).copied() + } + + /// Returns false if a launch is already in flight the re-entrancy guard + pub fn begin_launch(&mut self, cluster_id: i64) -> bool { + if self.is_active(cluster_id) || self.is_launch_pending(cluster_id) { + return false; + } + self.pending.insert(cluster_id); + true + } + + pub fn finish_launch(&mut self, cluster_id: i64) { + self.pending.remove(&cluster_id); + } + + #[must_use] + pub fn is_launch_pending(&self, cluster_id: i64) -> bool { + self.pending.contains(&cluster_id) + } + + #[must_use] + pub fn is_busy(&self, cluster_id: i64) -> bool { + self.stage(cluster_id).is_some_and(LaunchStage::is_busy) + } + + #[must_use] + pub fn is_running(&self, cluster_id: i64) -> bool { + self.stage(cluster_id) == Some(LaunchStage::Running) + } + + #[must_use] + pub fn is_active(&self, cluster_id: i64) -> bool { + matches!(self.stage(cluster_id), Some(s) if s != LaunchStage::Exited) + } + + #[must_use] + pub fn logs_for(&self, cluster_id: i64) -> Arc>> { + self.logs.get(&cluster_id).cloned().unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::app::launch_button_state; + + #[test] + fn a_second_click_is_refused_before_core_answers() { + let mut game = GameState::default(); + + assert!(game.begin_launch(1)); + assert!(!game.begin_launch(1)); + assert!(game.begin_launch(2)); + } + + #[test] + fn a_running_cluster_cannot_be_launched_again() { + let mut game = GameState::default(); + game.stages.insert(1, LaunchStage::Running); + + assert!(!game.begin_launch(1)); + } + + #[test] + fn the_claim_is_released_when_the_launch_settles() { + let mut game = GameState::default(); + + assert!(game.begin_launch(1)); + game.finish_launch(1); + assert!(game.begin_launch(1)); + } + + #[test] + fn the_button_disables_on_the_claim_alone() { + let mut game = GameState::default(); + assert_eq!(launch_button_state(&game, 1, false), ("Launch", true)); + + game.begin_launch(1); + assert_eq!(launch_button_state(&game, 1, false), ("Launching", false)); + + // Held past a failure which parks the stage at `Exited` + game.stages.insert(1, LaunchStage::Exited); + assert_eq!(launch_button_state(&game, 1, false), ("Launching", false)); + + game.finish_launch(1); + assert_eq!(launch_button_state(&game, 1, false), ("Launch", true)); + } +} diff --git a/packages/oneclient_app/src/theme/colors.rs b/packages/oneclient_app/src/theme/colors.rs new file mode 100644 index 00000000..0db8798d --- /dev/null +++ b/packages/oneclient_app/src/theme/colors.rs @@ -0,0 +1,152 @@ +use freya::prelude::Color; + +pub const MODRINTH_COLOR: Color = Color::from_rgb(27, 217, 106); +pub const CURSEFORGE_COLOR: Color = Color::from_rgb(241, 100, 54); + +pub fn page() -> Color { + Color::from_rgb(17, 23, 28) +} + +pub fn page_elevated() -> Color { + Color::from_rgb(21, 28, 34) +} + +pub fn page_overlay() -> Color { + Color::from_argb(140, 17, 23, 28) +} + +pub fn fg_primary() -> Color { + Color::from_rgb(213, 219, 255) +} + +pub fn fg_primary_hover() -> Color { + Color::from_rgb(218, 224, 255) +} + +pub fn fg_primary_pressed() -> Color { + Color::from_rgb(225, 229, 255) +} + +pub fn fg_primary_disabled() -> Color { + Color::from_rgb(205, 209, 235) +} + +pub fn fg_secondary() -> Color { + Color::from_rgb(120, 129, 141) +} + +pub fn fg_secondary_hover() -> Color { + Color::from_rgb(95, 104, 116) +} + +pub fn fg_secondary_pressed() -> Color { + Color::from_rgb(75, 84, 96) +} + +pub fn brand() -> Color { + Color::from_rgb(43, 75, 255) +} + +pub fn brand_hover() -> Color { + Color::from_rgb(40, 67, 221) +} + +pub fn brand_pressed() -> Color { + Color::from_rgb(57, 87, 255) +} + +pub fn brand_disabled() -> Color { + Color::from_rgb(31, 47, 129) +} + +pub fn ghost_overlay() -> Color { + Color::from_argb(12, 255, 255, 255) +} + +pub fn ghost_overlay_hover() -> Color { + Color::from_argb(26, 255, 255, 255) +} + +pub fn ghost_overlay_pressed() -> Color { + Color::from_argb(38, 255, 255, 255) +} + +pub fn component_bg() -> Color { + Color::from_rgb(26, 34, 40) +} + +pub fn component_bg_hover() -> Color { + Color::from_rgb(29, 36, 43) +} + +pub fn component_bg_pressed() -> Color { + Color::from_rgb(34, 44, 53) +} + +pub fn component_bg_disabled() -> Color { + Color::from_rgb(16, 24, 31) +} + +pub fn component_border() -> Color { + Color::from_argb(12, 255, 255, 255) +} + +pub fn component_border_hover() -> Color { + Color::from_argb(25, 255, 255, 255) +} + +pub fn component_border_pressed() -> Color { + Color::from_argb(38, 255, 255, 255) +} + +pub fn danger() -> Color { + Color::from_rgb(255, 68, 68) +} + +pub fn danger_hover() -> Color { + Color::from_rgb(214, 52, 52) +} + +pub fn danger_pressed() -> Color { + Color::from_rgb(255, 86, 86) +} + +pub fn danger_disabled() -> Color { + Color::from_rgb(235, 48, 48) +} + +pub fn success() -> Color { + Color::from_rgb(35, 154, 96) +} + +pub fn code_info() -> Color { + Color::from_rgb(97, 175, 239) +} + +pub fn code_warn() -> Color { + Color::from_rgb(229, 192, 123) +} + +pub fn code_error() -> Color { + Color::from_rgb(224, 108, 117) +} + +pub fn code_debug() -> Color { + Color::from_rgb(152, 195, 121) +} + +pub fn code_chat() -> Color { + Color::from_rgb(198, 120, 221) +} + +pub fn code_muted() -> Color { + Color::from_rgb(120, 128, 140) +} + +pub fn selection_bg() -> Color { + Color::from_rgb(97, 175, 239).with_a(60) +} + +pub fn toast_action() -> Color { + Color::from_rgb(155, 161, 166) +} diff --git a/packages/oneclient_app/src/theme/mod.rs b/packages/oneclient_app/src/theme/mod.rs new file mode 100644 index 00000000..771d4be5 --- /dev/null +++ b/packages/oneclient_app/src/theme/mod.rs @@ -0,0 +1,38 @@ +use bytes::Bytes; + +use crate::AppAssets; + +pub mod colors; + +pub const NAVBAR_HEIGHT_PX: f32 = 80.; +pub const HOME_PADDING_PX: f32 = 48.; +pub const DEFAULT_FONT: &str = "Poppins"; +pub const MONO_FONT: &str = "JetBrains Mono"; + +pub fn load_fonts() -> Vec<(&'static str, Bytes)> { + const FONTS: [(&str, &str); 11] = [ + (DEFAULT_FONT, "fonts/Poppins/Poppins-Bold.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-BoldItalic.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-SemiBold.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-SemiBoldItalic.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-Medium.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-MediumItalic.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-Regular.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-Italic.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-Light.ttf"), + (DEFAULT_FONT, "fonts/Poppins/Poppins-LightItalic.ttf"), + (MONO_FONT, "fonts/JetBrainsMono/JetBrainsMonoVariable.ttf"), + ]; + + FONTS + .iter() + .filter_map(|&(weight, file)| { + let bytes = AppAssets::get_bytes(file).or_else(|| { + tracing::error!("Failed to load embedded font '{file}'"); + None + })?; + + Some((weight, bytes)) + }) + .collect() +} diff --git a/packages/oneclient_app/src/transfer.rs b/packages/oneclient_app/src/transfer.rs new file mode 100644 index 00000000..85b52480 --- /dev/null +++ b/packages/oneclient_app/src/transfer.rs @@ -0,0 +1,114 @@ +use std::time::Instant; + +/// Weight of the existing average per sample kept sluggish because downloads are bursty +const SMOOTHING: f64 = 0.7; + +#[derive(Debug, Clone, Copy, Default)] +pub struct TransferMeter { + last_sample: Option<(Instant, u64)>, + speed_bps: f64, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TransferStats { + pub speed_bps: f64, + /// `None` until there is enough signal to estimate or when the total is unknown + pub eta_secs: Option, +} + +impl TransferMeter { + /// `completed` and `total` are cumulative bytes + /// Samples under 250ms apart are not folded in + /// the tiny elapsed divisor would swamp the average with noise + pub fn sample(&mut self, completed: u64, total: u64) -> Option { + let now = Instant::now(); + + let Some((last_at, last_completed)) = self.last_sample else { + self.last_sample = Some((now, completed)); + return None; + }; + + let elapsed = now.duration_since(last_at).as_secs_f64(); + if elapsed < 0.25 { + return self.stats(completed, total); + } + + let delta = completed.saturating_sub(last_completed) as f64; + let instant = delta / elapsed; + + self.speed_bps = if self.speed_bps <= 0.0 { + instant + } else { + self.speed_bps * SMOOTHING + instant * (1.0 - SMOOTHING) + }; + self.last_sample = Some((now, completed)); + + self.stats(completed, total) + } + + fn stats(&self, completed: u64, total: u64) -> Option { + if self.speed_bps <= 0.0 { + return None; + } + + let remaining = total.saturating_sub(completed); + Some(TransferStats { + speed_bps: self.speed_bps, + // No total means no estimate the denominator may still be climbing + eta_secs: (total > 0).then(|| (remaining as f64 / self.speed_bps) as u64), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_first_sample_only_establishes_a_baseline() { + let mut meter = TransferMeter::default(); + assert!(meter.sample(0, 1000).is_none()); + } + + #[test] + fn a_total_of_zero_yields_speed_without_an_eta() { + let mut meter = TransferMeter::default(); + meter.sample(0, 0); + std::thread::sleep(std::time::Duration::from_millis(300)); + + let stats = meter.sample(1_000, 0).expect("should have a rate by now"); + assert!(stats.speed_bps > 0.0); + assert_eq!(stats.eta_secs, None); + } + + #[test] + fn eta_falls_as_the_download_completes() { + let mut meter = TransferMeter::default(); + meter.sample(0, 10_000); + std::thread::sleep(std::time::Duration::from_millis(300)); + let early = meter.sample(1_000, 10_000).unwrap().eta_secs.unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(300)); + let late = meter.sample(9_000, 10_000).unwrap().eta_secs.unwrap(); + + assert!(late < early, "eta should shrink: {early} -> {late}"); + } + + #[test] + fn samples_taken_too_close_together_do_not_distort_the_average() { + let mut meter = TransferMeter::default(); + meter.sample(0, 10_000); + std::thread::sleep(std::time::Duration::from_millis(300)); + let settled = meter.sample(1_000, 10_000).unwrap().speed_bps; + + for i in 1..50 { + meter.sample(1_000 + i, 10_000); + } + + let after = meter.speed_bps; + assert!( + (after - settled).abs() < f64::EPSILON, + "burst of close samples changed the rate: {settled} -> {after}" + ); + } +} diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs new file mode 100644 index 00000000..0e075fe3 --- /dev/null +++ b/packages/oneclient_app/src/ui.rs @@ -0,0 +1,131 @@ +use std::time::Instant; + +use freya::prelude::*; + +use crate::theme; + +pub fn border_all(width: f32) -> Border { + Border::new() + .fill(theme::colors::component_border()) + .width(BorderWidth { + top: width, + right: width, + bottom: width, + left: width, + }) +} + +pub fn border_all_color(width: f32, color: Color) -> Border { + Border::new().fill(color).width(BorderWidth { + top: width, + right: width, + bottom: width, + left: width, + }) +} + +pub fn fmt_date(ts: chrono::DateTime) -> String { + ts.format("%Y-%m-%d %H:%M").to_string() +} + +/// Shortest band is a minute nothing re-renders on a timer so a seconds counter +/// would freeze at whatever it read when the row was drawn +pub fn relative_time(created_at: Instant) -> String { + let secs = created_at.elapsed().as_secs(); + match secs { + 0..=59 => "Just now".to_string(), + 60..=3599 => format!("{}m ago", secs / 60), + 3600..=86_399 => format!("{}h ago", secs / 3600), + _ => format!("{}d ago", secs / 86_400), + } +} + +/// Returns the `Rect` not an `Element` so callers can inset or round it +pub fn divider() -> Rect { + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .background(theme::colors::component_border()) +} + +pub fn centered_note(text: &str) -> Element { + rect() + .width(Size::fill()) + .height(Size::px(240.)) + .center() + .child( + label() + .text(text.to_string()) + .font_size(14.) + .color(theme::colors::fg_secondary()), + ) + .into_element() +} + +/// Returns 1 until the grid has measured itself so the first frame still renders +pub fn grid_columns_for_width(width: f32, max_col: f32, gap: f32) -> usize { + if width <= 0. { + return 1; + } + (((width + gap) / (max_col + gap)).ceil() as usize).max(1) +} + +/// Reports its measured size back through `width` for [`grid_columns_for_width`] +/// Short final rows are padded with empty flex cells so tiles keep the column width +pub fn flow_grid(items: Vec, cols: usize, mut width: State, gap: f32) -> Element { + let cols = cols.max(1); + + let mut root = rect().vertical().width(Size::fill()).spacing(gap); + let mut iter = items.into_iter(); + let mut remaining = true; + while remaining { + let mut row = rect() + .horizontal() + .width(Size::fill()) + .spacing(gap) + .content(Content::Flex); + let mut filled = 0; + for _ in 0..cols { + if let Some(item) = iter.next() { + row = row.child(item); + filled += 1; + } else { + row = row.child(rect().width(Size::flex(1.0))); + } + } + if filled == 0 { + break; + } + remaining = filled == cols; + root = root.child(row.into_element()); + } + + root.on_sized(move |event: Event| { + let w = event.data().area.width(); + if (w - *width.peek()).abs() > 0.5 { + *width.write() = w; + } + }) + .into_element() +} + +pub fn entrance_motion_layer( + slide_x: f32, + slide_y: f32, + opacity: f32, + child: impl IntoElement, +) -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute().top(slide_y).left(slide_x)) + .opacity(opacity) + .child(child), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/updater.rs b/packages/oneclient_app/src/updater.rs new file mode 100644 index 00000000..20691f2f --- /dev/null +++ b/packages/oneclient_app/src/updater.rs @@ -0,0 +1,185 @@ +use std::cell::Cell; + +use cargo_packager_updater::{Config, Update, check_update}; +use oneclient_events::{Choice, EventBus, Prompt}; +use uuid::Uuid; + +use crate::constants::{RELEASES_URL, UPDATER_ENDPOINT, UPDATER_PUBKEY}; + +pub const UPDATE_CHOICE_INSTALL: &str = "update.install"; + +enum UpdateAnswer { + Install, +} + +fn update_prompt(version: &str) -> Prompt { + Prompt::new( + "Update available", + format!("OneClient {version} is ready to install. Download and install it now?"), + ) + .option( + Choice::primary(UPDATE_CHOICE_INSTALL, "Install"), + UpdateAnswer::Install, + ) + .dismiss("Not now") +} + +const PROGRESS_STEP: u64 = 256 * 1024; + +pub fn spawn_update_check(auto_install: bool, events: EventBus) { + tokio::spawn(async move { + if let Err(err) = run_check(auto_install, events).await { + tracing::warn!("update check failed: {err:#}"); + } + }); +} + +/// Debug-only drives the full auto-update UX +pub fn spawn_simulated_update() { + tokio::spawn(async move { + if let Err(err) = run_simulated_update().await { + tracing::warn!("simulated update failed: {err:#}"); + } + }); +} + +async fn run_simulated_update() -> anyhow::Result<()> { + const FAKE_VERSION: &str = "9999.9999.9999"; + const FAKE_TOTAL: u64 = 48 * 1024 * 1024; + + let events = crate::launcher::state()?.services.events.clone(); + + if events.ask(update_prompt(FAKE_VERSION)).await?.is_none() { + tracing::info!("user declined simulated update"); + return Ok(()); + } + + let progress_id = Uuid::new_v4(); + let label = format!("Downloading OneClient {FAKE_VERSION}"); + + let mut downloaded = 0u64; + events.progress(progress_id, &label, downloaded, FAKE_TOTAL); + while downloaded < FAKE_TOTAL { + downloaded = (downloaded + PROGRESS_STEP * 8).min(FAKE_TOTAL); + events.progress(progress_id, &label, downloaded, FAKE_TOTAL); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + + events.finish_progress( + progress_id, + "Finished Downloading", + format!("OneClient {FAKE_VERSION} is ready. Restart to apply."), + ); + + Ok(()) +} + +async fn run_check(auto_install: bool, events: EventBus) -> anyhow::Result<()> { + // `check_update` performs a blocking HTTP request so offload it to a thread pool + let Some(update) = tokio::task::spawn_blocking(check_for_update).await?? else { + tracing::info!("no update available"); + return Ok(()); + }; + + tracing::info!("update available: {}", update.version); + + // cargo-packager-updater can only replace an AppImage in place deb/rpm installs live + // under a package-managed path that an install would fail on or clobber + if !can_self_update() { + tracing::info!("install is not self-updatable (non-AppImage Linux); notifying only"); + events + .notify("Update available") + .body(format!( + "OneClient {} is available. Download the latest package from {} to update.", + update.version, RELEASES_URL + )) + .send(); + return Ok(()); + } + + if !auto_install && events.ask(update_prompt(&update.version)).await?.is_none() { + tracing::info!("user declined update {}", update.version); + return Ok(()); + } + + download_and_install(update, events).await +} + +fn can_self_update() -> bool { + if cfg!(debug_assertions) { + return false; + } + + if std::env::var_os("ONECLIENT_DISABLE_AUTOUPDATE") + .is_some_and(|val| val.eq_ignore_ascii_case("1")) + { + return false; + } + + #[cfg(target_os = "linux")] + { + std::env::var_os("APPIMAGE").is_some() + } + #[cfg(not(target_os = "linux"))] + { + true + } +} + +fn check_for_update() -> anyhow::Result> { + let current = env!("CARGO_PKG_VERSION").parse()?; + let config = Config { + endpoints: vec![UPDATER_ENDPOINT.parse()?], + pubkey: UPDATER_PUBKEY.into(), + ..Default::default() + }; + + Ok(check_update(current, config)?) +} + +async fn download_and_install(update: Update, events: EventBus) -> anyhow::Result<()> { + let progress_id = Uuid::new_v4(); + let version = update.version.clone(); + let label = format!("Downloading OneClient {version}"); + + events.progress(progress_id, &label, 0, 0); + + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let downloaded = Cell::new(0u64); + let last_sent = Cell::new(0u64); + + let bytes = update.download_extended( + |chunk, total| { + let now = downloaded.get() + chunk as u64; + downloaded.set(now); + let total = total.unwrap_or(0); + + if now == chunk as u64 + || (total > 0 && now >= total) + || now - last_sent.get() >= PROGRESS_STEP + { + last_sent.set(now); + events.progress(progress_id, &label, now, total); + } + }, + || {}, + )?; + + let total = downloaded.get().max(1); + events.progress(progress_id, &label, total, total); + + update.install(bytes)?; + + // Converts the same download card into its finished state rather than adding a second + events.finish_progress( + progress_id, + "Finished Downloading", + format!("OneClient {version} is ready. Restart to apply."), + ); + Ok(()) + }) + .await??; + + tracing::info!("update installed; restart to apply"); + Ok(()) +} diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs new file mode 100644 index 00000000..ecea19aa --- /dev/null +++ b/packages/oneclient_app/src/utils.rs @@ -0,0 +1,485 @@ +use std::cmp::Ordering; +use std::collections::BTreeMap; + +use chrono::{Datelike, NaiveDate}; +use oneclient_common::domain::GameLoader; +use oneclient_common::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version}; +use oneclient_core::clusters::Cluster; +use sysinfo::{MemoryRefreshKind, RefreshKind, System}; + +pub type ClusterGroups = BTreeMap>; + +/// The modern scheme puts a full release in the first two components (`26.1`) so each +/// minor is its own line legacy `1.x` versions keep the whole major (`1.21`) as one line +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ReleaseLine { + pub major: u32, + /// `Some` only for the modern scheme where the minor is part of the line + pub minor: Option, +} + +impl ReleaseLine { + fn from_parsed(parsed: &ParsedMcVersion) -> Option { + let minor = if parsed.major >= 26 { + Some(parsed.minor?) + } else { + None + }; + Some(Self { + major: parsed.major, + minor, + }) + } + + pub fn from_version(version: &str) -> Option { + Self::from_parsed(&parse_mc_version(version)?) + } + + pub fn for_cluster(cluster: &Cluster) -> Option { + Self::from_version(&cluster.mc_version) + } + + /// The generic name of the line `26.1` `1.21` + pub fn pretty_name(&self) -> String { + match self.minor { + Some(minor) => format!("{}.{minor}", self.major), + None => format!("1.{}", self.major), + } + } + + fn art_key(&self) -> Option { + self.minor.map(|minor| (minor, None)) + } +} + +fn cluster_key(cluster: &Cluster) -> Option { + parse_mc_version(&cluster.mc_version).and_then(|parsed| parsed.key()) +} + +fn sole_version_key(clusters: &[Cluster]) -> Option { + match version_keys(clusters).as_slice() { + [only] => Some(*only), + _ => None, + } +} + +/// A line with a single version is shown as that full version (`26.1.2`) else `1.21` +pub fn line_title(line: ReleaseLine, clusters: &[Cluster]) -> String { + match sole_version_key(clusters) { + Some(key) => version_label(line.major, key), + None => line.pretty_name(), + } +} + +pub fn line_art_key(line: ReleaseLine, clusters: &[Cluster]) -> Option { + sole_version_key(clusters).or_else(|| line.art_key()) +} + +pub fn group_clusters_by_release(clusters: &[Cluster]) -> ClusterGroups { + let mut groups: ClusterGroups = BTreeMap::new(); + + for cluster in clusters { + let Some(line) = ReleaseLine::for_cluster(cluster) else { + continue; + }; + groups.entry(line).or_default().push(cluster.clone()); + } + + for list in groups.values_mut() { + list.sort_by_key(|b| std::cmp::Reverse(cluster_key(b))); + } + + groups +} + +pub fn loader_tags(clusters: &[Cluster]) -> Vec { + let mut tags = Vec::new(); + for cluster in clusters { + if cluster.mc_loader.is_modded() { + let label = cluster.mc_loader.to_string(); + if !tags.iter().any(|t| t == &label) { + tags.push(label); + } + } + } + tags.sort(); + tags +} + +pub fn version_keys(clusters: &[Cluster]) -> Vec { + let mut keys = Vec::new(); + for cluster in clusters { + if let Some(key) = cluster_key(cluster) + && !keys.contains(&key) + { + keys.push(key); + } + } + keys.sort(); + keys +} + +pub fn loaders_for_line(clusters: &[Cluster]) -> Vec { + let mut loaders = Vec::new(); + for cluster in clusters { + if cluster.mc_loader.is_modded() && !loaders.contains(&cluster.mc_loader) { + loaders.push(cluster.mc_loader); + } + } + loaders.sort_by_key(|loader| loader.to_string()); + loaders +} + +pub fn resolve_cluster( + clusters: &[Cluster], + key: Option, + loader: Option, +) -> Option { + clusters + .iter() + .find(|cluster| { + let version_ok = key.is_none_or(|key| cluster_key(cluster) == Some(key)); + let loader_ok = loader.is_none_or(|loader| cluster.mc_loader == loader); + version_ok && loader_ok + }) + .cloned() +} + +/// The line the page opens on the active cluster's line else the newest +pub fn default_line(groups: &ClusterGroups, active: Option) -> Option { + if let Some(cluster) = active + && let Some(line) = ReleaseLine::for_cluster(&cluster) + { + return Some(line); + } + groups.keys().next_back().copied() +} + +pub fn default_version_key( + clusters: &[Cluster], + preferred: Option, +) -> Option { + let keys = version_keys(clusters); + if keys.is_empty() { + return None; + } + if let Some(key) = preferred + && keys.contains(&key) + { + return Some(key); + } + keys.last().copied() +} + +pub fn default_loader(clusters: &[Cluster], preferred: Option) -> Option { + let loaders = loaders_for_line(clusters); + if loaders.is_empty() { + return clusters.first().map(|c| c.mc_loader); + } + if let Some(loader) = preferred + && loaders.contains(&loader) + { + return Some(loader); + } + Some(loaders[0]) +} + +pub fn version_label(major: u32, (minor, patch): VersionKey) -> String { + format_mc_version(major, minor, patch) +} + +pub fn format_size(bytes: u64) -> String { + const KB: f64 = 1024.; + const MB: f64 = KB * 1024.; + let b = bytes as f64; + if b >= MB { + format!("{:.1} MB", b / MB) + } else if b >= KB { + format!("{:.0} KB", b / KB) + } else { + format!("{bytes} B") + } +} + +pub fn format_res((w, h): (u32, u32)) -> String { + format!("{w}×{h}") +} + +/// Prevents user from choosing his max amount of ram preset (e.g Someone has 16GB of RAM, +/// so the max preset is 16GB - 2GB = 14GB) +const MEMORY_HEADROOM_GB: u32 = 2; +const MEMORY_PRESETS_GB: [u32; 10] = [2, 4, 6, 8, 12, 16, 24, 32, 48, 64]; + +pub fn total_ram_mb() -> u32 { + static TOTAL_RAM_MB: std::sync::OnceLock = std::sync::OnceLock::new(); + + *TOTAL_RAM_MB.get_or_init(|| { + let system = System::new_with_specifics( + RefreshKind::nothing().with_memory(MemoryRefreshKind::nothing().with_ram()), + ); + (system.total_memory() / 1024 / 1024).min(u32::MAX as u64) as u32 + }) +} + +pub fn memory_presets_mb() -> Vec { + presets_for_total_gb((total_ram_mb() as f32 / 1024.).round() as u32) +} + +fn presets_for_total_gb(total_gb: u32) -> Vec { + let usable_gb = total_gb.saturating_sub(MEMORY_HEADROOM_GB); + if usable_gb == 0 { + return vec![1024]; + } + + let mut presets: Vec = MEMORY_PRESETS_GB + .iter() + .copied() + .filter(|gb| *gb <= usable_gb) + .map(|gb| gb * 1024) + .collect(); + + let usable_mb = usable_gb * 1024; + if presets.last() != Some(&usable_mb) { + presets.push(usable_mb); + } + + presets +} + +/// `8192` -> `8 GB` `1536` -> `1.5 GB` +pub fn format_memory_gb(mb: u32) -> String { + if mb.is_multiple_of(1024) { + format!("{} GB", mb / 1024) + } else { + format!("{:.1} GB", mb as f32 / 1024.) + } +} + +/// `7384` -> `2h 3m` `540` -> `9m` `0` -> `0m` +pub fn format_duration_hm(secs: i64) -> String { + if secs <= 0 { + return "0m".to_string(); + } + let hours = secs / 3600; + let minutes = (secs % 3600) / 60; + if hours > 0 { + format!("{hours}h {minutes}m") + } else { + format!("{minutes}m") + } +} + +/// `3723` -> `1h 2m` `83` -> `1m 23s` `45` -> `45s` +pub fn format_duration_hms(secs: i64) -> String { + if secs <= 0 { + return "0s".to_string(); + } + if secs >= 3600 { + format!("{}h {}m", secs / 3600, (secs % 3600) / 60) + } else if secs >= 60 { + format!("{}m {}s", secs / 60, secs % 60) + } else { + format!("{secs}s") + } +} + +/// `1500` -> `1.5K` `2_400_000` -> `2.4M` +pub fn abbreviate_number(n: u64) -> String { + let f = n as f64; + if f >= 1_000_000.0 { + format!("{:.1}M", f / 1_000_000.0) + } else if f >= 1_000.0 { + format!("{:.1}K", f / 1_000.0) + } else { + n.to_string() + } +} + +pub fn plural(n: i64) -> &'static str { + if n == 1 { "" } else { "s" } +} + +pub fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +/// 24-hour clock label `9` -> `09:00` +pub fn format_hour(hour: usize) -> String { + format!("{hour:02}:00") +} + +pub fn parse_day(date: &str) -> Option { + NaiveDate::parse_from_str(date, "%Y-%m-%d").ok() +} + +/// `2026-07-05` -> `Jul 5` +pub fn format_day(date: NaiveDate) -> String { + const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + format!("{} {}", MONTHS[date.month0() as usize], date.day()) +} + +pub fn sort_clusters_for_home(mut clusters: Vec) -> Vec { + clusters.sort_by(compare_last_played); + clusters +} + +fn compare_last_played(a: &Cluster, b: &Cluster) -> Ordering { + match (a.last_played, b.last_played) { + // Most recently played first + (Some(a), Some(b)) => b.cmp(&a), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + // Never played latest version first (major then minor) + (None, None) => version_sort_key(b).cmp(&version_sort_key(a)), + } +} + +fn version_sort_key(cluster: &Cluster) -> (u32, u32, u32) { + parse_mc_version(&cluster.mc_version) + .map(|v| (v.major, v.minor.unwrap_or(0), v.patch.unwrap_or(0))) + .unwrap_or((0, 0, 0)) +} + +#[cfg(not(target_os = "linux"))] +pub fn is_wayland() -> bool { + false +} + +#[cfg(target_os = "linux")] +pub fn is_wayland() -> bool { + static IS_WAYLAND: std::sync::OnceLock = std::sync::OnceLock::new(); + + *IS_WAYLAND.get_or_init(|| { + if cfg!(target_os = "linux") { + std::env::var("XDG_SESSION_TYPE") + .map(|v| v == "wayland") + .unwrap_or(false) + } else { + false + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::cluster; + + fn versioned(id: i64, mc_version: &str) -> Cluster { + Cluster { + mc_version: mc_version.to_string(), + ..cluster(id) + } + } + + fn line(major: u32, minor: Option) -> ReleaseLine { + ReleaseLine { major, minor } + } + + fn lines_of(clusters: &[Cluster]) -> Vec { + group_clusters_by_release(clusters) + .keys() + .copied() + .collect() + } + + #[test] + fn modern_minors_are_separate_lines() { + let clusters = [ + versioned(1, "26.1"), + versioned(2, "26.2"), + versioned(3, "26.2.1"), + ]; + + assert_eq!( + lines_of(&clusters), + vec![line(26, Some(1)), line(26, Some(2))] + ); + } + + #[test] + fn legacy_minors_share_one_line() { + let clusters = [versioned(1, "1.21.5"), versioned(2, "1.21.11")]; + + assert_eq!(lines_of(&clusters), vec![line(21, None)]); + } + + #[test] + fn a_lone_version_is_shown_in_full() { + let clusters = [versioned(1, "26.1.2")]; + let line = line(26, Some(1)); + + assert_eq!(line_title(line, &clusters), "26.1.2"); + assert_eq!(line_art_key(line, &clusters), Some((1, Some(2)))); + } + + #[test] + fn several_loaders_of_one_version_still_count_as_one() { + let clusters = [ + versioned(1, "26.1.2"), + Cluster { + mc_loader: GameLoader::Vanilla, + ..versioned(2, "26.1.2") + }, + ]; + + assert_eq!(line_title(line(26, Some(1)), &clusters), "26.1.2"); + } + + #[test] + fn several_versions_fall_back_to_the_generic_name() { + let legacy = [ + versioned(1, "1.21.1"), + versioned(2, "1.21.10"), + versioned(3, "1.21.11"), + ]; + assert_eq!(line_title(line(21, None), &legacy), "1.21"); + assert_eq!(line_art_key(line(21, None), &legacy), None); + + let modern = [versioned(4, "26.1"), versioned(5, "26.1.2")]; + assert_eq!(line_title(line(26, Some(1)), &modern), "26.1"); + assert_eq!(line_art_key(line(26, Some(1)), &modern), Some((1, None))); + } + + #[test] + fn presets_stop_two_gigabytes_short_of_the_machine() { + assert_eq!( + presets_for_total_gb(16), + vec![2048, 4096, 6144, 8192, 12288, 14336] + ); + assert_eq!( + presets_for_total_gb(32), + vec![2048, 4096, 6144, 8192, 12288, 16384, 24576, 30720] + ); + } + + #[test] + fn a_ceiling_landing_on_a_round_step_is_not_repeated() { + assert_eq!(presets_for_total_gb(8), vec![2048, 4096, 6144]); + } + + #[test] + fn a_tiny_machine_still_gets_one_preset() { + assert_eq!(presets_for_total_gb(4), vec![2048]); + assert_eq!(presets_for_total_gb(2), vec![1024]); + } + + #[test] + fn memory_labels_drop_the_decimal_when_whole() { + assert_eq!(format_memory_gb(8192), "8 GB"); + assert_eq!(format_memory_gb(1536), "1.5 GB"); + } + + #[test] + fn a_bare_legacy_version_keeps_its_line_name() { + let clusters = [versioned(1, "1.21")]; + + assert_eq!(line_title(line(21, None), &clusters), "1.21"); + } +} diff --git a/packages/oneclient_app/src/view/app/analytics/charts.rs b/packages/oneclient_app/src/view/app/analytics/charts.rs new file mode 100644 index 00000000..7eb925a3 --- /dev/null +++ b/packages/oneclient_app/src/view/app/analytics/charts.rs @@ -0,0 +1,258 @@ +use std::collections::HashMap; + +use chrono::{Local, NaiveDate}; +use freya::prelude::*; + +use oneclient_core::game::{DayPlaytime, PlaytimeStats, WEEKDAY_LABELS}; + +use crate::components::{BarChart, IconType, Segment, SegmentedControl, ValueUnit}; +use crate::utils::{format_day, format_duration_hm, format_hour, parse_day}; + +use super::{chart_card, nav_button}; + +#[derive(Clone, Copy, PartialEq)] +enum WhenMode { + Weekday, + Hour, +} + +#[derive(PartialEq)] +pub(super) struct WhenChart { + per_weekday: Vec, + per_hour: Vec, + peak_weekday: Option, + peak_hour: Option, +} + +impl WhenChart { + pub(super) fn from_stats(stats: &PlaytimeStats) -> Self { + Self { + per_weekday: stats.per_weekday.to_vec(), + per_hour: stats.per_hour.to_vec(), + peak_weekday: stats.peak_weekday, + peak_hour: stats.peak_hour, + } + } +} + +impl Component for WhenChart { + fn render(&self) -> impl IntoElement { + let mode = use_state(|| WhenMode::Weekday); + let m = *mode.read(); + + let control = SegmentedControl::new(mode) + .height(30.) + .segment(Segment::new(WhenMode::Weekday).label("Day")) + .segment(Segment::new(WhenMode::Hour).label("Hour")) + .into_element(); + + let (subtitle, chart) = match m { + WhenMode::Weekday => { + let day = self + .peak_weekday + .map(|i| WEEKDAY_LABELS[i].to_string()) + .unwrap_or_else(|| "—".to_string()); + ( + format!("Most active on {day}"), + BarChart::new(self.per_weekday.clone(), weekday_labels()) + .highlight(self.peak_weekday) + .unit(ValueUnit::Duration) + .gap(6.) + .into_element(), + ) + } + WhenMode::Hour => { + let hour = self + .peak_hour + .map(format_hour) + .unwrap_or_else(|| "—".to_string()); + ( + format!("Peak around {hour}"), + BarChart::new(self.per_hour.clone(), (0..24).map(format_hour).collect()) + .highlight(self.peak_hour) + .unit(ValueUnit::Duration) + .gap(3.) + .into_element(), + ) + } + }; + + chart_card("When you play", subtitle, Some(control), chart) + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Range { + TwoWeeks, + Month, + Quarter, +} + +impl Range { + fn days(self) -> usize { + match self { + Range::TwoWeeks => 14, + Range::Month => 30, + Range::Quarter => 90, + } + } +} + +#[derive(PartialEq)] +pub(super) struct DailyChart { + daily: Vec, +} + +impl DailyChart { + pub(super) fn new(daily: Vec) -> Self { + Self { daily } + } +} + +impl Component for DailyChart { + fn render(&self) -> impl IntoElement { + let range = use_state(|| Range::TwoWeeks); + let mut offset = use_state(|| 0usize); + + let series = continuous_series(&self.daily); + if series.is_empty() { + return chart_card( + "Daily playtime", + "No sessions recorded yet".to_string(), + None, + rect().height(Size::px(120.)).into_element(), + ); + } + + let window = range.read().days(); + let total = series.len(); + let max_offset = total.saturating_sub(1) / window; + let off = (*offset.read()).min(max_offset); + + let end = total.saturating_sub(off * window); + let start = end.saturating_sub(window); + let slice = &series[start..end]; + + let values: Vec = slice.iter().map(|(_, s)| *s).collect(); + let labels: Vec = slice.iter().map(|(d, _)| format_day(*d)).collect(); + + let win_total: i64 = values.iter().sum(); + let subtitle = match (slice.first(), slice.last()) { + (Some((a, _)), Some((b, _))) => { + format!( + "{} · {} – {}", + format_duration_hm(win_total), + format_day(*a), + format_day(*b) + ) + } + _ => format_duration_hm(win_total), + }; + + let can_older = off < max_offset; + let can_newer = off > 0; + let nav = rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child(nav_button(IconType::ChevronsLeft, can_older, move |_| { + if can_older { + *offset.write() = off + 1; + } + })) + .child(nav_button(IconType::ChevronsRight, can_newer, move |_| { + if can_newer { + *offset.write() = off - 1; + } + })) + .child( + SegmentedControl::new(range) + .height(30.) + .segment(Segment::new(Range::TwoWeeks).label("2W")) + .segment(Segment::new(Range::Month).label("1M")) + .segment(Segment::new(Range::Quarter).label("3M")) + .into_element(), + ) + .into_element(); + + chart_card( + "Daily playtime", + subtitle, + Some(nav), + BarChart::new(values, labels) + .unit(ValueUnit::Duration) + .gap(3.) + .into_element(), + ) + } +} + +fn continuous_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> { + let map: HashMap = daily + .iter() + .filter_map(|d| parse_day(&d.date).map(|date| (date, d.secs))) + .collect(); + + let Some(first) = map.keys().min().copied() else { + return Vec::new(); + }; + let today = Local::now().date_naive(); + let last = map.keys().max().copied().unwrap_or(first).max(today); + + let mut out = Vec::new(); + let mut cur = first; + while cur <= last { + out.push((cur, map.get(&cur).copied().unwrap_or(0))); + cur = match cur.succ_opt() { + Some(next) => next, + None => break, + }; + } + out +} + +pub(super) fn distribution_card(session_secs: &[i64], force: bool) -> Option { + if session_secs.len() < 2 && !force { + return None; + } + + const EDGES: [(i64, &str); 6] = [ + (15 * 60, "<15m"), + (30 * 60, "15m-30m"), + (60 * 60, "30m-60m"), + (2 * 3600, "1h-2h"), + (4 * 3600, "2h-4h"), + (i64::MAX, "4h+"), + ]; + + let mut counts = [0i64; 6]; + for &s in session_secs { + let idx = EDGES.iter().position(|(edge, _)| s < *edge).unwrap_or(5); + counts[idx] += 1; + } + + let labels: Vec = EDGES.iter().map(|(_, l)| (*l).to_string()).collect(); + let peak = counts + .iter() + .enumerate() + .max_by_key(|(_, c)| **c) + .filter(|(_, c)| **c > 0) + .map(|(i, _)| i); + + let subtitle = format!("{} sessions grouped by length", session_secs.len()); + + Some(chart_card( + "Session length", + subtitle, + None, + BarChart::new(counts.to_vec(), labels) + .highlight(peak) + .unit(ValueUnit::Count) + .gap(6.) + .into_element(), + )) +} + +fn weekday_labels() -> Vec { + WEEKDAY_LABELS.iter().map(|s| (*s).to_string()).collect() +} diff --git a/packages/oneclient_app/src/view/app/analytics/mod.rs b/packages/oneclient_app/src/view/app/analytics/mod.rs new file mode 100644 index 00000000..52ee6097 --- /dev/null +++ b/packages/oneclient_app/src/view/app/analytics/mod.rs @@ -0,0 +1,245 @@ +use chrono::{Duration, Local}; +use freya::prelude::*; + +use oneclient_core::game::{Analytics, DayPlaytime, PlaytimeStats}; + +use crate::components::{Icon, IconType}; +use crate::theme::colors; +use crate::ui::border_all_color; + +mod charts; +mod servers; +mod tiles; + +use charts::{DailyChart, WhenChart, distribution_card}; +use servers::servers_section; +use tiles::{personas_row, tiles_row}; + +pub fn analytics_body(analytics: &Analytics) -> Element { + analytics_body_inner(analytics, false) +} + +/// `force_all` keeps the normally-hidden sections (session length servers) on screen +fn analytics_body_inner(analytics: &Analytics, force_all: bool) -> Element { + let stats = &analytics.playtime; + + let mut root = rect().vertical().width(Size::fill()).spacing(24.); + + root = root.child(tiles_row(analytics, force_all)); + + if !stats.personas.is_empty() { + root = root.child(personas_row(&stats.personas)); + } + + let mut charts: Vec = vec![ + WhenChart::from_stats(stats).into_element(), + DailyChart::new(stats.daily.clone()).into_element(), + ]; + if let Some(dist) = distribution_card(&stats.session_secs, force_all) { + charts.push(dist); + } + root = root.child(charts_grid(charts)); + + if force_all || !analytics.servers.is_empty() { + root = root.child(servers_section(&analytics.servers)); + } + + root.into_element() +} + +pub fn analytics_placeholder(note: &str) -> Element { + AnalyticsPlaceholder { + note: note.to_string(), + } + .into_element() +} + +#[derive(PartialEq)] +struct AnalyticsPlaceholder { + note: String, +} + +impl Component for AnalyticsPlaceholder { + fn render(&self) -> impl IntoElement { + let mut body_height = use_state(|| 0f32); + let scrim_height = *body_height.read(); + + rect() + .width(Size::fill()) + .child( + rect() + .width(Size::fill()) + .on_sized(move |e: Event| { + let height = e.area.height(); + if (*body_height.read() - height).abs() > 0.5 { + body_height.set(height); + } + }) + .child(analytics_body_inner(&empty_analytics(), true)), + ) + .child( + rect() + .position(Position::new_absolute()) + .width(Size::fill()) + .height(Size::px(scrim_height)) + .layer(Layer::Relative(1)) + .center() + .padding(40.) + .background(colors::page_overlay()) + .on_press(|_| {}) + .child( + rect() + .max_width(Size::px(360.)) + .padding(Gaps::new_symmetric(16., 24.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .center() + .child( + label() + .text(self.note.clone()) + .font_size(14.) + .text_align(TextAlign::Center) + .color(colors::fg_secondary()), + ), + ), + ) + } +} + +/// A run of zero-second days ending today so the timeline still renders columns for each range +fn empty_analytics() -> Analytics { + const EMPTY_DAYS: i64 = 90; + + let today = Local::now().date_naive(); + let daily: Vec = (0..EMPTY_DAYS) + .rev() + .map(|i| DayPlaytime { + date: (today - Duration::days(i)).format("%Y-%m-%d").to_string(), + secs: 0, + }) + .collect(); + + let playtime = PlaytimeStats { + total_secs: 0, + session_count: 0, + per_weekday: [0; 7], + per_hour: [0; 24], + daily, + session_secs: Vec::new(), + active_days: 0, + avg_secs_per_active_day: 0.0, + peak_hour: None, + peak_weekday: None, + night_share: 0.0, + personas: Vec::new(), + }; + + Analytics { + playtime, + servers: Vec::new(), + } +} + +fn charts_grid(cards: Vec) -> Element { + let mut grid = rect().vertical().width(Size::fill()).spacing(24.); + for pair in cards.chunks(2) { + if pair.len() == 1 { + grid = grid.child(rect().width(Size::fill()).child(pair[0].clone())); + continue; + } + + let mut row = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Start) + .spacing(16.); + for card in pair { + row = row.child(rect().width(Size::flex(1.0)).child(card.clone())); + } + grid = grid.child(row); + } + grid.into_element() +} + +pub(super) fn chart_card( + title: &str, + subtitle: String, + trailing: Option, + chart: Element, +) -> Element { + card() + .width(Size::fill()) + .spacing(16.) + .child(card_header(title, subtitle, trailing)) + .child(chart) + .into_element() +} + +pub(super) fn card_header(title: &str, subtitle: String, trailing: Option) -> Element { + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title.to_string()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ) + .maybe_child(trailing) + .into_element() +} + +pub(super) fn nav_button( + icon: IconType, + enabled: bool, + on_press: impl FnMut(Event) + 'static, +) -> Element { + let color = if enabled { + colors::fg_secondary() + } else { + colors::fg_secondary().with_a(70) + }; + rect() + .width(Size::px(30.)) + .height(Size::px(30.)) + .center() + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .maybe(enabled, |el| { + el.on_press(on_press).cursor(CursorIcon::Pointer) + }) + .child(Icon::new(icon).size(14.).color(color)) + .into_element() +} + +pub(super) fn card() -> Rect { + rect() + .vertical() + .padding(20.) + .corner_radius(CornerRadius::new_all(14.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) +} diff --git a/packages/oneclient_app/src/view/app/analytics/servers.rs b/packages/oneclient_app/src/view/app/analytics/servers.rs new file mode 100644 index 00000000..263343dc --- /dev/null +++ b/packages/oneclient_app/src/view/app/analytics/servers.rs @@ -0,0 +1,414 @@ +use freya::prelude::*; + +use oneclient_core::game::ServerStat; + +use crate::components::{ + Button, Icon, IconType, OverlayPopup, PieChart, ScrollArea, ValueUnit, slice_color, +}; +use crate::hooks::{loaded_image, use_cached_image}; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::{format_duration_hm, plural}; + +use super::{card, card_header}; + +/// Number of servers charted the rest are grouped into an "Other" slice +const TOP_SERVERS: usize = 10; + +pub(super) fn servers_section(servers: &[ServerStat]) -> Element { + ServersSection { + servers: servers.to_vec(), + } + .into_element() +} + +#[derive(PartialEq)] +struct ServersSection { + servers: Vec, +} + +impl Component for ServersSection { + fn render(&self) -> impl IntoElement { + let hovered = use_state(|| Option::::None); + let mut show_all = use_state(|| false); + + let total_joins: i64 = self.servers.iter().map(|s| s.joins).sum(); + + let mut sorted = self.servers.clone(); + sorted.sort_by_key(|s| std::cmp::Reverse(s.total_secs)); + + let (top, rest) = sorted.split_at(sorted.len().min(TOP_SERVERS)); + let mut values: Vec = top.iter().map(|s| s.total_secs).collect(); + let mut labels: Vec = top.iter().map(server_label).collect(); + if !rest.is_empty() { + values.push(rest.iter().map(|s| s.total_secs).sum()); + labels.push(format!("{} more", rest.len())); + } + + let details = (!self.servers.is_empty()).then(|| { + Button::new() + .ghost() + .small() + .on_press(move |_| show_all.set(true)) + .child(Icon::new(IconType::Eye).size(12.)) + .text("Details") + .into_element() + }); + + card() + .width(Size::fill()) + .spacing(14.) + .child(card_header( + "Servers", + format!( + "{} server{} · {} join{}", + self.servers.len(), + plural(self.servers.len() as i64), + total_joins, + plural(total_joins), + ), + details, + )) + .child( + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(20.) + .child( + PieChart::new(values.clone(), labels.clone()) + .unit(ValueUnit::Duration) + .hovered(hovered), + ) + .child(rect().width(Size::flex(1.0)).child(legend( + &values, + &labels, + *hovered.read(), + ))), + ) + .maybe_child(show_all.read().then(|| { + OverlayPopup::new() + .on_close(move |_| show_all.set(false)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(ServerDetailsPopup { + servers: sorted.clone(), + on_close: EventHandler::new(move |()| show_all.set(false)), + }), + ) + .into_element() + })) + } +} + +fn legend(values: &[i64], labels: &[String], active: Option) -> Element { + let total: i64 = values.iter().sum(); + let mut list = rect().vertical().width(Size::fill()).spacing(4.); + + for (i, (value, name)) in values.iter().zip(labels).enumerate() { + let is_active = Some(i) == active; + let pct = if total > 0 { + (*value as f32 / total as f32) * 100. + } else { + 0. + }; + let dim = active.is_some() && !is_active; + + list = list.child( + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(3., 6.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if is_active { + colors::component_bg() + } else { + Color::TRANSPARENT + }) + .child( + rect() + .width(Size::px(8.)) + .height(Size::px(8.)) + .corner_radius(CornerRadius::new_all(2.)) + .background(slice_color(i).with_a(if dim { 120 } else { 255 })), + ) + .child( + label() + .text(name.clone()) + .font_size(11.) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!("{pct:.0}%")) + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(format_duration_hm(*value)) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ); + } + + list.into_element() +} + +/// A Component not a builder mounting its `ScrollArea` conditionally would change the caller's hook count and panic Freya +#[derive(PartialEq)] +struct ServerDetailsPopup { + servers: Vec, + on_close: EventHandler<()>, +} + +impl Component for ServerDetailsPopup { + fn render(&self) -> impl IntoElement { + let servers = &self.servers; + let on_close = self.on_close.clone(); + let max_secs = servers + .iter() + .map(|s| s.total_secs) + .max() + .unwrap_or(0) + .max(1); + + let mut list = rect().vertical().width(Size::fill()).spacing(4.); + for server in servers { + list = list.child(ServerRow { + server: server.clone(), + max_secs, + }); + } + + rect() + .vertical() + .width(Size::px(520.)) + .max_height(Size::window_percent(70.)) + .padding(20.) + .spacing(14.) + .corner_radius(CornerRadius::new_all(14.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text("All servers") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "{} server{} by playtime", + servers.len(), + plural(servers.len() as i64) + )) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .ghost() + .small() + .on_press(move |_| on_close.call(())) + .child(Icon::new(IconType::XClose).size(12.)), + ), + ) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::px(380.)) + .child(list), + ) + } +} + +/// IPs stay masked in the always-visible legend the details popup reveals them on hover +fn server_label(s: &ServerStat) -> String { + if s.is_ip { + return "Direct IP".to_string(); + } + display_address(s) +} + +#[derive(PartialEq)] +struct ServerRow { + server: ServerStat, + max_secs: i64, +} + +impl Component for ServerRow { + fn render(&self) -> impl IntoElement { + let mut hovered = use_state(|| false); + let is_hovered = *hovered.read(); + + let s = &self.server; + let reveal = is_hovered || !s.is_ip; + let address = if reveal { + display_address(s) + } else { + mask(&display_address(s)) + }; + + let frac = (s.total_secs as f32 / self.max_secs as f32).clamp(0., 1.); + + let bar = rect() + .width(Size::fill()) + .height(Size::px(4.)) + .corner_radius(CornerRadius::new_all(2.)) + .background(colors::component_bg()) + .child( + rect() + .width(Size::percent(frac * 100.)) + .height(Size::fill()) + .corner_radius(CornerRadius::new_all(2.)) + .background(colors::brand().with_a(if reveal { 255 } else { 150 })), + ); + + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .padding(Gaps::new_symmetric(8., 10.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if is_hovered { + colors::component_bg_hover() + } else { + Color::TRANSPARENT + }) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| *hovered.write() = true) + .on_pointer_leave(move |_| *hovered.write() = false) + .child( + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(10.) + .child(ServerIcon { + url: server_icon_url(s), + is_ip: s.is_ip, + size: 28., + }) + .child( + label() + .text(address) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()) + .width(Size::flex(1.0)), + ) + .child( + label() + .text(format!("{}×", s.joins)) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(format_duration_hm(s.total_secs)) + .font_size(12.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child(bar) + } +} + +const SERVER_ICON_EDGE: u32 = 64; + +fn server_icon_url(s: &ServerStat) -> Option { + if s.is_ip { + return None; + } + + Some(format!("https://api.mcsrvstat.us/icon/{}", s.address)) +} + +#[derive(PartialEq)] +struct ServerIcon { + url: Option, + is_ip: bool, + size: f32, +} + +impl Component for ServerIcon { + fn render(&self) -> impl IntoElement { + let size = self.size; + let query = use_cached_image(self.url.clone(), SERVER_ICON_EDGE); + let loaded = loaded_image(self.url.as_deref(), &query); + + match loaded { + Some((url, bytes)) => ImageViewer::new((url, bytes)) + .width(Size::px(size)) + .height(Size::px(size)) + .aspect_ratio(AspectRatio::Min) + .corner_radius(CornerRadius::new_all(6.)) + .into_element(), + None => server_icon_placeholder(self.is_ip, size), + } + } +} + +fn server_icon_placeholder(is_ip: bool, size: f32) -> Element { + let icon = if is_ip { + IconType::Eye + } else { + IconType::Globe01 + }; + rect() + .center() + .width(Size::px(size)) + .height(Size::px(size)) + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::component_bg()) + .child( + Icon::new(icon) + .size(size * 0.5) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn display_address(s: &ServerStat) -> String { + match s.port { + Some(port) if port != 25565 => format!("{}:{}", s.address, port), + _ => s.address.clone(), + } +} + +fn mask(address: &str) -> String { + let (host, tail) = match address.split_once(':') { + Some((h, p)) => (h, format!(":{p}")), + None => (address, String::new()), + }; + let dots = "•".repeat(host.chars().count().clamp(6, 18)); + format!("{dots}{tail}") +} diff --git a/packages/oneclient_app/src/view/app/analytics/tiles.rs b/packages/oneclient_app/src/view/app/analytics/tiles.rs new file mode 100644 index 00000000..bdfa60a5 --- /dev/null +++ b/packages/oneclient_app/src/view/app/analytics/tiles.rs @@ -0,0 +1,137 @@ +use freya::prelude::*; + +use oneclient_core::game::{Analytics, Persona}; + +use crate::components::{Icon, IconType}; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::format_duration_hm; + +use super::card; + +pub(super) fn tiles_row(analytics: &Analytics, force_all: bool) -> Element { + let stats = &analytics.playtime; + let avg_session = if stats.session_count > 0 { + stats.total_secs / stats.session_count as i64 + } else { + 0 + }; + let total_joins: i64 = analytics.servers.iter().map(|s| s.joins).sum(); + + let mut tiles = vec![ + stat_tile( + IconType::ClockRewind, + "Total playtime", + format_duration_hm(stats.total_secs), + ), + stat_tile(IconType::Play, "Sessions", stats.session_count.to_string()), + stat_tile( + IconType::Calendar, + "Avg / session", + format_duration_hm(avg_session), + ), + stat_tile( + IconType::Rocket02, + "Active days", + stats.active_days.to_string(), + ), + ]; + if force_all || !analytics.servers.is_empty() { + tiles.push(stat_tile( + IconType::Globe01, + "Server joins", + format!("{total_joins}"), + )); + } + + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .spacing(16.) + .children(tiles) + .into_element() +} + +fn stat_tile(icon: IconType, caption: &str, value: String) -> Element { + card() + .width(Size::flex(1.0)) + .spacing(10.) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child(Icon::new(icon).size(16.).color(colors::fg_secondary())) + .child( + label() + .text(caption.to_string()) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child( + label() + .text(value) + .font_size(26.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .into_element() +} + +pub(super) fn personas_row(personas: &[Persona]) -> Element { + let mut row = rect().horizontal().width(Size::fill()).spacing(16.); + for persona in personas { + row = row.child(persona_card(*persona)); + } + row.into_element() +} + +fn persona_icon(persona: Persona) -> IconType { + match persona { + Persona::NightOwl => IconType::ClockRewind, + Persona::Gamer => IconType::Rocket02, + } +} + +fn persona_card(persona: Persona) -> Element { + card() + .width(Size::flex(1.0)) + .horizontal() + .cross_align(Alignment::Center) + .spacing(14.) + .border(border_all_color(1., colors::brand())) + .child( + rect() + .width(Size::px(44.)) + .height(Size::px(44.)) + .corner_radius(CornerRadius::new_all(22.)) + .background(colors::brand().with_a(40)) + .center() + .child( + Icon::new(persona_icon(persona)) + .size(22.) + .color(colors::brand()), + ), + ) + .child( + rect() + .vertical() + .spacing(2.) + .child( + label() + .text(persona.title().to_string()) + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(persona.description().to_string()) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs new file mode 100644 index 00000000..b05f09bd --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -0,0 +1,443 @@ +use super::*; + +use std::collections::HashMap; + +use freya::router::RouterContext; +use oneclient_content::packages::types::ProjectSummary; +use oneclient_content::packages::{ContentType, ProviderId}; + +use crate::components::{Button, Icon, IconType}; +use crate::hooks::{ + content_type_for_slug, use_browser_compat, use_cluster, use_dispatch, use_installs_snapshot, + use_package_versions_when, version_list, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; + +type InstalledMap = HashMap<(ProviderId, String), Installed>; + +/// Diameter of the round install button +const INSTALL_BUTTON: f32 = 30.; + +fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option { + installed + .get(&(item.provider, item.id.clone())) + .map(|installed| installed.source) +} + +pub(super) fn grid_row( + row: Vec, + cluster_id: i64, + package_type: &str, + installed: &InstalledMap, +) -> impl IntoElement { + let package_type = package_type.to_string(); + let fill = GRID_COLUMNS - row.len(); + let installed: Vec> = row + .iter() + .map(|item| installed_for(installed, item)) + .collect(); + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(CARD_H)) + .spacing(GRID_SPACING) + .content(Content::Flex) + .children( + row.into_iter() + .zip(installed) + .map(move |(item, installed)| { + PackageCard::new(item, cluster_id, package_type.clone(), installed) + .into_element() + }), + ) + .maybe(fill > 0, move |mut el| { + for _ in 0..fill { + el = el.child(rect().width(Size::flex(1.0))); + } + el + }) + .into_element() +} + +fn open_package(cluster_id: i64, package_type: &str, provider: ProviderId, id: &str) { + let _ = RouterContext::get().push(Route::BrowserPackage { + cluster_id, + package_type: package_type.to_string(), + package_id: encode_package_id(provider, id), + }); +} + +#[derive(PartialEq)] +struct PackageCard { + item: ProjectSummary, + cluster_id: i64, + package_type: String, + installed: Option, +} + +impl PackageCard { + fn new( + item: ProjectSummary, + cluster_id: i64, + package_type: String, + installed: Option, + ) -> Self { + Self { + item, + cluster_id, + package_type, + installed, + } + } +} + +impl Component for PackageCard { + fn render(&self) -> impl IntoElement { + let id = self.item.id.clone(); + let provider = self.item.provider; + let package_type = self.package_type.clone(); + let icon_url = self.item.icon_url.clone(); + let cluster_id = self.cluster_id; + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::px(CARD_H)) + .corner_radius(CornerRadius::new_all(10.)) + .background(CARD_BG) + .border(border_all_color( + 1., + if focused { + colors::brand() + } else { + colors::component_border() + }, + )) + .overflow(Overflow::Clip) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) + .child( + rect() + .margin(Gaps::new_all(1.)) + .corner_radius(CornerRadius::new(10., 10., 0., 0.)) + .overflow(Overflow::Clip) + .child(PackageBanner::new(icon_url, BANNER_H)) + // Top-left of the banner the first thing worth knowing about a result and the opposite corner is the install button's + .maybe_child(self.installed.map(|installed| { + rect() + .position(Position::new_absolute().top(8.).left(8.)) + .layer(Layer::Relative(7)) + .child(installed_badge_overlay(installed)) + .into_element() + })) + .maybe_child(self.installed.is_none().then(|| { + rect() + .position(Position::new_absolute().top(8.).right(8.)) + .layer(Layer::Relative(7)) + .child(InstallButton::new( + &self.item, + self.cluster_id, + &self.package_type, + false, + )) + .into_element() + })), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new_all(12.)) + .main_align(Alignment::SpaceBetween) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text(self.item.name.clone()) + .font_size(16.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(CARD_NAME), + ) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .child( + label() + .text(format!("by {}", self.item.author)) + .font_size(10.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .child(Icon::new(self.item.provider).size(12.)), + ) + .child( + label() + .text(self.item.summary.clone()) + .font_size(11.) + .max_lines(2) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ) + .child(downloads_row(self.item.downloads)), + ) + } +} + +pub(super) fn list_row( + item: ProjectSummary, + cluster_id: i64, + package_type: &str, + installed: &InstalledMap, +) -> impl IntoElement { + ListRow { + installed: installed_for(installed, &item), + item, + cluster_id, + package_type: package_type.to_string(), + } +} + +#[derive(PartialEq)] +struct ListRow { + item: ProjectSummary, + cluster_id: i64, + package_type: String, + installed: Option, +} + +impl Component for ListRow { + fn render(&self) -> impl IntoElement { + let item = self.item.clone(); + let cluster_id = self.cluster_id; + let id = item.id.clone(); + let provider = item.provider; + let package_type = self.package_type.clone(); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(LIST_ROW_H)) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_all(16.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(CARD_BG) + .border(border_all_color( + 1., + if focused { + colors::brand() + } else { + colors::component_border() + }, + )) + .overflow(Overflow::Clip) + .content(Content::Flex) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_all_press(move |_| open_package(cluster_id, &package_type, provider, &id)) + .child(Thumbnail::new(item.icon_url.clone(), 48.).radius(8.)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + label() + .text(item.name.clone()) + .font_size(15.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(CARD_NAME), + ) + .child( + label() + .text(format!("by {}", item.author)) + .font_size(10.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .child(Icon::new(item.provider).size(12.)) + // Beside the attribution rather than far right where it drifted from the package and crowded the install button + .maybe_child( + self.installed.map(|installed| { + installed_badge(installed, 10.).into_element() + }), + ), + ) + .child( + label() + .text(item.summary.clone()) + .font_size(11.) + .max_lines(2) + .width(Size::fill()) + .color(colors::fg_secondary()), + ), + ) + .child(downloads_row(item.downloads)) + // Kept once installed rather than dropped a row that loses its button leaves its neighbours misaligned + .child(InstallButton::new( + &self.item, + self.cluster_id, + &self.package_type, + self.installed.is_some(), + )) + } +} + +/// Same version pick and dispatch as the install button on the package page +#[derive(PartialEq)] +struct InstallButton { + provider: ProviderId, + project_id: String, + cluster_id: i64, + content_type: ContentType, + /// Cluster already has this one callers that hide the button entirely pass `false` + installed: bool, +} + +impl InstallButton { + fn new(item: &ProjectSummary, cluster_id: i64, package_type: &str, installed: bool) -> Self { + Self { + provider: item.provider, + project_id: item.id.clone(), + cluster_id, + content_type: content_type_for_slug(package_type), + installed, + } + } +} + +impl Component for InstallButton { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let provider = self.provider; + let project_id = self.project_id.clone(); + + let dispatch = use_dispatch(); + let compat = *use_browser_compat().read(); + let cluster = use_cluster(cluster_id); + + // The same narrowing the package page does so both agree on what "latest" installs + let (game_version, loader) = match (compat, &cluster) { + (true, Some(c)) => ( + Some(c.mc_version.clone()), + (self.content_type == ContentType::Mod).then_some(c.mc_loader), + ), + _ => (None, None), + }; + + let installed = self.installed; + + let versions = version_list(&use_package_versions_when( + !installed, + provider, + project_id.clone(), + game_version, + loader, + 0, + )); + let latest = versions.first().map(|v| v.version_id.clone()); + + // Nothing to start twice while an install is running or before versions arrive + let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id); + + rect() + // The card behind is one big press target stop here so the package page doesn't open on top of the install + .on_all_press(|e: Event| e.stop_propagation()) + .child( + Button::new() + .primary() + .width(Size::px(INSTALL_BUTTON)) + .height(Size::px(INSTALL_BUTTON)) + .padding(Gaps::new_all(0.)) + .corner_radius(CornerRadius::new_all(INSTALL_BUTTON / 2.)) + .alt(if installed { + "Already installed" + } else if installing { + "Installing" + } else { + "Install" + }) + .enabled(!installed && latest.is_some() && !installing) + .on_press(move |_| { + if let Some(version_id) = latest.clone() { + dispatch.install_package( + cluster_id, + provider, + project_id.clone(), + version_id, + ); + } + }) + .child( + Icon::new(if installing { + IconType::Loading02 + } else { + IconType::Download01 + }) + .size(15.) + .color(colors::fg_primary()), + ), + ) + } +} + +fn downloads_row(downloads: u64) -> impl IntoElement { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .child( + Icon::new(IconType::Download01) + .size(12.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(abbreviate_number(downloads)) + .font_size(11.) + .color(colors::fg_secondary()), + ) +} + +pub(super) fn empty_state() -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .child( + label() + .text("No results. Try a different search, category or provider.") + .font_size(14.) + .color(colors::fg_secondary()), + ) +} diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs new file mode 100644 index 00000000..a5e38650 --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -0,0 +1,418 @@ +use std::time::Duration; + +use freya::animation::{ + AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies, +}; +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_common::parse_mc_version; +use oneclient_common::search::normalize_query; +use oneclient_content::packages::types::ProjectSummary; +use oneclient_content::packages::types::SearchSort; +use oneclient_content::packages::{ContentType, ProviderId}; +use oneclient_core::VersionMetadata; +use oneclient_core::clusters::Cluster; +use oneclient_core::settings::ViewLayout; + +use crate::components::{ + Dropdown, Icon, IconType, Pagination, ScrollArea, Segment, SegmentedControl, TextInput, +}; +use crate::hooks::use_cluster; +use crate::hooks::{ + BROWSE_PAGE_SIZE, BrowserUiState, bundles_with_status_items, category_list, + cluster_content_items, content_type_for_slug, pick_version_metadata, search_items, + search_pending, search_total, settled_or_loading, use_browser_compat, use_browser_state_store, + use_bundles_with_status, use_cluster_content, use_clusters, use_debounced, + use_package_categories, use_package_search, use_versions, use_view_state, versions_metadata, +}; +use crate::routes::Route; +use crate::theme::colors; + +use super::{ + InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_badge_overlay, + installed_map, +}; +use crate::utils::{abbreviate_number, sort_clusters_for_home}; + +mod cards; +mod sidebar; +mod skeletons; +use cards::{empty_state, grid_row, list_row}; +use sidebar::CategorySidebar; +use skeletons::{SkeletonListRow, skeleton_grid_row}; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const CARD_NAME: Color = Color::from_rgb(213, 219, 255); +const GRID_COLUMNS: usize = 3; +const SCROLLBAR_GUTTER: f32 = 18.; +const CARD_H: f32 = 240.; +const BANNER_H: f32 = 100.; +const LIST_ROW_H: f32 = 78.; +const GRID_SPACING: f32 = 12.; +const LIST_SPACING: f32 = 8.; +const SEARCH_DEBOUNCE_MS: u64 = 250; + +fn type_title(package_type: &str) -> &'static str { + match package_type { + "shader" => "Shaders", + "texture" => "Textures", + _ => "Mods", + } +} + +fn encode_package_id(provider: ProviderId, id: &str) -> String { + format!("{}:{}", provider as u8, id) +} + +#[derive(PartialEq)] +pub struct Browser { + pub cluster_id: i64, + pub package_type: String, + /// Entered from the navbar so the cluster to install into is picked here + pub pick_cluster: bool, +} + +impl Component for Browser { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let package_type = self.package_type.clone(); + let content_type = content_type_for_slug(&package_type); + + let store = use_browser_state_store(); + let state_key = format!("{cluster_id}:{package_type}"); + let saved = store.peek().get(&state_key).cloned().unwrap_or_default(); + + let query = use_state(|| saved.query.clone()); + let provider = use_state(|| saved.provider); + let view_mode = use_view_state(&format!("browser.{package_type}")).layout; + let compatible_only = use_browser_compat(); + let selected_categories = use_state(|| saved.categories.clone()); + let page = use_state(|| saved.page); + + { + let mut store = store; + let key = state_key.clone(); + use_side_effect(move || { + let snapshot = BrowserUiState { + query: query.read().clone(), + provider: *provider.read(), + categories: selected_categories.read().clone(), + page: *page.read(), + }; + store.write().insert(key.clone(), snapshot); + }); + } + + // Debounce the search query so typing doesn't fire a request per keystroke + let debounced_query = use_debounced( + query.read().clone(), + Duration::from_millis(SEARCH_DEBOUNCE_MS), + ); + + let cluster = use_cluster(cluster_id); + let provider_id = *provider.read(); + let compat = *compatible_only.read(); + let cats = selected_categories.read().clone(); + + let (game_versions, loaders) = match (compat, &cluster) { + (true, Some(c)) => { + let loaders = if content_type == ContentType::Mod { + vec![c.mc_loader] + } else { + Vec::new() + }; + (vec![c.mc_version.clone()], loaders) + } + _ => (Vec::new(), Vec::new()), + }; + + let mut page_state = page; + // Normalised like the search key so a query differing only by a space does not reset the page + let signature = format!( + "{provider_id:?}|{}|{compat}|{}", + normalize_query(&debounced_query.read()).to_lowercase(), + cats.join(",") + ); + let mut last_signature = use_state(|| signature.clone()); + if *last_signature.peek() != signature { + last_signature.set(signature); + if *page_state.peek() != 0 { + page_state.set(0); + } + } + + let search = use_package_search( + provider_id, + content_type, + debounced_query.read().clone(), + game_versions, + loaders, + cats.clone(), + SearchSort::Relevance, + *page.read(), + ); + let categories_query = use_package_categories(provider_id, content_type); + let all_categories = category_list(&categories_query); + + let installed = installed_map( + cluster_content_items(&use_cluster_content(cluster_id, content_type)), + &bundles_with_status_items(&use_bundles_with_status(cluster_id)), + ); + + let packages = search_items(&search); + let total = search_total(&search); + let pending = search_pending(&search); + let current_page = *page.read(); + + // A page switch reports no total until it settles holding the last count keeps the pager in place + let live_pages = (total > 0).then(|| total.div_ceil(BROWSE_PAGE_SIZE).max(1)); + let mut settled_pages = use_state(|| live_pages); + if !pending { + settled_pages.set_if_modified(live_pages); + } + let pages = if pending { + *settled_pages.read() + } else { + live_pages + }; + + let mode = *view_mode.read(); + + let fade_dep = (current_page, packages.is_empty(), pending, mode); + let fade = use_animation_with_dependencies(&fade_dep, |conf, _| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + AnimNum::new(0., 1.) + .time(240) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let fade_opacity = fade.read().value(); + + let sa = ScrollArea::new() + .reset_key(current_page as u64) + .padding(Gaps::new(0., SCROLLBAR_GUTTER, 0., 0.)); + + let pkg = package_type.clone(); + let results = if !packages.is_empty() { + match mode { + ViewLayout::Grid => { + let rows: Vec> = + packages.chunks(GRID_COLUMNS).map(|c| c.to_vec()).collect(); + + sa.lazy(rows.len(), CARD_H, GRID_SPACING, move |i| { + grid_row(rows[i].clone(), cluster_id, &pkg, &installed).into_element() + }) + } + ViewLayout::List => sa.lazy(packages.len(), LIST_ROW_H, LIST_SPACING, move |i| { + list_row(packages[i].clone(), cluster_id, &pkg, &installed).into_element() + }), + } + } else if pending { + match mode { + ViewLayout::Grid => { + let rows = BROWSE_PAGE_SIZE.div_ceil(GRID_COLUMNS); + sa.lazy(rows, CARD_H, GRID_SPACING, |_| { + skeleton_grid_row().into_element() + }) + } + ViewLayout::List => sa.lazy(BROWSE_PAGE_SIZE, LIST_ROW_H, LIST_SPACING, |_| { + SkeletonListRow.into_element() + }), + } + } else { + sa.children([empty_state().into_element()]) + } + .into_element(); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(Gaps::new(0., 40., 40., 40.)) + .spacing(16.) + .child(page_title( + &package_type, + cluster.as_ref().map(|c| c.name.clone()), + self.pick_cluster.then(|| ClusterPicker { + cluster_id, + package_type: package_type.clone(), + }), + )) + .child(controls(provider, query, view_mode)) + .child( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(24.) + .maybe(!all_categories.is_empty(), |el| { + el.child(CategorySidebar { + categories: all_categories, + selected: selected_categories, + }) + }) + .child( + rect() + .vertical() + .content(Content::Flex) + .width(Size::flex(1.0)) + .height(Size::fill()) + .spacing(12.) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .child(results) + .opacity(fade_opacity), + ) + .maybe_child(pages.map(|pages| { + Pagination::new(page, pages) + .enabled(!pending) + .into_element() + })), + ), + ) + } +} + +fn page_title( + package_type: &str, + cluster_name: Option, + picker: Option, +) -> impl IntoElement { + rect() + .vertical() + .spacing(2.) + .child( + label() + .text(format!("Browse {}", type_title(package_type))) + .font_size(36.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .maybe_child(match picker { + Some(picker) => Some(picker.into_element()), + // Without the picker the cluster is fixed so it's just a subtitle + None => cluster_name.map(|name| { + label() + .text(format!("for {name}")) + .font_size(14.) + .color(colors::fg_secondary()) + .into_element() + }), + }) +} + +/// Falls back to the bare version while the manifest hasn't arrived or doesn't cover it +fn version_name(metadata: &[VersionMetadata], cluster: &Cluster) -> String { + parse_mc_version(&cluster.mc_version) + .and_then(|parsed| { + pick_version_metadata( + metadata, + parsed.major, + parsed.key(), + Some(cluster.mc_loader), + ) + }) + .map(|m| m.name) + .unwrap_or_else(|| cluster.mc_version.clone()) +} + +/// The choice lives in the route so back and forward keep the cluster being browsed +#[derive(PartialEq)] +struct ClusterPicker { + cluster_id: i64, + package_type: String, +} + +impl Component for ClusterPicker { + fn render(&self) -> impl IntoElement { + let clusters = + sort_clusters_for_home(settled_or_loading(&use_clusters()).unwrap_or_default()); + let metadata = versions_metadata(&use_versions()).unwrap_or_default(); + + let labels: Vec = clusters + .iter() + .map(|c| format!("{} · {}", c.name, version_name(&metadata, c))) + .collect(); + let ids: Vec = clusters.iter().map(|c| c.id).collect(); + + let selected = ids + .iter() + .position(|id| *id == self.cluster_id) + .and_then(|idx| labels.get(idx).cloned()) + .unwrap_or_else(|| "Select a version".to_string()); + + let package_type = self.package_type.clone(); + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .margin(Gaps::new(4., 0., 0., 0.)) + .child( + label() + .text("for") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .child( + Dropdown::new(selected, labels) + .width(Size::px(240.)) + .height(Size::px(28.)) + .on_select(move |idx: usize| { + if let Some(cluster_id) = ids.get(idx).copied() { + let _ = RouterContext::get().replace(Route::Browser { + cluster_id, + package_type: package_type.clone(), + pick_cluster: true, + }); + } + }), + ) + } +} + +fn controls( + provider: State, + query: State, + view_mode: State, +) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .content(Content::Flex) + .child(rect().width(Size::flex(1.0))) + .child( + SegmentedControl::new(view_mode) + .equal_width(30.) + .icon_size(16.) + .segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)) + .segment(Segment::new(ViewLayout::List).icon(IconType::LayoutTop)), + ) + .child( + SegmentedControl::new(provider) + .no_tint() + .segments(ProviderId::REMOTE_PROVIDERS.iter().map(|provider| { + Segment::new(*provider) + .icon(IconType::from(*provider)) + .label(provider.to_string()) + })) + .into_element(), + ) + .child( + TextInput::new(query) + .width(Size::px(260.)) + .placeholder("Search for content") + .leading( + Icon::new(IconType::SearchMd) + .size(14.) + .color(colors::fg_secondary()) + .into_element(), + ), + ) +} diff --git a/packages/oneclient_app/src/view/app/browser/index/sidebar.rs b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs new file mode 100644 index 00000000..66b7dfdb --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/index/sidebar.rs @@ -0,0 +1,117 @@ +use super::*; + +use crate::components::ScrollArea; +use crate::theme::colors; +use crate::utils::capitalize; + +#[derive(PartialEq)] +pub(super) struct CategorySidebar { + pub(super) categories: Vec, + pub(super) selected: State>, +} + +impl Component for CategorySidebar { + fn render(&self) -> impl IntoElement { + let selected = self.selected; + let list = rect().vertical().width(Size::fill()).spacing(2.).children( + self.categories.clone().into_iter().map(move |cat| { + let is_selected = selected.read().contains(&cat); + let mut selected = selected; + let cat_for_press = cat.clone(); + let on_toggle: EventHandler<()> = (move |()| { + let mut current = selected.read().clone(); + if let Some(i) = current.iter().position(|c| c == &cat_for_press) { + current.remove(i); + } else { + current.push(cat_for_press.clone()); + } + selected.set(current); + }) + .into(); + CategoryRow { + name: cat.clone(), + selected: is_selected, + on_toggle, + key: DiffKey::None, + } + .key(cat) + .into_element() + }), + ); + + rect() + .vertical() + .width(Size::px(190.)) + .height(Size::fill()) + .spacing(8.) + .child( + label() + .text("CATEGORIES") + .font_size(11.) + .font_weight(FontWeight::LIGHT) + .color(colors::fg_secondary()), + ) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .children([list.into_element()]), + ) + } +} + +#[derive(PartialEq)] +struct CategoryRow { + name: String, + selected: bool, + on_toggle: EventHandler<()>, + key: DiffKey, +} + +impl KeyExt for CategoryRow { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for CategoryRow { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let selected = self.selected; + let on_toggle = self.on_toggle.clone(); + let hovered = *hovering.read(); + let focused = focus().is_focused(); + let color = if selected || hovered || focused { + colors::fg_primary() + } else { + colors::fg_secondary() + }; + + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(3., 0.)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_all_press(move |_| on_toggle.call(())) + .child( + label() + .text(capitalize(&self.name)) + .font_size(13.) + .max_lines(1) + .font_weight(if selected { + FontWeight::SEMI_BOLD + } else { + FontWeight::NORMAL + }) + .color(color), + ) + } +} diff --git a/packages/oneclient_app/src/view/app/browser/index/skeletons.rs b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs new file mode 100644 index 00000000..30f6d2aa --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/index/skeletons.rs @@ -0,0 +1,100 @@ +use super::*; + +use freya::animation::{AnimNum, Ease, Function, OnCreation, OnFinish, use_animation}; + +use crate::theme::colors; +use crate::ui::border_all_color; + +fn skeleton_block(width: Size, height: f32) -> impl IntoElement { + rect() + .width(width) + .height(Size::px(height)) + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::component_bg()) + .into_element() +} + +fn use_skeleton_pulse() -> f32 { + let pulse = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + conf.on_finish(OnFinish::reverse()); + AnimNum::new(0.4, 0.9) + .time(800) + .ease(Ease::InOut) + .function(Function::Sine) + }); + pulse.read().value() +} + +#[derive(PartialEq)] +struct SkeletonCard; + +impl Component for SkeletonCard { + fn render(&self) -> impl IntoElement { + let pulse = use_skeleton_pulse(); + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::px(CARD_H)) + .corner_radius(CornerRadius::new_all(10.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .overflow(Overflow::Clip) + .opacity(pulse) + .child(skeleton_block(Size::fill(), BANNER_H)) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new_all(12.)) + .spacing(8.) + .child(skeleton_block(Size::percent(70.), 16.)) + .child(skeleton_block(Size::percent(40.), 10.)) + .child(skeleton_block(Size::fill(), 10.)) + .child(skeleton_block(Size::percent(85.), 10.)) + .child(rect().width(Size::fill()).height(Size::flex(1.0))) + .child(skeleton_block(Size::percent(30.), 10.)), + ) + } +} + +#[derive(PartialEq)] +pub(super) struct SkeletonListRow; + +impl Component for SkeletonListRow { + fn render(&self) -> impl IntoElement { + let pulse = use_skeleton_pulse(); + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(LIST_ROW_H)) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_all(10.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(CARD_BG) + .border(border_all_color(1., colors::component_border())) + .opacity(pulse) + .child(skeleton_block(Size::px(48.), 48.)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(6.) + .child(skeleton_block(Size::percent(45.), 14.)) + .child(skeleton_block(Size::percent(80.), 10.)), + ) + } +} + +pub(super) fn skeleton_grid_row() -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(CARD_H)) + .spacing(GRID_SPACING) + .content(Content::Flex) + .children((0..GRID_COLUMNS).map(|_| SkeletonCard.into_element())) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/browser/mod.rs b/packages/oneclient_app/src/view/app/browser/mod.rs new file mode 100644 index 00000000..2bf654f7 --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/mod.rs @@ -0,0 +1,552 @@ +mod index; +mod package; + +pub use index::Browser; +pub use package::BrowserPackage; + +use std::collections::HashMap; + +use freya::prelude::*; +use oneclient_content::packages::ProviderId; +use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; + +use crate::components::{Icon, IconType}; +use crate::hooks::{loaded_image, use_cached_image}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const BANNER_BG: Color = Color::from_rgb(21, 28, 34); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum InstallSource { + Manual, + Bundled, +} + +impl InstallSource { + fn label(self) -> &'static str { + match self { + Self::Manual => "Installed", + Self::Bundled => "Bundled", + } + } + + /// Bundled reads blue rather than green the cluster's bundle put it there not the user + fn color(self) -> Color { + match self { + Self::Manual => colors::success(), + Self::Bundled => colors::code_info(), + } + } +} + +#[derive(Clone, PartialEq)] +pub(crate) struct InstalledVersion { + pub version_id: String, + /// The artifact to remove + /// `None` when a bundle names this version but nothing is linked + pub hash: Option, + /// Only meaningful alongside a `hash` a bundle pin with nothing linked is neither on nor off + pub enabled: bool, + pub source: InstallSource, +} + +/// Holds every version found rather than an arbitrary winner a cluster can end up with more than one +#[derive(Clone, PartialEq)] +pub(crate) struct Installed { + /// A bundle claiming the project wins over a hand-installed copy + pub source: InstallSource, + pub versions: Vec, +} + +impl Installed { + pub fn is_version(&self, version_id: &str) -> bool { + self.find_version(version_id).is_some() + } + + pub fn find_version(&self, version_id: &str) -> Option<&InstalledVersion> { + self.versions.iter().find(|v| v.version_id == version_id) + } + + /// Counts linked artifacts only a bundle pin the cluster never downloaded is not a second copy + pub fn is_duplicated(&self) -> bool { + self.versions.iter().filter(|v| v.hash.is_some()).count() > 1 + } +} + +/// Local files and a bundle's external files are left out they have no project id to match against +pub(crate) fn installed_map( + content: Vec, + bundles: &[BundleWithUpdateStatus], +) -> HashMap<(ProviderId, String), Installed> { + let mut map: HashMap<(ProviderId, String), Installed> = HashMap::new(); + + for item in content { + let (Some(provider), Some(project_id)) = (item.provider, item.project_id) else { + continue; + }; + + // Created even without a recorded version the package is in the cluster it just cannot be tied to a version row + let installed = map.entry((provider, project_id)).or_insert(Installed { + source: InstallSource::Manual, + versions: Vec::new(), + }); + + if let Some(version_id) = item.version_id { + installed.versions.push(InstalledVersion { + version_id, + hash: Some(item.hash), + enabled: item.enabled, + source: InstallSource::Manual, + }); + } + } + + // Bundle membership wins a bundle's files would otherwise read as hand-installed + // The manifest pin is only a fallback for a missing linked version + for bundle in bundles { + for (file, _status) in &bundle.files { + if let BundleFileKind::Managed { + provider, + project_id, + version_id, + .. + } = &file.kind + { + match map.get_mut(&(*provider, project_id.clone())) { + Some(installed) => { + installed.source = InstallSource::Bundled; + if let Some(version) = installed + .versions + .iter_mut() + .find(|v| &v.version_id == version_id) + { + version.source = InstallSource::Bundled; + } + } + None => { + map.insert( + (*provider, project_id.clone()), + Installed { + source: InstallSource::Bundled, + versions: vec![InstalledVersion { + version_id: version_id.clone(), + hash: None, + enabled: false, + source: InstallSource::Bundled, + }], + }, + ); + } + } + } + } + } + + map +} + +pub(crate) fn installed_badge(installed: InstallSource, font_size: f32) -> impl IntoElement { + badge(installed, font_size, installed.color().with_a(38), None) +} + +/// Brings its own backdrop and outline so it stays legible over card artwork +pub(crate) fn installed_badge_overlay(installed: InstallSource) -> impl IntoElement { + badge( + installed, + 10., + BANNER_BG.with_a(225), + Some(installed.color().with_a(110)), + ) +} + +/// Which of several installed versions the game actually loads +pub(crate) fn activity_badge(active: bool) -> impl IntoElement { + let (text, color) = if active { + ("Active", colors::brand()) + } else { + ("Inactive", colors::fg_secondary()) + }; + + // No icon three pills in a row all wearing a tick reads as decoration + rect() + .horizontal() + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(color.with_a(38)) + .child(label().text(text).font_size(11.).max_lines(1).color(color)) +} + +fn badge( + installed: InstallSource, + font_size: f32, + background: Color, + border: Option, +) -> impl IntoElement { + let color = installed.color(); + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(background) + .map(border, |el, border| el.border(border_all_color(1., border))) + .child( + Icon::new(IconType::CheckCircle) + .size(font_size) + .color(color), + ) + .child( + label() + .text(installed.label()) + .font_size(font_size) + .max_lines(1) + .color(color), + ) +} + +#[derive(PartialEq)] +pub(crate) struct Thumbnail { + icon_url: Option, + size: f32, + radius: f32, + key: DiffKey, +} + +impl Thumbnail { + pub fn new(icon_url: Option, size: f32) -> Self { + Self { + icon_url, + size, + radius: 10., + key: DiffKey::None, + } + } + + pub fn radius(mut self, radius: f32) -> Self { + self.radius = radius; + self + } +} + +impl KeyExt for Thumbnail { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for Thumbnail { + fn render(&self) -> impl IntoElement { + let size = self.size; + let radius = self.radius; + let query = use_cached_image(self.icon_url.clone(), 256); + let loaded = loaded_image(self.icon_url.as_deref(), &query); + + match loaded { + Some((url, bytes)) => ImageViewer::new((url, bytes)) + .width(Size::px(size)) + .height(Size::px(size)) + .aspect_ratio(AspectRatio::Min) + .corner_radius(CornerRadius::new_all(radius)) + .into_element(), + None => rect() + .center() + .width(Size::px(size)) + .height(Size::px(size)) + .corner_radius(CornerRadius::new_all(radius)) + .background(colors::component_bg()) + .child( + Icon::new(IconType::DotsGrid) + .size(size * 0.4) + .color(colors::fg_secondary()), + ) + .into_element(), + } + } +} + +#[derive(PartialEq)] +pub(crate) struct PackageBanner { + icon_url: Option, + height: f32, + key: DiffKey, +} + +impl PackageBanner { + pub fn new(icon_url: Option, height: f32) -> Self { + Self { + icon_url, + height, + key: DiffKey::None, + } + } +} + +impl KeyExt for PackageBanner { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for PackageBanner { + fn render(&self) -> impl IntoElement { + let h = self.height; + let icon = h * 0.62; + let query = use_cached_image(self.icon_url.clone(), 512); + let loaded = loaded_image(self.icon_url.as_deref(), &query); + + let banner = rect() + .width(Size::fill()) + .height(Size::px(h)) + .center() + .overflow(Overflow::Clip) + .background(BANNER_BG); + + match loaded { + Some((url, bytes)) => banner + .child( + rect() + .position(Position::new_absolute().top(0.).left(0.)) + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .child( + ImageViewer::new((url.clone(), bytes.clone())) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::Max) + .image_cover(ImageCover::Center), + ) + .layer(Layer::Relative(1)), + ) + .child( + rect() + .position(Position::new_absolute().top(0.).left(0.)) + .width(Size::fill()) + .height(Size::fill()) + .blur(12.) + .background(BANNER_BG.with_a(120)) + .overflow(Overflow::Clip) + .layer(Layer::Relative(3)), + ) + .child( + rect() + .width(Size::px(icon)) + .height(Size::px(icon)) + .child( + ImageViewer::new((url, bytes)) + .width(Size::px(icon)) + .height(Size::px(icon)) + .aspect_ratio(AspectRatio::Min) + .corner_radius(CornerRadius::new_all(10.)), + ) + .layer(Layer::Relative(5)), + ), + None => banner.child( + rect() + .center() + .width(Size::px(icon)) + .height(Size::px(icon)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::component_bg()) + .child( + Icon::new(IconType::DotsGrid) + .size(icon * 0.45) + .color(colors::fg_secondary()), + ), + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::archive; + use oneclient_content::packages::ContentType; + use oneclient_core::{BundleFile, FileUpdateStatus}; + + fn linked(project_id: &str, version_id: Option<&str>, hash: &str) -> LinkedArtifactInfo { + disabled_linked(project_id, version_id, hash, true) + } + + fn disabled_linked( + project_id: &str, + version_id: Option<&str>, + hash: &str, + enabled: bool, + ) -> LinkedArtifactInfo { + LinkedArtifactInfo { + hash: hash.to_string(), + cluster_file_name: format!("{hash}.jar"), + enabled, + content_type: ContentType::Mod, + file_name: format!("{hash}.jar"), + project_id: Some(project_id.to_string()), + version_id: version_id.map(Into::into), + display_name: None, + display_version: None, + provider: Some(ProviderId::Modrinth), + published_at: None, + } + } + + fn managed(project_id: &str, version_id: &str) -> BundleFile { + BundleFile { + enabled: true, + hidden: false, + path: format!("mods/{project_id}.jar"), + size: 1, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: project_id.to_string(), + version_id: version_id.to_string(), + sha1: format!("sha-{version_id}"), + }, + } + } + + fn bundles(files: Vec) -> Vec { + vec![BundleWithUpdateStatus { + files: files + .iter() + .cloned() + .map(|f| (f, FileUpdateStatus::UpToDate)) + .collect(), + archive: archive("performance", true, files), + has_updates: false, + }] + } + + fn entry(map: &HashMap<(ProviderId, String), Installed>, project: &str) -> Installed { + map.get(&(ProviderId::Modrinth, project.to_string())) + .expect("project missing from the map") + .clone() + } + + #[test] + fn every_installed_version_of_one_project_is_listed() { + let map = installed_map( + vec![ + linked("sodium", Some("v1"), "hash-1"), + linked("sodium", Some("v2"), "hash-2"), + ], + &[], + ); + + let sodium = entry(&map, "sodium"); + assert!( + sodium.is_version("v1"), + "the older version is still in there" + ); + assert!(sodium.is_version("v2")); + assert_eq!( + sodium.find_version("v2").and_then(|v| v.hash.clone()), + Some("hash-2".to_string()), + "each version carries the artifact the remove button needs" + ); + } + + #[test] + fn a_bundle_marks_only_the_version_it_ships() { + let map = installed_map( + vec![ + linked("sodium", Some("v1"), "hash-1"), + linked("sodium", Some("v2"), "hash-2"), + ], + &bundles(vec![managed("sodium", "v1")]), + ); + + let sodium = entry(&map, "sodium"); + assert_eq!( + sodium.source, + InstallSource::Bundled, + "the bundle owns the project" + ); + assert_eq!( + sodium.find_version("v1").map(|v| v.source), + Some(InstallSource::Bundled) + ); + assert_eq!( + sodium.find_version("v2").map(|v| v.source), + Some(InstallSource::Manual), + "a version the user added themselves does not become the bundle's" + ); + } + + #[test] + fn an_unlinked_bundle_pin_is_the_fallback() { + let map = installed_map(Vec::new(), &bundles(vec![managed("sodium", "v1")])); + + let sodium = entry(&map, "sodium"); + assert_eq!(sodium.source, InstallSource::Bundled); + assert_eq!( + sodium.find_version("v1").map(|v| v.hash.clone()), + Some(None), + "nothing is linked, so there is no artifact to remove" + ); + } + + #[test] + fn a_bundle_pin_is_not_listed_beside_a_linked_version() { + let map = installed_map( + vec![linked("sodium", Some("v2"), "hash-2")], + &bundles(vec![managed("sodium", "v1")]), + ); + + let sodium = entry(&map, "sodium"); + assert!( + !sodium.is_version("v1"), + "a pin the cluster never downloaded is not installed" + ); + assert_eq!(sodium.versions.len(), 1); + } + + #[test] + fn a_single_copy_is_not_a_duplicate() { + let map = installed_map(vec![linked("sodium", Some("v1"), "hash-1")], &[]); + + assert!(!entry(&map, "sodium").is_duplicated()); + } + + #[test] + fn an_unlinked_bundle_pin_is_not_a_second_copy() { + let map = installed_map(Vec::new(), &bundles(vec![managed("sodium", "v1")])); + + assert!( + !entry(&map, "sodium").is_duplicated(), + "one pin and nothing linked is not two of anything" + ); + } + + #[test] + fn each_copy_reports_whether_the_game_loads_it() { + let map = installed_map( + vec![ + disabled_linked("sodium", Some("v1"), "hash-1", false), + linked("sodium", Some("v2"), "hash-2"), + ], + &[], + ); + + let sodium = entry(&map, "sodium"); + assert!(sodium.is_duplicated()); + assert_eq!(sodium.find_version("v1").map(|v| v.enabled), Some(false)); + assert_eq!(sodium.find_version("v2").map(|v| v.enabled), Some(true)); + } + + #[test] + fn an_artifact_with_no_recorded_version_still_marks_the_project() { + let map = installed_map(vec![linked("sodium", None, "hash-1")], &[]); + + let sodium = entry(&map, "sodium"); + assert!( + sodium.versions.is_empty(), + "there is no version to tie to a row in the list" + ); + assert_eq!(sodium.source, InstallSource::Manual); + } +} diff --git a/packages/oneclient_app/src/view/app/browser/package/gallery.rs b/packages/oneclient_app/src/view/app/browser/package/gallery.rs new file mode 100644 index 00000000..a8b66d45 --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/package/gallery.rs @@ -0,0 +1,325 @@ +//! Remote images behind the shared image cache a tile and the opened view ask for the same url at two different sizes + +use super::*; + +use oneclient_content::packages::types::GalleryImage; + +use crate::components::{Button, Icon, IconType, OverlayPopup}; +use crate::hooks::{loaded_image, query_is_busy, use_cached_image}; +use crate::theme::colors; +use crate::ui::{border_all_color, flow_grid, grid_columns_for_width}; + +/// How wide a tile is allowed to get before the grid takes another column +const MAX_COL_W: f32 = 400.; +const GRID_GAP: f32 = 16.; +const TILE_PREVIEW_H: f32 = 168.; + +/// Downscale ceilings for the image cache full-size tiles made the grid slow to fill +const TILE_EDGE: u32 = 640; +const FULL_EDGE: u32 = 2048; + +pub(super) fn gallery_panel(images: Vec) -> impl IntoElement { + Gallery { images } +} + +#[derive(PartialEq)] +struct Gallery { + images: Vec, +} + +impl Component for Gallery { + fn render(&self) -> impl IntoElement { + let grid_width = use_state(|| 0f32); + let mut viewing = use_state(|| None::); + + let tiles: Vec = self + .images + .iter() + .enumerate() + .map(|(idx, image)| { + let key = image.url.clone(); + GalleryTile { + image: image.clone(), + on_open: (move |()| viewing.set(Some(idx))).into(), + key: DiffKey::None, + } + .key(key) + .into_element() + }) + .collect(); + + let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP); + let images = self.images.clone(); + + rect() + .width(Size::fill()) + .child(flow_grid(tiles, cols, grid_width, GRID_GAP)) + .maybe_child((*viewing.read()).map(|start| { + GalleryViewer { + images: images.clone(), + start, + on_close: (move |()| viewing.set(None)).into(), + } + .into_element() + })) + } +} + +#[derive(PartialEq)] +struct GalleryTile { + image: GalleryImage, + on_open: EventHandler<()>, + key: DiffKey, +} + +impl KeyExt for GalleryTile { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for GalleryTile { + fn render(&self) -> impl IntoElement { + let query = use_cached_image(Some(self.image.url.clone()), TILE_EDGE); + let loaded = loaded_image(Some(&self.image.url), &query); + + let mut hovered = use_state(|| false); + let on_open = self.on_open.clone(); + + let preview = rect() + .width(Size::fill()) + .height(Size::px(TILE_PREVIEW_H)) + .center() + .overflow(Overflow::Clip) + .background(colors::component_bg()) + .maybe_child(loaded.map(|(url, bytes)| { + ImageViewer::new((url, bytes)) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::Max) + .image_cover(ImageCover::Center) + .into_element() + })); + + rect() + .vertical() + .width(Size::flex(1.0)) + .max_width(Size::px(MAX_COL_W)) + .corner_radius(CornerRadius::new_all(10.)) + .overflow(Overflow::Clip) + .background(PANEL_BG) + .border( + border_all_color( + 1., + if *hovered.read() { + colors::component_border_hover() + } else { + colors::component_border() + }, + ) + .alignment(BorderAlignment::Inner), + ) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(move |_| on_open.call(())) + .child(preview) + .maybe(self.image.title.is_some(), |el| { + el.child( + rect() + .width(Size::fill()) + .padding(Gaps::new_all(10.)) + .child( + label() + .text(self.image.title.clone().unwrap_or_default()) + .font_size(12.) + .max_lines(2) + .color(colors::fg_secondary()), + ), + ) + }) + } +} + +#[derive(PartialEq)] +struct GalleryViewer { + images: Vec, + start: usize, + on_close: EventHandler<()>, +} + +impl Component for GalleryViewer { + fn render(&self) -> impl IntoElement { + let len = self.images.len(); + + let mut index = use_state({ + let start = self.start.min(len.saturating_sub(1)); + move || start + }); + + if len == 0 { + return rect().into_element(); + } + let idx = (*index.read()).min(len - 1); + let image = self.images[idx].clone(); + + let query = use_cached_image(Some(image.url.clone()), FULL_EDGE); + let loaded = loaded_image(Some(&image.url), &query); + + let close = self.on_close.clone(); + let scrim_close = self.on_close.clone(); + + let has_prev = idx > 0; + let has_next = idx + 1 < len; + + let preview = rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .center() + .overflow(Overflow::Clip) + .maybe_child(loaded.map(|(url, bytes)| { + ImageViewer::new((url, bytes)) + .width(Size::fill()) + .height(Size::fill()) + .aspect_ratio(AspectRatio::Min) + .into_element() + })) + // A full-size fetch separate from the tile's cached copy a failed fetch is not loading + .maybe_child(query_is_busy(&query).then(|| { + label() + .text("Loading image...") + .font_size(13.) + .color(colors::fg_secondary()) + .into_element() + })); + + OverlayPopup::new() + .on_close(move |_| scrim_close.call(())) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .on_global_key_down(move |e: Event| match &e.key { + Key::Named(NamedKey::ArrowLeft) if idx > 0 => index.set(idx - 1), + Key::Named(NamedKey::ArrowRight) if idx + 1 < len => index.set(idx + 1), + _ => {} + }) + .child( + rect() + .vertical() + .width(Size::window_percent(88.)) + .height(Size::window_percent(90.)) + .spacing(12.) + .padding(Gaps::new_all(16.)) + .content(Content::Flex) + .child(header_row(&image, idx, len, move |_| close.call(()))) + .child( + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .horizontal() + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(12.) + .overflow(Overflow::Clip) + .child(chevron_btn(IconType::ArrowLeft, has_prev, move |_| { + if idx > 0 { + index.set(idx - 1); + } + })) + .child(preview) + .child(chevron_btn( + IconType::ArrowRight, + has_next, + move |_| { + if idx + 1 < len { + index.set(idx + 1); + } + }, + )), + ), + ), + ) + .into_element() + } +} + +fn chevron_btn( + icon: IconType, + enabled: bool, + on_press: impl Into>>, +) -> impl IntoElement { + let base = rect() + .width(Size::px(44.)) + .height(Size::px(44.)) + .center() + .corner_radius(CornerRadius::new_all(22.)); + + if !enabled { + return base + .background(Color::from_argb(70, 0, 0, 0)) + .on_press(|_| {}) + .child( + Icon::new(icon) + .size(26.) + .color(colors::fg_secondary().with_a(90)), + ) + .into_element(); + } + + base.background(Color::from_argb(140, 0, 0, 0)) + .cursor(CursorIcon::Pointer) + .on_press(on_press) + .child(Icon::new(icon).size(26.).color(colors::fg_primary())) + .into_element() +} + +fn header_row( + image: &GalleryImage, + idx: usize, + len: usize, + on_close: impl Into>>, +) -> impl IntoElement { + let title = image + .title + .clone() + .unwrap_or_else(|| format!("Image {}", idx + 1)); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title) + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ) + .maybe(len > 1, |el| { + el.child( + label() + .text(format!("{} of {len}", idx + 1)) + .font_size(11.) + .color(colors::fg_secondary()), + ) + }), + ) + .child( + Button::new() + .ghost() + .icon() + .on_press(on_close) + .child(Icon::new(IconType::XClose).size(18.)), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/browser/package/mod.rs b/packages/oneclient_app/src/view/app/browser/package/mod.rs new file mode 100644 index 00000000..31770866 --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/package/mod.rs @@ -0,0 +1,226 @@ +use freya::prelude::*; +use oneclient_content::packages::{ContentType, ProviderId}; + +use crate::components::ScrollArea; +use crate::hooks::use_cluster; +use crate::hooks::{ + bundles_with_status_items, cluster_content_items, content_type_for_slug, project_detail, + use_browser_compat, use_bundles_with_status, use_cluster_content, use_dispatch, + use_installs_snapshot, use_link_confirm, use_package_project, use_package_versions, + version_list, versions_total, +}; +use crate::theme::colors; +use crate::ui::border_all_color; + +use super::{ + Installed, InstalledVersion, PackageBanner, Thumbnail, activity_badge, installed_badge, + installed_badge_overlay, installed_map, +}; +use crate::utils::abbreviate_number; + +mod gallery; +mod panels; +mod sidebar; +use gallery::gallery_panel; +use panels::{about_panel, loading_body, tabs, versions_panel}; +use sidebar::sidebar; + +const PANEL_BG: Color = Color::from_rgb(21, 28, 34); +const SIDEBAR_W: f32 = 280.; +const SCROLLBAR_GUTTER: f32 = 18.; + +fn decode_package_id(package_id: &str) -> (ProviderId, String) { + match package_id.split_once(':') { + Some((repr, id)) => { + let provider = repr + .parse::() + .ok() + .and_then(ProviderId::from_repr) + .unwrap_or(ProviderId::Modrinth); + (provider, id.to_string()) + } + None => (ProviderId::Modrinth, package_id.to_string()), + } +} + +fn pill(text: String) -> impl IntoElement { + rect() + .center() + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(text) + .font_size(10.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn pill_flow(items: &[String], per_row: usize, max: usize) -> impl IntoElement { + let shown: Vec = items.iter().take(max).cloned().collect(); + let overflow = items.len().saturating_sub(shown.len()); + let mut all = shown; + if overflow > 0 { + all.push(format!("+{overflow}")); + } + + rect() + .vertical() + .spacing(4.) + .children(all.chunks(per_row.max(1)).map(|chunk| { + rect() + .horizontal() + .spacing(4.) + .children(chunk.iter().map(|t| pill(t.clone()).into_element())) + .into_element() + })) + .into_element() +} + +fn pill_detail(key: &str, items: &[String]) -> Element { + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text(key.to_string()) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child(pill_flow(items, 4, 18)) + .into_element() +} + +#[derive(PartialEq)] +pub struct BrowserPackage { + pub cluster_id: i64, + pub package_type: String, + pub package_id: String, +} + +impl Component for BrowserPackage { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let (provider, project_id) = decode_package_id(&self.package_id); + let content_type = content_type_for_slug(&self.package_type); + + let active_tab = use_state(|| 0usize); + let versions_page = use_state(|| 0usize); + let compatible_only = use_browser_compat(); + let dispatch = use_dispatch(); + let confirm = use_link_confirm(); + + let cluster = use_cluster(cluster_id); + let compat = *compatible_only.read(); + let (game_version, loader) = match (compat, &cluster) { + (true, Some(c)) => ( + Some(c.mc_version.clone()), + (content_type == ContentType::Mod).then_some(c.mc_loader), + ), + _ => (None, None), + }; + + let mut versions_page_state = versions_page; + let mut last_compat = use_state(|| compat); + if *last_compat.peek() != compat { + last_compat.set(compat); + if *versions_page_state.peek() != 0 { + versions_page_state.set(0); + } + } + + let project_query = use_package_project(provider, project_id.clone()); + let versions_query = use_package_versions( + provider, + project_id.clone(), + game_version, + loader, + *versions_page.read(), + ); + + let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id); + + let installed = installed_map( + cluster_content_items(&use_cluster_content(cluster_id, content_type)), + &bundles_with_status_items(&use_bundles_with_status(cluster_id)), + ) + .remove(&(provider, project_id.clone())); + + let project = project_detail(&project_query); + let versions = version_list(&versions_query); + let total_versions = versions_total(&versions_query); + let latest_version = versions.first().map(|v| v.version_id.clone()); + + let gallery = project + .as_ref() + .map(|p| p.gallery.clone()) + .unwrap_or_default(); + let has_gallery = !gallery.is_empty(); + let max_tab = if has_gallery { 2 } else { 1 }; + let current = (*active_tab.read()).min(max_tab); + + let body = match (&project, current) { + (None, _) => loading_body().into_element(), + (Some(project), 0) => about_panel(project).into_element(), + (Some(project), 1) => versions_panel( + versions, + total_versions, + versions_page, + provider, + project.id.clone(), + cluster_id, + dispatch.clone(), + installed.clone(), + installing, + ) + .into_element(), + (Some(_), _) => gallery_panel(gallery).into_element(), + }; + + let row = rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Start) + .spacing(24.) + .content(Content::Flex) + .child(sidebar( + project, + latest_version, + provider, + cluster_id, + dispatch, + confirm, + installed, + installing, + )) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(12.) + .child(tabs(active_tab, has_gallery)) + .child(body), + ) + .into_element(); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(Gaps::new(0., 40., 40., 40.)) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .reset_key(current as u64) + .padding(Gaps::new(0., SCROLLBAR_GUTTER, 0., 0.)) + .children([row]), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/browser/package/panels.rs b/packages/oneclient_app/src/view/app/browser/package/panels.rs new file mode 100644 index 00000000..829aca06 --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/package/panels.rs @@ -0,0 +1,339 @@ +use super::*; + +use oneclient_content::packages::ProviderId; +use oneclient_content::packages::types::{PackageBody, ProjectDetail, ReleaseType, VersionSummary}; + +use crate::Actions; +use crate::components::{Button, Icon, IconType, Segment, SegmentedControl}; +use crate::hooks::VERSIONS_PAGE_SIZE; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::format_size; + +pub(super) fn loading_body() -> impl IntoElement { + rect() + .width(Size::fill()) + .center() + .padding(Gaps::new_all(32.)) + .child( + label() + .text("Loading package...") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +pub(super) fn tabs(active_tab: State, has_gallery: bool) -> impl IntoElement { + let mut control = SegmentedControl::new(active_tab) + .height(36.) + .segment(Segment::new(0usize).label("About")) + .segment(Segment::new(1usize).label("Versions")); + if has_gallery { + control = control.segment(Segment::new(2usize).label("Gallery")); + } + control +} + +pub(super) fn about_panel(project: &ProjectDetail) -> impl IntoElement { + let body = match &project.body { + PackageBody::Raw(md) => md.clone(), + PackageBody::Url(url) => format!("{}\n\n[View online]({url})", project.summary), + }; + MarkdownPanel { body }.into_element() +} + +#[derive(PartialEq)] +struct MarkdownPanel { + body: String, +} + +impl Component for MarkdownPanel { + fn render(&self) -> impl IntoElement { + rect() + .width(Size::fill()) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(PANEL_BG) + .border(border_all_color(1., colors::component_border())) + .child( + MarkdownViewer::new(self.body.clone()) + .width(Size::fill()) + .color(colors::fg_primary()) + .color_link(colors::code_info()) + .background_code(colors::component_bg()) + .color_code(colors::fg_primary()) + .background_blockquote(colors::component_bg()) + .border_blockquote(colors::brand()) + .background_divider(colors::component_border()) + .heading_h1(26.) + .heading_h2(22.) + .heading_h3(18.) + .heading_h4(16.) + .heading_h5(14.) + .heading_h6(13.) + .paragraph_size(13.) + .code_font_size(12.), + ) + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn versions_panel( + versions: Vec, + total_versions: usize, + versions_page: State, + provider: ProviderId, + project_id: String, + cluster_id: i64, + dispatch: Actions, + installed: Option, + installing: bool, +) -> impl IntoElement { + let current = *versions_page.read(); + let total_pages = total_versions.div_ceil(VERSIONS_PAGE_SIZE).max(1); + + let rows: Element = if versions.is_empty() { + rect() + .width(Size::fill()) + .center() + .padding(Gaps::new_all(32.)) + .child( + label() + .text("No matching versions.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() + } else { + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .children(versions.into_iter().map(move |v| { + let tag = installed + .as_ref() + .and_then(|installed| installed.find_version(&v.version_id)) + .cloned(); + let duplicated = installed + .as_ref() + .is_some_and(|installed| installed.is_duplicated()); + version_row( + v, + provider, + project_id.clone(), + cluster_id, + dispatch.clone(), + tag, + duplicated, + installing, + ) + .into_element() + })) + .into_element() + }; + + rect() + .vertical() + .width(Size::fill()) + .spacing(12.) + .child(rows) + .maybe(total_versions > 0, |el| { + el.child(version_pager(current, total_pages, versions_page)) + }) + .into_element() +} + +fn version_pager(current: usize, total_pages: usize, page: State) -> impl IntoElement { + let nav = move |target: usize, enabled: bool, icon: IconType| { + let mut page = page; + rect() + .center() + .width(Size::px(32.)) + .height(Size::px(32.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .maybe(enabled, |el| { + el.cursor(CursorIcon::Pointer) + .on_press(move |_| page.set(target)) + }) + .child(Icon::new(icon).size(14.).color(if enabled { + colors::fg_primary() + } else { + colors::fg_secondary().with_a(90) + })) + .into_element() + }; + + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::Center) + .cross_align(Alignment::Center) + .spacing(12.) + .child(nav( + current.saturating_sub(1), + current > 0, + IconType::ArrowLeft, + )) + .child( + label() + .text(format!("Page {} / {}", current + 1, total_pages)) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child(nav( + current + 1, + current + 1 < total_pages, + IconType::ArrowRight, + )) + .into_element() +} + +#[allow(clippy::too_many_arguments)] +fn version_row( + v: VersionSummary, + provider: ProviderId, + project_id: String, + cluster_id: i64, + dispatch: Actions, + installed: Option, + // Saying which version is live only tells the user anything when there are several + duplicated: bool, + installing: bool, +) -> impl IntoElement { + let version_id = v.version_id.clone(); + let mut chips: Vec = v.loaders.iter().map(|l| l.to_string()).collect(); + chips.extend(v.game_versions.iter().cloned()); + let stats = { + let mut parts = vec![format!("{} downloads", abbreviate_number(v.downloads))]; + if v.file_size > 0 { + parts.push(format_size(v.file_size)); + } + parts.push(v.published.format("%Y-%m-%d").to_string()); + parts.join(" · ") + }; + let has_chips = !chips.is_empty(); + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .content(Content::Flex) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(PANEL_BG) + .border(border_all_color(1., colors::component_border())) + .child(release_badge(v.release_type)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(6.) + .child( + label() + .text(v.name.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(colors::fg_primary()), + ) + .maybe(has_chips, |el| el.child(pill_flow(&chips, 8, 10))) + .child( + label() + .text(stats) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .maybe_child( + installed + .as_ref() + .map(|installed| installed_badge(installed.source, 11.).into_element()), + ) + .maybe_child( + installed + .as_ref() + .filter(|_| duplicated) + .map(|installed| activity_badge(installed.enabled).into_element()), + ) + .child(version_button( + installed, v.name, provider, project_id, version_id, cluster_id, dispatch, installing, + )) +} + +/// A bundle pin with nothing linked leaves nothing to press no artifact to remove and installing by hand would duplicate it +#[allow(clippy::too_many_arguments)] +fn version_button( + installed: Option, + version_name: String, + provider: ProviderId, + project_id: String, + version_id: String, + cluster_id: i64, + dispatch: Actions, + busy: bool, +) -> impl IntoElement { + let Some(installed) = installed else { + return Button::new() + .secondary() + .small() + .enabled(!busy) + .on_press(move |_| { + dispatch.install_package( + cluster_id, + provider, + project_id.clone(), + version_id.clone(), + ); + }) + .text("Install"); + }; + + let Some(hash) = installed.hash else { + return Button::new() + .secondary() + .small() + .enabled(false) + .text("Install"); + }; + + Button::new() + .danger() + .small() + .enabled(!busy) + .on_press(move |_| { + dispatch.remove_package_version( + cluster_id, + provider, + project_id.clone(), + hash.clone(), + version_name.clone(), + ); + }) + .text("Remove") +} + +fn release_badge(release_type: ReleaseType) -> impl IntoElement { + let (text, color) = match release_type { + ReleaseType::Release => ("R", colors::code_info()), + ReleaseType::Beta => ("B", colors::code_warn()), + ReleaseType::Alpha => ("A", colors::code_error()), + }; + rect() + .center() + .width(Size::px(28.)) + .height(Size::px(28.)) + .corner_radius(CornerRadius::new_all(7.)) + .background(color.with_a(40)) + .child( + label() + .text(text) + .font_size(13.) + .font_weight(FontWeight::BOLD) + .color(color), + ) +} diff --git a/packages/oneclient_app/src/view/app/browser/package/sidebar.rs b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs new file mode 100644 index 00000000..04939b5d --- /dev/null +++ b/packages/oneclient_app/src/view/app/browser/package/sidebar.rs @@ -0,0 +1,455 @@ +use super::*; + +use oneclient_content::packages::ProviderId; +use oneclient_content::packages::types::{ProjectDetail, ProjectMember}; + +use crate::Actions; +use crate::components::{Button, Icon, IconType}; +use crate::theme::colors; +use crate::ui::border_all_color; + +#[allow(clippy::too_many_arguments)] +pub(super) fn sidebar( + project: Option, + latest_version: Option, + provider: ProviderId, + cluster_id: i64, + dispatch: Actions, + confirm: State>, + installed: Option, + installing: bool, +) -> impl IntoElement { + let Some(project) = project else { + return rect() + .width(Size::px(SIDEBAR_W)) + .min_width(Size::px(SIDEBAR_W)) + .into_element(); + }; + + let project_id = project.id.clone(); + // Nothing to do when this version is already there or while an install is running + let have_latest = match (&installed, &latest_version) { + (Some(installed), Some(latest)) => installed.is_version(latest), + _ => false, + }; + let can_install = latest_version.is_some() && !have_latest && !installing; + + rect() + .vertical() + .width(Size::px(SIDEBAR_W)) + .min_width(Size::px(SIDEBAR_W)) + .spacing(12.) + .child( + rect() + .vertical() + .width(Size::fill()) + .corner_radius(CornerRadius::new_all(12.)) + .overflow(Overflow::Clip) + .background(PANEL_BG) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .width(Size::fill()) + .overflow(Overflow::Clip) + .child(PackageBanner::new(project.icon_url.clone(), 110.)) + .maybe_child(installed.as_ref().map(|installed| { + rect() + .position(Position::new_absolute().top(8.).left(8.)) + .layer(Layer::Relative(7)) + .child(installed_badge_overlay(installed.source)) + .into_element() + })), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .padding(Gaps::new_all(12.)) + .spacing(8.) + .child( + label() + .text(project.name.clone()) + .font_size(18.) + .font_weight(FontWeight::BOLD) + .max_lines(2) + .color(colors::fg_primary()), + ) + .child(ProviderTag { + text: format!("{} on {}", project.content_type, project.provider), + provider: project.provider, + url: provider_project_url(&project), + confirm, + }) + .maybe(!project.summary.is_empty(), |el| { + el.child( + label() + .text(project.summary.clone()) + .font_size(12.) + .max_lines(4) + .color(colors::fg_secondary()), + ) + }) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .child( + Icon::new(IconType::Download01) + .size(12.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(format!( + "{} downloads", + abbreviate_number(project.downloads) + )) + .font_size(11.) + .color(colors::fg_secondary()), + ), + ), + ), + ) + .child( + Button::new() + .primary() + .width(Size::fill()) + .enabled(can_install) + .on_press(move |_| { + if let Some(version_id) = latest_version.clone() { + dispatch.install_package( + cluster_id, + provider, + project_id.clone(), + version_id, + ); + } + }) + .child(Icon::new(IconType::Download01).size(14.)) + .text(if installing { + "Installing..." + } else if have_latest { + "Latest installed" + } else { + "Install latest" + }), + ) + .maybe( + !project.members.is_empty() || !project.author.is_empty(), + |el| el.child(authors_card(&project, confirm)), + ) + .child(details_card(&project)) + .maybe(!project.links.is_empty(), |el| { + el.child(links_card(&project.links, confirm)) + }) + .into_element() +} + +/// Modrinth doesn't hand a url out so it's rebuilt from the slug CurseForge lists it as "Website" +fn provider_project_url(project: &ProjectDetail) -> Option { + match project.provider { + ProviderId::Modrinth => Some(format!( + "{}{}/{}", + ProviderId::Modrinth.website(), + project.content_type.modrinth_type(), + project.slug + )), + ProviderId::CurseForge => project + .links + .iter() + .find(|(label, _)| label == "Website") + .map(|(_, url)| url.clone()), + ProviderId::Local => None, + } +} + +/// Plain text when the provider has no page so a local package doesn't look clickable +#[derive(PartialEq)] +struct ProviderTag { + text: String, + provider: ProviderId, + url: Option, + confirm: State>, +} + +impl Component for ProviderTag { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let url = self.url.clone(); + let interactive = url.is_some(); + let mut confirm = self.confirm; + + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .child(Icon::new(self.provider).size(12.)) + .child( + label() + .text(self.text.clone()) + .font_size(11.) + .color(if interactive { + colors::code_info() + } else { + colors::fg_secondary() + }), + ) + .maybe(interactive, |el| { + let url = url.clone().unwrap_or_default(); + el.a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Link) + .corner_radius(CornerRadius::new_all(6.)) + .maybe(focused, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .cursor(CursorIcon::Pointer) + .on_all_press(move |_| confirm.set(Some(url.clone()))) + }) + } +} + +fn card(title: &str, rows: Vec) -> impl IntoElement { + card_spaced(title, rows, 10.) +} + +/// `card` with a caller-chosen row gap one-line link lists read better tight +fn card_spaced(title: &str, rows: Vec, spacing: f32) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(spacing) + .padding(Gaps::new_all(16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(PANEL_BG) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(title.to_string()) + .font_size(13.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .children(rows) + .into_element() +} + +fn authors_card(project: &ProjectDetail, confirm: State>) -> impl IntoElement { + let rows: Vec = if project.members.is_empty() { + vec![author_row( + &ProjectMember { + name: project.author.clone(), + role: "Author".to_string(), + url: None, + avatar_url: None, + }, + confirm, + )] + } else { + project + .members + .iter() + .map(|m| author_row(m, confirm)) + .collect() + }; + card("Authors", rows) +} + +fn author_row(member: &ProjectMember, confirm: State>) -> Element { + AuthorRow { + name: member.name.clone(), + role: member.role.clone(), + url: member.url.clone(), + avatar_url: member.avatar_url.clone(), + confirm, + } + .into_element() +} + +#[derive(PartialEq)] +struct AuthorRow { + name: String, + role: String, + url: Option, + avatar_url: Option, + confirm: State>, +} + +impl Component for AuthorRow { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let url = self.url.clone(); + let interactive = url.is_some(); + let mut confirm = self.confirm; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .corner_radius(CornerRadius::new_all(6.)) + .padding(Gaps::new_all(4.)) + .maybe(interactive, |el| { + let url = url.clone().unwrap_or_default(); + el.a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_all_press(move |_| confirm.set(Some(url.clone()))) + }) + .maybe(interactive && focused, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .child(Thumbnail::new(self.avatar_url.clone(), 32.).radius(6.)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .child( + label() + .text(self.name.clone()) + .font_size(12.) + .max_lines(1) + .color(colors::fg_primary()), + ) + .maybe(!self.role.is_empty(), |el| { + el.child( + label() + .text(self.role.clone()) + .font_size(10.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + }), + ) + } +} + +fn details_card(project: &ProjectDetail) -> impl IntoElement { + let mut rows: Vec = Vec::new(); + if !project.game_versions.is_empty() { + rows.push(pill_detail("Versions", &project.game_versions)); + } + if !project.loaders.is_empty() { + let loaders: Vec = project.loaders.iter().map(|l| l.to_string()).collect(); + rows.push(pill_detail("Loaders", &loaders)); + } + if let Some(license) = &project.license { + rows.push(detail_row(IconType::Key01, "License", license)); + } + rows.push(detail_row( + IconType::Calendar, + "Created", + &project.created.format("%Y-%m-%d").to_string(), + )); + rows.push(detail_row( + IconType::ClockRewind, + "Updated", + &project.updated.format("%Y-%m-%d").to_string(), + )); + card("Details", rows) +} + +fn detail_row(icon: IconType, key: &str, value: &str) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .spacing(8.) + .content(Content::Flex) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child(Icon::new(icon).size(14.).color(colors::fg_secondary())) + .child( + label() + .text(key.to_string()) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child( + rect() + .width(Size::flex(1.0)) + .main_align(Alignment::End) + .child( + label() + .text(value.to_string()) + .font_size(12.) + .max_lines(3) + .width(Size::fill()) + .color(colors::fg_primary()), + ), + ) + .into_element() +} + +fn links_card(links: &[(String, String)], confirm: State>) -> impl IntoElement { + let rows: Vec = links + .iter() + .map(|(lbl, url)| link_row(lbl.clone(), url.clone(), confirm)) + .collect(); + card_spaced("Links", rows, 2.) +} + +fn link_row(label_text: String, url: String, confirm: State>) -> Element { + LinkRow { + label_text, + url, + confirm, + } + .into_element() +} + +#[derive(PartialEq)] +struct LinkRow { + label_text: String, + url: String, + confirm: State>, +} + +impl Component for LinkRow { + fn render(&self) -> impl IntoElement { + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let url = self.url.clone(); + let mut confirm = self.confirm; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .corner_radius(CornerRadius::new_all(6.)) + .padding(Gaps::new_symmetric(2., 4.)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Link) + .maybe(focused, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .cursor(CursorIcon::Pointer) + .on_all_press(move |_| confirm.set(Some(url.clone()))) + .child( + Icon::new(IconType::Link03) + .size(14.) + .color(colors::code_info()), + ) + .child( + label() + .text(self.label_text.clone()) + .font_size(12.) + .color(colors::code_info()), + ) + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs new file mode 100644 index 00000000..0f27bfb7 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -0,0 +1,772 @@ +use freya::prelude::*; +use oneclient_common::Patch; +use oneclient_common::domain::GameLoader; +use oneclient_core::settings::{GameSettingsProfile, PackageUpdateMode, ProfileUpdate, Resolution}; +use oneclient_java::JavaRuntime; + +use crate::components::{ + Button, Dropdown, Icon, IconType, ScrollArea, TextInput, memory_field, toggle, + toggle_controlled, validate_number, +}; +use crate::hooks::{ + ClusterAction, java_runtimes, loader_versions, mutation_is_running, query_error, + try_game_profile, use_cluster_mutation, use_dispatch, use_game_profile, use_java_runtimes, + use_loader_versions, use_settings_snapshot, +}; +use crate::layout::cluster_content; +use crate::theme::colors; +use crate::ui::centered_note; +use crate::view::app::settings::{section_header, settings_row}; + +use super::cluster_not_found; +use crate::hooks::use_cluster; + +#[derive(PartialEq)] +pub struct ClusterSettings { + pub cluster_id: i64, +} + +impl Component for ClusterSettings { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let global = use_settings_snapshot().settings.global_game_settings; + + let cluster = use_cluster(cluster_id); + let profile_name = cluster + .as_ref() + .and_then(|c| c.setting_profile_name.clone()); + let profile_query = use_game_profile(profile_name); + let mc_version = cluster + .as_ref() + .map(|c| c.mc_version.clone()) + .unwrap_or_default(); + let loader = cluster + .as_ref() + .map(|c| c.mc_loader) + .unwrap_or(GameLoader::Fabric); + let versions_query = use_loader_versions(mc_version, loader); + let runtimes_query = use_java_runtimes(); + + let Some(cluster) = cluster else { + return cluster_not_found(); + }; + + let Some(profile) = try_game_profile(&profile_query) else { + let note = match query_error(&profile_query) { + Some(err) => format!("Could not load these settings: {err}"), + None => "Loading settings...".to_string(), + }; + return cluster_content().child(centered_note(¬e)).into_element(); + }; + + let versions = loader_versions(&versions_query); + let runtimes = java_runtimes(&runtimes_query); + + cluster_content() + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .spacing(4.) + .child(section_header("LOADER")) + .child( + LoaderRow { + cluster_id, + loader, + selected: cluster.mc_loader_version.clone(), + versions, + } + .into_element(), + ) + .child(section_header("JAVA")) + .child( + JavaRow { + cluster_id, + value: profile.java_path.clone(), + global: global.java_path.clone(), + runtimes, + } + .into_element(), + ) + .child(text_row(cluster_id, TextField::JvmArgs, &profile, &global)) + .child(section_header("GAME")) + .child( + ToggleRow { + cluster_id, + value: profile.force_fullscreen, + global: global.force_fullscreen.unwrap_or(false), + } + .into_element(), + ) + .child( + ResolutionRow { + cluster_id, + value: profile.resolution, + global: global.resolution.unwrap_or_default(), + } + .into_element(), + ) + .child( + MemoryRow { + cluster_id, + value: profile.mem_max, + global: global.mem_max.unwrap_or(4096), + } + .into_element(), + ) + .child(section_header("CONTENT")) + .child( + BrowserUpdateModeRow { + cluster_id, + value: profile.browser_update_mode, + global: global.browser_update_mode.unwrap_or_default(), + } + .into_element(), + ) + .child(section_header("DIRECTORY")) + .child( + DedicatedDirRow { + cluster_id, + dedicated: cluster.uses_dedicated_dir(), + } + .into_element(), + ) + .child(section_header("PROCESS")) + .child(text_row(cluster_id, TextField::Pre, &profile, &global)) + .child(text_row(cluster_id, TextField::Wrapper, &profile, &global)) + .child(text_row(cluster_id, TextField::Post, &profile, &global)) + .child(section_header("REPAIR")) + .child(VerifyFilesRow { cluster_id }.into_element()), + ) + .into_element() + } +} + +#[derive(Clone, Copy, PartialEq)] +enum Field { + ForceFullscreen, + Resolution, + MemMax, + JavaPath, + BrowserUpdateMode, +} + +fn clear_update(field: Field) -> ProfileUpdate { + let mut u = ProfileUpdate::default(); + match field { + Field::ForceFullscreen => u.force_fullscreen = Patch::Clear, + Field::Resolution => u.resolution = Patch::Clear, + Field::MemMax => u.mem_max = Patch::Clear, + Field::JavaPath => u.java_path = Patch::Clear, + Field::BrowserUpdateMode => u.browser_update_mode = Patch::Clear, + } + u +} + +fn runtime_label(runtime: &JavaRuntime) -> String { + format!("{} {} ({})", runtime.vendor, runtime.major, runtime.version) +} + +fn reset_button(overridden: bool, on_reset: EventHandler<()>) -> impl IntoElement { + let color = if overridden { + colors::fg_secondary() + } else { + colors::fg_secondary().with_a(60) + }; + + Button::new() + .small() + .ghost() + .icon() + .corner_radius(CornerRadius::new_all(7.)) + .maybe(overridden, |el| el.on_press(move |_| on_reset.call(()))) + .enabled(overridden) + .child(Icon::new(IconType::RefreshCcw02).size(15.).color(color)) + .into_element() +} + +fn override_cell( + control: impl IntoElement, + overridden: bool, + on_reset: EventHandler<()>, +) -> impl IntoElement { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child(control) + .child(reset_button(overridden, on_reset)) + .into_element() +} + +#[derive(PartialEq)] +struct ToggleRow { + cluster_id: i64, + value: Option, + global: bool, +} + +impl Component for ToggleRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let overridden = self.value.is_some(); + let global = self.global; + let dispatch = use_dispatch(); + + let initial = self.value.unwrap_or(self.global); + let mut state = use_state(move || initial); + let mut last = use_state(move || initial); + { + let dispatch = dispatch.clone(); + use_side_effect(move || { + let v = *state.read(); + if v == *last.peek() { + return; + } + last.set(v); + dispatch.update_cluster_profile( + cluster_id, + ProfileUpdate { + force_fullscreen: Patch::Set(v), + ..Default::default() + }, + ); + }); + } + + let on_reset: EventHandler<()> = (move |()| { + last.set(global); + state.set(global); + dispatch.update_cluster_profile(cluster_id, clear_update(Field::ForceFullscreen)); + }) + .into(); + + settings_row( + IconType::Maximize01, + "Force Fullscreen", + "Force Minecraft to start in fullscreen mode.", + override_cell(toggle(state), overridden, on_reset), + ) + } +} + +#[derive(PartialEq)] +struct VerifyFilesRow { + cluster_id: i64, +} + +impl Component for VerifyFilesRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let mutation = use_cluster_mutation(); + let running = mutation_is_running(&mutation); + + let on_press = move |_| { + mutation.mutate(ClusterAction::VerifyFiles { cluster_id }); + }; + + // The pass reads every installed file so say it takes a while otherwise the button reads as broken + let button = Button::new() + .small() + .secondary() + .enabled(!running) + .maybe(!running, |el| el.on_press(on_press)) + .text(if running { + "Verifying..." + } else { + "Verify Files" + }); + + settings_row( + IconType::ClipboardCheck, + "Verify Files", + "Check every installed file against its official checksum and \ + re-download anything corrupt or missing. Takes a few minutes.", + button, + ) + } +} + +#[derive(PartialEq)] +struct DedicatedDirRow { + cluster_id: i64, + dedicated: bool, +} + +impl Component for DedicatedDirRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let dedicated = self.dedicated; + let mutation = use_cluster_mutation(); + + let on_toggle: EventHandler<()> = (move |()| { + mutation.mutate(ClusterAction::SetDedicatedDir { + cluster_id, + dedicated: !dedicated, + }); + }) + .into(); + + settings_row( + IconType::Folder, + "Dedicated Directory", + "Run this cluster in its own .minecraft instead of the shared one.", + toggle_controlled(dedicated, on_toggle), + ) + } +} + +#[derive(PartialEq)] +struct MemoryRow { + cluster_id: i64, + value: Option, + global: u32, +} + +impl Component for MemoryRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let overridden = self.value.is_some(); + let dispatch = use_dispatch(); + + let global = self.global.to_string(); + let initial = self.value.unwrap_or(self.global).to_string(); + let mut memory = use_state({ + let v = initial.clone(); + move || v + }); + let mut last = use_state(move || initial); + { + let dispatch = dispatch.clone(); + use_side_effect(move || { + let raw = memory.read().clone(); + if raw == *last.peek() { + return; + } + last.set(raw.clone()); + let mem_max = match raw.trim() { + "" => Patch::Clear, + m => m.parse::().map(Patch::Set).unwrap_or(Patch::Unchanged), + }; + dispatch.update_cluster_profile( + cluster_id, + ProfileUpdate { + mem_max, + ..Default::default() + }, + ); + }); + } + + let on_reset: EventHandler<()> = (move |()| { + last.set(global.clone()); + memory.set(global.clone()); + dispatch.update_cluster_profile(cluster_id, clear_update(Field::MemMax)); + }) + .into(); + + let control = memory_field(memory); + + settings_row( + IconType::Database01, + "Memory", + "The amount of memory in megabytes allocated for the game. Presets leave 2 GB for the system.", + override_cell(control, overridden, on_reset), + ) + } +} + +#[derive(PartialEq)] +struct ResolutionRow { + cluster_id: i64, + value: Option, + global: Resolution, +} + +impl Component for ResolutionRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let overridden = self.value.is_some(); + let dispatch = use_dispatch(); + let global = self.global; + let resolved = self.value.unwrap_or(self.global); + + let mut width = use_state({ + let v = resolved.width.to_string(); + move || v + }); + let mut height = use_state({ + let v = resolved.height.to_string(); + move || v + }); + let mut last = use_state(move || (resolved.width.to_string(), resolved.height.to_string())); + { + let dispatch = dispatch.clone(); + use_side_effect(move || { + let w = width.read().clone(); + let h = height.read().clone(); + if (w.clone(), h.clone()) == *last.peek() { + return; + } + last.set((w.clone(), h.clone())); + let resolution = match (w.trim(), h.trim()) { + ("", "") => Patch::Clear, + (w, h) => match (w.parse::(), h.parse::()) { + (Ok(w), Ok(h)) => Patch::Set(Resolution::new(w, h)), + _ => Patch::Unchanged, + }, + }; + dispatch.update_cluster_profile( + cluster_id, + ProfileUpdate { + resolution, + ..Default::default() + }, + ); + }); + } + + let on_reset: EventHandler<()> = (move |()| { + let gw = global.width.to_string(); + let gh = global.height.to_string(); + last.set((gw.clone(), gh.clone())); + width.set(gw); + height.set(gh); + dispatch.update_cluster_profile(cluster_id, clear_update(Field::Resolution)); + }) + .into(); + + let control = rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + TextInput::new(width) + .placeholder("854") + .on_validate(validate_number) + .text_align(TextAlign::Center) + .width(Size::px(70.)), + ) + .child( + Icon::new(IconType::X) + .size(14.) + .color(colors::fg_secondary()), + ) + .child( + TextInput::new(height) + .placeholder("480") + .on_validate(validate_number) + .text_align(TextAlign::Center) + .width(Size::px(70.)), + ); + + settings_row( + IconType::LayoutTop, + "Resolution", + "The game window resolution in pixels.", + override_cell(control, overridden, on_reset), + ) + } +} + +#[derive(Clone, Copy, PartialEq)] +enum TextField { + JvmArgs, + Pre, + Wrapper, + Post, +} + +impl TextField { + fn meta(self) -> (IconType, &'static str, &'static str, &'static str) { + match self { + Self::JvmArgs => ( + IconType::Terminal, + "JVM Arguments", + "Extra arguments passed to Java. Separate them with spaces; quote values containing spaces.", + "-XX:+UseG1GC", + ), + Self::Pre => ( + IconType::FilePlus02, + "Pre-Launch Command", + "Command to run before launching the game.", + "echo 'Game started'", + ), + Self::Wrapper => ( + IconType::ParagraphWrap, + "Wrapper Command", + "Command to run when launching the game.", + "gamescope", + ), + Self::Post => ( + IconType::FileX02, + "Post-Exit Command", + "Command to run after exiting the game.", + "echo 'Game exited'", + ), + } + } + + fn value(self, profile: &GameSettingsProfile) -> Option { + match self { + Self::JvmArgs => profile.launch_args.clone(), + Self::Pre => profile.hook_pre.clone(), + Self::Wrapper => profile.hook_wrapper.clone(), + Self::Post => profile.hook_post.clone(), + } + } + + fn patch(self, value: Patch) -> ProfileUpdate { + let mut u = ProfileUpdate::default(); + match self { + Self::JvmArgs => u.launch_args = value, + Self::Pre => u.hook_pre = value, + Self::Wrapper => u.hook_wrapper = value, + Self::Post => u.hook_post = value, + } + u + } +} + +fn text_row( + cluster_id: i64, + field: TextField, + profile: &GameSettingsProfile, + global: &GameSettingsProfile, +) -> impl IntoElement { + TextRow { + cluster_id, + field, + value: field.value(profile), + global: field.value(global).unwrap_or_default(), + } + .into_element() +} + +#[derive(PartialEq)] +struct TextRow { + cluster_id: i64, + field: TextField, + value: Option, + global: String, +} + +impl Component for TextRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let field = self.field; + let overridden = self.value.is_some(); + let (icon, title, description, placeholder) = field.meta(); + let dispatch = use_dispatch(); + + let global = self.global.clone(); + let initial = self.value.clone().unwrap_or_else(|| self.global.clone()); + let mut text = use_state({ + let v = initial.clone(); + move || v + }); + let mut last = use_state(move || initial); + { + let dispatch = dispatch.clone(); + use_side_effect(move || { + let raw = text.read().clone(); + if raw == *last.peek() { + return; + } + last.set(raw.clone()); + let patch = match raw.trim() { + "" => Patch::Clear, + v => Patch::Set(v.to_string()), + }; + dispatch.update_cluster_profile(cluster_id, field.patch(patch)); + }); + } + + let on_reset: EventHandler<()> = (move |()| { + last.set(global.clone()); + text.set(global.clone()); + dispatch.update_cluster_profile(cluster_id, field.patch(Patch::Clear)); + }) + .into(); + + let control = TextInput::new(text) + .placeholder(placeholder) + .width(Size::px(220.)); + + settings_row( + icon, + title, + description, + override_cell(control, overridden, on_reset), + ) + } +} + +#[derive(PartialEq)] +struct JavaRow { + cluster_id: i64, + value: Option, + global: Option, + runtimes: Vec, +} + +impl Component for JavaRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let overridden = self.value.is_some(); + let dispatch = use_dispatch(); + let runtimes = self.runtimes.clone(); + + let resolved = self.value.clone().or_else(|| self.global.clone()); + + let control: Element = if runtimes.is_empty() { + label() + .text("No runtimes installed") + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + } else { + let mut options: Vec = vec!["Automatic".into()]; + options.extend(runtimes.iter().map(runtime_label)); + + let selected = resolved + .as_ref() + .and_then(|path| runtimes.iter().find(|r| &r.absolute_path == path)) + .map(runtime_label) + .unwrap_or_else(|| "Automatic".into()); + + let paths: Vec = runtimes.iter().map(|r| r.absolute_path.clone()).collect(); + + let dispatch = dispatch.clone(); + Dropdown::new(selected, options) + .width(Size::px(260.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| { + if idx == 0 { + dispatch.update_cluster_profile(cluster_id, clear_update(Field::JavaPath)); + } else if let Some(path) = paths.get(idx - 1) { + dispatch.update_cluster_profile( + cluster_id, + ProfileUpdate { + java_path: Patch::Set(path.clone()), + ..Default::default() + }, + ); + } + }) + .into_element() + }; + + let on_reset: EventHandler<()> = (move |()| { + dispatch.update_cluster_profile(cluster_id, clear_update(Field::JavaPath)); + }) + .into(); + + settings_row( + IconType::CodeSnippet02, + "Java Runtime", + "The Java runtime used to launch this cluster.", + override_cell(control, overridden, on_reset), + ) + } +} + +#[derive(PartialEq)] +struct BrowserUpdateModeRow { + cluster_id: i64, + value: Option, + global: PackageUpdateMode, +} + +impl Component for BrowserUpdateModeRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let overridden = self.value.is_some(); + let resolved = self.value.unwrap_or(self.global); + let dispatch = use_dispatch(); + + let options: Vec = PackageUpdateMode::ALL + .iter() + .map(|mode| mode.label().to_string()) + .collect(); + + let control = { + let dispatch = dispatch.clone(); + Dropdown::new(resolved.label(), options) + .width(Size::px(220.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| { + if let Some(mode) = PackageUpdateMode::ALL.get(idx).copied() { + dispatch.update_cluster_profile( + cluster_id, + ProfileUpdate { + browser_update_mode: Patch::Set(mode), + ..Default::default() + }, + ); + } + }) + }; + + let on_reset: EventHandler<()> = (move |()| { + dispatch.update_cluster_profile(cluster_id, clear_update(Field::BrowserUpdateMode)); + }) + .into(); + + settings_row( + IconType::RefreshCw01, + "Browser Package Updates", + "What to do when content installed from the browser has a newer version. Bundle content is not affected.", + override_cell(control, overridden, on_reset), + ) + } +} + +#[derive(PartialEq)] +struct LoaderRow { + cluster_id: i64, + loader: GameLoader, + selected: Option, + versions: Vec, +} + +impl Component for LoaderRow { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let dispatch = use_dispatch(); + let versions = self.versions.clone(); + let selected = self + .selected + .clone() + .unwrap_or_else(|| versions.first().cloned().unwrap_or_else(|| "Latest".into())); + + let control: Element = if versions.is_empty() { + label() + .text("No versions available") + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + } else { + let options = versions.clone(); + Dropdown::new(selected, options.clone()) + .width(Size::px(220.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| { + if let Some(version) = options.get(idx) { + dispatch.set_cluster_loader_version(cluster_id, version.clone()); + } + }) + .into_element() + }; + + settings_row( + IconType::Rocket02, + "Loader Version", + format!( + "The {} loader version used to launch this cluster.", + self.loader + ), + control, + ) + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/logs.rs b/packages/oneclient_app/src/view/app/cluster/logs.rs new file mode 100644 index 00000000..0ac84ca0 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/logs.rs @@ -0,0 +1,684 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use freya::prelude::*; +use freya::query::UseQuery; +use oneclient_common::search::SearchQuery; +use oneclient_core::{LogFileInfo, LogKind, LogLevel}; + +use crate::components::upload_mclogs::{UploadToMclogs, use_mclogs_feedback}; +use crate::components::{ + Button, Icon, IconType, LogViewer, OverlayPopup, ScrollArea, Segment, SegmentedControl, + TextInput, open_folder_button, +}; +use crate::hooks::{ + ClusterLogsQuery, LogAction, UploadLogKeys, UseLogAction, UseUploadLog, + invalidate_logs_queries, try_cluster_logs, try_log_content, use_cluster_logs, use_log_action, + use_log_content, use_upload_log, +}; +use crate::layout::cluster_content; +use crate::theme::colors; +use crate::ui; +use crate::utils::format_size; + +use super::cluster_not_found; +use crate::hooks::use_cluster; +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const PICKER_ROW_H: f32 = 44.; +const PICKER_ROW_SPACING: f32 = 2.; +const PICKER_LIST_MAX_H: f32 = 300.; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LevelFilter { + All, + Error, + Warn, + Info, + Debug, +} + +impl LevelFilter { + pub fn to_level(self) -> Option { + match self { + Self::All => None, + Self::Error => Some(LogLevel::Error), + Self::Warn => Some(LogLevel::Warn), + Self::Info => Some(LogLevel::Info), + Self::Debug => Some(LogLevel::Debug), + } + } + + pub fn label(self) -> &'static str { + match self { + Self::All => "All", + Self::Error => "Errors", + Self::Warn => "Warnings", + Self::Info => "Info", + Self::Debug => "Debug", + } + } +} + +#[derive(Clone, Copy, PartialEq)] +pub enum Confirm { + Clear, + Upload, +} + +/// A crash report buried among thirty rotated `.log.gz` files is one nobody finds +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Category { + Logs, + CrashReports, +} + +impl Category { + fn accepts(self, kind: LogKind) -> bool { + kind.is_crash_report() == matches!(self, Self::CrashReports) + } + + fn label(self) -> &'static str { + match self { + Self::Logs => "Logs", + Self::CrashReports => "Crash reports", + } + } + + fn empty_label(self) -> &'static str { + match self { + Self::Logs => "No logs", + Self::CrashReports => "No crash reports", + } + } + + fn empty_note(self) -> &'static str { + match self { + Self::Logs => "No logs recorded yet", + Self::CrashReports => "No crash reports, which is the good outcome", + } + } +} + +fn kind_badge(kind: LogKind) -> &'static str { + match kind { + LogKind::Game { .. } => "Game output", + LogKind::Minecraft => "Minecraft", + LogKind::CrashReport => "Crash report", + LogKind::Other => "Other", + } +} + +#[derive(PartialEq)] +pub struct ClusterLogs { + pub cluster_id: i64, +} + +impl Component for ClusterLogs { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + + let logs_query = use_cluster_logs(cluster_id); + let upload = use_upload_log(); + let action = use_log_action(); + let search = use_state(String::new); + let level = use_state(|| LevelFilter::All); + let confirm = use_state(|| None::); + let category = use_state(|| Category::Logs); + let mut selected = use_state(PathBuf::new); + + use_mclogs_feedback(upload); + + // Reads `category` too so switching the picker re-picks a file instead of keeping a stale selection + use_side_effect(move || { + let files = visible_files(&logs_query, *category.read()); + + if files.is_empty() { + if !selected.peek().as_os_str().is_empty() { + selected.set(PathBuf::new()); + } + return; + } + + let current = selected.peek().clone(); + let valid = !current.as_os_str().is_empty() && files.iter().any(|f| f.path == current); + + if !valid { + let pick = files + .iter() + .find(|f| f.name == "latest.log") + .or_else(|| files.first()); + + if let Some(file) = pick { + selected.set(file.path.clone()); + } + } + }); + + let selected_path = selected.read().clone(); + let search_query = { + let value = search.read().clone(); + (!value.trim().is_empty()).then_some(value) + }; + + let content_query = use_log_content( + selected_path.clone(), + level.read().to_level(), + search_query, + None, + ); + + let Some(cluster) = use_cluster(cluster_id) else { + return cluster_not_found(); + }; + let folder = cluster.dir().ok().map(|d| d.join("logs")); + + let files = visible_files(&logs_query, *category.read()); + let has_log = !files.is_empty(); + let selected_info = files.iter().find(|f| f.path == selected_path).cloned(); + + let lines = try_log_content(&content_query).unwrap_or_default(); + let viewer_lines: Arc>> = Arc::new( + lines + .iter() + .map(|line| Arc::::from(line.text.as_str())) + .collect(), + ); + + let body = rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .spacing(12.) + .child(top_bar( + files.clone(), + selected_info.clone(), + folder, + selected, + confirm, + category, + )) + .child( + rect().width(Size::fill()).height(Size::flex(1.0)).child( + LogViewer::new("", viewer_lines) + .header(viewer_header(search, level, confirm, has_log)), + ), + ); + + cluster_content() + .child(body.into_element()) + .maybe_child((*confirm.read()).map(move |kind| { + confirm_overlay(kind, confirm, action, upload, selected_path.clone()) + })) + .into_element() + } +} + +pub fn visible_files(query: &UseQuery, category: Category) -> Vec { + try_cluster_logs(query) + .unwrap_or_default() + .into_iter() + .filter(|file| category.accepts(file.kind)) + .collect() +} + +fn top_bar( + files: Vec, + selected_info: Option, + folder: Option, + selected: State, + mut confirm: State>, + category: State, +) -> impl IntoElement { + let has_log = !files.is_empty(); + + let picker_label = selected_info + .as_ref() + .map(|i| i.name.clone()) + .unwrap_or_else(|| category.read().empty_label().to_string()); + + let size_text = selected_info + .as_ref() + .map(|i| format!("{} on disk", format_size(i.size_bytes))) + .unwrap_or_else(|| category.read().empty_note().to_string()); + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .child( + LogPicker { + label: picker_label, + files, + category, + on_select: (move |path: PathBuf| { + let mut selected = selected; + selected.set(path); + }) + .into(), + } + .into_element(), + ) + .child( + label() + .text(size_text) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .child(rect().width(Size::flex(1.0))) + .maybe_child(folder.map(open_folder_button)) + .child( + Button::new() + .secondary() + .on_press(move |_| { + spawn(async move { + invalidate_logs_queries().await; + }); + }) + .child(Icon::new(IconType::RefreshCw01).size(15.)) + .text("Refresh"), + ) + .child( + Button::new() + .danger() + .enabled(has_log) + .on_press(move |_| confirm.set(Some(Confirm::Clear))) + .child(Icon::new(IconType::Trash01).size(15.)) + .text("Clear log"), + ) + .into_element() +} + +pub fn viewer_header( + search: State, + level: State, + confirm: State>, + has_log: bool, +) -> impl IntoElement { + let search_input = { + let base = TextInput::new(search).enabled(has_log).leading( + Icon::new(IconType::SearchMd) + .size(15.) + .color(colors::fg_secondary()) + .into_element(), + ); + if has_log { + base.placeholder("Search this log...") + } else { + base + } + }; + + let row = rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .padding(Gaps::new_symmetric(10., 14.)) + .child(rect().width(Size::flex(1.0)).child(search_input)) + .child( + SegmentedControl::new(level) + .no_tint() + .disabled(!has_log) + .segments( + [ + LevelFilter::All, + LevelFilter::Error, + LevelFilter::Warn, + LevelFilter::Info, + LevelFilter::Debug, + ] + .into_iter() + .map(|filter| Segment::new(filter).label(filter.label())), + ) + .into_element(), + ) + .child(UploadToMclogs::new(has_log, confirm)); + + rect() + .vertical() + .width(Size::fill()) + .child(row) + .child( + rect() + .width(Size::fill()) + .height(Size::px(1.)) + .background(colors::component_border()), + ) + .into_element() +} + +#[derive(PartialEq)] +struct LogPicker { + label: String, + files: Vec, + /// Owned by the page it filters `files` by this so both must see the same value + category: State, + on_select: EventHandler, +} + +impl Component for LogPicker { + fn render(&self) -> impl IntoElement { + let mut open = use_state(|| false); + let filter = use_state(String::new); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + let category = self.category; + let label_text = self.label.clone(); + let files = self.files.clone(); + let on_select = self.on_select.clone(); + // Stays usable with an empty list the category selector lives inside the panel + let is_open = open(); + + let query = SearchQuery::new(&filter.read()); + let no_files = files.is_empty(); + let rows: Vec = files + .into_iter() + .filter(|f| query.matches(&f.name) || query.matches(kind_badge(f.kind))) + .map(|file| { + let on_select = on_select.clone(); + let path = file.path.clone(); + PickerRow { + file, + on_press: (move |_: Event| { + on_select.call(path.clone()); + open.set(false); + }) + .into(), + } + .into_element() + }) + .collect(); + let list_h = ((rows.len() as f32 * (PICKER_ROW_H + PICKER_ROW_SPACING)) + - PICKER_ROW_SPACING) + .clamp(0., PICKER_LIST_MAX_H); + + rect() + .width(Size::px(260.)) + .child( + rect() + .width(Size::fill()) + .height(Size::px(36.)) + .horizontal() + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(8.) + .padding(Gaps::new_symmetric(0., 12.)) + .corner_radius(CornerRadius::new_all(9.)) + .background(colors::component_bg()) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .border(ui::border_all_color( + 1., + if focus().is_focused() { + colors::brand() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .on_all_press(move |e: Event| { + e.stop_propagation(); + open.toggle(); + }) + .child( + rect().width(Size::flex(1.0)).child( + label() + .text(label_text) + .font_size(13.) + .max_lines(1) + .color(colors::fg_primary()), + ), + ) + .child( + Icon::new(IconType::ChevronDown) + .size(15.) + .color(colors::fg_secondary()), + ), + ) + .maybe_child(is_open.then(|| { + rect() + .height(Size::px(0.)) + .width(Size::px(0.)) + .layer(Layer::Overlay) + .child( + rect() + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .layer(Layer::OverlayLevel(10)) + .on_press(move |_| open.set(false)), + ) + .child( + rect() + .margin(Gaps::new(4., 0., 0., 0.)) + .width(Size::px(320.)) + // Room for the category selector above the search box + .max_height(Size::px(400.)) + .layer(Layer::OverlayLevel(12)) + .vertical() + .spacing(4.) + .padding(6.) + .corner_radius(CornerRadius::new_all(10.)) + .border(ui::border_all_color(1., colors::component_border())) + .background(colors::page_elevated()) + .overflow(Overflow::Clip) + .child( + SegmentedControl::new(category) + .no_tint() + .equal_width(147.) + .segments( + [Category::Logs, Category::CrashReports] + .into_iter() + .map(|value| Segment::new(value).label(value.label())), + ), + ) + .child( + TextInput::new(filter) + .auto_focus(true) + .placeholder("Search logs...") + .leading( + Icon::new(IconType::SearchMd) + .size(14.) + .color(colors::fg_secondary()) + .into_element(), + ), + ) + .maybe_child(rows.is_empty().then(|| { + label() + .text(if no_files { + category.read().empty_note() + } else { + "No matches" + }) + .font_size(12.) + .width(Size::fill()) + .text_align(TextAlign::Center) + .padding(Gaps::new_symmetric(12., 0.)) + .color(colors::fg_secondary()) + .into_element() + })) + .child( + ScrollArea::new() + .height(Size::px(list_h)) + .spacing(PICKER_ROW_SPACING) + .children(rows), + ), + ) + })) + } +} + +struct PickerRow { + file: LogFileInfo, + on_press: EventHandler>, +} + +impl PartialEq for PickerRow { + fn eq(&self, other: &Self) -> bool { + self.file.path == other.file.path + } +} + +impl Component for PickerRow { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + let file = self.file.clone(); + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + + rect() + .width(Size::fill()) + .horizontal() + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(8.) + .padding(Gaps::new_symmetric(6., 8.)) + .corner_radius(CornerRadius::new_all(7.)) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::Button) + .background(if hovering() || focus().is_focused() { + colors::ghost_overlay_hover() + } else { + Color::TRANSPARENT + }) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_all_press(self.on_press.clone()) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(1.) + .child( + label() + .text(file.name.clone()) + .font_size(13.) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "{} • {}", + kind_badge(file.kind), + format_size(file.size_bytes) + )) + .font_size(10.) + .color(colors::fg_secondary()), + ), + ) + .into_element() + } +} + +pub fn confirm_overlay( + kind: Confirm, + mut confirm: State>, + action: UseLogAction, + upload: UseUploadLog, + path: PathBuf, +) -> impl IntoElement { + let (title, body, confirm_label, danger) = match kind { + Confirm::Clear => ( + "Delete this log?", + "This log file will be permanently removed from disk.".to_string(), + "Delete", + true, + ), + Confirm::Upload => ( + "Upload to mclo.gs?", + "This log will be uploaded to mclo.gs and hosted publicly. Anyone with the link can read it. Don't upload logs containing sensitive information.".to_string(), + "Upload", + false, + ), + }; + + let run = move |_| { + match kind { + Confirm::Clear => action.mutate(LogAction::Delete { path: path.clone() }), + Confirm::Upload => upload.mutate(UploadLogKeys { path: path.clone() }), + } + confirm.set(None); + }; + + OverlayPopup::new() + .on_close(move |_| confirm.set(None)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(440.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(CARD_BG) + .border(ui::border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .child(Icon::new(IconType::AlertTriangle).size(20.).color( + if danger { + colors::code_error() + } else { + colors::code_warn() + }, + )) + .child( + label() + .text(title) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(body) + .font_size(12.) + .max_lines(6) + .width(Size::fill()) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| confirm.set(None)) + .text("Cancel"), + ) + .child( + if danger { + Button::new().danger() + } else { + Button::new().primary() + } + .on_press(run) + .text(confirm_label), + ), + ), + ), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/cluster/mod.rs b/packages/oneclient_app/src/view/app/cluster/mod.rs new file mode 100644 index 00000000..94ae1ec8 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/mod.rs @@ -0,0 +1,38 @@ +pub mod logs; +mod overview; +mod package_manager; + +mod cluster_settings; +mod mods; +mod process_logs; +mod screenshots; +mod shaders; +mod textures; + +pub use logs::ClusterLogs; +pub use overview::ClusterOverview; + +pub use cluster_settings::ClusterSettings; +pub use mods::ClusterMods; +pub use process_logs::ProcessLogs; +pub use screenshots::ClusterScreenshots; +pub use shaders::ClusterShaders; +pub use textures::ClusterTextures; + +use freya::prelude::*; + +use crate::theme::colors; + +pub(crate) fn cluster_not_found() -> Element { + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .child( + label() + .text("Cluster not found.") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/cluster/mods.rs b/packages/oneclient_app/src/view/app/cluster/mods.rs new file mode 100644 index 00000000..e5afc692 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/mods.rs @@ -0,0 +1,60 @@ +use freya::prelude::*; +use oneclient_content::packages::ContentType; + +use crate::hooks::{ + bundle_overrides_map, bundles_with_status_items, cluster_content_items, stale_hashes, + use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_updates, +}; +use crate::layout::cluster_content; + +use super::cluster_not_found; +use super::package_manager::{ + PackageManager, bundle_categories, bundle_packages, use_content_meta, +}; +use crate::hooks::use_cluster; + +#[derive(PartialEq)] +pub struct ClusterMods { + pub cluster_id: i64, +} + +impl Component for ClusterMods { + fn render(&self) -> impl IntoElement { + let content = use_cluster_content(self.cluster_id, ContentType::Mod); + let bundles = use_bundles_with_status(self.cluster_id); + let overrides = use_bundle_overrides(self.cluster_id); + let updates = use_package_updates(self.cluster_id); + let bundle_items = bundles_with_status_items(&bundles); + let content_items = cluster_content_items(&content); + let meta = use_content_meta(&content_items, &bundle_items, ContentType::Mod); + + let Some(_cluster) = use_cluster(self.cluster_id) else { + return cluster_not_found(); + }; + + let all_categories = bundle_categories(&bundle_items); + let items = bundle_packages( + content_items, + &bundle_items, + &bundle_overrides_map(&overrides), + &meta, + &stale_hashes(&updates), + ContentType::Mod, + ); + + cluster_content() + .child( + PackageManager::new( + "Mods", + "mods", + "mod", + ContentType::Mod, + self.cluster_id, + items, + all_categories, + ) + .into_element(), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/overview.rs b/packages/oneclient_app/src/view/app/cluster/overview.rs new file mode 100644 index 00000000..d2cb0ce3 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/overview.rs @@ -0,0 +1,73 @@ +use freya::prelude::*; + +use crate::components::ScrollArea; +use crate::hooks::{try_cluster_analytics, use_cluster_analytics}; +use crate::layout::cluster_content; +use crate::theme::colors; +use crate::ui::centered_note; +use crate::view::app::{analytics_body, analytics_placeholder}; + +use super::cluster_not_found; +use crate::hooks::use_cluster; + +#[derive(PartialEq)] +pub struct ClusterOverview { + pub cluster_id: i64, +} + +impl Component for ClusterOverview { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + + let Some(cluster) = use_cluster(cluster_id) else { + return cluster_content().child(cluster_not_found()).into_element(); + }; + + let query = use_cluster_analytics(cluster_id); + let analytics = try_cluster_analytics(&query); + + let body: Element = match &analytics { + None => centered_note("Loading play history…"), + Some(a) if a.playtime.session_count == 0 => analytics_placeholder( + "No sessions recorded for this cluster yet. Launch it and come back!", + ), + Some(a) => analytics_body(a), + }; + + cluster_content() + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(24.) + .child(overview_header(&cluster.name)) + .child(body), + ), + ) + .into_element() + } +} + +fn overview_header(name: &str) -> Element { + rect() + .vertical() + .spacing(2.) + .child( + label() + .text("Overview") + .font_size(20.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!("Play history & servers for {name}")) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs new file mode 100644 index 00000000..22587ce6 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/mod.rs @@ -0,0 +1,455 @@ +use std::collections::{HashMap, HashSet}; + +use freya::prelude::*; +use oneclient_common::search::{MatchScore, SearchQuery}; +use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId}; +use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo}; +use oneclient_db::models::OverrideType; + +use crate::components::{CardLayout, PackageEntry}; +use crate::hooks::{package_meta_batch, use_game_snapshot, use_package_meta_batch, use_view_state}; + +mod views; +use views::{ContentBox, ContentKind, EnabledFilter, HiddenFilter, SortMode, toolbar_bar}; + +const CARD_H: f32 = 84.; +const CARD_SPACING: f32 = 8.; +const CARD_GRID_H: f32 = 148.; +const GRID_GAP: f32 = 10.; +const GRID_MIN_W: f32 = 260.; +const LAZY_OVERSCAN: i64 = 2; + +pub type PackageMetaMap = HashMap<(ProviderId, String), CachedPackageMeta>; + +fn provider_project_ids( + content: &[LinkedArtifactInfo], + bundles: &[BundleWithUpdateStatus], + content_type: ContentType, + provider: ProviderId, +) -> Vec { + let mut ids = Vec::new(); + for bundle in bundles { + for (file, _status) in &bundle.files { + if file.content_type() != content_type { + continue; + } + if let BundleFileKind::Managed { + project_id, + provider: file_provider, + .. + } = &file.kind + && *file_provider == provider + { + ids.push(project_id.clone()); + } + } + } + for info in content { + if info.content_type != content_type || info.provider != Some(provider) { + continue; + } + if let Some(project_id) = &info.project_id { + ids.push(project_id.clone()); + } + } + ids +} + +pub fn use_content_meta( + content: &[LinkedArtifactInfo], + bundles: &[BundleWithUpdateStatus], + content_type: ContentType, +) -> PackageMetaMap { + let mut out = PackageMetaMap::new(); + for provider in ProviderId::REMOTE_PROVIDERS.iter().copied() { + let ids = provider_project_ids(content, bundles, content_type, provider); + let query = use_package_meta_batch(provider, ids); + for (project_id, meta) in package_meta_batch(&query) { + out.insert((provider, project_id), meta); + } + } + out +} + +/// `stale` is the artifact hashes with a newer version bundle rows never carry it so the two update flows cannot both claim a package +/// Hidden bundle files get flagged rows [`HiddenFilter`] decides whether they show +pub fn bundle_packages( + content: Vec, + bundles: &[BundleWithUpdateStatus], + overrides: &HashMap<(String, String), String>, + meta: &PackageMetaMap, + stale: &HashSet, + content_type: ContentType, +) -> Vec { + let mut by_project: HashMap<&str, &LinkedArtifactInfo> = HashMap::new(); + let mut by_hash: HashMap<&str, &LinkedArtifactInfo> = HashMap::new(); + for info in &content { + if let Some(pid) = &info.project_id { + by_project.insert(pid.as_str(), info); + } + by_hash.insert(info.hash.as_str(), info); + } + + // Hidden is per-bundle so one bundle carrying a mod as a private dependency must not suppress a bundle that offers it openly + let mut shown_elsewhere: HashSet = HashSet::new(); + for bundle in bundles { + for (file, _status) in &bundle.files { + if !file.hidden && file.content_type() == content_type { + shown_elsewhere.insert(file.kind.package_id()); + } + } + } + + let mut rows = Vec::new(); + let mut seen: HashSet = HashSet::new(); + + for bundle in bundles { + let bundle_name = &bundle.archive.manifest.name; + let category = bundle.archive.manifest.category.clone(); + for (file, _status) in &bundle.files { + if file.content_type() != content_type { + continue; + } + let pid = file.kind.package_id(); + // Let the bundle that lists it openly own the row whatever the iteration order + if file.hidden && shown_elsewhere.contains(&pid) { + continue; + } + if !seen.insert(pid.clone()) { + continue; + } + + let provider = match &file.kind { + BundleFileKind::Managed { provider, .. } => *provider, + BundleFileKind::External(_) => ProviderId::Local, + }; + let installed_info = by_project + .get(pid.as_str()) + .or_else(|| by_hash.get(pid.as_str())) + .copied(); + let ov = overrides + .get(&(bundle_name.clone(), pid.clone())) + .map(String::as_str); + let enabled = match installed_info { + Some(info) => info.enabled, + None => oneclient_core::effective_enabled(file, ov.and_then(OverrideType::parse)), + }; + + let categories = if category.is_empty() { + Vec::new() + } else { + vec![category.clone()] + }; + + rows.push(make_row( + pid, + Some(bundle_name.clone()), + provider, + file.size, + categories, + enabled, + file.enabled, + installed_info, + meta, + file.display_name(), + false, + // Flagged rather than dropped `HiddenFilter` filters on the row and the seen id stops the loose-content pass resurrecting it as a local file + file.hidden, + )); + } + } + + for info in &content { + let in_bundle = info.project_id.as_deref().is_some_and(|p| seen.contains(p)) + || seen.contains(&info.hash); + if in_bundle { + continue; + } + let provider = info.provider.unwrap_or(ProviderId::Local); + let pid = info.project_id.clone().unwrap_or_else(|| info.hash.clone()); + let outdated = stale.contains(&info.hash); + rows.push(make_row( + pid, + None, + provider, + 0, + Vec::new(), + info.enabled, + true, + Some(info), + meta, + info.display_name + .clone() + .unwrap_or_else(|| info.file_name.clone()), + outdated, + false, + )); + } + + rows +} + +#[allow(clippy::too_many_arguments)] +fn make_row( + package_id: String, + bundle_name: Option, + provider: ProviderId, + size: u64, + categories: Vec, + enabled: bool, + manifest_default: bool, + installed_info: Option<&LinkedArtifactInfo>, + meta: &PackageMetaMap, + fallback_name: String, + update_available: bool, + hidden: bool, +) -> PackageEntry { + let m = meta.get(&(provider, package_id.clone())); + let name = m + .map(|p| p.name.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| fallback_name.clone()); + let file_name = installed_info + .map(|i| i.file_name.clone()) + .unwrap_or(fallback_name); + let author = m + .map(|p| p.author.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_default(); + let description = m + .map(|p| p.summary.clone()) + .filter(|s| !s.is_empty()) + .or_else(|| { + installed_info + .and_then(|i| i.display_version.clone()) + .map(|v| format!("Version {v}")) + }) + .unwrap_or_default(); + + PackageEntry { + package_id, + bundle_name, + provider, + name, + file_name, + author, + description, + icon_url: m.and_then(|p| p.icon_url.clone()), + size, + categories, + enabled, + manifest_default, + installed: installed_info.is_some(), + hash: installed_info.map(|i| i.hash.clone()), + update_available, + hidden, + } +} + +#[derive(Clone)] +pub(super) enum Tab { + All, + Category(String), + Browser, + Local, +} + +impl Tab { + pub(super) fn label(&self) -> String { + match self { + Tab::All => "All".to_string(), + Tab::Category(c) => c.clone(), + Tab::Browser => "Browser".to_string(), + Tab::Local => "Local".to_string(), + } + } + + pub(super) fn matches(&self, p: &PackageEntry) -> bool { + match self { + Tab::All => true, + Tab::Category(c) => p.categories.iter().any(|pc| pc == c), + Tab::Browser => p.is_remote() && !p.in_bundle(), + Tab::Local => !p.is_remote(), + } + } +} + +fn query_score(p: &PackageEntry, query: &SearchQuery) -> Option { + query.best_score([p.name.as_str(), p.file_name.as_str()]) +} + +/// Search is scoped to the active tab a package must match the tab *and* the query +/// `Tab::All` searches the whole cluster +fn visible_packages( + items: &[PackageEntry], + tab: Option<&Tab>, + query: &SearchQuery, + show: EnabledFilter, + hidden: HiddenFilter, +) -> Vec { + items + .iter() + .filter(|p| tab.is_none_or(|t| t.matches(p)) && query_score(p, query).is_some()) + .filter(|p| show.keep(p) && hidden.keep(p)) + .cloned() + .collect() +} + +/// Stable so the toolbar's sort survives as the tie-break between equally good matches +fn rank_by_query(rows: &mut [PackageEntry], query: &SearchQuery) { + rows.sort_by_key(|p| std::cmp::Reverse(query_score(p, query))); +} + +pub fn bundle_categories(bundles: &[BundleWithUpdateStatus]) -> Vec { + let mut cats: Vec = Vec::new(); + for bundle in bundles { + let category = &bundle.archive.manifest.category; + if !category.is_empty() && !cats.contains(category) { + cats.push(category.clone()); + } + } + cats +} + +/// `hidden` applies here too so a category of only hidden dependencies offers no tab +fn build_tabs(categories: &[String], items: &[PackageEntry], hidden: HiddenFilter) -> Vec { + let mut cats: Vec = categories.to_vec(); + for item in items.iter().filter(|p| hidden.keep(p)) { + for c in &item.categories { + if !cats.contains(c) { + cats.push(c.clone()); + } + } + } + + // Category tabs are hidden when empty All + Browser + Local are always shown + let mut tabs: Vec = vec![Tab::All]; + tabs.extend( + cats.into_iter() + .map(Tab::Category) + .filter(|t| items.iter().any(|p| hidden.keep(p) && t.matches(p))), + ); + + tabs.push(Tab::Browser); + tabs.push(Tab::Local); + tabs +} + +#[derive(PartialEq)] +pub struct PackageManager { + title: &'static str, + noun_plural: &'static str, + package_type: &'static str, + content_type: ContentType, + cluster_id: i64, + items: Vec, + categories: Vec, +} + +impl PackageManager { + pub fn new( + title: &'static str, + noun_plural: &'static str, + package_type: &'static str, + content_type: ContentType, + cluster_id: i64, + items: Vec, + categories: Vec, + ) -> Self { + Self { + title, + noun_plural, + package_type, + content_type, + cluster_id, + items, + categories, + } + } +} + +impl Component for PackageManager { + fn render(&self) -> impl IntoElement { + let items = self.items.clone(); + let noun_plural = self.noun_plural; + let package_type = self.package_type; + let cluster_id = self.cluster_id; + let content_type = self.content_type; + + // Minecraft reads its content once at startup so a toggle now cannot reach the running session + let session_live = use_game_snapshot().is_active(cluster_id); + let active = use_state(|| 0usize); + + let search = use_state(String::new); + let enabled_filter = use_state(|| EnabledFilter::All); + let hidden_filter = use_state(|| HiddenFilter::Hide); + let view = use_view_state("cluster.packages"); + let sort = view.sort; + let layout = view.layout; + let query = SearchQuery::new(&search.read()); + let sort_mode = sort + .read() + .as_deref() + .and_then(SortMode::from_key) + .unwrap_or(SortMode::NameAsc); + + let show = *enabled_filter.read(); + let hidden = *hidden_filter.read(); + let card_layout = CardLayout::from(*layout.read()); + + let tabs = build_tabs(&self.categories, &items, hidden); + let active_idx = (*active.read()).min(tabs.len().saturating_sub(1)); + let tab_filter = tabs.get(active_idx); + + let mut filtered = visible_packages(&items, tab_filter, &query, show, hidden); + sort_mode.sort(&mut filtered); + // Relevance has to win while searching a fuzzy typo match would otherwise outrank the exact one + if !query.is_empty() { + rank_by_query(&mut filtered, &query); + } + + // Coming up empty during a search is about the query not the tab so it gets its own empty state + let content_kind = if filtered.is_empty() && !query.is_empty() { + ContentKind::NoMatches { + scope: match tab_filter { + Some(Tab::All) | None => None, + Some(tab) => Some(tab.label()), + }, + } + } else { + match tab_filter { + Some(Tab::Browser) => ContentKind::Browser, + Some(Tab::Local) => ContentKind::Local, + _ => ContentKind::Other, + } + }; + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .child(toolbar_bar( + &tabs, + active_idx, + active, + search, + sort, + sort_mode, + enabled_filter, + hidden_filter, + layout, + cluster_id, + package_type, + )) + .maybe_child(session_live.then(|| views::running_notice(noun_plural))) + .child(ContentBox::new( + filtered, + noun_plural, + package_type, + content_type, + cluster_id, + content_kind, + card_layout, + )) + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs new file mode 100644 index 00000000..c5b789e4 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -0,0 +1,814 @@ +use super::*; + +use freya::animation::{AnimNum, Ease, OnCreation, use_animation}; +use freya::router::RouterContext; +use oneclient_content::packages::ContentType; +use oneclient_core::settings::ViewLayout; + +use crate::components::{ + Button, CardLayout, Icon, IconType, PackageEntry, PackageRow, ScrollArea, ScrollAreaCtx, + Segment, SegmentedControl, TabBar, TabItem, TextInput, +}; +use crate::hooks::use_dispatch; +use crate::routes::Route; +use crate::theme::colors; +use crate::{Actions, utils}; + +#[derive(Clone, Copy, PartialEq)] +pub(super) enum SortMode { + NameAsc, + NameDesc, + SizeAsc, + SizeDesc, +} + +impl SortMode { + const ALL: [SortMode; 4] = [ + SortMode::NameAsc, + SortMode::NameDesc, + SortMode::SizeAsc, + SortMode::SizeDesc, + ]; + + fn key(self) -> &'static str { + match self { + SortMode::NameAsc => "name_asc", + SortMode::NameDesc => "name_desc", + SortMode::SizeAsc => "size_asc", + SortMode::SizeDesc => "size_desc", + } + } + + pub(super) fn from_key(key: &str) -> Option { + SortMode::ALL.into_iter().find(|s| s.key() == key) + } + + fn label(self) -> &'static str { + match self { + SortMode::NameAsc => "Name (A-Z)", + SortMode::NameDesc => "Name (Z-A)", + SortMode::SizeAsc => "Size (Smallest)", + SortMode::SizeDesc => "Size (Largest)", + } + } + + pub(super) fn sort(self, rows: &mut [PackageEntry]) { + let title = |p: &PackageEntry| { + if p.is_remote() { + p.name.to_lowercase() + } else { + p.file_name.to_lowercase() + } + }; + + match self { + SortMode::NameDesc => rows.sort_by_key(|p| std::cmp::Reverse(title(p))), + SortMode::SizeAsc => rows.sort_by_key(|p| p.size), + SortMode::SizeDesc => rows.sort_by_key(|p| std::cmp::Reverse(p.size)), + _ => rows.sort_by_key(title), + } + } +} + +#[derive(Clone, Copy, PartialEq)] +pub(super) enum EnabledFilter { + All, + Enabled, + Disabled, +} + +impl EnabledFilter { + const ALL: [EnabledFilter; 3] = [ + EnabledFilter::All, + EnabledFilter::Enabled, + EnabledFilter::Disabled, + ]; + + fn label(self) -> &'static str { + match self { + EnabledFilter::All => "All packages", + EnabledFilter::Enabled => "Enabled only", + EnabledFilter::Disabled => "Disabled only", + } + } + + pub(super) fn keep(self, p: &PackageEntry) -> bool { + match self { + EnabledFilter::All => true, + EnabledFilter::Enabled => p.enabled, + EnabledFilter::Disabled => !p.enabled, + } + } +} + +/// Hidden files are dependencies the bundle manages so the list leaves them out until asked +#[derive(Clone, Copy, PartialEq)] +pub(super) enum HiddenFilter { + Hide, + Show, +} + +impl HiddenFilter { + const ALL: [HiddenFilter; 2] = [HiddenFilter::Hide, HiddenFilter::Show]; + + fn label(self) -> &'static str { + match self { + HiddenFilter::Hide => "Hide", + HiddenFilter::Show => "Show", + } + } + + pub(super) fn keep(self, p: &PackageEntry) -> bool { + match self { + HiddenFilter::Hide => !p.hidden, + HiddenFilter::Show => true, + } + } +} + +const FILTER_PANEL_W: f32 = 172.; +const FILTER_BTN_W: f32 = 34.; + +#[allow(clippy::too_many_arguments)] +pub(super) fn toolbar_bar( + tabs: &[Tab], + active_idx: usize, + active: State, + search: State, + sort: State>, + current_sort: SortMode, + enabled_filter: State, + hidden_filter: State, + layout: State, + cluster_id: i64, + package_type: &'static str, +) -> impl IntoElement { + let tab_items = tabs.iter().enumerate().map(|(i, tab)| { + let mut active = active; + TabItem::new(tab.label(), i == active_idx).on_press(move |_| *active.write() = i) + }); + + let mut top_corners = CornerRadius::new_all(0.); + top_corners.fill_top(12.); + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .content(Content::Flex) + .overflow(Overflow::Clip) + .padding(Gaps::new_symmetric(8., 12.)) + .corner_radius(top_corners) + .background(colors::page_elevated()) + .child( + ScrollView::new() + .direction(Direction::Horizontal) + .show_scrollbar(false) + .width(Size::flex(1.0)) + .height(Size::auto()) + .child( + TabBar::new() + .width(Size::auto()) + .height(Size::auto()) + .spacing(20.) + .font_size(12.) + .tabs(tab_items), + ), + ) + .child( + TextInput::new(search) + .placeholder("Search...") + .width(Size::px(180.)) + .leading( + Icon::new(IconType::SearchMd) + .size(14.) + .color(colors::fg_secondary()) + .into_element(), + ), + ) + .child(FilterButton { + sort, + current_sort, + enabled_filter, + hidden_filter, + }) + .child( + SegmentedControl::new(layout) + .height(34.) + .icon_size(15.) + .equal_width(34.) + .segment(Segment::new(ViewLayout::List).icon(IconType::ParagraphWrap)) + .segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)), + ) + .child( + Button::new() + .primary() + .height(Size::px(34.)) + .font_size(12.) + .on_press(move |_| open_browser(cluster_id, package_type)) + .child(Icon::new(IconType::Plus).size(15.)) + .text("Add Content"), + ) + .into_element() +} + +/// Enabling still stores and applies at the next launch but the running session cannot pick it up +pub(super) fn running_notice(noun_plural: &'static str) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .margin(Gaps::new(8., 0., 0., 0.)) + .padding(Gaps::new_symmetric(9., 12.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::brand().with_a(30)) + .border(crate::ui::border_all_color(1., colors::brand().with_a(90))) + .child( + Icon::new(IconType::InfoCircle) + .size(16.) + .color(colors::brand()), + ) + .child( + label() + .text(format!( + "Minecraft is running. Changes to your {noun_plural} are saved, and take effect the next time you launch this version." + )) + .font_size(12.) + .width(Size::flex(1.0)) + .color(colors::fg_secondary()), + ) + .into_element() +} + +#[derive(PartialEq)] +struct FilterButton { + sort: State>, + current_sort: SortMode, + enabled_filter: State, + hidden_filter: State, +} + +impl Component for FilterButton { + fn render(&self) -> impl IntoElement { + let mut open = use_state(|| false); + + let sort = self.sort; + let current_sort = self.current_sort; + let enabled_filter = self.enabled_filter; + let hidden_filter = self.hidden_filter; + + // Hiding hidden packages is the default so it does not count as the filters being touched + let is_open = open(); + let active = current_sort != SortMode::NameAsc + || *enabled_filter.read() != EnabledFilter::All + || *hidden_filter.read() != HiddenFilter::Hide; + + let icon_color = if active { + colors::brand() + } else { + colors::fg_secondary() + }; + + rect() + .width(Size::px(FILTER_BTN_W)) + .child( + Button::new() + .secondary() + .icon() + .width(Size::px(FILTER_BTN_W)) + .height(Size::px(34.)) + .on_press(move |e: Event| { + e.stop_propagation(); + open.toggle(); + }) + .child(Icon::new(IconType::Sliders04).size(16.).color(icon_color)), + ) + .maybe_child(is_open.then(|| { + let on_close: EventHandler<()> = (move |()| open.set(false)).into(); + FilterPopover { + sort, + current_sort, + enabled_filter, + hidden_filter, + on_close, + } + .into_element() + })) + } +} + +#[derive(PartialEq)] +struct FilterPopover { + sort: State>, + current_sort: SortMode, + enabled_filter: State, + hidden_filter: State, + on_close: EventHandler<()>, +} + +impl Component for FilterPopover { + fn render(&self) -> impl IntoElement { + let mut sort = self.sort; + let current_sort = self.current_sort; + let mut enabled_filter = self.enabled_filter; + let mut hidden_filter = self.hidden_filter; + let backdrop_close = self.on_close.clone(); + let key_close = self.on_close.clone(); + + let a11y_id = use_a11y(); + + let fade = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(160).ease(Ease::Out) + }); + let progress = fade.read().value(); + + let show = *enabled_filter.read(); + let hidden = *hidden_filter.read(); + + let mut panel = rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .padding(Gaps::new_all(8.)) + .opacity(progress) + .offset_y((progress - 1.0) * 6.) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::page_elevated().with_a(230)) + .blur(12.) + .border(crate::ui::border_all_color(1., colors::component_border())) + .shadow(Shadow::from(( + 0., + 8., + 32., + 0., + Color::from_argb(120, 0, 0, 0), + ))) + .a11y_id(a11y_id) + .a11y_role(AccessibilityRole::Dialog) + .on_global_key_down(move |e: Event| { + if e.key == Key::Named(NamedKey::Escape) { + key_close.call(()); + } + }) + .child(section_label("Sort by")); + + for mode in SortMode::ALL { + let selected = mode == current_sort; + let on_press: EventHandler> = (move |_| { + sort.set(Some(mode.key().to_string())); + }) + .into(); + panel = panel.child(ChoiceRow { + text: mode.label(), + selected, + on_press, + }); + } + + panel = panel.child(section_label("Show")); + for filter in EnabledFilter::ALL { + let selected = filter == show; + let on_press: EventHandler> = (move |_| { + enabled_filter.set(filter); + }) + .into(); + panel = panel.child(ChoiceRow { + text: filter.label(), + selected, + on_press, + }); + } + + panel = panel.child(section_label("Hidden packages")); + for filter in HiddenFilter::ALL { + let selected = filter == hidden; + let on_press: EventHandler> = (move |_| { + hidden_filter.set(filter); + }) + .into(); + panel = panel.child(ChoiceRow { + text: filter.label(), + selected, + on_press, + }); + } + + rect() + .height(Size::px(0.)) + .width(Size::px(FILTER_PANEL_W)) + .layer(Layer::Overlay) + .child( + rect() + .layer(Layer::OverlayLevel(10)) + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .on_press(move |_| backdrop_close.call(())), + ) + .child( + rect() + .width(Size::fill()) + .layer(Layer::OverlayLevel(12)) + .margin(Gaps::new(6., 0., 0., -(FILTER_PANEL_W - FILTER_BTN_W))) + .child(panel), + ) + } +} + +fn section_label(text: &'static str) -> impl IntoElement { + label() + .text(text) + .font_size(10.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_secondary()) +} + +#[derive(PartialEq)] +struct ChoiceRow { + text: &'static str, + selected: bool, + on_press: EventHandler>, +} + +impl Component for ChoiceRow { + fn render(&self) -> impl IntoElement { + let text = self.text; + let selected = self.selected; + + let a11y_id = use_a11y(); + let focus = use_focus(a11y_id); + let focused = focus().is_focused(); + + let color = if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + }; + + let bg = if selected { + colors::component_bg() + } else if focused { + colors::ghost_overlay_hover() + } else { + Color::TRANSPARENT + }; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(5., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(bg) + .content(Content::Flex) + .a11y_id(a11y_id) + .a11y_focusable(true) + .a11y_role(AccessibilityRole::MenuItemRadio) + .maybe(focused, |el| { + el.border(crate::ui::border_all_color(1., colors::brand())) + }) + .cursor(CursorIcon::Pointer) + .on_all_press(self.on_press.clone()) + .child( + label() + .text(text) + .font_size(12.) + .width(Size::flex(1.0)) + .color(color), + ) + .maybe_child(selected.then(|| { + Icon::new(IconType::Check) + .size(14.) + .color(colors::brand()) + .into_element() + })) + } +} + +fn add_from_file_button( + cluster_id: i64, + content_type: ContentType, + dispatch: Actions, +) -> impl IntoElement { + Button::new() + .primary() + .on_press(move |_| { + let dispatch = dispatch.clone(); + spawn(async move { + if let Some(handles) = rfd::AsyncFileDialog::new() + .set_title("Select files to import") + .pick_files() + .await + { + for handle in handles { + dispatch.import_local_file( + cluster_id, + content_type, + handle.path().to_path_buf(), + ); + } + } + }); + }) + .child(Icon::new(IconType::FilePlus02).size(14.)) + .text("Add from file") +} + +fn open_browser(cluster_id: i64, package_type: &'static str) { + let _ = RouterContext::get().push(Route::Browser { + cluster_id, + package_type: package_type.to_string(), + pick_cluster: false, + }); +} + +fn browse_button(cluster_id: i64, package_type: &'static str) -> impl IntoElement { + Button::new() + .primary() + .on_press(move |_| open_browser(cluster_id, package_type)) + .child(Icon::new(IconType::SearchMd).size(14.)) + .text("Browse Content") +} + +#[derive(Clone, PartialEq)] +pub(super) enum ContentKind { + Browser, + Local, + Other, + /// `scope` names the tab that was searched when it is narrower than "All" + NoMatches { + scope: Option, + }, +} + +#[derive(PartialEq)] +pub(super) struct ContentBox { + items: Vec, + noun_plural: &'static str, + package_type: &'static str, + content_type: ContentType, + cluster_id: i64, + kind: ContentKind, + layout: CardLayout, +} + +impl ContentBox { + #[allow(clippy::too_many_arguments)] + pub(super) fn new( + items: Vec, + noun_plural: &'static str, + package_type: &'static str, + content_type: ContentType, + cluster_id: i64, + kind: ContentKind, + layout: CardLayout, + ) -> Self { + Self { + items, + noun_plural, + package_type, + content_type, + cluster_id, + kind, + layout, + } + } +} + +impl Component for ContentBox { + fn render(&self) -> impl IntoElement { + let items = self.items.clone(); + let package_type = self.package_type; + let content_type = self.content_type; + let cluster_id = self.cluster_id; + let noun_plural = self.noun_plural; + let kind = &self.kind; + let layout = self.layout; + + let dispatch = use_dispatch(); + + let count = items.len(); + let scroll = (count > 0).then(|| match layout { + CardLayout::List => { + let items = items.clone(); + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .lazy(count, CARD_H, CARD_SPACING, move |i| { + let item = items[i].clone(); + let key = item.package_id.clone(); + PackageRow::new(item, cluster_id, package_type) + .layout(CardLayout::List) + .key(key) + .into_element() + }) + .into_element() + } + CardLayout::Grid => ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .content(move |ctx: ScrollAreaCtx| { + grid_content(&items, package_type, cluster_id, ctx).into_element() + }) + .into_element(), + }); + + let empty = (count == 0).then(|| match kind { + ContentKind::Browser => browser_empty(cluster_id, package_type).into_element(), + ContentKind::Local => { + local_empty(cluster_id, content_type, dispatch.clone(), noun_plural).into_element() + } + ContentKind::Other => empty_state(noun_plural).into_element(), + ContentKind::NoMatches { scope } => { + search_empty(noun_plural, scope.as_deref()).into_element() + } + }); + + let header = (count > 0) + .then(|| match kind { + // The toolbar already carries a permanent "Add Content" button + ContentKind::Browser => None, + ContentKind::Local => Some( + action_header(add_from_file_button( + cluster_id, + content_type, + dispatch.clone(), + )) + .into_element(), + ), + ContentKind::Other | ContentKind::NoMatches { .. } => None, + }) + .flatten(); + + let mut bottom_corners = CornerRadius::new_all(0.); + bottom_corners.fill_bottom(12.); + + // File drops are handled window-wide by the app shell so this box does not claim them + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(8.) + .padding(Gaps::new_all(8.)) + .corner_radius(bottom_corners) + .background(colors::page_elevated()) + .overflow(Overflow::Clip) + .maybe_child(header) + .maybe_child(scroll) + .maybe_child(empty) + } +} + +fn action_header(button: impl IntoElement) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .child(rect().width(Size::flex(1.0))) + .child(button) +} + +fn grid_content( + items: &[PackageEntry], + package_type: &'static str, + cluster_id: i64, + ctx: ScrollAreaCtx, +) -> impl IntoElement { + let count = items.len(); + let cols = + (((ctx.viewport_w + GRID_GAP) / (GRID_MIN_W + GRID_GAP)).floor() as usize).clamp(1, 3); + let rows_total = count.div_ceil(cols); + let slot = CARD_GRID_H + GRID_GAP; + + let first_row = (((-ctx.corrected_y) / slot).floor() as i64 - LAZY_OVERSCAN).max(0) as usize; + let span = ((ctx.viewport_h / slot).ceil() as i64 + 2 * LAZY_OVERSCAN).max(0) as usize; + let last_row = (first_row + span).min(rows_total); + + let top_pad = first_row as f32 * slot; + let bottom_pad = rows_total.saturating_sub(last_row) as f32 * slot; + + let mut container = rect().vertical().width(Size::fill()); + if top_pad > 0. { + container = container.child(rect().width(Size::fill()).height(Size::px(top_pad))); + } + for r in first_row..last_row { + let mut row = rect() + .key(r) + .horizontal() + .width(Size::fill()) + .height(Size::px(slot)) + .spacing(GRID_GAP) + .content(Content::Flex); + for c in 0..cols { + let idx = r * cols + c; + let cell = rect().width(Size::flex(1.0)).height(Size::px(CARD_GRID_H)); + row = row.child(if idx < count { + let item = items[idx].clone(); + let key = item.package_id.clone(); + cell.child( + PackageRow::new(item, cluster_id, package_type) + .layout(CardLayout::Grid) + .key(key) + .into_element(), + ) + } else { + cell + }); + } + container = container.child(row); + } + if bottom_pad > 0. { + container = container.child(rect().width(Size::fill()).height(Size::px(bottom_pad))); + } + container.into_element() +} + +fn empty_shell(icon: IconType) -> Rect { + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .center() + .padding(Gaps::new_all(48.)) + .spacing(8.) + .child(Icon::new(icon).size(28.).color(colors::fg_secondary())) +} + +fn empty_title(text: impl Into) -> impl IntoElement { + label() + .text(text.into()) + .font_size(14.) + .color(colors::fg_secondary()) +} + +fn empty_hint(text: impl Into) -> impl IntoElement { + label() + .text(text.into()) + .font_size(12.) + .color(colors::fg_secondary()) +} + +fn empty_state(noun_plural: &'static str) -> impl IntoElement { + empty_shell(IconType::DotsGrid) + .child(empty_title(format!("No {noun_plural} here yet."))) + .child(empty_hint( + "Add one from a file or browse provider content.", + )) + .into_element() +} + +/// The search only looks inside the active tab so name it and point at the tab that covers everything +fn search_empty(noun_plural: &'static str, scope: Option<&str>) -> impl IntoElement { + let (title, hint) = match scope { + Some(tab) => ( + format!("No {noun_plural} in \"{tab}\" match your search."), + "Try a different term, or search from the All tab.", + ), + None => ( + format!("No {noun_plural} match your search."), + "Try a different term.", + ), + }; + + empty_shell(IconType::SearchMd) + .child(empty_title(title)) + .child(empty_hint(hint)) + .into_element() +} + +fn browser_empty(cluster_id: i64, package_type: &'static str) -> impl IntoElement { + empty_shell(IconType::SearchMd) + .child(empty_title("No content installed from the browser.")) + .child(empty_hint( + "Browse providers to add mods, resource packs and more.", + )) + .child(rect().height(Size::px(6.))) + .child(browse_button(cluster_id, package_type)) + .into_element() +} + +fn local_empty( + cluster_id: i64, + content_type: ContentType, + dispatch: Actions, + noun_plural: &'static str, +) -> impl IntoElement { + let is_wayland = utils::is_wayland(); + + empty_shell(IconType::FilePlus02) + .child(empty_title(format!("No local {noun_plural} yet."))) + .maybe(!is_wayland, |e| { + e.child(empty_hint( + "Tip: drag files onto the window to install them.", + )) + }) + .child(rect().height(Size::px(6.))) + .child(add_from_file_button(cluster_id, content_type, dispatch)) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/cluster/process_logs.rs b/packages/oneclient_app/src/view/app/cluster/process_logs.rs new file mode 100644 index 00000000..4f8ca2cf --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/process_logs.rs @@ -0,0 +1,119 @@ +use std::sync::Arc; + +use freya::prelude::*; +use oneclient_cluster::logs::parse_level; +use oneclient_core::{LogKind, LogLevel}; + +use crate::components::LogViewer; +use crate::components::upload_mclogs::use_mclogs_feedback; +use crate::hooks::{ + try_cluster_logs, use_cluster, use_cluster_logs, use_game_snapshot, use_log_action, + use_upload_log, +}; +use crate::layout::cluster_content; +use crate::theme::colors; +use crate::view::app::cluster::logs::{Confirm, LevelFilter, confirm_overlay, viewer_header}; + +use super::cluster_not_found; + +#[derive(PartialEq)] +pub struct ProcessLogs { + pub cluster_id: i64, +} + +impl Component for ProcessLogs { + fn render(&self) -> impl IntoElement { + let logs_query = use_cluster_logs(self.cluster_id); + let upload = use_upload_log(); + let action = use_log_action(); + + let search = use_state(String::new); + let level = use_state(|| LevelFilter::All); + let confirm = use_state(|| None::); + + use_mclogs_feedback(upload); + + let game = use_game_snapshot(); + let Some(_cluster) = use_cluster(self.cluster_id) else { + return cluster_not_found(); + }; + + let active = game.stage(self.cluster_id).is_some(); + + let body = if active { + let lines = game.logs_for(self.cluster_id); + let has_log = !lines.is_empty(); + let visible = filter_lines(&lines, &search.read(), *level.read()); + + LogViewer::new("Game output", visible) + .streaming(true) + .header(viewer_header(search, level, confirm, has_log)) + .into_element() + } else { + not_running() + }; + + let output_path = try_cluster_logs(&logs_query) + .unwrap_or_default() + .into_iter() + .find(|file| matches!(file.kind, LogKind::Game { .. })) + .map(|file| file.path); + + cluster_content() + .child(body) + .maybe_child( + (*confirm.read()) + .zip(output_path) + .map(|(kind, path)| confirm_overlay(kind, confirm, action, upload, path)), + ) + .into_element() + } +} + +fn filter_lines( + lines: &Arc>>, + search: &str, + level: LevelFilter, +) -> Arc>> { + let needle = (!search.trim().is_empty()).then(|| search.to_lowercase()); + let wanted = level.to_level(); + + if needle.is_none() && wanted.is_none() { + return lines.clone(); + } + + let mut carried = LogLevel::Unknown; + let mut out = Vec::new(); + + for line in lines.iter() { + carried = parse_level(line).unwrap_or(carried); + + if wanted.is_some_and(|wanted| wanted != carried) { + continue; + } + if needle + .as_ref() + .is_some_and(|needle| !line.to_lowercase().contains(needle)) + { + continue; + } + + out.push(line.clone()); + } + + Arc::new(out) +} + +fn not_running() -> Element { + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .child( + label() + .text("This cluster is not running.") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/cluster/screenshots.rs b/packages/oneclient_app/src/view/app/cluster/screenshots.rs new file mode 100644 index 00000000..f5a7e849 --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/screenshots.rs @@ -0,0 +1,580 @@ +use std::collections::HashSet; +use std::path::PathBuf; + +use freya::prelude::*; +use oneclient_core::ScreenshotInfo; +use oneclient_core::settings::ViewLayout; + +use crate::components::{ + Button, ContextMenu, Icon, IconType, LocalImage, OverlayPopup, ScreenshotViewer, ScrollArea, + Segment, SegmentedControl, open_folder_button, +}; +use crate::hooks::{ + ScreenshotAction, query_is_loading, try_cluster_screenshots, use_cluster_screenshots, + use_dispatch, use_screenshot_action, use_screenshot_folder_watch, use_view_state, +}; +use crate::layout::cluster_content; +use crate::theme::colors; +use crate::ui::{border_all_color, flow_grid, fmt_date, grid_columns_for_width}; +use crate::utils::{format_res, format_size}; + +use super::cluster_not_found; +use crate::hooks::use_cluster; + +const THUMB_EDGE: u32 = 480; +const MAX_COL_W: f32 = 400.; +const GRID_GAP: f32 = 16.; +const TILE_PREVIEW_H: f32 = 168.; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const CARD_NAME: Color = Color::from_rgb(213, 219, 255); + +#[derive(PartialEq)] +pub struct ClusterScreenshots { + pub cluster_id: i64, +} + +impl Component for ClusterScreenshots { + fn render(&self) -> impl IntoElement { + let Some(cluster) = use_cluster(self.cluster_id) else { + return cluster_not_found(); + }; + let folder = cluster.game_dir().ok().map(|d| d.join("screenshots")); + + let query = use_cluster_screenshots(self.cluster_id); + // Otherwise a screenshot taken in game only shows up on the next visit + use_screenshot_folder_watch(folder.clone(), query); + let action = use_screenshot_action(); + + let shots = try_cluster_screenshots(&query).unwrap_or_default(); + + let view_mode = use_view_state("cluster.screenshots").layout; + let menu_dispatch = use_dispatch(); + + let mut edit_mode = use_state(|| false); + let mut selected = use_state(HashSet::::new); + let mut viewing = use_state(|| None::); + let confirm_delete = use_state(|| false); + let grid_width = use_state(|| 0f32); + let mut menu = use_state(|| None::<(f32, f32, PathBuf)>); + + let toolbar = toolbar_row( + &shots, + folder, + view_mode, + edit_mode, + selected, + confirm_delete, + ); + + let content: Element = if shots.is_empty() { + empty_state(query_is_loading(&query)).into_element() + } else { + let mut items: Vec = Vec::new(); + for (idx, info) in shots.iter().enumerate() { + let info = info.clone(); + let is_selected = selected.read().contains(&info.path); + let editing = *edit_mode.read(); + + let activate_path = info.path.clone(); + let on_activate = move |_| { + if editing { + let p = activate_path.clone(); + let mut set = selected.write(); + if !set.remove(&p) { + set.insert(p); + } + } else { + viewing.set(Some(idx)); + } + }; + + let ctx_path = info.path.clone(); + let on_context: EventHandler<(f32, f32)> = (move |(x, y)| { + menu.set(Some((x, y, ctx_path.clone()))); + }) + .into(); + + items.push(match *view_mode.read() { + ViewLayout::Grid => ScreenshotTile { + info, + selected: is_selected, + edit_mode: editing, + on_activate: on_activate.into(), + on_context, + } + .into_element(), + + ViewLayout::List => ScreenshotRow { + info, + selected: is_selected, + edit_mode: editing, + on_activate: on_activate.into(), + on_context, + } + .into_element(), + }); + } + + match *view_mode.read() { + ViewLayout::Grid => { + let cols = grid_columns_for_width(*grid_width.read(), MAX_COL_W, GRID_GAP); + flow_grid(items, cols, grid_width, GRID_GAP) + } + ViewLayout::List => rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .children(items) + .into_element(), + } + }; + + let body = ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .children(vec![content]); + + let confirm_overlay = confirm_delete.read().then(|| { + let count = selected.read().len(); + confirm_panel(count, confirm_delete, move || { + let paths: Vec = selected.read().iter().cloned().collect(); + for path in paths { + action.mutate(ScreenshotAction::Delete { path }); + } + selected.write().clear(); + edit_mode.set(false); + confirm_delete.clone().set(false); + }) + }); + + let menu_overlay = menu.read().clone().map(|(x, y, path)| { + let copy_dispatch = menu_dispatch.clone(); + let open_path = path.clone(); + let copy_path = path.clone(); + let delete_path = path.clone(); + ContextMenu::new(x, y) + .on_close(move |_| menu.set(None)) + .action(IconType::Folder, "Open in folder", move |()| { + if let Some(dir) = open_path.parent() { + crate::platform::open_url(&dir.to_string_lossy()); + } + }) + .action(IconType::Copy01, "Copy", move |()| { + crate::platform::copy_image_to_clipboard(copy_path.clone()); + copy_dispatch + .notify("Copied to clipboard") + .body("Screenshot copied to your clipboard.") + .info() + .icon(IconType::ClipboardCheck) + .send(); + }) + .separator() + .danger_action(IconType::Trash01, "Delete", move |()| { + action.mutate(ScreenshotAction::Delete { + path: delete_path.clone(), + }); + }) + .into_element() + }); + + cluster_content() + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .spacing(16.) + .child(toolbar) + .child(body), + ) + .maybe_child((*viewing.read()).map(|start| { + ScreenshotViewer::new(shots.clone(), start, move |_| viewing.clone().set(None)) + .into_element() + })) + .maybe_child(confirm_overlay) + .maybe_child(menu_overlay) + .into_element() + } +} + +fn toolbar_row( + shots: &[ScreenshotInfo], + folder: Option, + view_mode: State, + mut edit_mode: State, + mut selected: State>, + mut confirm_delete: State, +) -> impl IntoElement { + let editing = *edit_mode.read(); + let count = selected.read().len(); + let all_paths: Vec = shots.iter().map(|s| s.path.clone()).collect(); + + let mut right = rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(10.) + .maybe_child( + (!editing) + .then_some(folder) + .flatten() + .map(open_folder_button), + ); + + if editing { + let select_all_paths = all_paths.clone(); + right = right + .child( + label() + .text(format!("{count} selected")) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + Button::new() + .secondary() + .small() + .on_press(move |_| { + let mut set = selected.write(); + *set = select_all_paths.iter().cloned().collect(); + }) + .text("Select all"), + ) + .child( + Button::new() + .ghost() + .small() + .enabled(count > 0) + .on_press(move |_| selected.write().clear()) + .text("Deselect all"), + ) + .child( + Button::new() + .danger() + .small() + .enabled(count > 0) + .on_press(move |_| confirm_delete.set(true)) + .child(Icon::new(IconType::Trash01).size(14.)) + .text(format!("Delete ({count})")), + ) + .child( + Button::new() + .ghost() + .small() + .on_press(move |_| { + selected.write().clear(); + edit_mode.set(false); + }) + .text("Cancel"), + ); + } else { + right = right + .child( + SegmentedControl::new(view_mode) + .equal_width(40.) + .segment(Segment::new(ViewLayout::Grid).icon(IconType::DotsGrid)) + .segment(Segment::new(ViewLayout::List).icon(IconType::ParagraphWrap)), + ) + .child( + Button::new() + .secondary() + .small() + .enabled(!shots.is_empty()) + .on_press(move |_| edit_mode.set(true)) + .child(Icon::new(IconType::Pencil01).size(14.)) + .text("Select"), + ); + } + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .child( + rect().width(Size::flex(1.0)).child( + label() + .text("Screenshots") + .font_size(20.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ) + .child(right) + .into_element() +} + +fn empty_state(loading: bool) -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .center() + .child( + label() + .text(if loading { + "Loading screenshots..." + } else { + "No screenshots yet. Press F2 in-game to capture one." + }) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn confirm_panel( + count: usize, + confirm: State, + mut on_confirm: impl FnMut() + 'static, +) -> impl IntoElement { + let mut cancel = confirm; + + OverlayPopup::new() + .on_close(move |_| confirm.clone().set(false)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(400.)) + .max_width(Size::window_percent(90.)) + .spacing(14.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(14.)) + .background(Color::from_rgb(26, 34, 41)) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text(format!( + "Move {count} screenshot{} to trash?", + if count == 1 { "" } else { "s" } + )) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("They can be restored from your system trash.") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .secondary() + .on_press(move |_| cancel.set(false)) + .text("Cancel"), + ) + .child( + Button::new() + .danger() + .on_press(move |_| on_confirm()) + .child(Icon::new(IconType::Trash01).size(14.)) + .text("Delete"), + ), + ), + ), + ) + .into_element() +} + +fn res_badge(res: Option<(u32, u32)>) -> Option { + res.map(format_res).map(|text| { + rect() + .position(Position::new_absolute().bottom(6.).right(6.)) + .padding(Gaps::new_symmetric(2., 6.)) + .corner_radius(CornerRadius::new_all(5.)) + .background(Color::from_argb(170, 0, 0, 0)) + .layer(Layer::Relative(3)) + .child(label().text(text).font_size(9.).color(Color::WHITE)) + .into_element() + }) +} + +fn preview_box(path: PathBuf, height: f32, res: Option<(u32, u32)>) -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::px(height)) + .overflow(Overflow::Clip) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::component_bg()) + .child(LocalImage::new(path, THUMB_EDGE, true).skeleton(true)) + .maybe_child(res_badge(res)) + .margin(1.) + .into_element() +} + +#[derive(PartialEq)] +struct ScreenshotTile { + info: ScreenshotInfo, + selected: bool, + edit_mode: bool, + on_activate: EventHandler<()>, + on_context: EventHandler<(f32, f32)>, +} + +impl Component for ScreenshotTile { + fn render(&self) -> impl IntoElement { + let info = self.info.clone(); + let mut hovered = use_state(|| false); + + let selected = self.selected; + let on_activate = self.on_activate.clone(); + let on_context = self.on_context.clone(); + + let border_color = if selected { + colors::brand() + } else if *hovered.read() { + colors::component_border_hover() + } else { + colors::component_border() + }; + + rect() + .vertical() + .width(Size::flex(1.0)) + .max_width(Size::px(MAX_COL_W)) + .corner_radius(CornerRadius::new_all(10.)) + .background(CARD_BG) + .overflow(Overflow::Clip) + .border(border_all_color(1., border_color).alignment(BorderAlignment::Inner)) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(move |_| on_activate.call(())) + .on_secondary_down(move |e: Event| { + if let PressEventData::Mouse(m) = e.data() { + on_context.call((m.global_location.x as f32, m.global_location.y as f32)); + } + }) + .child(preview_box( + info.path.clone(), + TILE_PREVIEW_H, + info.resolution, + )) + .child( + rect() + .vertical() + .width(Size::fill()) + .padding(Gaps::new_all(12.)) + .spacing(6.) + .child( + label() + .text(info.name.clone()) + .font_size(16.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .width(Size::fill()) + .color(CARD_NAME), + ) + .child( + label() + .text(format!( + "{} • {}", + format_size(info.size_bytes), + fmt_date(info.created) + )) + .font_size(11.) + .color(colors::fg_secondary()), + ), + ) + } +} + +#[derive(PartialEq)] +struct ScreenshotRow { + info: ScreenshotInfo, + selected: bool, + edit_mode: bool, + on_activate: EventHandler<()>, + on_context: EventHandler<(f32, f32)>, +} + +impl Component for ScreenshotRow { + fn render(&self) -> impl IntoElement { + let info = self.info.clone(); + let mut hovered = use_state(|| false); + + let selected = self.selected; + let on_activate = self.on_activate.clone(); + let on_context = self.on_context.clone(); + + let thumb = rect() + .width(Size::px(64.)) + .height(Size::px(40.)) + .corner_radius(CornerRadius::new_all(6.)) + .overflow(Overflow::Clip) + .background(colors::component_bg()) + .child(LocalImage::new(info.path.clone(), THUMB_EDGE, true).skeleton(true)); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_symmetric(8., 10.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(if *hovered.read() { + colors::component_bg_hover() + } else { + CARD_BG + }) + .border(border_all_color( + if selected { 2. } else { 1. }, + if selected { + colors::brand() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovered.set(true)) + .on_pointer_leave(move |_| hovered.set(false)) + .on_press(move |_| on_activate.call(())) + .on_secondary_down(move |e: Event| { + if let PressEventData::Mouse(m) = e.data() { + on_context.call((m.global_location.x as f32, m.global_location.y as f32)); + } + }) + .child(thumb) + .child( + rect().width(Size::flex(1.0)).child( + label() + .text(info.name.clone()) + .font_size(13.) + .max_lines(1) + .width(Size::fill()) + .color(CARD_NAME), + ), + ) + .maybe_child(info.resolution.map(|res| { + label() + .text(format_res(res)) + .font_size(11.) + .color(colors::fg_secondary()) + .into_element() + })) + .child( + label() + .text(format_size(info.size_bytes)) + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(fmt_date(info.created)) + .font_size(11.) + .color(colors::fg_secondary()), + ) + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/shaders.rs b/packages/oneclient_app/src/view/app/cluster/shaders.rs new file mode 100644 index 00000000..cea5ad8c --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/shaders.rs @@ -0,0 +1,60 @@ +use freya::prelude::*; +use oneclient_content::packages::ContentType; + +use crate::hooks::{ + bundle_overrides_map, bundles_with_status_items, cluster_content_items, stale_hashes, + use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_updates, +}; +use crate::layout::cluster_content; + +use super::cluster_not_found; +use super::package_manager::{ + PackageManager, bundle_categories, bundle_packages, use_content_meta, +}; +use crate::hooks::use_cluster; + +#[derive(PartialEq)] +pub struct ClusterShaders { + pub cluster_id: i64, +} + +impl Component for ClusterShaders { + fn render(&self) -> impl IntoElement { + let content = use_cluster_content(self.cluster_id, ContentType::Shader); + let bundles = use_bundles_with_status(self.cluster_id); + let overrides = use_bundle_overrides(self.cluster_id); + let updates = use_package_updates(self.cluster_id); + let bundle_items = bundles_with_status_items(&bundles); + let content_items = cluster_content_items(&content); + let meta = use_content_meta(&content_items, &bundle_items, ContentType::Shader); + + let Some(_cluster) = use_cluster(self.cluster_id) else { + return cluster_not_found(); + }; + + let all_categories = bundle_categories(&bundle_items); + let items = bundle_packages( + content_items, + &bundle_items, + &bundle_overrides_map(&overrides), + &meta, + &stale_hashes(&updates), + ContentType::Shader, + ); + + cluster_content() + .child( + PackageManager::new( + "Shaders", + "shaders", + "shader", + ContentType::Shader, + self.cluster_id, + items, + all_categories, + ) + .into_element(), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/cluster/textures.rs b/packages/oneclient_app/src/view/app/cluster/textures.rs new file mode 100644 index 00000000..bfdf038a --- /dev/null +++ b/packages/oneclient_app/src/view/app/cluster/textures.rs @@ -0,0 +1,60 @@ +use freya::prelude::*; +use oneclient_content::packages::ContentType; + +use crate::hooks::{ + bundle_overrides_map, bundles_with_status_items, cluster_content_items, stale_hashes, + use_bundle_overrides, use_bundles_with_status, use_cluster_content, use_package_updates, +}; +use crate::layout::cluster_content; + +use super::cluster_not_found; +use super::package_manager::{ + PackageManager, bundle_categories, bundle_packages, use_content_meta, +}; +use crate::hooks::use_cluster; + +#[derive(PartialEq)] +pub struct ClusterTextures { + pub cluster_id: i64, +} + +impl Component for ClusterTextures { + fn render(&self) -> impl IntoElement { + let content = use_cluster_content(self.cluster_id, ContentType::ResourcePack); + let bundles = use_bundles_with_status(self.cluster_id); + let overrides = use_bundle_overrides(self.cluster_id); + let updates = use_package_updates(self.cluster_id); + let bundle_items = bundles_with_status_items(&bundles); + let content_items = cluster_content_items(&content); + let meta = use_content_meta(&content_items, &bundle_items, ContentType::ResourcePack); + + let Some(_cluster) = use_cluster(self.cluster_id) else { + return cluster_not_found(); + }; + + let all_categories = bundle_categories(&bundle_items); + let items = bundle_packages( + content_items, + &bundle_items, + &bundle_overrides_map(&overrides), + &meta, + &stale_hashes(&updates), + ContentType::ResourcePack, + ); + + cluster_content() + .child( + PackageManager::new( + "Textures", + "resource packs", + "texture", + ContentType::ResourcePack, + self.cluster_id, + items, + all_categories, + ) + .into_element(), + ) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/clusters/mod.rs b/packages/oneclient_app/src/view/app/clusters/mod.rs new file mode 100644 index 00000000..64e98e08 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/mod.rs @@ -0,0 +1,3 @@ +mod page; + +pub use page::Clusters; diff --git a/packages/oneclient_app/src/view/app/clusters/page.rs b/packages/oneclient_app/src/view/app/clusters/page.rs new file mode 100644 index 00000000..5a3c5807 --- /dev/null +++ b/packages/oneclient_app/src/view/app/clusters/page.rs @@ -0,0 +1,501 @@ +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_common::VersionKey; +use oneclient_common::domain::GameLoader; +use oneclient_core::clusters::Cluster; + +use crate::components::{ + Button, ClusterLandscapeArt, Dropdown, Icon, IconType, ScrollArea, VersionCard, +}; +use crate::hooks::{ + settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch, use_game_snapshot, + use_launcher, use_version_metadata, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::{ + ReleaseLine, default_line, default_loader, default_version_key, group_clusters_by_release, + line_title, loaders_for_line, resolve_cluster, version_keys, version_label, +}; +use crate::view::app::launch_button_state; + +const GRID_GAP_PX: f32 = 12.; +const MIN_CARD_WIDTH_PX: f32 = 280.; +const SIDEBAR_WIDTH_PX: f32 = 300.; +const CARD_HEIGHT_PX: f32 = 146.; +const PLACEHOLDER_VERSION_INFO: &str = "Placeholder version info"; + +#[derive(PartialEq)] +pub struct Clusters; + +impl Component for Clusters { + fn render(&self) -> impl IntoElement { + let clusters_query = use_clusters(); + let active_id = use_active_cluster_id(); + let mut selected_line = use_state(|| None::); + let mut selected_version = use_state(|| None::); + let mut selected_loader = use_state(|| None::); + let mut grid_columns = use_state(|| 2_usize); + + let clusters = settled_or_loading(&clusters_query).unwrap_or_default(); + + let groups = group_clusters_by_release(&clusters); + // Newest version first + let lines: Vec = groups.keys().rev().copied().collect(); + + if lines.is_empty() { + return rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(40.) + .spacing(24.) + .child(page_header()) + .child( + rect() + .vertical() + .width(Size::fill()) + .main_align(Alignment::Center) + .child( + label() + .text("No versions available yet. Bundles are still syncing.") + .font_size(16.) + .color(colors::fg_secondary()), + ), + ); + } + + let active_cluster = active_id + .read() + .and_then(|id| clusters.iter().find(|c| c.id == id).cloned()); + + if selected_line.read().is_none() { + *selected_line.write() = default_line(&groups, active_cluster.clone()); + } + + let line = (*selected_line.read()) + .filter(|l| groups.contains_key(l)) + .unwrap_or(lines[0]); + + if *selected_line.read() != Some(line) { + *selected_line.write() = Some(line); + } + + let clusters_for_line = groups.get(&line).cloned().unwrap_or_default(); + + if selected_version.read().is_none() { + let preferred = active_cluster + .as_ref() + .and_then(|c| oneclient_common::parse_mc_version(&c.mc_version)) + .and_then(|p| p.key()); + *selected_version.write() = default_version_key(&clusters_for_line, preferred); + } + + if selected_loader.read().is_none() { + let preferred = active_cluster.as_ref().map(|c| c.mc_loader); + *selected_loader.write() = default_loader(&clusters_for_line, preferred); + } + + let cluster = resolve_cluster( + &clusters_for_line, + *selected_version.read(), + *selected_loader.read(), + ) + .or_else(|| clusters_for_line.first().cloned()); + + let columns = *grid_columns.read(); + let grid_rows = chunk_lines(&lines, columns); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(Gaps::new(0., 40., 40., 40.)) + .spacing(16.) + .child(page_header()) + .child( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::flex(1.0)) + .content(Content::Flex) + .spacing(GRID_GAP_PX) + .on_sized(move |event: Event| { + let width = event.data().area.width(); + let next = grid_columns_for_width(width); + if next != *grid_columns.peek() { + *grid_columns.write() = next; + } + }) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .overflow(Overflow::Clip) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .spacing(GRID_GAP_PX) + .children(grid_rows.into_iter().map(|row| { + let row_len = row.len(); + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .spacing(GRID_GAP_PX) + .children(row.into_iter().map(|line| { + let list = + groups.get(&line).cloned().unwrap_or_default(); + let selected = *selected_line.read() == Some(line); + let mut selected_line = selected_line; + let mut selected_version = selected_version; + let mut selected_loader = selected_loader; + + VersionCard::new(line, &list, selected, move |_| { + *selected_line.write() = Some(line); + *selected_version.write() = None; + *selected_loader.write() = None; + }) + .into_element() + })) + .children((row_len..columns).map(|_| { + rect() + .width(Size::flex(1.0)) + .height(Size::px(CARD_HEIGHT_PX)) + .into_element() + })) + .into_element() + })), + ), + ) + .child(match cluster { + Some(cluster) => DetailSidebar { + line, + cluster_id: cluster.id, + clusters_for_line, + selected_version, + selected_loader, + } + .into_element(), + None => sidebar_error(), + }), + ) + } +} + +#[derive(PartialEq)] +struct DetailSidebar { + line: ReleaseLine, + cluster_id: i64, + clusters_for_line: Vec, + selected_version: State>, + selected_loader: State>, +} + +impl Component for DetailSidebar { + fn render(&self) -> impl IntoElement { + let active_id = use_active_cluster_id(); + let dispatch = use_dispatch(); + let game = use_game_snapshot(); + let launcher = use_launcher(); + + let clusters_for_line = self.clusters_for_line.as_slice(); + let syncing = launcher.fetching || launcher.syncing_bundles; + let keys = version_keys(clusters_for_line); + let loaders = loaders_for_line(clusters_for_line); + let version_title = line_title(self.line, clusters_for_line); + let version_value = (self.selected_version)(); + let loader_value = (self.selected_loader)(); + + let metadata = use_version_metadata(Some(self.line.major), version_value, loader_value); + + let heading = metadata + .as_ref() + .map(|m| m.name.clone()) + .unwrap_or_else(|| format!("Version {version_title}")); + let description = metadata + .as_ref() + .and_then(|m| m.long_description.clone()) + .unwrap_or_else(|| PLACEHOLDER_VERSION_INFO.to_string()); + let tags = metadata + .as_ref() + .map(|m| m.tags.clone()) + .unwrap_or_default(); + + rect() + .width(Size::px(SIDEBAR_WIDTH_PX)) + .min_width(Size::px(SIDEBAR_WIDTH_PX)) + .height(Size::fill()) + .vertical() + .spacing(8.) + .padding(8.) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .overflow(Overflow::Clip) + .child(rect().width(Size::fill()).max_height(Size::px(140.)).child( + ClusterLandscapeArt::for_version( + self.line.major, + version_value, + loader_value, + false, + ), + )) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .content(Content::Flex) + .padding(Gaps::new_all(8.)) + .spacing(8.) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(4.) + .child( + label() + .text(heading) + .font_size(24.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .maybe_child(tags_row(&tags)) + .child( + label() + .text(description) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .children(version_rows( + self.line, + &keys, + version_value, + self.selected_version, + )) + .children(loader_rows(&loaders, loader_value, self.selected_loader)), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .main_align(Alignment::Center) + .spacing(8.) + .child(play_button( + self.cluster_id, + dispatch, + launch_button_state(&game, self.cluster_id, syncing), + )) + .child(view_button(self.cluster_id, active_id)), + ), + ) + .into_element() + } +} + +fn tags_row(tags: &[String]) -> Option { + if tags.is_empty() { + return None; + } + + Some( + rect() + .horizontal() + .width(Size::fill()) + .spacing(6.) + .children(tags.iter().map(|tag| { + rect() + .padding(Gaps::new_symmetric(3., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(colors::component_bg()) + .child( + label() + .text(tag.clone()) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .into_element() + })) + .into_element(), + ) +} + +/// A single-version line already names it on the card and in the heading +fn version_rows( + line: ReleaseLine, + keys: &[VersionKey], + selected: Option, + mut selected_version: State>, +) -> Option { + if keys.len() <= 1 { + return None; + } + + let options: Vec = keys.iter().map(|k| version_label(line.major, *k)).collect(); + let current = selected + .and_then(|s| keys.iter().position(|k| *k == s)) + .unwrap_or(0); + let keys = keys.to_vec(); + + Some(info_row( + "Version", + Dropdown::new(options[current].clone(), options) + .on_select(move |idx: usize| { + if let Some(key) = keys.get(idx).copied() { + *selected_version.write() = Some(key); + } + }) + .into_element(), + )) +} + +fn loader_rows( + loaders: &[GameLoader], + selected: Option, + mut selected_loader: State>, +) -> Option { + if loaders.len() <= 1 { + return None; + } + + let options: Vec = loaders.iter().map(|l| l.to_string()).collect(); + let current = selected + .and_then(|s| loaders.iter().position(|l| *l == s)) + .unwrap_or(0); + let loaders = loaders.to_vec(); + + Some(info_row( + "Mod Loader", + Dropdown::new(options[current].clone(), options) + .on_select(move |idx: usize| { + if let Some(loader) = loaders.get(idx).copied() { + *selected_loader.write() = Some(loader); + } + }) + .into_element(), + )) +} + +fn info_row(label_text: impl Into, control: impl IntoElement) -> Element { + let label_text = label_text.into(); + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .padding(Gaps::new_symmetric(4.0, 0.0)) + .child( + label() + .text(label_text) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child(control) + .into_element() +} + +fn play_button( + cluster_id: i64, + dispatch: crate::Actions, + state: (&'static str, bool), +) -> impl IntoElement { + let (label, enabled) = state; + Button::new() + .primary() + .width(Size::flex(1.)) + .enabled(enabled) + .on_press(move |_| { + if enabled { + dispatch.launch_cluster(cluster_id); + } + }) + .text(label) +} + +fn view_button(cluster_id: i64, mut active_id: State>) -> impl IntoElement { + Button::new() + .secondary() + .width(Size::flex(1.)) + .on_press(move |_| { + *active_id.write() = Some(cluster_id); + let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); + }) + .text("View") + .child(Icon::new(IconType::ArrowRight).size(14.)) +} + +fn page_header() -> impl IntoElement { + rect() + .vertical() + .spacing(6.) + .child( + label() + .text("Versions") + .font_size(36.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text( + "Something something in corporate style fashion about picking your \ + preferred gamemodes and versions and optionally loader so that oneclient \ + can pick something for them", + ) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) +} + +fn sidebar_error() -> Element { + rect() + .width(Size::px(SIDEBAR_WIDTH_PX)) + .height(Size::fill()) + .vertical() + .padding(16.) + .spacing(8.) + .corner_radius(CornerRadius::new_all(16.)) + .border(border_all_color(1., colors::component_border())) + .background(colors::component_bg()) + .child( + label() + .text("Could not resolve a cluster for this version.") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text("Try selecting a different version or mod loader.") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn chunk_lines(lines: &[ReleaseLine], columns: usize) -> Vec> { + let columns = columns.max(1); + lines.chunks(columns).map(|chunk| chunk.to_vec()).collect() +} + +fn grid_columns_for_width(available_width_px: f32) -> usize { + const GAP: f32 = 16.; + + let grid_width = available_width_px - SIDEBAR_WIDTH_PX - GAP; + if grid_width < MIN_CARD_WIDTH_PX { + return 1; + } + + let cols = (grid_width / (MIN_CARD_WIDTH_PX + GRID_GAP_PX)).floor() as usize; + cols.clamp(1, 3) +} diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs new file mode 100644 index 00000000..5c001b65 --- /dev/null +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -0,0 +1,1177 @@ +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_db::console::{ConsoleQueryResult, run_console_query}; + +use oneclient_auth::preview_samples; +use oneclient_core::simulate::Damage; +use oneclient_net::status::{self, ServiceStatus}; + +use crate::Actions; +use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialog, toggle}; +use crate::hooks::use_dispatch; +use crate::notifications::{ + ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; + +type SqlResult = Option>; + +#[derive(PartialEq)] +pub struct Debug; + +impl Component for Debug { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let log_debug_info = use_state(|| false); + let show_dev_stuff = use_state(|| false); + let seen_onboarding = use_state(|| true); + let use_grid_on_mods_list = use_state(|| true); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .padding(40.) + .spacing(8.) + .child( + ScrollView::new() + .child( + rect() + .vertical() + .child( + label() + .text("Debug") + .font_size(32.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("The end user won't really be looking at this page.") + .font_size(13.) + .color(colors::fg_secondary()), + ), + ) + .child(divider()) + .child(section( + "Settings", + vec![ + toggle_row("Log Debug Info", log_debug_info), + toggle_row("Show Dev stuff", show_dev_stuff), + toggle_row("Seen Onboarding", seen_onboarding), + toggle_row("Use Grid On Mods List", use_grid_on_mods_list), + ], + )) + .child(divider()) + .child(section( + "Toast Controller", + vec![ToastController.into_element()], + )) + .child(divider()) + .child(section( + "Cluster Update Simulator", + vec![ClusterUpdateSimulator.into_element()], + )) + .child(divider()) + .child(section( + "Launcher Auto Update", + vec![LauncherUpdateSimulator.into_element()], + )) + .child(divider()) + .child(section( + "Connectivity Status Bar", + vec![StatusBarSimulator.into_element()], + )) + .child(divider()) + .child(section( + "Auth Error Guidance", + vec![AuthGuidancePreview.into_element()], + )) + .child(divider()) + .child(section( + "Corruption Simulator", + vec![CorruptionSimulator.into_element()], + )) + .child(divider()) + .child(section("SQL Console", vec![SqlConsole.into_element()])) + .child(divider()) + .child(section( + "Other", + vec![action_row( + &dispatch, + vec![ + ("Open Dev Tools", IconType::CodeSnippet02), + ("Open Onboarding", IconType::Rocket02), + ("Open Launcher Data", IconType::Folder), + ("Log Running Processes", IconType::Terminal), + ], + )], + )), + ) + } +} + +#[derive(PartialEq)] +struct ToastController; + +impl Component for ToastController { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let title = use_state(|| "Downloading assets".to_string()); + let body = use_state(|| "PolyBlock by Polyfrost".to_string()); + let mut progress = use_state(|| 0u64); + + let info = dispatch.clone(); + let error = dispatch.clone(); + let prog = dispatch.clone(); + let reset = dispatch; + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + rect() + .width(Size::flex(1.0)) + .child(TextInput::new(title).placeholder("Title")), + ) + .child( + rect() + .width(Size::flex(1.0)) + .child(TextInput::new(body).placeholder("Body")), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::InfoCircle).size(16.)) + .text("Send Info") + .on_press(move |_| { + info.notify(title.read().clone()) + .body(body.read().clone()) + .info() + .send(); + }), + ) + .child( + Button::new() + .danger() + .child(Icon::new(IconType::AlertTriangle).size(16.)) + .text("Send Error") + .on_press(move |_| { + error + .notify(title.read().clone()) + .body(body.read().clone()) + .error() + .send(); + }), + ) + .child( + Button::new() + .secondary() + .child(Icon::new(IconType::FolderDownload).size(16.)) + .text("Progress +10") + .on_press(move |_| { + let next = (*progress.read() + 10).min(100); + progress.set(next); + prog.send_test_progress(next, 100); + }), + ) + .child( + Button::new() + .secondary() + .text("Reset Progress") + .on_press(move |_| { + progress.set(0); + reset.send_test_progress(0, 100); + }), + ), + ) + .into_element() + } +} + +#[derive(PartialEq)] +struct ClusterUpdateSimulator; + +impl Component for ClusterUpdateSimulator { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let cluster_id = use_state(|| "1".to_string()); + let cluster_name = use_state(|| "PolyBlock".to_string()); + let updated = use_state(|| "Sodium 0.5 → 0.6, Iris 1.7 → 1.8".to_string()); + let added = use_state(|| "Lithium".to_string()); + let removed = use_state(|| "OptiFine".to_string()); + + let simulate = dispatch.clone(); + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Builds the same notification the bundle sync sends: one \"View changes\" action carrying every changed cluster, opened into the changes modal.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + rect() + .width(Size::px(120.)) + .child(TextInput::new(cluster_id).placeholder("Cluster ID")), + ) + .child( + rect() + .width(Size::flex(1.0)) + .child(TextInput::new(cluster_name).placeholder("Cluster name")), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(updated).placeholder("Updated (comma separated)"), + ), + ) + .child( + rect() + .width(Size::flex(1.0)) + .child(TextInput::new(added).placeholder("Added (comma separated)")), + ) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(removed).placeholder("Removed (comma separated)"), + ), + ), + ) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::DownloadCloud02).size(16.)) + .text("Simulate Cluster Update") + .on_press(move |_| { + let summary = ClusterUpdateSummary { + cluster_id: cluster_id.read().trim().parse().unwrap_or(1), + cluster_name: cluster_name.read().clone(), + updated: cluster_update_items(&split_csv(&updated.read())), + added: cluster_update_items(&split_csv(&added.read())), + removed: cluster_update_items(&split_csv(&removed.read())), + }; + send_cluster_update(&simulate, vec![summary]); + }), + ) + .child( + label() + .text("Presets: every shape the notification and modal can take.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .children(cluster_update_preset_rows(&dispatch)) + .into_element() + } +} + +type ClusterUpdatePreset = fn() -> Vec; + +/// Each preset pins one variation plural copy empty category tab single- vs multi-cluster footer grouping header overflow +const CLUSTER_UPDATE_PRESETS: [(&str, IconType, ClusterUpdatePreset); 6] = [ + ( + "1 cluster · all categories", + IconType::DownloadCloud02, + || { + vec![preset_summary( + 1, + "PolyBlock", + &["Sodium 0.5 → 0.6", "Iris 1.7 → 1.8", "Patcher 1.8 → 1.9"], + &["Lithium", "FerriteCore"], + &["OptiFine"], + )] + }, + ), + ("1 cluster · updates only", IconType::RefreshCw01, || { + vec![preset_summary( + 1, + "PolyBlock", + &[ + "Sodium 0.5 → 0.6", + "Iris 1.7 → 1.8", + "Indium 1.0 → 1.1", + "Fabric API 0.92 → 0.100", + ], + &[], + &[], + )] + }), + ("1 cluster · single change", IconType::Plus, || { + vec![preset_summary(1, "PolyBlock", &[], &["Lithium"], &[])] + }), + ("3 clusters · mixed", IconType::DotsGrid, || { + vec![ + preset_summary( + 1, + "PolyBlock", + &["Sodium 0.5 → 0.6", "Iris 1.7 → 1.8"], + &["Lithium"], + &[], + ), + preset_summary(2, "Skyblock", &[], &[], &["OptiFine", "Skytils"]), + preset_summary(3, "Vanilla+", &[], &["Sodium", "Iris", "FerriteCore"], &[]), + ] + }), + ("2 clusters · removals only", IconType::Trash01, || { + vec![ + preset_summary(1, "PolyBlock", &[], &[], &["OptiFine"]), + preset_summary(2, "Skyblock", &[], &[], &["Skytils", "NotEnoughUpdates"]), + ] + }), + ("6 clusters · long names", IconType::Database01, || { + (1..=6) + .map(|i| ClusterUpdateSummary { + cluster_id: i, + cluster_name: format!( + "Cluster {i} with a deliberately overlong name that has to truncate" + ), + updated: cluster_update_items(&[ + format!("Sodium 0.{i} → 0.{}", i + 1), + format!("Iris 1.{i} → 1.{}", i + 1), + ]), + added: cluster_update_items(&[format!("Lithium {i}")]), + removed: Vec::new(), + }) + .collect() + }), +]; + +fn cluster_update_preset_rows(dispatch: &crate::Actions) -> Vec { + CLUSTER_UPDATE_PRESETS + .chunks(3) + .map(|chunk| { + let mut row = rect().horizontal().width(Size::fill()).spacing(12.); + + for (text, icon, build) in chunk { + let dispatch = dispatch.clone(); + let build = *build; + row = row.child( + Button::new() + .secondary() + .child(Icon::new(*icon).size(16.)) + .text(*text) + .on_press(move |_| send_cluster_update(&dispatch, build())), + ); + } + + row.into_element() + }) + .collect() +} + +fn preset_summary( + cluster_id: i64, + cluster_name: &str, + updated: &[&str], + added: &[&str], + removed: &[&str], +) -> ClusterUpdateSummary { + ClusterUpdateSummary { + cluster_id, + cluster_name: cluster_name.to_string(), + updated: cluster_update_items(updated), + added: cluster_update_items(added), + removed: cluster_update_items(removed), + } +} + +fn cluster_update_items(names: &[impl AsRef]) -> Vec { + names + .iter() + .map(|name| ClusterUpdateItem::from_name(name.as_ref())) + .collect() +} + +/// Mirrors the copy the bridge builds so the simulated notification matches the real one +fn send_cluster_update(dispatch: &crate::Actions, summaries: Vec) { + if summaries.is_empty() { + return; + } + + let total: usize = summaries.iter().map(|s| s.total()).sum(); + let plural = if total == 1 { "" } else { "s" }; + let (title, body) = match summaries.as_slice() { + [only] => ( + "Cluster updated", + format!("{total} package{plural} changed in {}", only.cluster_name), + ), + many => ( + "Mods updated", + format!( + "{total} package{plural} updated across {} clusters", + many.len() + ), + ), + }; + + dispatch + .notify(title) + .body(body) + .icon(IconType::DownloadCloud02) + .action(NotificationAction { + label: "View changes".to_string(), + kind: NotificationActionKind::OpenClusterUpdate(summaries), + }) + .send(); +} + +#[derive(PartialEq)] +struct LauncherUpdateSimulator; + +impl Component for LauncherUpdateSimulator { + fn render(&self) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Drives the real auto-update UX (prompt → download progress → \"restart to apply\") against a fake release, with no network or disk. \"Check for Updates Now\" runs the real check against the release feed.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::DownloadCloud02).size(16.)) + .text("Simulate Auto Update") + .on_press(|_| crate::updater::spawn_simulated_update()), + ) + .child( + Button::new() + .secondary() + .child(Icon::new(IconType::RefreshCw01).size(16.)) + .text("Check for Updates Now") + .on_press(|_| { + if let Ok(state) = crate::launcher::state() { + crate::updater::spawn_update_check( + false, + state.services.events.clone(), + ); + } + }), + ), + ) + .into_element() + } +} + +/// Pins `oneclient_net::status` so the connectivity banner can be seen without unplugging anything +#[derive(PartialEq)] +struct StatusBarSimulator; + +const STATUS_PRESETS: [(&str, IconType, ServiceStatus); 4] = [ + ( + "No internet", + IconType::Globe01, + ServiceStatus { + online: false, + mc_auth_up: false, + polyfrost_up: false, + }, + ), + ( + "Minecraft auth down", + IconType::AlertTriangle, + ServiceStatus { + online: true, + mc_auth_up: false, + polyfrost_up: true, + }, + ), + ( + "Polyfrost down", + IconType::AlertCircle, + ServiceStatus { + online: true, + mc_auth_up: true, + polyfrost_up: false, + }, + ), + ( + "Both services down", + IconType::AlertTriangle, + ServiceStatus { + online: true, + mc_auth_up: false, + polyfrost_up: false, + }, + ), +]; + +impl Component for StatusBarSimulator { + fn render(&self) -> impl IntoElement { + let mut pinned = use_state(status::forced); + + let mut row = rect().horizontal().width(Size::fill()).spacing(12.); + for (text, icon, forced) in STATUS_PRESETS { + row = row.child( + Button::new() + .secondary() + .child(Icon::new(icon).size(16.)) + .text(text) + .on_press(move |_| { + status::force(Some(forced)); + pinned.set(Some(forced)); + }), + ); + } + + let current = *pinned.read(); + let state_text = match current { + Some(_) => "Pinned. Probing still runs, but its result is not published.".to_string(), + None => "Not pinned; showing the real probe result.".to_string(), + }; + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Forces what the connectivity prober reports, so the real status bar renders its banner. Dismissing a banner is remembered until the issue clears.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child(row) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::RefreshCw01).size(16.)) + .text("Clear + recheck") + .enabled(current.is_some()) + .on_press(move |_| { + status::force(None); + pinned.set(None); + }), + ) + .child( + label() + .text(state_text) + .font_size(13.) + .color(colors::fg_secondary()), + ), + ) + .into_element() + } +} + +#[derive(PartialEq)] +struct AuthGuidancePreview; + +impl Component for AuthGuidancePreview { + fn render(&self) -> impl IntoElement { + let samples = preview_samples(); + // Index 0 is the no-error state the rest line up with `samples` + let mut choice = use_state(|| 0usize); + let mut open = use_state(|| false); + + let mut options: Vec = vec!["No error (in progress)".to_string()]; + options.extend(samples.iter().map(|s| s.label.to_string())); + + let index = (*choice.read()).min(options.len() - 1); + let selected_label = options[index].clone(); + + let popup = open().then(|| { + let (error, guidance) = match index.checked_sub(1) { + None => (None, None), + Some(i) => { + let s = &samples[i]; + (Some(s.message.clone()), s.guidance.clone()) + } + }; + login_dialog( + "https://login.live.com/oauth20_authorize (preview)".to_string(), + "ABCD-EFGH".to_string(), + "https://www.microsoft.com/link".to_string(), + false, + None, + error, + guidance, + move || open.set(false), + ) + }); + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Opens the real Microsoft sign-in popup exactly as users see it, in the chosen error state.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + Dropdown::new(selected_label, options) + .width(Size::px(300.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| choice.set(idx)), + ) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::Eye).size(16.)) + .text("Open preview") + .on_press(move |_| open.set(true)), + ), + ) + .maybe_child(popup) + .into_element() + } +} + +/// Presets are grouped by which layer should catch them same-length corruption only the hashing pass finds truncation the launch-time size check +#[derive(PartialEq)] +struct CorruptionSimulator; + +/// `(label, icon, what it does, which check is meant to catch it)` +type DamagePreset = (&'static str, IconType, DamageKind, &'static str); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DamageKind { + Assets(usize, Damage), + Libraries(usize, Damage), + Content(usize, Damage), + DeleteAssetsTree, +} + +const DAMAGE_PRESETS: [DamagePreset; 6] = [ + ( + "Corrupt 25 assets", + IconType::AlertTriangle, + DamageKind::Assets(25, Damage::Corrupt), + "Survives launch; only Verify Files finds it", + ), + ( + "Truncate 25 assets", + IconType::FileX02, + DamageKind::Assets(25, Damage::Truncate), + "Caught at launch by the size check", + ), + ( + "Delete 25 assets", + IconType::Trash01, + DamageKind::Assets(25, Damage::Delete), + "Caught at launch, re-downloaded", + ), + ( + "Corrupt 5 libraries", + IconType::AlertCircle, + DamageKind::Libraries(5, Damage::Corrupt), + "Crashes with ZipException, then offers repair", + ), + ( + "Corrupt 5 mods", + IconType::File02, + DamageKind::Content(5, Damage::Corrupt), + "Repaired from the provider, not Mojang", + ), + ( + "Delete the assets tree", + IconType::Folder, + DamageKind::DeleteAssetsTree, + "The reported bug; auto-repairs on launch", + ), +]; + +impl Component for CorruptionSimulator { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let cluster_id = use_state(|| "1".to_string()); + + let mut rows = Vec::new(); + for chunk in DAMAGE_PRESETS.chunks(3) { + let mut row = rect().horizontal().width(Size::fill()).spacing(8.); + + for (text, icon, kind, hint) in chunk { + let dispatch = dispatch.clone(); + let kind = *kind; + let hint = *hint; + + row = row.child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + Button::new() + .danger() + .width(Size::fill()) + .child(Icon::new(*icon).size(16.)) + .text(*text) + .on_press(move |_| { + let target = + cluster_id.read().trim().parse::().unwrap_or(1); + run_damage(&dispatch, kind, target); + }), + ) + .child( + label() + .text(hint) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ); + } + + rows.push(row.into_element()); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Damages the real installation so the repair paths can be exercised. Everything here is repairable by \"Verify Files\" in cluster settings, or by launching — which is the point.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + label() + .text("Cluster (for mods)") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::px(120.)) + .child(TextInput::new(cluster_id).placeholder("Cluster ID")), + ), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .children(rows), + ) + .child( + label() + .text("Modals: the real prompts, built by the code that raises them for real.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child(prompt_button( + "Failed assets prompt", + IconType::AlertTriangle, + 3, + 0, + )) + .child(prompt_button( + "Failed libraries prompt", + IconType::AlertCircle, + 0, + 2, + )) + .child(prompt_button("Mixed failures prompt", IconType::File02, 4, 1)), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .child(crash_repair_button( + "Crash repair prompt (named jar)", + Some("fabric-loader-0.15.11.jar"), + cluster_id, + )) + .child(crash_repair_button( + "Crash repair prompt (no jar named)", + None, + cluster_id, + )), + ) + .into_element() + } +} + +/// Calls the same function the download path calls so the simulated modal cannot drift from the real one +fn prompt_button( + text: &'static str, + icon: IconType, + failed_assets: usize, + failed_libraries: usize, +) -> impl IntoElement { + Button::new() + .secondary() + .child(Icon::new(icon).size(16.)) + .text(text) + .on_press(move |_| { + spawn(async move { + let Ok(state) = crate::launcher::state() else { + return; + }; + let answer = oneclient_core::game::confirm_incomplete_install( + &state.services.mc(), + failed_assets, + failed_libraries, + ) + .await; + tracing::info!(?answer, "incomplete install prompt answered"); + }); + }) +} + +/// Accepting really runs the verify pass +/// To test detection itself corrupt libraries above and launch the game +fn crash_repair_button( + text: &'static str, + jar: Option<&'static str>, + cluster_id: State, +) -> impl IntoElement { + Button::new() + .secondary() + .child(Icon::new(IconType::AlertCircle).size(16.)) + .text(text) + .on_press(move |_| { + let target = cluster_id.read().trim().parse::().unwrap_or(1); + spawn(async move { + let Ok(state) = crate::launcher::state() else { + return; + }; + let diagnosis = oneclient_core::game::CrashDiagnosis::CorruptArchive { + file: jar.map(str::to_string), + }; + oneclient_core::game::offer_repair(&state, target, &diagnosis).await; + }); + }) +} + +fn run_damage(dispatch: &crate::Actions, kind: DamageKind, cluster_id: i64) { + let dispatch = dispatch.clone(); + spawn(async move { + let result = match kind { + DamageKind::Assets(count, damage) => { + oneclient_core::simulate::damage_assets(count, damage) + .await + .map(|report| (report, damage.verb())) + } + DamageKind::Libraries(count, damage) => { + oneclient_core::simulate::damage_libraries(count, damage) + .await + .map(|report| (report, damage.verb())) + } + DamageKind::Content(count, damage) => match crate::launcher::state() { + Ok(state) => oneclient_core::simulate::damage_cluster_content( + &state, cluster_id, count, damage, + ) + .await + .map(|report| (report, damage.verb())), + Err(err) => Err(err), + }, + DamageKind::DeleteAssetsTree => oneclient_core::simulate::delete_assets_tree() + .await + .map(|report| (report, "Deleted")), + }; + + match result { + Ok((report, verb)) => { + dispatch + .notify("Damage simulated") + .body(report.summary(verb)) + .icon(IconType::AlertTriangle) + .error() + .send(); + } + Err(err) => { + dispatch + .notify("Simulation failed") + .body(err.to_string()) + .error() + .send(); + } + } + }); +} + +#[derive(PartialEq)] +struct SqlConsole; + +impl Component for SqlConsole { + fn render(&self) -> impl IntoElement { + let query = use_state(|| "SELECT * FROM clusters;".to_string()); + let result = use_state(|| None::>); + let running = use_state(|| false); + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(query) + .placeholder("SELECT * FROM …") + .on_submit(move |_| run_sql(query, result, running)), + ), + ) + .child( + Button::new() + .primary() + .child(Icon::new(IconType::Terminal).size(16.)) + .text(if *running.read() { "Running…" } else { "Run" }) + .on_press(move |_| run_sql(query, result, running)), + ), + ) + .child(sql_result(&result.read())) + .into_element() + } +} + +fn run_sql(query: State, mut result: State, mut running: State) { + if *running.read() { + return; + } + let sql = query.read().clone(); + if sql.trim().is_empty() { + return; + } + + running.set(true); + spawn(async move { + let res = match crate::launcher::state() { + Ok(state) => run_console_query(&state.services.db, &sql) + .await + .map_err(|e| e.to_string()), + Err(e) => Err(e.to_string()), + }; + result.set(Some(res)); + running.set(false); + }); +} + +fn sql_result(state: &SqlResult) -> Element { + match state { + None => label() + .text("No query run yet.") + .font_size(13.) + .color(colors::fg_secondary()) + .into_element(), + Some(Err(err)) => rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(10., 14.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::danger())) + .child( + label() + .text(err.clone()) + .font_size(13.) + .color(colors::danger()), + ) + .into_element(), + Some(Ok(res)) if !res.is_select => label() + .text(format!("OK, {} row(s) affected.", res.rows_affected)) + .font_size(13.) + .color(colors::success()) + .into_element(), + Some(Ok(res)) => sql_table(res), + } +} + +fn sql_table(res: &ConsoleQueryResult) -> Element { + if res.columns.is_empty() { + return label() + .text("0 rows returned.") + .font_size(13.) + .color(colors::fg_secondary()) + .into_element(); + } + + let header = table_row(&res.columns, true); + let mut table = rect() + .vertical() + .width(Size::fill()) + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., colors::component_border())) + .child(header); + + for row in &res.rows { + table = table.child(table_row(row, false)); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text(format!("{} row(s)", res.rows.len())) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child(table) + .into_element() +} + +fn table_row(cells: &[String], header: bool) -> Element { + let mut row = rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .background(if header { + colors::page_elevated() + } else { + colors::component_bg() + }); + + for cell in cells { + row = row.child( + rect() + .width(Size::flex(1.0)) + .padding(Gaps::new_symmetric(6., 10.)) + .border(border_all_color(0.5, colors::component_border())) + .child( + label() + .text(cell.clone()) + .font_size(12.) + .max_lines(1) + .font_weight(if header { + FontWeight::SEMI_BOLD + } else { + FontWeight::NORMAL + }) + .color(if header { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ), + ); + } + + row.into_element() +} + +fn split_csv(raw: &str) -> Vec { + raw.split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +fn divider() -> impl IntoElement { + crate::ui::divider() + .margin(Gaps::new_symmetric(8., 0.)) + .corner_radius(CornerRadius::new_all(1.)) + .into_element() +} + +fn section(title: &'static str, rows: Vec) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text(title) + .font_size(22.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .children(rows) + .into_element() +} + +fn toggle_row(title: &'static str, on: State) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .padding(Gaps::new_symmetric(10., 14.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(colors::page_elevated()) + .child( + label() + .text(title) + .font_size(14.) + .color(colors::fg_primary()), + ) + .child(toggle(on)) + .into_element() +} + +fn action_row(dispatch: &Actions, buttons: Vec<(&'static str, IconType)>) -> Element { + let mut row = rect().horizontal().width(Size::fill()).spacing(12.); + + for (text, icon) in buttons { + let mut button = Button::new() + .secondary() + .child(Icon::new(icon).size(16.)) + .text(text); + + if text == "Open Onboarding" { + dispatch.reset_onboarding(); + button = button.on_press(|_| { + let _ = RouterContext::get().replace(Route::OnboardingWelcome {}); + }); + } + + row = row.child(button); + } + + row.into_element() +} diff --git a/packages/oneclient_app/src/view/app/home/index.rs b/packages/oneclient_app/src/view/app/home/index.rs new file mode 100644 index 00000000..2ccb85ff --- /dev/null +++ b/packages/oneclient_app/src/view/app/home/index.rs @@ -0,0 +1,29 @@ +use freya::prelude::*; + +use crate::components::{ActiveClusterPanel, RecentsRow}; +use crate::theme; + +#[derive(PartialEq)] +pub struct Home; + +impl Component for Home { + fn render(&self) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .overflow(Overflow::Clip) + .padding(theme::HOME_PADDING_PX) + .main_align(Alignment::Center) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .main_align(Alignment::Center) + .child(ActiveClusterPanel), + ) + .child(RecentsRow) + } +} diff --git a/packages/oneclient_app/src/view/app/home/mod.rs b/packages/oneclient_app/src/view/app/home/mod.rs new file mode 100644 index 00000000..4b7dc64b --- /dev/null +++ b/packages/oneclient_app/src/view/app/home/mod.rs @@ -0,0 +1,3 @@ +mod index; + +pub use index::Home; diff --git a/packages/oneclient_app/src/view/app/mod.rs b/packages/oneclient_app/src/view/app/mod.rs new file mode 100644 index 00000000..2f95edd3 --- /dev/null +++ b/packages/oneclient_app/src/view/app/mod.rs @@ -0,0 +1,41 @@ +mod analytics; +mod clusters; +mod debug; +mod home; +mod skins; +mod stats; + +pub mod browser; +pub mod cluster; +pub mod settings; + +use crate::state::GameState as GameSnapshot; +use oneclient_events::LaunchStage; + +pub fn launch_button_state( + game: &GameSnapshot, + cluster_id: i64, + syncing: bool, +) -> (&'static str, bool) { + let state = match game.stage(cluster_id) { + Some(LaunchStage::Checking) => ("Checking", false), + Some(LaunchStage::Downloading) => ("Downloading", false), + Some(LaunchStage::Launching) => ("Launching", false), + Some(LaunchStage::Running) => ("Running", false), + // The click's own flag held for a beat after a failure so a burst of clicks starts one game + _ if game.is_launch_pending(cluster_id) => ("Launching", false), + _ => ("Launch", true), + }; + // Block launching while the startup bundle download is still running + if syncing && state.1 { + return (state.0, false); + } + state +} + +pub(crate) use analytics::{analytics_body, analytics_placeholder}; +pub use clusters::Clusters; +pub use debug::Debug; +pub use home::Home; +pub use skins::AccountSkins; +pub use stats::Stats; diff --git a/packages/oneclient_app/src/view/app/settings/accounts.rs b/packages/oneclient_app/src/view/app/settings/accounts.rs new file mode 100644 index 00000000..1f26ba82 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/accounts.rs @@ -0,0 +1,746 @@ +use chrono::{DateTime, Utc}; +use freya::animation::{AnimNum, Function, OnFinish, use_animation}; +use freya::prelude::*; +use freya::query::{MutationCapability, MutationStateData, UseMutation}; +use oneclient_auth::{AccountKind, MinecraftAccount}; +use uuid::Uuid; + +use super::{section_header, settings_page}; +use crate::components::{ + Avatar, Button, Icon, IconType, OverlayPopup, PlayerModel, TextInput, use_microsoft_login, +}; +use crate::hooks::{ + AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys, + accounts_have_microsoft, try_accounts, try_default_account, use_accounts, + use_add_offline_account, use_current_account, use_refresh_account, use_remove_account, + use_set_default_account, +}; +use crate::theme::colors; +use crate::ui::border_all_color; + +/// The shader scales the model off `res.y` alone so height makes the player bigger width only needs to clear the arms +const MODEL_WIDTH_PX: f32 = 160.; +const MODEL_HEIGHT_PX: f32 = 264.; + +const AVATAR_SIZE_PX: f32 = 36.; + +#[derive(PartialEq)] +pub struct SettingsAccounts; + +impl Component for SettingsAccounts { + fn render(&self) -> impl IntoElement { + let accounts_query = use_accounts(); + let default_query = use_current_account(); + + let msa = use_microsoft_login(); + let add_offline = use_add_offline_account(); + let set_default = use_set_default_account(); + let remove = use_remove_account(); + let refresh = use_refresh_account(); + + let mut username = use_state(String::new); + let mut show_offline = use_state(|| false); + let edit_account = use_state(|| None::<(Uuid, String)>); + let edit_name = use_state(|| String::new()); + let mut closing_offline = use_state(|| false); + + use_side_effect(move || { + if !*closing_offline.read() { + return; + } + match &*add_offline.read().state() { + MutationStateData::Settled { res: Ok(_), .. } => { + closing_offline.set(false); + show_offline.set(false); + username.set(String::new()); + } + MutationStateData::Settled { res: Err(_), .. } => { + closing_offline.set(false); + } + _ => {} + } + }); + + let accounts = try_accounts(&accounts_query).unwrap_or_default(); + let default_account = try_default_account(&default_query); + let default_id = default_account.as_ref().map(|a| a.id); + let has_microsoft = accounts_have_microsoft(&accounts); + + let offline_name = username.read().trim().to_string(); + let offline_uuid = (!offline_name.is_empty()) + .then(|| oneclient_auth::offline_uuid(&offline_name).to_string()); + + let offline_error = mutation_err_text(&add_offline); + + let on_confirm_offline = move |_| { + let name = username.peek().trim().to_string(); + if name.is_empty() { + return; + } + add_offline.mutate(AddOfflineAccountKeys { username: name }); + closing_offline.set(true); + }; + + let mut rows: Vec = accounts + .iter() + .map(|account| { + AccountRow { + id: account.id, + username: account.username.clone(), + kind: account.kind, + expires: account.expires, + is_default: Some(account.id) == default_id, + set_default, + remove, + refresh, + edit_account, + edit_name, + } + .into_element() + }) + .collect(); + if rows.is_empty() { + rows.push(empty_state()); + } + + settings_page() + .child(hero( + default_account, + has_microsoft, + msa.pending, + msa.error.clone(), + move |_| show_offline.set(true), + { + let msa = msa.clone(); + move |_| msa.start() + }, + )) + .child(section_header("YOUR ACCOUNTS")) + .children(rows) + .maybe_child(show_offline.read().then(|| { + offline_dialog( + username, + offline_uuid, + offline_error, + on_confirm_offline, + show_offline, + ) + })) + .maybe_child(edit_account.read().as_ref().map(|(id, _)| { + let id = *id; + let mut edit_account = edit_account; + let edit_name = edit_name; + OverlayPopup::new() + .on_close(move |()| edit_account.set(None)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(380.)) + .max_width(Size::window_percent(90.)) + .spacing(16.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text("Edit offline username") + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child(field_label("New Username")) + .child(TextInput::new(edit_name).placeholder("Enter new username")), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| edit_account.set(None)) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(move |_| { + let new_name = edit_name.peek().trim().to_string(); + if !new_name.is_empty() { + let mut edit_account = edit_account; + spawn(async move { + if let Ok(state) = crate::launcher::state() { + let _ = state.auth.remove_account(id).await; + if let Ok(new_acc) = state.auth.add_offline_account(new_name).await { + let _ = state.auth.set_default_account(Some(new_acc.id)).await; + crate::hooks::invalidate_auth_queries(Some(new_acc.id)).await; + } + } + edit_account.set(None); + }); + } + }) + .text("Save"), + ), + ), + ), + ) + })) + .maybe_child(msa.popup()) + .into_element() + } +} + +fn hero( + account: Option, + _has_microsoft: bool, + microsoft_pending: bool, + error: Option, + on_open_offline: impl FnMut(Event) + 'static, + on_add_microsoft: impl FnMut(Event) + 'static, +) -> impl IntoElement { + let (name, subtitle) = match &account { + Some(account) => (account.username.clone(), kind_label(account.kind)), + None => ( + "No active account".to_string(), + "Add an account to start playing", + ), + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_all(16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child(model_frame(account.as_ref().map(|account| account.id))) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(16.) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(2.) + .child( + label() + .text("ACTIVE ACCOUNT") + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(name) + .font_size(24.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + // The two buttons overflow the ~245px beside the model at the 800px minimum so they wrap + rect() + .horizontal() + .width(Size::fill()) + .content(Content::wrap_spacing(8.)) + .spacing(8.) + .child( + Button::new() + .primary() + .enabled(!microsoft_pending) + .on_press(on_add_microsoft) + .child(Icon::new(IconType::Globe01).size(16.)) + .text(if microsoft_pending { + "Signing in..." + } else { + "Add Microsoft" + }), + ) + .child( + Button::new() + .secondary() + .enabled(true) + .on_press(on_open_offline) + .child(Icon::new(IconType::Plus).size(16.)) + .text("Add offline"), + ), + ) + .map(error, |el, msg| { + el.child(hint_line(IconType::AlertTriangle, msg, colors::danger())) + }) + .maybe(false, |el| { + el.child(hint_line( + IconType::InfoCircle, + "Add a Microsoft account before creating offline accounts." + .to_string(), + colors::fg_secondary(), + )) + }), + ), + ) + .into_element() +} + +fn model_frame(id: Option) -> impl IntoElement { + rect() + .vertical() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + rect() + .width(Size::px(MODEL_WIDTH_PX)) + .height(Size::px(MODEL_HEIGHT_PX)) + .center() + .overflow(Overflow::Clip) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child(match id { + Some(id) => PlayerModel::new(id) + .yaw(-0.5) + .width(Size::fill()) + .height(Size::fill()) + .into_element(), + None => Icon::new(IconType::Users01) + .size(28.) + .color(colors::fg_secondary()) + .into_element(), + }), + ) + .maybe_child(id.map(|_| { + label() + .text("Drag to rotate") + .font_size(10.) + .color(colors::fg_secondary()) + .into_element() + })) + .into_element() +} + +fn mutation_err_text(mutation: &UseMutation) -> Option +where + M: MutationCapability, + M::Err: std::fmt::Display, +{ + match &*mutation.read().state() { + MutationStateData::Settled { res: Err(err), .. } => Some(err.to_string()), + MutationStateData::Loading { + res: Some(Err(err)), + } => Some(err.to_string()), + _ => None, + } +} + +fn offline_dialog( + username: State, + uuid_preview: Option, + error: Option, + on_confirm: impl FnMut(Event) + 'static, + mut show_offline: State, +) -> impl IntoElement { + OverlayPopup::new() + .on_close(move |()| show_offline.set(false)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child( + rect() + .vertical() + .width(Size::px(380.)) + .max_width(Size::window_percent(90.)) + .spacing(16.) + .padding(Gaps::new_all(20.)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + label() + .text("Add offline account") + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child(field_label("Username")) + .child(TextInput::new(username).placeholder("Offline username")), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child(field_label("UUID")) + .child( + label() + .text(uuid_preview.unwrap_or_else(|| "—".to_string())) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .map(error, |el, msg| { + el.child(hint_line(IconType::AlertTriangle, msg, colors::danger())) + }) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .spacing(8.) + .child( + Button::new() + .ghost() + .on_press(move |_| show_offline.set(false)) + .text("Cancel"), + ) + .child( + Button::new() + .primary() + .on_press(on_confirm) + .child(Icon::new(IconType::Plus).size(16.)) + .text("Add account"), + ), + ), + ), + ) + .into_element() +} + +fn field_label(text: &str) -> impl IntoElement { + label() + .text(text.to_string()) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()) + .into_element() +} + +/// Sits in whatever is left of the hero beside the model so the text must wrap +fn hint_line(icon: IconType, text: String, color: Color) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(6.) + .child(Icon::new(icon).size(13.).color(color)) + .child( + label() + .text(text) + .font_size(12.) + .width(Size::flex(1.0)) + .color(color), + ) + .into_element() +} + +const REFRESH_SPIN_TIME: u64 = 800; + +const REFRESHING_OPACITY: f32 = 0.7; + +struct AccountRow { + id: Uuid, + username: String, + kind: AccountKind, + expires: DateTime, + is_default: bool, + set_default: crate::hooks::UseSetDefaultAccount, + remove: crate::hooks::UseRemoveAccount, + refresh: crate::hooks::UseRefreshAccount, + edit_account: State>, + edit_name: State, +} + +impl PartialEq for AccountRow { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + && self.username == other.username + && self.kind == other.kind + && self.expires == other.expires + && self.is_default == other.is_default + && self.edit_account == other.edit_account + && self.edit_name == other.edit_name + } +} + +impl Component for AccountRow { + fn render_key(&self) -> DiffKey { + DiffKey::from(&self.id) + } + + fn render(&self) -> impl IntoElement { + let id = self.id; + let is_default = self.is_default; + let set_default = self.set_default; + let remove = self.remove; + let refresh = self.refresh; + + let is_microsoft = self.kind == AccountKind::Microsoft; + let expired = is_microsoft && self.expires <= Utc::now(); + + let mut refreshing = use_state(|| false); + let is_refreshing = *refreshing.read(); + + let spin = use_animation(|conf| { + conf.on_finish(OnFinish::restart()); + AnimNum::new(0., 360.) + .time(REFRESH_SPIN_TIME) + .function(Function::Linear) + }); + + use_side_effect_with_deps(&is_refreshing, move |&is_refreshing| { + let mut spin = spin; + if is_refreshing { + spin.start(); + } else { + spin.reset(); + } + }); + + let rotation = if is_refreshing { + spin.get().value() + } else { + 0. + }; + + let border_color = if expired { + colors::danger() + } else if is_default { + colors::brand() + } else { + colors::component_border() + }; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(12.) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., border_color)) + .opacity(if is_refreshing { + REFRESHING_OPACITY + } else { + 1. + }) + .a11y_role(AccessibilityRole::Button) + .maybe(!is_default, |el| { + el.on_press(move |_| set_default.mutate(SetDefaultAccountKeys { id: Some(id) })) + }) + .child( + Avatar::new(id.to_string()) + .width(Size::px(AVATAR_SIZE_PX)) + .height(Size::px(AVATAR_SIZE_PX)), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(4.) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + label() + .text(self.username.clone()) + .font_size(15.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .color(colors::fg_primary()), + ) + .maybe_child(is_default.then(default_badge)) + .maybe_child(expired.then(expired_badge)), + ) + // The kind rides with the id a third badge and a full uuid do not both fit + .child( + label() + .text(format!("{} · {id}", kind_label(self.kind))) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .maybe_child(is_microsoft.then(|| { + Button::new() + .ghost() + .icon() + .on_press(move |e: Event| { + e.stop_propagation(); + if *refreshing.peek() { + return; + } + refreshing.set(true); + spawn(async move { + refresh.mutate_async(RefreshAccountKeys { id }).await; + refreshing.set(false); + }); + }) + .child( + rect().rotate(rotation).child( + Icon::new(IconType::RefreshCw01) + .size(18.) + .color(if expired { + colors::danger() + } else { + colors::fg_secondary() + }), + ), + ) + .into_element() + })) + .child({ + let is_offline = !is_microsoft; + let mut edit_acc_state = self.edit_account; + let mut edit_name_state = self.edit_name; + let username_val = self.username.clone(); + let id_val = self.id; + Button::new() + .ghost() + .icon() + .enabled(is_offline) + .on_press(move |e: Event| { + e.stop_propagation(); + edit_name_state.set(username_val.clone()); + edit_acc_state.set(Some((id_val, username_val.clone()))); + }) + .child( + Icon::new(IconType::Pencil01) + .size(18.) + .color(if is_offline { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ) + }) + .child( + Button::new() + .ghost() + .icon() + .on_press(move |e: Event| { + e.stop_propagation(); + remove.mutate(RemoveAccountKeys { id }); + }) + .child( + Icon::new(IconType::Trash01) + .size(18.) + .color(colors::fg_secondary()), + ), + ) + .into_element() + } +} + +fn kind_label(kind: AccountKind) -> &'static str { + match kind { + AccountKind::Microsoft => "Microsoft", + AccountKind::Offline => "Offline", + } +} + +fn default_badge() -> impl IntoElement { + badge( + Icon::new(IconType::CheckCircle) + .size(12.) + .color(colors::brand()) + .into_element(), + "Default".to_string(), + colors::brand(), + colors::brand(), + ) +} + +fn expired_badge() -> impl IntoElement { + badge( + Icon::new(IconType::AlertTriangle) + .size(12.) + .color(colors::danger()) + .into_element(), + "Expired".to_string(), + colors::danger(), + colors::danger(), + ) +} + +fn badge(icon: impl IntoElement, text: String, border: Color, fg: Color) -> impl IntoElement { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(4.) + .padding(Gaps::new_symmetric(2., 8.)) + .corner_radius(CornerRadius::new_all(999.)) + .border(border_all_color(1., border)) + .background(colors::component_bg()) + .child(icon) + .child( + label() + .text(text) + .font_size(10.) + .font_weight(FontWeight::MEDIUM) + .color(fg), + ) + .into_element() +} + +fn empty_state() -> Element { + rect() + .vertical() + .width(Size::fill()) + .center() + .padding(Gaps::new_all(32.)) + .spacing(8.) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + Icon::new(IconType::Users01) + .size(32.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text("No accounts yet. Add one above.") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/settings/apis.rs b/packages/oneclient_app/src/view/app/settings/apis.rs new file mode 100644 index 00000000..13dd6729 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/apis.rs @@ -0,0 +1,97 @@ +use freya::prelude::*; + +use super::settings_page; +use crate::components::{IconType, TextInput}; +use crate::hooks::{use_dispatch, use_settings_snapshot}; +use crate::view::app::settings::{section_header, settings_row}; + +fn normalize(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +#[derive(PartialEq)] +pub struct SettingsApis; + +impl Component for SettingsApis { + fn render(&self) -> impl IntoElement { + let settings = use_settings_snapshot().settings; + let dispatch = use_dispatch(); + + let modrinth_key = use_state({ + let v = settings.modrinth_api_key.clone().unwrap_or_default(); + move || v + }); + let curseforge_key = use_state({ + let v = settings.curseforge_api_key.clone().unwrap_or_default(); + move || v + }); + let custom_api_endpoint = use_state({ + let v = settings.custom_api_endpoint.clone().unwrap_or_default(); + move || v + }); + let custom_meta_url_base = use_state({ + let v = settings.custom_meta_url_base.clone().unwrap_or_default(); + move || v + }); + + let mut first = use_state(|| true); + use_side_effect(move || { + let modrinth = modrinth_key.read().clone(); + let curseforge = curseforge_key.read().clone(); + let endpoint = custom_api_endpoint.read().clone(); + let meta_url_base = custom_meta_url_base.read().clone(); + if *first.peek() { + first.set(false); + return; + } + let mut next = settings.clone(); + next.modrinth_api_key = normalize(&modrinth); + next.curseforge_api_key = normalize(&curseforge); + next.custom_api_endpoint = normalize(&endpoint); + next.custom_meta_url_base = normalize(&meta_url_base); + dispatch.set_settings(next); + }); + + settings_page() + .child(section_header("PROVIDERS")) + .child(settings_row( + IconType::Key01, + "Modrinth", + "Personal access token used for Modrinth requests.", + TextInput::new(modrinth_key) + .placeholder("Optional") + .width(Size::px(220.)), + )) + .child(settings_row( + IconType::Key01, + "CurseForge", + "API key used for CurseForge requests.", + TextInput::new(curseforge_key) + .placeholder("Default") + .width(Size::px(220.)), + )) + .child(section_header("ADVANCED")) + .child(settings_row( + IconType::Globe01, + "Custom API Endpoint", + "Override the default OneClient backend endpoint.", + TextInput::new(custom_api_endpoint) + .placeholder("Default") + .width(Size::px(220.)), + )) + .child(settings_row( + IconType::Globe01, + "Custom Meta URL Base", + "Override the default bundles and versions data host.", + TextInput::new(custom_meta_url_base) + .placeholder("Default") + .width(Size::px(220.)), + )) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/settings/appearance.rs b/packages/oneclient_app/src/view/app/settings/appearance.rs new file mode 100644 index 00000000..accfc8b6 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/appearance.rs @@ -0,0 +1,264 @@ +use freya::prelude::*; + +use super::settings_page; +use crate::components::{Icon, IconType, toggle}; +use crate::hooks::{use_dispatch, use_settings_snapshot}; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::app::settings::{settings_row, settings_row_disabled}; + +#[derive(Clone, Copy)] +struct ThemePreview { + background: Color, + accent: Color, + light: bool, +} + +fn themes() -> [ThemePreview; 5] { + [ + ThemePreview { + background: Color::from_rgb(17, 23, 28), + accent: Color::from_rgb(43, 75, 255), + light: false, + }, + ThemePreview { + background: Color::from_rgb(0, 0, 0), + accent: Color::from_rgb(111, 143, 255), + light: false, + }, + ThemePreview { + background: Color::from_rgb(40, 42, 54), + accent: Color::from_rgb(121, 112, 169), + light: false, + }, + ThemePreview { + background: Color::from_rgb(244, 250, 255), + accent: Color::from_rgb(57, 87, 255), + light: true, + }, + ThemePreview { + background: Color::from_rgb(255, 255, 255), + accent: Color::from_rgb(192, 195, 210), + light: true, + }, + ] +} + +#[derive(PartialEq)] +pub struct SettingsAppearance; + +impl Component for SettingsAppearance { + fn render(&self) -> impl IntoElement { + let selected_theme = use_state(|| 0usize); + + let settings = use_settings_snapshot().settings; + let dispatch = use_dispatch(); + + let dynamic_bg = use_state({ + let v = settings.dynamic_background_enabled; + move || v + }); + + let animations_on = use_state({ + let v = settings.animations_enabled; + move || v + }); + + let mut first = use_state(|| true); + use_side_effect(move || { + let parallax = *dynamic_bg.read(); + let animations = *animations_on.read(); + if *first.peek() { + first.set(false); + return; + } + let mut next = settings.clone(); + next.dynamic_background_enabled = parallax; + next.animations_enabled = animations; + dispatch.set_settings(next); + }); + + settings_page() + .child(theme_section(selected_theme)) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .padding(Gaps::new(8., 0., 0., 0.)) + .child(accent_color_row()) + .child(custom_theme_row()) + .child(dynamic_background_row(dynamic_bg)) + .child(animations_row(animations_on)), + ) + .child( + rect().padding(Gaps::new(8., 0., 0., 0.)).child( + label() + .text("Looking to change your language?") + .font_size(12.) + .color(colors::fg_primary()), + ), + ) + .into_element() + } +} + +fn theme_section(selected: State) -> impl IntoElement { + let list = themes(); + let current = list[(*selected.read()).min(list.len() - 1)]; + + rect() + .horizontal() + .spacing(16.) + .opacity(0.4) + .child(big_preview(current)) + .child( + rect() + .vertical() + .spacing(16.) + .child( + rect() + .horizontal() + .spacing(16.) + .children((0..3).map(|i| theme_card(list[i], i, selected))), + ) + .child( + rect() + .horizontal() + .spacing(16.) + .children((3..5).map(|i| theme_card(list[i], i, selected))), + ), + ) + .into_element() +} + +fn big_preview(theme: ThemePreview) -> impl IntoElement { + rect() + .width(Size::px(296.)) + .height(Size::px(183.)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page()) + .border(border_all_color(3., Color::from_argb(26, 255, 255, 255))) + .center() + .child( + rect() + .vertical() + .width(Size::px(168.)) + .height(Size::px(123.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(theme.background) + .border(border_all_color(1., colors::component_border())) + .padding(20.) + .spacing(20.) + .child(mock_line(108., theme.accent)) + .child(mock_line(84., line_tint(theme))) + .child(split_bar(theme.accent, 96.)), + ) + .into_element() +} + +fn theme_card(theme: ThemePreview, index: usize, selected: State) -> Element { + let is_selected = *selected.read() == index; + let border_color = if is_selected { + colors::brand() + } else { + colors::component_border() + }; + + rect() + .vertical() + .width(Size::px(126.)) + .height(Size::px(78.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(theme.background) + .border(border_all_color(1., border_color)) + .padding(8.) + .spacing(20.) + .child(mock_line(108., line_tint(theme))) + .child(split_bar(theme.accent, 96.)) + .into_element() +} + +fn mock_line(width: f32, color: Color) -> impl IntoElement { + rect() + .width(Size::px(width)) + .height(Size::px(10.)) + .corner_radius(CornerRadius::new_all(4.)) + .background(color) + .into_element() +} + +fn split_bar(accent: Color, width: f32) -> impl IntoElement { + rect() + .horizontal() + .width(Size::px(width)) + .height(Size::px(16.)) + .corner_radius(CornerRadius::new_all(4.)) + .background(accent.with_a(128)) + .child( + rect() + .width(Size::px(width / 3.)) + .height(Size::fill()) + .corner_radius(CornerRadius::new_all(4.)) + .background(accent), + ) + .into_element() +} + +fn line_tint(theme: ThemePreview) -> Color { + if theme.light { + Color::from_argb(38, 0, 0, 0) + } else { + Color::from_argb(46, 255, 255, 255) + } +} + +fn accent_color_row() -> impl IntoElement { + settings_row_disabled( + IconType::PaintPour, + "Accent color", + "The main color used across the launcher. This doesn't edit your theme.", + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_symmetric(8., 16.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::brand()) + .child(Icon::new(IconType::Colors).size(18.)) + .child( + label() + .text("#2B4BFF") + .font_size(12.) + .color(colors::fg_primary()), + ) + .into_element(), + ) +} + +fn custom_theme_row() -> impl IntoElement { + settings_row_disabled( + IconType::Colors, + "Custom theme", + "Create, edit, and import launcher themes", + Icon::new(IconType::ChevronRight).size(18.).into_element(), + ) +} + +fn dynamic_background_row(enabled: State) -> impl IntoElement { + settings_row( + IconType::Eye, + "Parallax background", + "Make the home screen background drift with your cursor. Turning it off keeps the background but disables the motion.", + toggle(enabled), + ) +} + +fn animations_row(animations_on: State) -> impl IntoElement { + settings_row( + IconType::Play, + "Animations", + "Disable all launcher animations and transitions.", + toggle(animations_on), + ) +} diff --git a/packages/oneclient_app/src/view/app/settings/changelog.rs b/packages/oneclient_app/src/view/app/settings/changelog.rs new file mode 100644 index 00000000..110d71d5 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/changelog.rs @@ -0,0 +1,192 @@ +use freya::animation::{ + AnimNum, Ease, Function, OnChange, OnCreation, use_animation_with_dependencies, +}; +use freya::prelude::*; + +use super::settings_page; +use crate::components::{Icon, IconType}; +use crate::hooks::{ + changelog_error, changelog_groups, changelog_is_loading, latest_changelog_version, + use_changelog, use_dispatch, use_settings_snapshot, +}; +use crate::theme::colors; + +#[derive(PartialEq)] +pub struct SettingsChangelog; + +impl Component for SettingsChangelog { + fn render(&self) -> impl IntoElement { + let query = use_changelog(); + let dispatch = use_dispatch(); + let settings = use_settings_snapshot().settings; + let mut marked = use_state(|| None::); + let installed_version = env!("CARGO_PKG_VERSION").to_string(); + + // Opening this page counts as reading it `marked` avoids re-sending while the settings snapshot catches up + if let Some(latest) = latest_changelog_version(&query) { + let already_marked = marked.peek().as_deref() == Some(latest.as_str()); + let already_seen = settings.seen_changelog_version.as_deref() == Some(latest.as_str()); + + if !already_marked && !already_seen { + marked.set(Some(latest.clone())); + dispatch.set_seen_changelog_version(latest); + } + } + + if changelog_is_loading(&query) { + return settings_page() + .child( + label() + .text("Loading changelog...") + .font_size(14.) + .color(colors::fg_secondary()), + ) + .into_element(); + } + + if let Some(error) = changelog_error(&query) { + return settings_page() + .child( + label() + .text(format!("Couldn't load changelog: {error}")) + .font_size(14.) + .color(colors::fg_secondary()), + ) + .into_element(); + } + + let groups = changelog_groups(&query).unwrap_or_default(); + + settings_page() + .children(groups.into_iter().enumerate().map(|(i, group)| { + let current = group.version == installed_version; + ReleaseCard { + version: group.version, + current, + changes: group.changes, + initially_open: i == 0, + } + .into_element() + })) + .into_element() + } +} + +/// `ChevronDown` swung a quarter turn anticlockwise points right +const CHEVRON_CLOSED_DEG: f32 = -90.; + +#[derive(PartialEq)] +struct ReleaseCard { + version: String, + current: bool, + changes: Vec, + initially_open: bool, +} + +impl Component for ReleaseCard { + fn render(&self) -> impl IntoElement { + let mut open = use_state(|| self.initially_open); + let is_open = *open.read(); + + // One chevron that swings so the arrow tracks the section instead of cutting to a different glyph + let swing = use_animation_with_dependencies(&is_open, |conf, open| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + let (from, to) = if *open { + (CHEVRON_CLOSED_DEG, 0.) + } else { + (0., CHEVRON_CLOSED_DEG) + }; + AnimNum::new(from, to) + .time(180) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let chevron_deg = swing.get().value(); + + let title = if self.current { + format!("{} (Currently Installed)", self.version) + } else { + self.version.clone() + }; + + let changes = self.changes.clone(); + + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .main_align(Alignment::SpaceBetween) + .cursor(CursorIcon::Pointer) + .on_press(move |_| { + let next = !*open.peek(); + *open.write() = next; + }) + .child( + label() + .text(title) + .font_size(18.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + Icon::new(IconType::ChevronDown) + .size(18.) + .rotate(chevron_deg), + ), + ) + .maybe_child(is_open.then(|| { + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .padding(Gaps::new(0., 0., 0., 6.)) + .children(if changes.is_empty() { + vec![ + rect() + .child( + label() + .text("No changes recorded for this version.") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element(), + ] + } else { + changes + .into_iter() + .map(|change| { + rect() + .horizontal() + .width(Size::fill()) + .spacing(8.) + .child( + label() + .text("•") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(change) + .font_size(12.) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .into_element() + }) + .collect() + }) + .into_element() + })) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/settings/developer.rs b/packages/oneclient_app/src/view/app/settings/developer.rs new file mode 100644 index 00000000..3c28b5d1 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/developer.rs @@ -0,0 +1,69 @@ +use freya::prelude::*; +use freya::router::RouterContext; + +use super::settings_page; +use crate::Route; +use crate::components::{IconType, link_button, toggle}; +use crate::hooks::{use_browser_compat, use_dispatch, use_settings_snapshot}; +use crate::view::app::settings::{section_header, settings_row}; +use crate::view::console::open_log_console; + +#[derive(PartialEq)] +pub struct SettingsDeveloper; + +impl Component for SettingsDeveloper { + fn render(&self) -> impl IntoElement { + let settings = use_settings_snapshot().settings; + let dispatch = use_dispatch(); + + let log_debug = use_state({ + let v = settings.log_debug; + move || v + }); + + let mut first = use_state(|| true); + use_side_effect(move || { + let enabled = *log_debug.read(); + if *first.peek() { + first.set(false); + return; + } + let mut next = settings.clone(); + next.log_debug = enabled; + dispatch.set_settings(next); + }); + + let browser_compat = use_browser_compat(); + + settings_page() + .child(section_header("BROWSER")) + .child(settings_row( + IconType::SearchMd, + "Compatible content only", + "Filter the content browser to the active cluster's version and loader.", + toggle(browser_compat), + )) + .child(section_header("DEV TOOLS")) + .child(settings_row( + IconType::Sliders04, + "Log Debug Info", + "WARNING! This requires a restart to apply. Logs out debug info.", + toggle(log_debug), + )) + .child(settings_row( + IconType::Terminal, + "Log Console", + "Stream the launcher's logs in a separate window. Nothing is captured while it is closed.", + link_button().on_press(move |_| open_log_console()), + )) + .child(settings_row( + IconType::CodeSnippet02, + "Debug Page", + "View the debug page.", + link_button().on_press(move |_| { + let _ = RouterContext::get().push(Route::Debug {}); + }), + )) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/settings/feedback.rs b/packages/oneclient_app/src/view/app/settings/feedback.rs new file mode 100644 index 00000000..d60b586d --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/feedback.rs @@ -0,0 +1,55 @@ +use freya::prelude::*; + +use crate::components::{Button, TextInput}; +use crate::layout::{SettingsShell, SettingsTab}; +use crate::theme::colors; + +#[derive(PartialEq)] +pub struct SettingsFeedback; + +impl Component for SettingsFeedback { + fn render(&self) -> impl IntoElement { + SettingsShell::new(SettingsTab::Feedback, "Feedback") + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .padding(Gaps::new(0., 0., 8., 0.)) + .child( + label() + .text("Help us improve OneClient") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("Tell us what's working, what isn't, and what you'd like to see next.") + .font_size(12.) + .color(colors::fg_primary()), + ), + ) + .child(feedback_box()) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .padding(Gaps::new(12., 0., 0., 0.)) + .child( + Button::new().primary().text("Send Feedback"), + ), + ) + .into_element() + } +} + +fn feedback_box() -> impl IntoElement { + let feedback = use_state(String::new); + + TextInput::new(feedback) + .width(Size::fill()) + .placeholder("Write your feedback here...") + .height(Size::px(160.)) +} diff --git a/packages/oneclient_app/src/view/app/settings/java.rs b/packages/oneclient_app/src/view/app/settings/java.rs new file mode 100644 index 00000000..bf191f72 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/java.rs @@ -0,0 +1,293 @@ +use freya::prelude::*; +use oneclient_java::{JavaRuntime, JavaVendor}; + +use super::settings_page; +use crate::components::{Button, Icon, IconType, JavaInstallManager, ScrollArea}; +use crate::hooks::{Actions, java_runtimes, use_dispatch, use_java_runtimes}; +use crate::invalidate_java_queries; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::app::settings::section_header; + +#[derive(PartialEq)] +pub struct SettingsJava; + +impl Component for SettingsJava { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let runtimes_query = use_java_runtimes(); + let runtimes = java_runtimes(&runtimes_query); + let mut show_manager = use_state(|| false); + + fn invalidate_runtimes(dispatch: Actions) { + spawn(async move { + invalidate_java_queries().await; + dispatch + .notify("Java runtimes refreshed") + .body("The installed runtime list is up to date") + .info() + .send(); + }); + } + + let refresh_dispatch = dispatch.clone(); + + let mut shell = settings_page() + .child(section_header("ADD RUNTIME")) + .child(AddRow { show_manager }.into_element()) + .child( + rect() + .width(Size::Fill) + .direction(Direction::Horizontal) + .main_align(Alignment::SpaceBetween) + .cross_align(Alignment::Center) + .child(section_header("INSTALLED RUNTIMES")) + .child( + Button::new() + .secondary() + .small() + .enabled(false) // disabled for now + .on_press(move |_| { + invalidate_runtimes(refresh_dispatch.clone()); + }) + .child(label().text("Refresh")), + ), + ) + .child(runtimes_table(runtimes)); + + if *show_manager.read() { + shell = shell.child( + JavaInstallManager::new() + .on_install(move |(vendor, major): (JavaVendor, u32)| { + dispatch.install_java_runtime(vendor, major); + show_manager.set(false); + }) + .on_close(move |()| show_manager.set(false)) + .into_element(), + ); + } + + shell.into_element() + } +} + +#[derive(PartialEq)] +struct AddRow { + show_manager: State, +} + +impl Component for AddRow { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let mut show_manager = self.show_manager; + + let pick = move |_| { + let dispatch = dispatch.clone(); + spawn(async move { + if let Some(handle) = rfd::AsyncFileDialog::new() + .set_title("Select a Java installation folder") + .pick_folder() + .await + { + dispatch.add_custom_java_runtime(handle.path().to_path_buf()); + } + }); + }; + + rect() + .horizontal() + .width(Size::fill()) + .spacing(10.) + .child( + Button::new() + .primary() + .on_press(move |_| show_manager.set(true)) + .child(Icon::new(IconType::Download01).size(14.)) + .text("Install Manager"), + ) + .child( + Button::new() + .secondary() + .on_press(pick) + .child(Icon::new(IconType::Folder).size(14.)) + .text("Add from folder"), + ) + } +} + +fn runtimes_table(runtimes: Vec) -> impl IntoElement { + if runtimes.is_empty() { + return rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(16., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + label() + .text("No Java runtimes installed yet.") + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element(); + } + + let mut table = rect() + .vertical() + .width(Size::fill()) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .overflow(Overflow::Clip) + .child(table_header()); + + let count = runtimes.len(); + for (idx, runtime) in runtimes.into_iter().enumerate() { + table = table.child( + RuntimeRow { + runtime, + last: idx + 1 == count, + } + .into_element(), + ); + } + + table.into_element() +} + +fn table_header() -> impl IntoElement { + fn head(text: &'static str, width: Size) -> impl IntoElement { + rect() + .width(width) + .child( + label() + .text(text) + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .into_element() + } + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_symmetric(8., 14.)) + .background(colors::component_bg()) + .child(head("VENDOR", Size::px(130.))) + .child(head("VERSION", Size::px(90.))) + .child( + rect().width(Size::flex(1.0)).child( + label() + .text("PATH") + .font_size(11.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ), + ) + .child(rect().width(Size::px(34.))) + .into_element() +} + +#[derive(PartialEq)] +struct RuntimeRow { + runtime: JavaRuntime, + last: bool, +} + +impl Component for RuntimeRow { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let runtime = &self.runtime; + let path = runtime.absolute_path.clone(); + + // so the scrollarea becomes bigger when horizontal scroll bar is visible (then it won't obscure the file path) + let mut viewport_w = use_state(|| 0f32); + let content_w = path_content_width(&runtime.absolute_path); + let measured_w = *viewport_w.read(); + let has_scrollbar = measured_w <= 0. || measured_w < content_w; + let path_height = if has_scrollbar { 28. } else { 18. }; + + fn cell(text: String, width: Size) -> impl IntoElement { + rect() + .width(width) + .child( + label() + .text(text) + .font_size(13.) + .color(colors::fg_primary()), + ) + .into_element() + } + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new_symmetric(8., 14.)) + .maybe(!self.last, |el| { + el.border( + Border::new() + .width(BorderWidth { + bottom: 1., + ..Default::default() + }) + .fill(colors::component_border()), + ) + }) + .child(cell( + format!("{} {}", runtime.vendor, runtime.major), + Size::px(130.), + )) + .child(cell(runtime.version.clone(), Size::px(90.))) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(path_height)) + .overflow(Overflow::Clip) + .on_sized(move |e: Event| { + let w = e.area.width(); + if (*viewport_w.read() - w).abs() > 0.5 { + viewport_w.set(w); + } + }) + .child( + ScrollArea::new() + .horizontal(content_w) + .width(Size::fill()) + .height(Size::px(path_height)) + .show_scrollbar(false) + .child( + label() + .text(runtime.absolute_path.clone()) + .font_size(12.) + .max_lines(1) + .color(colors::fg_secondary()) + .into_element(), + ), + ), + ) + .child(remove_button(dispatch, path)) + } +} + +fn path_content_width(path: &str) -> f32 { + (path.chars().count() as f32 * 7.0).max(1.0) +} + +fn remove_button(dispatch: Actions, path: String) -> impl IntoElement { + Button::new() + .ghost() + .small() + .on_press(move |_| dispatch.remove_java_runtime(path.clone())) + .child( + Icon::new(IconType::Trash01) + .size(14.) + .color(colors::danger()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/settings/language.rs b/packages/oneclient_app/src/view/app/settings/language.rs new file mode 100644 index 00000000..809e7b8d --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/language.rs @@ -0,0 +1,70 @@ +use freya::prelude::*; + +use super::settings_page; +use crate::components::{Icon, IconType}; +use crate::theme::colors; +use crate::view::app::settings::section_header; + +const LANGUAGES: &[(&str, &str)] = &[("English", "English (US)")]; + +#[derive(PartialEq)] +pub struct SettingsLanguage; + +impl Component for SettingsLanguage { + fn render(&self) -> impl IntoElement { + let selected = use_state(|| 0usize); + + settings_page() + .child(section_header("AVAILABLE LANGUAGES")) + .children(LANGUAGES.iter().enumerate().map(|(i, (native, english))| { + language_row(i, native, english, selected).into_element() + })) + .into_element() + } +} + +fn language_row( + index: usize, + native: &'static str, + english: &'static str, + mut selected: State, +) -> impl IntoElement { + let is_selected = *selected.read() == index; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .cursor(CursorIcon::Pointer) + .on_press(move |_| *selected.write() = index) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(native) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(english) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .maybe_child(is_selected.then(|| { + Icon::new(IconType::Check) + .size(20.) + .color(colors::brand()) + .into_element() + })) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/settings/launcher.rs b/packages/oneclient_app/src/view/app/settings/launcher.rs new file mode 100644 index 00000000..c5420fb7 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/launcher.rs @@ -0,0 +1,71 @@ +use freya::prelude::*; + +use super::settings_page; +use crate::components::{IconType, link_button, toggle}; +use crate::hooks::{use_dispatch, use_launcher, use_settings_snapshot}; +use crate::platform; +use crate::view::app::settings::{section_header, settings_row}; + +#[derive(PartialEq)] +pub struct SettingsLauncher; + +impl Component for SettingsLauncher { + fn render(&self) -> impl IntoElement { + let settings = use_settings_snapshot().settings; + let data_dir = use_launcher().data_dir; + let dispatch = use_dispatch(); + + let discord_rpc = use_state({ + let v = settings.discord_enabled; + move || v + }); + + let crash_reporting = use_state({ + let v = settings.crash_reporting; + move || v + }); + + let mut first = use_state(|| true); + { + let settings = settings.clone(); + use_side_effect(move || { + let discord = *discord_rpc.read(); + let crash = *crash_reporting.read(); + if *first.peek() { + first.set(false); + return; + } + let mut next = settings.clone(); + next.discord_enabled = discord; + next.crash_reporting = crash; + dispatch.set_settings(next); + }); + } + + let folder = data_dir.clone(); + let open_folder = link_button().on_press(move |_| platform::open_url(&folder)); + + settings_page() + .child(section_header("GENERAL")) + .child(settings_row( + IconType::Link03, + "Discord RPC", + "Enable Discord Rich Presence.", + toggle(discord_rpc), + )) + .child(settings_row( + IconType::AlertTriangle, + "Crash Reporting", + "Send anonymous crash and error reports to help fix bugs. Applies on restart.", + toggle(crash_reporting), + )) + .child(section_header("FOLDERS AND FILES")) + .child(settings_row( + IconType::Folder, + "Launcher Folder", + data_dir, + open_folder, + )) + .into_element() + } +} diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs new file mode 100644 index 00000000..43758807 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs @@ -0,0 +1,237 @@ +use freya::prelude::*; +use oneclient_common::Patch; +use oneclient_core::settings::{PackageUpdateMode, ProfileUpdate, Resolution}; + +use super::settings_page; +use crate::components::{ + Dropdown, Icon, IconType, TextInput, memory_field, toggle, validate_number, +}; +use crate::hooks::{use_dispatch, use_settings_snapshot}; +use crate::theme::colors; +use crate::view::app::settings::{section_header, settings_row}; + +#[derive(PartialEq)] +pub struct SettingsMinecraft; + +impl Component for SettingsMinecraft { + fn render(&self) -> impl IntoElement { + let profile = use_settings_snapshot().settings.global_game_settings; + let dispatch = use_dispatch(); + + let fullscreen = use_state({ + let v = profile.force_fullscreen.unwrap_or(false); + move || v + }); + let width = use_state({ + let v = profile + .resolution + .map(|r| r.width.to_string()) + .unwrap_or_default(); + move || v + }); + let height = use_state({ + let v = profile + .resolution + .map(|r| r.height.to_string()) + .unwrap_or_default(); + move || v + }); + let memory = use_state({ + let v = profile.mem_max.map(|m| m.to_string()).unwrap_or_default(); + move || v + }); + let jvm_args = use_state({ + let v = profile.launch_args.clone().unwrap_or_default(); + move || v + }); + let pre_launch_command = use_state({ + let v = profile.hook_pre.clone().unwrap_or_default(); + move || v + }); + let wrapper_command = use_state({ + let v = profile.hook_wrapper.clone().unwrap_or_default(); + move || v + }); + let post_exit_command = use_state({ + let v = profile.hook_post.clone().unwrap_or_default(); + move || v + }); + + let mut first = use_state(|| true); + let batched = dispatch.clone(); + use_side_effect(move || { + let update = build_update( + *fullscreen.read(), + &width.read(), + &height.read(), + &memory.read(), + &jvm_args.read(), + &pre_launch_command.read(), + &wrapper_command.read(), + &post_exit_command.read(), + ); + if *first.peek() { + first.set(false); + return; + } + batched.update_global_profile(update); + }); + + settings_page() + .child(section_header("GAME")) + .child(settings_row( + IconType::Maximize01, + "Force Fullscreen", + "Force Minecraft to start in fullscreen mode.", + toggle(fullscreen), + )) + .child(settings_row( + IconType::LayoutTop, + "Resolution", + "The game window resolution in pixels.", + resolution_field(width, height), + )) + .child(settings_row( + IconType::Database01, + "Memory", + "The amount of memory in megabytes allocated for the game. Presets leave 2 GB for the system.", + memory_field(memory), + )) + .child(settings_row( + IconType::Terminal, + "JVM Arguments", + "Extra arguments passed to Java. Separate them with spaces; quote values containing spaces.", + TextInput::new(jvm_args) + .placeholder("-XX:+UseG1GC") + .width(Size::px(220.)), + )) + .child(section_header("CONTENT")) + .child(settings_row( + IconType::RefreshCw01, + "Browser Package Updates", + "What to do when content you installed from the browser has a newer version. Packs from bundles are not affected.", + update_mode_field( + profile.browser_update_mode.unwrap_or_default(), + dispatch, + ), + )) + .child(section_header("PROCESS")) + .child(settings_row( + IconType::FilePlus02, + "Pre-Launch Command", + "Command to run before launching the game.", + TextInput::new(pre_launch_command) + .placeholder("echo 'Game started'") + .width(Size::px(220.)), + )) + .child(settings_row( + IconType::ParagraphWrap, + "Wrapper Command", + "Command to run when launching the game.", + TextInput::new(wrapper_command) + .placeholder("gamescope") + .width(Size::px(220.)), + )) + .child(settings_row( + IconType::FileX02, + "Post-Exit Command", + "Command to run after exiting the game.", + TextInput::new(post_exit_command) + .placeholder("echo 'Game exited'") + .width(Size::px(220.)), + )) + .into_element() + } +} + +#[allow(clippy::too_many_arguments)] +fn build_update( + fullscreen: bool, + width: &str, + height: &str, + memory: &str, + jvm_args: &str, + pre: &str, + wrapper: &str, + post: &str, +) -> ProfileUpdate { + let resolution = match (width.trim(), height.trim()) { + ("", "") => Patch::Clear, + (w, h) => match (w.parse::(), h.parse::()) { + (Ok(w), Ok(h)) => Patch::Set(Resolution::new(w, h)), + _ => Patch::Unchanged, + }, + }; + + let mem_max = match memory.trim() { + "" => Patch::Clear, + m => m.parse::().map(Patch::Set).unwrap_or(Patch::Unchanged), + }; + + ProfileUpdate { + force_fullscreen: Patch::Set(fullscreen), + resolution, + mem_max, + launch_args: command_patch(jvm_args), + hook_pre: command_patch(pre), + hook_wrapper: command_patch(wrapper), + hook_post: command_patch(post), + ..Default::default() + } +} + +/// Dispatched separately from [`build_update`] which debounces keystrokes a dropdown has no intermediate states +fn update_mode_field(selected: PackageUpdateMode, dispatch: crate::Actions) -> impl IntoElement { + let options: Vec = PackageUpdateMode::ALL + .iter() + .map(|mode| mode.label().to_string()) + .collect(); + + Dropdown::new(selected.label(), options) + .width(Size::px(220.)) + .height(Size::px(34.)) + .on_select(move |idx: usize| { + if let Some(mode) = PackageUpdateMode::ALL.get(idx).copied() { + dispatch.update_global_profile(ProfileUpdate { + browser_update_mode: Patch::Set(mode), + ..Default::default() + }); + } + }) +} + +fn command_patch(value: &str) -> Patch { + match value.trim() { + "" => Patch::Clear, + v => Patch::Set(v.to_string()), + } +} + +fn resolution_field(width: State, height: State) -> impl IntoElement { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + TextInput::new(width) + .placeholder("854") + .on_validate(validate_number) + .text_align(TextAlign::Center) + .width(Size::px(70.)) + .text_align(TextAlign::Center), + ) + .child( + Icon::new(IconType::X) + .size(14.) + .color(colors::fg_secondary()), + ) + .child( + TextInput::new(height) + .placeholder("480") + .on_validate(validate_number) + .text_align(TextAlign::Center) + .width(Size::px(70.)) + .text_align(TextAlign::Center), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/settings/mod.rs b/packages/oneclient_app/src/view/app/settings/mod.rs new file mode 100644 index 00000000..5f41b7b1 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/mod.rs @@ -0,0 +1,106 @@ +mod accounts; +mod appearance; + +mod apis; +mod changelog; +mod developer; +mod java; +mod language; +mod launcher; +mod minecraft; +mod storage; + +use freya::prelude::*; + +pub use accounts::SettingsAccounts; +pub use apis::SettingsApis; +pub use appearance::SettingsAppearance; +pub use changelog::SettingsChangelog; +pub use developer::SettingsDeveloper; +pub use java::SettingsJava; +pub use language::SettingsLanguage; +pub use launcher::SettingsLauncher; +pub use minecraft::SettingsMinecraft; +pub use storage::SettingsStorage; + +use crate::{ + components::{Icon, IconType}, + theme::colors, +}; + +pub fn settings_page() -> Rect { + rect().vertical().width(Size::fill()).spacing(4.) +} + +pub fn section_header(text: &'static str) -> impl IntoElement { + rect() + .padding(Gaps::new(16., 0., 8., 2.)) + .child( + label() + .text(text) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .into_element() +} + +pub fn settings_row( + icon: IconType, + title: &'static str, + description: impl Into, + trailing: impl IntoElement, +) -> impl IntoElement { + settings_row_inner(icon, title, description, trailing, false) +} + +pub fn settings_row_disabled( + icon: IconType, + title: &'static str, + description: impl Into, + trailing: impl IntoElement, +) -> impl IntoElement { + settings_row_inner(icon, title, description, trailing, true) +} + +fn settings_row_inner( + icon: IconType, + title: &'static str, + description: impl Into, + trailing: impl IntoElement, + disabled: bool, +) -> impl IntoElement { + let description = description.into(); + rect() + .maybe(disabled, |el| el.opacity(0.4)) + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child(Icon::new(icon)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title) + .font_size(16.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text(description) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child(trailing) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/settings/storage.rs b/packages/oneclient_app/src/view/app/settings/storage.rs new file mode 100644 index 00000000..00d97769 --- /dev/null +++ b/packages/oneclient_app/src/view/app/settings/storage.rs @@ -0,0 +1,325 @@ +use freya::prelude::*; +use oneclient_core::storage::{ReclaimableEntry, StorageEntry, StorageReport, format_bytes}; + +use super::{section_header, settings_page}; +use crate::components::{Button, Icon, IconType, open_folder_button}; +use crate::hooks::{ + StorageAction, mutation_is_running, try_storage_report, use_storage_action, use_storage_report, +}; +use crate::theme::colors; + +/// Rows narrower than this would render as a sliver so they get a floor +const MIN_BAR_FRACTION: f32 = 0.015; + +#[derive(PartialEq)] +pub struct SettingsStorage; + +impl Component for SettingsStorage { + fn render(&self) -> impl IntoElement { + // Every hook before any early return the report is absent on the first render and a later-only hook would change the hook order + let report_query = use_storage_report(); + + let Some(report) = try_storage_report(&report_query) else { + return settings_page().child(hero_placeholder()).into_element(); + }; + + let refresh = Button::new() + .secondary() + .small() + .on_press(move |_| { + report_query.invalidate(); + }) + .child(label().text("Refresh")); + + let mut page = settings_page() + .child(hero(&report, refresh.into_element())) + .child(section_header("FREE UP SPACE")) + .child( + ReclaimRow { + icon: IconType::FileX02, + title: "Unreferenced cache files", + description: unused_cache_description(&report.unreferenced_cache), + action: StorageAction::CleanUnreferencedCache, + empty: report.unreferenced_cache.is_empty(), + } + .into_element(), + ) + .child( + ReclaimRow { + icon: IconType::Trash01, + title: "Leftover cluster content", + description: legacy_content_description(&report.legacy_cluster_content), + action: StorageAction::CleanLegacyClusterContent, + empty: report.legacy_cluster_content.is_empty(), + } + .into_element(), + ); + + page = page.child(section_header("WHAT'S USING SPACE")); + page = match largest(&report.categories) { + Some(largest) => { + let mut list = page; + for entry in report.categories.iter().filter(|e| e.bytes > 0) { + list = list.child(usage_row(entry, largest, colors::brand())); + } + list + } + None => page.child(empty_note("Nothing stored yet.")), + }; + + page = page.child(section_header("CLUSTERS")); + page = match largest(&report.clusters) { + Some(largest) => { + let mut list = page; + for entry in report.clusters.iter().filter(|e| e.bytes > 0) { + list = list.child(usage_row(entry, largest, colors::fg_secondary())); + } + list + } + None => page.child(empty_note("No cluster is using any space.")), + }; + + page.child(footnote()).into_element() + } +} + +fn hero(report: &StorageReport, refresh: Element) -> impl IntoElement { + let reclaimable = report.unreferenced_cache.bytes + report.legacy_cluster_content.bytes; + + let subtitle = if reclaimable > 0 { + format!("{} can be freed", format_bytes(reclaimable)) + } else { + "Nothing to free up".to_string() + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_symmetric(20., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(format_bytes(report.total_bytes)) + .font_size(28.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle) + .font_size(12.) + .color(if reclaimable > 0 { + colors::brand() + } else { + colors::fg_secondary() + }), + ), + ) + .child(refresh) + .into_element() +} + +fn hero_placeholder() -> impl IntoElement { + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(20., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + label() + .text("Measuring disk usage…") + .font_size(16.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +/// A component rather than a function because it owns a hook each instance gets its own scope so a button tracks only its own progress +#[derive(PartialEq)] +struct ReclaimRow { + icon: IconType, + title: &'static str, + description: String, + action: StorageAction, + empty: bool, +} + +impl Component for ReclaimRow { + fn render(&self) -> impl IntoElement { + let action = use_storage_action(); + let running = mutation_is_running(&action); + + let kind = self.action; + let button = Button::new() + .secondary() + .small() + .disabled(self.empty || running) + .on_press(move |_| { + action.mutate(kind); + }) + .child(label().text(if running { "Cleaning…" } else { "Clean up" })); + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .maybe(self.empty, |el| el.opacity(0.55)) + .child(Icon::new(self.icon)) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(self.title) + .font_size(16.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text(self.description.clone()) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child(button) + .into_element() + } +} + +fn usage_row(entry: &StorageEntry, largest: u64, bar: Color) -> impl IntoElement { + let fraction = if largest == 0 { + 0.0 + } else { + (entry.bytes as f32 / largest as f32).max(MIN_BAR_FRACTION) + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(16.) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(6.) + .child( + label() + .text(entry.label.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child(proportion_bar(fraction, bar)), + ) + .child( + label() + .text(format_bytes(entry.bytes)) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child(open_folder_button(entry.path.clone())) + .into_element() +} + +fn proportion_bar(fraction: f32, fill: Color) -> impl IntoElement { + rect() + .width(Size::fill()) + .height(Size::px(4.)) + .corner_radius(CornerRadius::new_all(2.)) + .background(colors::component_bg()) + .child( + rect() + .width(Size::percent(fraction.clamp(0.0, 1.0) * 100.)) + .height(Size::fill()) + .corner_radius(CornerRadius::new_all(2.)) + .background(fill), + ) + .into_element() +} + +fn largest(entries: &[StorageEntry]) -> Option { + entries.iter().map(|e| e.bytes).max().filter(|max| *max > 0) +} + +fn unused_cache_description(entry: &ReclaimableEntry) -> String { + if entry.is_empty() { + return "Nothing here — every cached file belongs to an installed package.".to_string(); + } + + format!( + "{} across {} file{} that no installed package points at.", + format_bytes(entry.bytes), + entry.files, + plural(entry.files) + ) +} + +fn legacy_content_description(entry: &ReclaimableEntry) -> String { + if entry.is_empty() { + return "Nothing here — no cluster folder is holding old content.".to_string(); + } + + format!( + "{} across {} file{} left in cluster folders by an older version. Already ignored when \ + you play; this just reclaims the space.", + format_bytes(entry.bytes), + entry.files, + plural(entry.files) + ) +} + +fn empty_note(text: &'static str) -> impl IntoElement { + rect() + .width(Size::fill()) + .padding(Gaps::new_symmetric(14., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .child( + label() + .text(text) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn footnote() -> impl IntoElement { + rect() + .padding(Gaps::new(16., 4., 8., 4.)) + .child( + label() + .text( + "Packages live in a shared cache and are copied into the game folder only \ + while you play. Removing a package frees its cache entry automatically.", + ) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn plural(count: usize) -> &'static str { + if count == 1 { "" } else { "s" } +} diff --git a/packages/oneclient_app/src/view/app/skins.rs b/packages/oneclient_app/src/view/app/skins.rs new file mode 100644 index 00000000..5ea92368 --- /dev/null +++ b/packages/oneclient_app/src/view/app/skins.rs @@ -0,0 +1,169 @@ +use freya::prelude::*; + +use crate::components::{Button, ButtonVariant, Icon, IconType, PlayerModel}; +use crate::hooks::{try_default_account, use_current_account}; +use crate::theme::colors; +use crate::ui::border_all_color; + +const ANIMATIONS: &[&str] = &["Idle", "Walking", "Running", "Crouch", "Flying"]; + +#[derive(PartialEq)] +pub struct AccountSkins; + +impl Component for AccountSkins { + fn render(&self) -> impl IntoElement { + let account_uuid = try_default_account(&use_current_account()).map(|a| a.id.to_string()); + + rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(40.) + .spacing(24.) + .child(preview_panel(account_uuid)) + .child(side_panel()) + } +} + +fn preview_panel(account_uuid: Option) -> impl IntoElement { + rect() + .vertical() + .width(Size::px(320.)) + .height(Size::fill()) + .spacing(16.) + .child( + rect() + .center() + .width(Size::fill()) + .height(Size::flex(1.0)) + .corner_radius(CornerRadius::new_all(16.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child(match account_uuid { + Some(uuid) => PlayerModel::new(uuid) + .width(Size::fill()) + .height(Size::fill()) + .into_element(), + None => Icon::new(IconType::Users01) + .size(64.) + .color(colors::fg_secondary()) + .into_element(), + }), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::Center) + .spacing(8.) + .children( + ANIMATIONS + .iter() + .enumerate() + .map(|(i, name)| anim_chip(name, i == 0).into_element()), + ), + ) + .into_element() +} + +fn anim_chip(name: &'static str, active: bool) -> impl IntoElement { + rect() + .center() + .padding(Gaps::new_symmetric(6., 12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(if active { + colors::brand() + } else { + colors::component_bg() + }) + .cursor(CursorIcon::Pointer) + .child( + label() + .text(name) + .font_size(12.) + .color(colors::fg_primary()), + ) + .into_element() +} + +fn side_panel() -> impl IntoElement { + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .overflow(Overflow::Clip) + .spacing(20.) + .child( + label() + .text("Skins") + .font_size(32.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .horizontal() + .spacing(12.) + .child(action_button("Import Skin", IconType::Plus, true)) + .child(action_button("Download", IconType::Download01, false)) + .child(action_button("Remove", IconType::Trash01, false)), + ) + .child(section_label("SKIN HISTORY")) + .child(skin_grid(8)) + .child(section_label("CAPES")) + .child(skin_grid(4)) + .into_element() +} + +fn action_button(text: &'static str, icon: IconType, primary: bool) -> impl IntoElement { + Button::new() + .variant(if primary { + ButtonVariant::Primary + } else { + ButtonVariant::Secondary + }) + .child(Icon::new(icon).size(16.).color(colors::fg_primary())) + .text(text) + .into_element() +} + +fn section_label(text: &'static str) -> impl IntoElement { + label() + .text(text) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_secondary()) + .into_element() +} + +fn skin_grid(count: usize) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .spacing(12.) + .children((0..count).map(|i| { + rect() + .center() + .width(Size::px(64.)) + .height(Size::px(80.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::page_elevated()) + .border(border_all_color( + if i == 0 { 2. } else { 1. }, + if i == 0 { + colors::brand() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .child( + Icon::new(IconType::Users01) + .size(24.) + .color(colors::fg_secondary()), + ) + .into_element() + })) + .into_element() +} diff --git a/packages/oneclient_app/src/view/app/stats.rs b/packages/oneclient_app/src/view/app/stats.rs new file mode 100644 index 00000000..49340a53 --- /dev/null +++ b/packages/oneclient_app/src/view/app/stats.rs @@ -0,0 +1,69 @@ +use freya::prelude::*; + +use crate::components::ScrollArea; +use crate::hooks::{try_global_analytics, use_global_analytics}; +use crate::theme::colors; +use crate::ui::centered_note; +use crate::view::app::{analytics_body, analytics_placeholder}; + +#[derive(PartialEq)] +pub struct Stats; + +impl Component for Stats { + fn render(&self) -> impl IntoElement { + let query = use_global_analytics(); + let analytics = try_global_analytics(&query); + + let body: Element = match &analytics { + None => loading_state(), + Some(a) if a.playtime.session_count == 0 => { + analytics_placeholder("No playtime recorded yet. Launch a game and come back!") + } + Some(a) => analytics_body(a), + }; + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::fill()) + .child( + rect() + .vertical() + .width(Size::fill()) + .padding(Gaps::new(0., 40., 40., 40.)) + .spacing(24.) + .child(header()) + .child(body), + ), + ) + } +} + +fn header() -> Element { + rect() + .vertical() + .spacing(2.) + .child( + label() + .text("Statistics") + .font_size(32.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text("How, when, and how much you play.") + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} + +fn loading_state() -> Element { + centered_note("Crunching your playtime…") +} diff --git a/packages/oneclient_app/src/view/console.rs b/packages/oneclient_app/src/view/console.rs new file mode 100644 index 00000000..85bfcd41 --- /dev/null +++ b/packages/oneclient_app/src/view/console.rs @@ -0,0 +1,298 @@ +//! Its own Freya window sharing no radio station or query storage with the main one all it needs is the process-wide channel in [`oneclient_core::logger::console`] + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; + +use freya::prelude::*; +use freya::winit::window::WindowId; +use oneclient_core::logger; +use oneclient_core::logger::console::{self, ConsoleLine}; +use tokio::sync::broadcast::error::RecvError; +use tracing::Level; + +use crate::components::{Button, Icon, IconType, LogViewer, Segment, SegmentedControl, TextInput}; +use crate::theme::colors; + +const WINDOW_TITLE: &str = "OneClient — Log Console"; + +/// Old lines are dropped from the front this window can be left open for a whole session +const MAX_LINES: usize = 5_000; + +/// `Opening` is cleared by the window itself not the task that asks for it so a cancelled launch cannot strand the state +enum ConsoleWindow { + Closed, + Opening, + Open(WindowId), +} + +static CONSOLE_WINDOW: Mutex = Mutex::new(ConsoleWindow::Closed); + +pub fn open_log_console() { + { + let mut state = CONSOLE_WINDOW.lock().expect("console window state"); + match *state { + ConsoleWindow::Open(id) => { + Platform::get().focus_window(Some(id)); + return; + } + ConsoleWindow::Opening => return, + ConsoleWindow::Closed => *state = ConsoleWindow::Opening, + } + } + + // Taken in the caller's scope the future below runs detached and `Platform::get()` needs a component scope + let platform = Platform::get(); + spawn(async move { + platform.launch_window(window_config()).await; + }); +} + +fn window_config() -> WindowConfig { + WindowConfig::new_app(LogConsoleApp) + .with_title(WINDOW_TITLE) + .with_app_id(crate::constants::WINDOW_APP_ID) + .with_icon(LaunchConfig::window_icon(include_bytes!( + "../../icons/128x128.png" + ))) + .with_size(1000., 640.) + .with_min_size(520., 320.) + .with_background(colors::page()) + // Native decorations a developer tool is not worth a second bespoke titlebar + .with_decorations(true) + .with_window_handle(|window| { + *CONSOLE_WINDOW.lock().expect("console window state") = + ConsoleWindow::Open(window.id()); + }) + .with_on_close(|_, _| { + *CONSOLE_WINDOW.lock().expect("console window state") = ConsoleWindow::Closed; + CloseDecision::Close + }) +} + +struct LogConsoleApp; + +impl App for LogConsoleApp { + fn render(&self) -> impl IntoElement { + LogConsole + } +} + +/// A floor rather than an exact match unlike the cluster logs page +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Severity { + All, + Debug, + Info, + Warn, + Error, +} + +impl Severity { + fn floor(self) -> Option { + match self { + Self::All => None, + Self::Debug => Some(Level::DEBUG), + Self::Info => Some(Level::INFO), + Self::Warn => Some(Level::WARN), + Self::Error => Some(Level::ERROR), + } + } + + fn label(self) -> &'static str { + match self { + Self::All => "All", + Self::Debug => "Debug", + Self::Info => "Info", + Self::Warn => "Warnings", + Self::Error => "Errors", + } + } +} + +#[derive(PartialEq)] +struct LogConsole; + +impl Component for LogConsole { + fn render(&self) -> impl IntoElement { + let mut entries = use_state(VecDeque::::new); + let severity = use_state(|| Severity::All); + let search = use_state(String::new); + let directives = use_state(logger::active_directives); + let directives_error = use_state(|| None::); + + use_hook(move || { + spawn(async move { + // The subscription lives only in this task so closing the window drops the receiver and switches capture off + let mut subscription = console::subscribe(); + tracing::info!("live log console attached"); + + loop { + match subscription.recv().await { + Ok(line) => push(&mut entries, line), + Err(RecvError::Lagged(skipped)) => push( + &mut entries, + ConsoleLine { + level: Level::WARN, + text: format!( + "[console] dropped {skipped} lines, the console fell behind" + ) + .into(), + }, + ), + Err(RecvError::Closed) => break, + } + } + }) + }); + + let floor = severity.read().floor(); + let query = search.read().to_lowercase(); + let lines: Arc>> = Arc::new( + entries + .read() + .iter() + .filter(|line| floor.is_none_or(|floor| line.level <= floor)) + .filter(|line| query.is_empty() || line.text.to_lowercase().contains(&query)) + .map(|line| line.text.clone()) + .collect(), + ); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .padding(Gaps::new_all(12.)) + .background(colors::page()) + .child( + LogViewer::new("Launcher logs", lines.clone()) + .streaming(true) + .header( + rect() + .vertical() + .width(Size::fill()) + .child(view_row(search, severity, lines.len(), entries)) + .child(crate::ui::divider()) + .child(filter_row(directives, directives_error)) + .child(crate::ui::divider()), + ), + ) + } +} + +fn push(entries: &mut State>, line: ConsoleLine) { + let mut entries = entries.write(); + while entries.len() >= MAX_LINES { + entries.pop_front(); + } + entries.push_back(line); +} + +/// Purely local nothing here changes what the launcher records +fn view_row( + search: State, + severity: State, + shown: usize, + mut entries: State>, +) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .padding(Gaps::new_symmetric(10., 14.)) + .child( + rect().width(Size::flex(1.0)).child( + TextInput::new(search) + .placeholder("Filter the stream...") + .leading( + Icon::new(IconType::SearchMd) + .size(15.) + .color(colors::fg_secondary()) + .into_element(), + ), + ), + ) + .child( + label() + .text(format!("{shown} lines")) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .child( + SegmentedControl::new(severity) + .no_tint() + .segments( + [ + Severity::All, + Severity::Error, + Severity::Warn, + Severity::Info, + Severity::Debug, + ] + .into_iter() + .map(|value| Segment::new(value).label(value.label())), + ) + .into_element(), + ) + .child( + Button::new() + .secondary() + .on_press(move |_| entries.write().clear()) + .child(Icon::new(IconType::Trash01).size(15.)) + .text("Clear"), + ) + .into_element() +} + +/// Applying reloads the subscriber's filter so a target the launcher was not recording at all starts arriving +fn filter_row(directives: State, mut error: State>) -> impl IntoElement { + let apply = move |_| match logger::set_filter(&directives.read()) { + Ok(()) => error.set(None), + Err(err) => error.set(Some(err.to_string())), + }; + + let mut reset = directives; + let row = rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .spacing(10.) + .padding(Gaps::new_symmetric(10., 14.)) + .child( + label() + .text("Capture") + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .child( + rect() + .width(Size::flex(1.0)) + .child(TextInput::new(directives).placeholder("RUST_LOG directives...")), + ) + .child( + Button::new() + .secondary() + .on_press(move |_| reset.set(logger::default_directives())) + .text("Defaults"), + ) + .child(Button::new().primary().on_press(apply).text("Apply")); + + rect() + .vertical() + .width(Size::fill()) + .child(row) + .maybe_child(error.read().clone().map(|message| { + label() + .text(message) + .font_size(11.) + .max_lines(2) + .width(Size::fill()) + .padding(Gaps::new(0., 14., 10., 14.)) + .color(colors::code_error()) + .into_element() + })) + .into_element() +} diff --git a/packages/oneclient_app/src/view/mod.rs b/packages/oneclient_app/src/view/mod.rs new file mode 100644 index 00000000..e063a4c9 --- /dev/null +++ b/packages/oneclient_app/src/view/mod.rs @@ -0,0 +1,9 @@ +mod not_found; +mod startup; + +pub mod app; +pub mod console; +pub mod onboarding; + +pub use not_found::NotFound; +pub use startup::Startup; diff --git a/packages/oneclient_app/src/view/not_found.rs b/packages/oneclient_app/src/view/not_found.rs new file mode 100644 index 00000000..8dfac539 --- /dev/null +++ b/packages/oneclient_app/src/view/not_found.rs @@ -0,0 +1,35 @@ +use freya::prelude::*; + +use crate::theme; + +#[derive(PartialEq, Clone)] +pub struct NotFound { + pub path: Vec, +} + +impl Component for NotFound { + fn render(&self) -> impl IntoElement { + tracing::warn!("Route not found: /{}", self.path.join("/")); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .padding(40.) + .spacing(16.) + .child( + label() + .text("404 - Not Found") + .font_size(32.) + .font_weight(FontWeight::BOLD) + .color(theme::colors::fg_primary()), + ) + .child( + label() + .text("The page you are looking for does not exist.") + .font_size(16.) + .color(theme::colors::fg_secondary()), + ) + } +} diff --git a/packages/oneclient_app/src/view/onboarding/account.rs b/packages/oneclient_app/src/view/onboarding/account.rs new file mode 100644 index 00000000..176fced8 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/account.rs @@ -0,0 +1,159 @@ +use freya::prelude::*; +use oneclient_auth::MinecraftAccount; + +use crate::components::{Avatar, Button, Icon, IconType, use_microsoft_login}; +use crate::hooks::{try_default_account, use_current_account}; +use crate::routes::Route; +use crate::theme::colors; +use crate::view::onboarding::{ + onboarding_illustration, onboarding_nav, onboarding_page, step_heading, +}; + +#[derive(PartialEq)] +pub struct OnboardingAccount; + +impl Component for OnboardingAccount { + fn render(&self) -> impl IntoElement { + let account_query = use_current_account(); + let msa = use_microsoft_login(); + + let account = try_default_account(&account_query); + let has_account = account.is_some(); + + let content = rect() + .vertical() + .width(Size::fill()) + .spacing(24.) + .child(step_heading( + "Account", + "Before you continue, we require you to own a copy of Minecraft: Java Edition.", + )) + .child(match &account { + Some(account) => account_preview(account).into_element(), + None => { + let start = msa.clone(); + rect() + .vertical() + .spacing(12.) + .child(sign_in_card(msa.pending, msa.error.clone(), move |_| { + start.start() + })) + .child( + Button::new() + .secondary() + .large() + .on_press(move |_| { + spawn(async move { + if let Ok(state) = crate::launcher::state() { + if let Ok(acc) = state + .auth + .add_offline_account("Player".to_string()) + .await + { + let _ = state + .auth + .set_default_account(Some(acc.id)) + .await; + } + } + }); + }) + .text("Play Offline as Player"), + ) + .into_element() + } + }) + .into_element(); + + let page = onboarding_page( + onboarding_illustration(IconType::OnboardingAccount), + content, + onboarding_nav( + Some(Route::OnboardingLanguage {}), + Route::OnboardingBundles {}, + has_account, + ), + ); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .child(page) + .maybe_child(msa.popup()) + } +} + +fn account_preview(account: &MinecraftAccount) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .spacing(24.) + .child( + rect() + .horizontal() + .spacing(12.) + .cross_align(Alignment::Center) + .child( + Avatar::new(account.id.to_string()) + .width(Size::px(48.)) + .height(Size::px(48.)), + ) + .child( + rect() + .vertical() + .spacing(4.) + .child( + label() + .text(account.username.clone()) + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(account.id.to_string()) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ), + ) + .into_element() +} + +fn sign_in_card( + pending: bool, + error: Option, + on_add: impl FnMut(Event) + 'static, +) -> impl IntoElement { + rect() + .vertical() + .spacing(12.) + .cross_align(Alignment::Start) + .child( + Button::new() + .primary() + .large() + .enabled(!pending) + .on_press(on_add) + .child(Icon::new(IconType::Globe01).size(16.)) + .text(if pending { + "Signing in..." + } else { + "Add Account" + }), + ) + .maybe_child(error.map(|message| { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(6.) + .child( + Icon::new(IconType::AlertTriangle) + .size(13.) + .color(colors::danger()), + ) + .child(label().text(message).font_size(12.).color(colors::danger())) + .into_element() + })) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs new file mode 100644 index 00000000..647d13d3 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs @@ -0,0 +1,532 @@ +use std::collections::{HashMap, HashSet}; + +use freya::prelude::*; +use oneclient_content::packages::ProviderId; +use oneclient_core::clusters::Cluster; +use oneclient_core::{BundleArchive, BundleFile, BundleFileKind}; + +use crate::components::ScrollArea; +use crate::hooks::{ + ClusterBundles, onboarding_bundles_items, package_meta_batch, query_error, query_is_loading, + use_onboarding_bundles, use_onboarding_selection, use_package_meta_batch, +}; + +type MetaMap = HashMap; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::onboarding::{ + archive_selected, choice_row_sized, is_default_bundle, is_optional_file, onboarding_nav, + onboarding_slide, pkg_key, predownload_toggle_row, set_archive_selected, step_heading, + version_chip, +}; + +mod row; +use row::{CARD_GRID_H, GRID_GAP, OnboardingModCard, empty_hint}; + +const CHIPS_PER_ROW: usize = 4; + +const MOD_GRID_COLS: usize = 3; + +const ANSWER_H: f32 = 48.; + +const FPS_WARNING_BUNDLE: &str = "SkyBlock"; + +const FPS_WARNING_RUNS: &[(&str, bool, bool)] = &[ + ("YOUR ", false, false), + ("FPS", true, false), + (" WILL BE ", false, false), + ("SIGNIFICANTLY WORSE", true, false), + (" WITH SKYBLOCK MODS. ", false, false), + ("DO NOT INSTALL", true, true), + (" UNLESS YOU PLAY HYPIXEL SKYBLOCK.", false, true), +]; + +fn fps_warning_banner() -> impl IntoElement { + let mut text = paragraph(); + for (run, bold, underlined) in FPS_WARNING_RUNS { + let mut span = Span::new(*run) + .font_size(12.) + .font_weight(if *bold { + FontWeight::BOLD + } else { + FontWeight::NORMAL + }) + .color(colors::code_warn()); + if *underlined { + span = span.text_decoration(TextDecoration::Underline); + } + text = text.span(span); + } + + rect() + .width(Size::fill()) + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., colors::code_warn())) + .padding(Gaps::new_all(8.)) + .child(text) +} + +fn file_provider(file: &BundleFile) -> ProviderId { + match &file.kind { + BundleFileKind::Managed { provider, .. } => *provider, + BundleFileKind::External(_) => ProviderId::Local, + } +} + +fn bundle_display_name(archive: &BundleArchive) -> String { + let category = archive.manifest.category.trim(); + if category.is_empty() { + archive.manifest.name.clone() + } else { + category.to_string() + } +} + +#[derive(Clone)] +struct OptInBundle { + display_name: String, + members: Vec<(Cluster, BundleArchive)>, +} + +impl OptInBundle { + fn chosen(&self, selected: &HashSet) -> Vec { + self.members + .iter() + .filter(|(cluster, archive)| archive_selected(cluster.id, archive, selected)) + .map(|(cluster, _)| cluster.id) + .collect() + } + + fn wanted(&self, selected: &HashSet) -> bool { + !self.chosen(selected).is_empty() + } + + fn mod_count(&self) -> usize { + self.members + .first() + .map(|(_, a)| { + a.manifest + .files + .iter() + .filter(|f| f.enabled && !f.hidden) + .count() + }) + .unwrap_or(0) + } +} + +fn opt_in_bundles(clusters: &[ClusterBundles]) -> Vec { + let mut order: Vec = Vec::new(); + let mut map: HashMap = HashMap::new(); + + for cb in clusters { + for archive in &cb.archives { + if is_default_bundle(archive) { + continue; + } + let name = bundle_display_name(archive); + let entry = map.entry(name.clone()).or_insert_with(|| { + order.push(name.clone()); + OptInBundle { + display_name: name.clone(), + members: Vec::new(), + } + }); + entry.members.push((cb.cluster.clone(), archive.clone())); + } + } + + for bundle in map.values_mut() { + bundle + .members + .sort_by(|(a, _), (b, _)| a.mc_version.cmp(&b.mc_version)); + } + + order.into_iter().filter_map(|n| map.remove(&n)).collect() +} + +#[derive(Clone)] +struct OptionalMod { + package_id: String, + provider: ProviderId, + fallback_name: String, + size: u64, + /// `(cluster_id, bundle_name)` pairs this mod is available from + locations: Vec<(i64, String)>, +} + +impl OptionalMod { + fn keys(&self) -> Vec { + self.locations + .iter() + .map(|(cluster_id, bundle)| pkg_key(*cluster_id, bundle, &self.package_id)) + .collect() + } + + fn selected(&self, selected: &HashSet) -> bool { + self.keys().iter().any(|k| selected.contains(k)) + } +} + +fn optional_mods(clusters: &[ClusterBundles]) -> Vec { + let mut order: Vec = Vec::new(); + let mut map: HashMap = HashMap::new(); + + for cb in clusters { + for archive in &cb.archives { + for file in archive + .manifest + .files + .iter() + .filter(|f| is_optional_file(f)) + { + let package_id = file.kind.package_id(); + let entry = map.entry(package_id.clone()).or_insert_with(|| { + order.push(package_id.clone()); + OptionalMod { + package_id: package_id.clone(), + provider: file_provider(file), + fallback_name: file.display_name(), + size: file.size, + locations: Vec::new(), + } + }); + entry + .locations + .push((cb.cluster.id, archive.manifest.name.clone())); + } + } + } + + order.into_iter().filter_map(|n| map.remove(&n)).collect() +} + +fn set_bundle_versions( + mut selected: State>, + mut user_touched: State, + bundle: &OptInBundle, + clusters: &[i64], +) { + let mut set = selected.peek().clone(); + for (cluster, archive) in &bundle.members { + let on = clusters.contains(&cluster.id); + set_archive_selected(cluster.id, archive, on, &mut set); + } + user_touched.set(true); + selected.set(set); +} + +fn toggle_optional_mod( + mut selected: State>, + mut user_touched: State, + entry: &OptionalMod, +) { + let mut set = selected.peek().clone(); + let on = entry.selected(&set); + for key in entry.keys() { + if on { + set.remove(&key); + } else { + set.insert(key); + } + } + user_touched.set(true); + selected.set(set); +} + +#[derive(PartialEq)] +pub struct OnboardingBundles; + +impl Component for OnboardingBundles { + fn render(&self) -> impl IntoElement { + let bundles_query = use_onboarding_bundles(); + let selection = use_onboarding_selection(); + let selected = selection.selected; + let user_touched = selection.user_touched; + let predownload = selection.predownload; + + let clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default(); + let bundles_loaded = onboarding_bundles_items(&bundles_query).is_some(); + let catalog_msg = match query_error(&bundles_query) { + Some(err) => format!("Couldn't load bundles: {err}"), + None if query_is_loading(&bundles_query) => "Loading bundles...".to_string(), + None => "No optional bundles available".to_string(), + }; + + let selected_set = selected.read().clone(); + let opt_ins = opt_in_bundles(&clusters); + let extras = optional_mods(&clusters); + + // Hooks so they run every render regardless of whether there are extras yet + let (mr_ids, cf_ids) = collect_ids(&extras); + let mr_meta = package_meta_batch(&use_package_meta_batch(ProviderId::Modrinth, mr_ids)); + let cf_meta = package_meta_batch(&use_package_meta_batch(ProviderId::CurseForge, cf_ids)); + + let mut sections: Vec = Vec::new(); + + for bundle in &opt_ins { + sections + .push(opt_in_card(bundle, &selected_set, selected, user_touched).into_element()); + } + + if !extras.is_empty() { + sections.push(extras_section( + &extras, + &selected_set, + selected, + user_touched, + &mr_meta, + &cf_meta, + )); + } + + let nothing_to_ask = opt_ins.is_empty() && extras.is_empty(); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child(onboarding_slide( + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new(40., 80., 12., 80.)) + .spacing(16.) + .child(step_heading( + "Mods", + "Everything you need is already included. Pick anything extra you want.", + )) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .content(Content::Flex) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .padding(Gaps::new_all(12.)) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(20.) + .children(sections), + ) + .maybe_child(nothing_to_ask.then(|| empty_hint(&catalog_msg))), + ) + .child(predownload_toggle_row(predownload)), + )) + .child(onboarding_nav( + Some(Route::OnboardingAccount {}), + Route::OnboardingPreferences {}, + bundles_loaded, + )) + } +} + +fn opt_in_card( + bundle: &OptInBundle, + selected_set: &HashSet, + selected: State>, + user_touched: State, +) -> impl IntoElement { + let name = bundle.display_name.clone(); + let wanted = bundle.wanted(selected_set); + let chosen = bundle.chosen(selected_set); + let all_ids: Vec = bundle.members.iter().map(|(c, _)| c.id).collect(); + + let yes_bundle = bundle.clone(); + let yes_ids = all_ids.clone(); + let no_bundle = bundle.clone(); + + let mut card = rect().vertical().width(Size::fill()).spacing(10.).child( + label() + .text(format!("Do you want to install {name} mods?")) + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ); + + card = card.child( + rect() + .horizontal() + .width(Size::fill()) + .spacing(8.) + .content(Content::Flex) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(ANSWER_H)) + .child(choice_row_sized( + "Yes", + &format!("{} mods", yes_bundle.mod_count()), + wanted, + Size::fill(), + move |()| { + if wanted { + return; + } + set_bundle_versions(selected, user_touched, &yes_bundle, &yes_ids) + }, + )), + ) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::px(ANSWER_H)) + .child(choice_row_sized( + "No", + "", + !wanted, + Size::fill(), + move |()| set_bundle_versions(selected, user_touched, &no_bundle, &[]), + )), + ), + ); + + if wanted && name.eq_ignore_ascii_case(FPS_WARNING_BUNDLE) { + card = card.child(fps_warning_banner()); + } + + if wanted { + let mut rows: Vec = Vec::new(); + for chunk in bundle.members.chunks(CHIPS_PER_ROW) { + let mut row = rect().horizontal().spacing(8.); + for (cluster, _) in chunk { + let active = chosen.contains(&cluster.id); + let chip_bundle = bundle.clone(); + let chosen_now = chosen.clone(); + let cluster_id = cluster.id; + row = row.child(version_chip(&cluster.mc_version, active, move |()| { + let mut next = chosen_now.clone(); + if active { + next.retain(|id| *id != cluster_id); + } else { + next.push(cluster_id); + } + set_bundle_versions(selected, user_touched, &chip_bundle, &next); + })); + } + rows.push(row.into_element()); + } + + card = card.child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .padding(Gaps::new(4., 0., 0., 0.)) + .child( + label() + .text(format!("What versions do you play {name} on?")) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .child(rect().vertical().spacing(8.).children(rows)), + ); + } + + card +} + +fn extras_section( + extras: &[OptionalMod], + selected_set: &HashSet, + selected: State>, + user_touched: State, + mr_meta: &MetaMap, + cf_meta: &MetaMap, +) -> Element { + let cards: Vec = extras + .iter() + .map(|entry| { + let meta = match entry.provider { + ProviderId::Modrinth => mr_meta.get(&entry.package_id), + ProviderId::CurseForge => cf_meta.get(&entry.package_id), + ProviderId::Local => None, + }; + let name = meta + .map(|m| m.name.clone()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| entry.fallback_name.clone()); + let author = meta.map(|m| m.author.clone()).unwrap_or_default(); + let description = meta.map(|m| m.summary.clone()).unwrap_or_default(); + let icon_url = meta.and_then(|m| m.icon_url.clone()); + let toggle_entry = entry.clone(); + + OnboardingModCard { + provider: entry.provider, + name, + author, + description, + icon_url, + size: entry.size, + enabled: entry.selected(selected_set), + on_toggle: (move |()| toggle_optional_mod(selected, user_touched, &toggle_entry)) + .into(), + } + .into_element() + }) + .collect(); + + let mut rows: Vec = Vec::new(); + for (index, chunk) in cards.chunks(MOD_GRID_COLS).enumerate() { + let mut row = rect() + .key(index) + .horizontal() + .width(Size::fill()) + .height(Size::px(CARD_GRID_H)) + .spacing(GRID_GAP) + .content(Content::Flex); + for card in chunk { + row = row.child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .child(card.clone()), + ); + } + for _ in chunk.len()..MOD_GRID_COLS { + row = row.child(rect().width(Size::flex(1.0)).height(Size::fill())); + } + rows.push(row.into_element()); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Try out these additional mods:") + .font_size(15.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(GRID_GAP) + .children(rows), + ) + .into_element() +} + +fn collect_ids(extras: &[OptionalMod]) -> (Vec, Vec) { + let mut mr = Vec::new(); + let mut cf = Vec::new(); + for entry in extras { + match entry.provider { + ProviderId::Modrinth => mr.push(entry.package_id.clone()), + ProviderId::CurseForge => cf.push(entry.package_id.clone()), + ProviderId::Local => {} + } + } + (mr, cf) +} diff --git a/packages/oneclient_app/src/view/onboarding/bundles/row.rs b/packages/oneclient_app/src/view/onboarding/bundles/row.rs new file mode 100644 index 00000000..479bf341 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/bundles/row.rs @@ -0,0 +1,157 @@ +use freya::prelude::*; +use oneclient_content::packages::ProviderId; + +use crate::components::{Icon, provider_badge}; +use crate::hooks::{loaded_image, use_cached_image}; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::format_size; + +const CARD_BG: Color = Color::from_rgb(26, 34, 41); +const CARD_NAME: Color = Color::from_rgb(213, 219, 255); + +pub(super) const CARD_GRID_H: f32 = 148.; +pub(super) const GRID_GAP: f32 = 10.; + +#[derive(PartialEq)] +pub(super) struct OnboardingModCard { + pub(super) provider: ProviderId, + pub(super) name: String, + pub(super) author: String, + pub(super) description: String, + pub(super) icon_url: Option, + pub(super) size: u64, + pub(super) enabled: bool, + pub(super) on_toggle: EventHandler<()>, +} + +impl Component for OnboardingModCard { + fn render(&self) -> impl IntoElement { + let icon_query = use_cached_image(self.icon_url.clone(), 128); + let loaded = loaded_image(self.icon_url.as_deref(), &icon_query); + + let icon = match loaded { + Some((url, bytes)) => ImageViewer::new((url, bytes)) + .width(Size::px(44.)) + .height(Size::px(44.)) + .aspect_ratio(AspectRatio::Min) + .corner_radius(CornerRadius::new_all(8.)) + .into_element(), + + None => icon_box(self.provider).into_element(), + }; + + let (bg, border, alpha) = if self.enabled { + (colors::brand().with_a(38), colors::brand(), 255) + } else { + (CARD_BG, colors::component_border(), 140) + }; + + let on_toggle = self.on_toggle.clone(); + let size = self.size; + + let header = rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(10.) + .content(Content::Flex) + .child(icon) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(self.name.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .max_lines(1) + .width(Size::fill()) + .color(CARD_NAME.with_a(alpha)), + ) + .maybe(!self.author.is_empty(), |el| { + el.child( + label() + .text(format!("by {}", self.author)) + .font_size(10.) + .max_lines(1) + .width(Size::fill()) + .color(colors::fg_secondary().with_a(alpha)), + ) + }) + .child(provider_badge(self.provider)), + ); + + let description = (!self.description.is_empty()).then(|| { + label() + .text(self.description.clone()) + .font_size(11.) + .max_lines(3) + .width(Size::fill()) + .color(colors::fg_secondary().with_a(alpha)) + .into_element() + }); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .spacing(8.) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(bg) + .border(border_all_color(1.5, border)) + .content(Content::Flex) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_toggle.call(())) + .child(header) + .child( + rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .maybe_child(description), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .maybe_child((size > 0).then(|| { + label() + .text(format_size(size)) + .font_size(11.) + .color(colors::fg_secondary().with_a(alpha)) + .into_element() + })), + ) + } +} + +fn icon_box(provider: ProviderId) -> impl IntoElement { + rect() + .center() + .width(Size::px(44.)) + .height(Size::px(44.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .child(Icon::new(provider).size(20.).color(colors::fg_secondary())) + .into_element() +} + +pub(super) fn empty_hint(text: &str) -> impl IntoElement { + rect() + .width(Size::fill()) + .padding(Gaps::new_all(24.)) + .center() + .child( + label() + .text(text.to_string()) + .font_size(13.) + .color(colors::fg_secondary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs b/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs new file mode 100644 index 00000000..c346abe1 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/downloading/backdrop.rs @@ -0,0 +1,187 @@ +use super::*; + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use oneclient_core::clusters::Cluster; + +use crate::components::{ART_PREVIEW_EDGE, DynamicArt}; +use crate::hooks::use_settings_snapshot; +use crate::layout::gradient_overlay_radial; +use crate::theme::colors; + +pub(super) const BACKDROP_INTERVAL_SECS: u64 = 12; + +const ROTATION_PERIOD: Duration = Duration::from_secs(BACKDROP_INTERVAL_SECS); + +fn since_epoch() -> Duration { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) +} + +pub(super) fn rotation_tick() -> usize { + (since_epoch().as_secs() / BACKDROP_INTERVAL_SECS) as usize +} + +pub(super) fn next_rotation_delay() -> Duration { + let into_period = since_epoch().as_nanos() % ROTATION_PERIOD.as_nanos(); + ROTATION_PERIOD - Duration::from_nanos(into_period as u64) +} + +const PARALLAX_SCALE: f32 = 1.10; +const PARALLAX_STRENGTH: f32 = 0.008; +const PARALLAX_STEP: f32 = 0.12; +const PARALLAX_SETTLE: f32 = 0.002; +const PARALLAX_FRAME: Duration = Duration::from_millis(33); + +#[derive(PartialEq)] +pub struct LoadingBackdrop { + pub clusters: Vec, +} + +impl Component for LoadingBackdrop { + fn render(&self) -> impl IntoElement { + let clusters = &self.clusters; + let count = clusters.len().max(1); + // Offset against the shared tick so the first art shown is still the + // first cluster + let start = use_hook(rotation_tick); + let mut tick = use_state(rotation_tick); + + let stop = use_hook(|| Arc::new(AtomicBool::new(false))); + + use_drop({ + let stop = stop.clone(); + move || stop.store(true, Ordering::Relaxed) + }); + + use_hook({ + let stop = stop.clone(); + move || { + if count <= 1 { + return; + } + + spawn(async move { + loop { + tokio::time::sleep(next_rotation_delay()).await; + + if stop.load(Ordering::Relaxed) { + break; + } + + tick.set(rotation_tick()); + } + }); + } + }); + + let current = (*tick.read()).wrapping_sub(start) % count; + + let parallax_enabled = use_settings_snapshot().settings.dynamic_background_enabled; + + let mut size = use_state(|| (0f32, 0f32)); + let mut target = use_state(|| (0f32, 0f32)); + let mut pan = use_state(|| (0f32, 0f32)); + + let mut anim_task = use_state(|| None::); + use_side_effect_with_deps(¶llax_enabled, move |enabled| { + if *enabled { + let handle = spawn(async move { + loop { + let (tx, ty) = *target.peek(); + let (cx, cy) = *pan.peek(); + + if (tx - cx).abs() > PARALLAX_SETTLE || (ty - cy).abs() > PARALLAX_SETTLE { + pan.set(( + cx + (tx - cx) * PARALLAX_STEP, + cy + (ty - cy) * PARALLAX_STEP, + )); + } + + tokio::time::sleep(PARALLAX_FRAME).await; + } + }); + anim_task.set(Some(handle.owned())); + } else { + anim_task.set(None); + target.set((0.0, 0.0)); + pan.set((0.0, 0.0)); + } + }); + + let (cx, cy) = if parallax_enabled { + *pan.read() + } else { + (0.0, 0.0) + }; + let (sw, sh) = *size.read(); + let pan_x = cx * sw * PARALLAX_STRENGTH; + let pan_y = cy * sh * PARALLAX_STRENGTH; + + // Default edge on purpose home shows the same art next so the same variant avoids a second fetch + let art = match clusters.get(current) { + Some(cluster) => DynamicArt::for_cluster(cluster), + None => DynamicArt::fallback(), + } + .preview_edge(ART_PREVIEW_EDGE); + + let fade = use_animation_with_dependencies(¤t, |conf, _| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + AnimNum::new(0.0, 1.0) + .time(900) + .ease(Ease::Out) + .function(Function::Cubic) + }); + + rect() + .position(Position::new_absolute().top(0.).left(0.)) + .width(Size::fill()) + .height(Size::fill()) + .overflow(Overflow::Clip) + .interactive(false) + .background(colors::page()) + .on_sized(move |e: Event| { + let (nw, nh) = (e.area.width(), e.area.height()); + let (pw, ph) = *size.peek(); + if (pw - nw).abs() > 0.5 || (ph - nh).abs() > 0.5 { + size.set((nw, nh)); + } + }) + .maybe(parallax_enabled, |el| { + el.on_capture_global_pointer_move(move |e: Event| { + let (sw, sh) = *size.peek(); + if sw > 0.0 && sh > 0.0 { + let loc = e.global_location(); + target.set(( + (loc.x as f32 / sw) * 2.0 - 1.0, + (loc.y as f32 / sh) * 2.0 - 1.0, + )); + } + }) + }) + .child( + rect() + .position(Position::new_absolute().top(0.).left(0.)) + .width(Size::fill()) + .height(Size::fill()) + .layer(Layer::Relative(0)) + .scale(PARALLAX_SCALE) + .offset_x(pan_x) + .offset_y(pan_y) + .opacity(fade.get().value()) + .child(art), + ) + .child( + rect() + .position(Position::new_absolute().top(0.).left(0.)) + .width(Size::fill()) + .height(Size::fill()) + .layer(Layer::Relative(1)) + .child(gradient_overlay_radial()), + ) + } +} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs new file mode 100644 index 00000000..56f99787 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs @@ -0,0 +1,1146 @@ +use std::collections::{BTreeMap, HashMap}; +use std::time::{Duration, Instant}; + +use freya::animation::*; +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::{ + BundleArchive, BundleFile, Cluster, ImportTarget, MigrationSource, SentryExclusion, + VersionMetadata, +}; +use oneclient_db::models::OverrideType; +use oneclient_events::{ + EventBus, GroupedProgressEvent, GroupedProgressSession, ProgressEvent, TaskCategory, +}; +use tokio::sync::mpsc; +use uuid::Uuid; + +use crate::hooks::{ + Actions, ClusterBundles, invalidate_cluster_queries, migration_detection, + onboarding_bundles_items, try_default_account, use_current_account, use_dispatch, + use_migration, use_onboarding_bundles, use_onboarding_selection, use_settings_snapshot, + use_versions, versions_metadata, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::transfer::{TransferMeter, TransferStats}; +use crate::view::onboarding::{matching_new_cluster_id, pkg_key}; + +mod backdrop; +mod progress; +mod summary; +mod tips; +pub use backdrop::LoadingBackdrop; +use progress::{ProgressView, failure_panel, progress_panel}; +use summary::summary_view; +use tips::{tip_panel, use_onboarding_tip}; + +const FADE_DURATION_MS: u64 = 700; +const MAX_TASK_ROWS: usize = 6; + +#[derive(Clone, PartialEq)] +struct ClusterPlan { + cluster_id: i64, + mc_version: String, + /// `(bundle_name, package_id, override)` for every file whose fate differs from the manifest default + overrides: Vec<(String, String, OverrideType)>, + /// Manifest `predownload` flag versions without it fetch on first launch + predownload: bool, +} + +#[derive(Clone, PartialEq)] +struct InstallFailure { + plan: ClusterPlan, + reason: String, +} + +#[derive(Clone, PartialEq)] +struct TaskLine { + label: String, + phase: &'static str, + current: u64, + total: u64, + category: TaskCategory, +} + +/// Display order for the task rows mirroring the notification centre +const TASK_LANES: [TaskCategory; 7] = [ + TaskCategory::Java, + TaskCategory::Client, + TaskCategory::Libraries, + TaskCategory::Natives, + TaskCategory::Assets, + TaskCategory::Metadata, + TaskCategory::Packages, +]; + +#[derive(Clone, PartialEq, Default)] +struct GroupedAgg { + children: HashMap, + done_units: u64, + carried: u64, +} + +impl GroupedAgg { + fn fraction(&self) -> f32 { + let completed: u64 = self.done_units + + self + .children + .values() + .map(|t| t.current.min(t.total)) + .sum::(); + let total: u64 = self.done_units + self.children.values().map(|t| t.total).sum::(); + if total == 0 { + 0.0 + } else { + (completed as f32 / total as f32).clamp(0.0, 1.0) + } + } + + /// Round-robin assets run 32-at-a-time and sort first so a flat sorted list showed nothing but assets for the whole download + fn task_list(&self) -> Vec { + let mut lanes: Vec> = TASK_LANES + .iter() + .map(|category| { + let mut lane: Vec = self + .children + .values() + .filter(|task| task.category == *category) + .cloned() + .collect(); + lane.sort_by(|a, b| a.label.cmp(&b.label)); + lane + }) + .collect(); + + let deepest = lanes.iter().map(Vec::len).max().unwrap_or(0); + let mut tasks = Vec::new(); + for index in 0..deepest { + for lane in &mut lanes { + if let Some(task) = lane.get(index) { + tasks.push(task.clone()); + } + } + } + tasks + } + + fn downloaded_bytes(&self) -> u64 { + self.carried + + self.done_units + + self + .children + .values() + .map(|t| t.current.min(t.total)) + .sum::() + } + + fn summary(&self) -> Option { + if self.children.is_empty() { + return None; + } + + let mut kinds: BTreeMap<&'static str, usize> = BTreeMap::new(); + let mut phases: BTreeMap<&'static str, usize> = BTreeMap::new(); + for task in self.children.values() { + *kinds.entry(categorize(&task.label)).or_default() += 1; + *phases.entry(task.phase).or_default() += 1; + } + + let verb = phases + .iter() + .max_by_key(|(_, n)| **n) + .map(|(p, _)| *p) + .unwrap_or("Downloading"); + + let mut ordered: Vec<(&str, usize)> = kinds.into_iter().collect(); + ordered.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0))); + let parts: Vec = ordered + .into_iter() + .take(3) + .map(|(kind, count)| { + if count > 1 { + format!("{kind} ×{count}") + } else { + kind.to_string() + } + }) + .collect(); + + Some(format!("{verb} {}", parts.join(", "))) + } +} + +fn categorize(label: &str) -> &'static str { + if label.starts_with("Assets index") { + "asset index" + } else if label.starts_with("Asset") { + "assets" + } else if label.starts_with("Library") { + "libraries" + } else if label.starts_with("Natives") { + "natives" + } else if label.starts_with("Client") { + "game client" + } else if label.starts_with("Version metadata") || label.starts_with("Loader metadata") { + "metadata" + } else if label.starts_with("Mod") { + "mods" + } else { + "content" + } +} + +#[derive(Clone, PartialEq, Default)] +struct DownloadStage { + index: usize, + total: usize, + label: String, +} + +enum InstallUiEvent { + Progress(usize, usize), + Stage(DownloadStage), + Activity(String), + TotalEstimate(u64), + Finished(Vec), +} + +#[derive(Clone, Copy)] +struct InstallHandles { + progress: State<(usize, usize)>, + agg: State, + stage: State, + activity: State, + started_at: State>, + meter: State>, + total_estimate: State, + failures: State>, + running: State, + complete: State, +} + +#[derive(PartialEq)] +pub struct OnboardingDownloading; + +impl Component for OnboardingDownloading { + fn render(&self) -> impl IntoElement { + let dispatch = use_dispatch(); + let bundles_query = use_onboarding_bundles(); + let migration_query = use_migration(); + let versions_query = use_versions(); + let selection = use_onboarding_selection(); + let settings = use_settings_snapshot().settings; + let account_query = use_current_account(); + + let selected_state = selection.selected; + let language_state = selection.language; + let reduce_motion_state = selection.reduce_motion; + let predownload_state = selection.predownload; + let mut setup_started = selection.setup_started; + + let mut confirmed = use_state(|| false); + let mut started = use_state(|| false); + let progress = use_state(|| (0usize, 0usize)); + let agg = use_state(GroupedAgg::default); + let stage = use_state(DownloadStage::default); + let activity = use_state(String::new); + let started_at = use_state(|| None::); + let meter = use_state(|| None::); + let total_estimate = use_state(|| 0u64); + let failures = use_state(Vec::::new); + let running = use_state(|| false); + let complete = use_state(|| false); + let mut leaving = use_state(|| false); + + let handles = InstallHandles { + progress, + agg, + stage, + activity, + started_at, + meter, + total_estimate, + failures, + running, + complete, + }; + + use_side_effect(move || { + if !*confirmed.read() || *started.peek() { + return; + } + let Some(items) = onboarding_bundles_items(&bundles_query) else { + return; + }; + let Some(versions) = versions_metadata(&versions_query) else { + return; + }; + let selected = selected_state.peek().clone(); + let plans = build_plans(&items, &selected, &versions); + let predownload = *predownload_state.peek(); + started.set(true); + run_install_batch(plans, predownload, handles); + }); + + let dispatch_finish = dispatch.clone(); + use_side_effect(move || { + if !*complete.read() || !failures.read().is_empty() || *leaving.peek() { + return; + } + + leaving.set(true); + finish_onboarding(dispatch_finish.clone(), &bundles_query); + }); + + let (done, total) = *progress.read(); + let stage_now = stage.read().clone(); + let agg_now = agg.read().clone(); + let activity_now = activity.read().clone(); + let transfer = *meter.read(); + let total_estimate_now = *total_estimate.read(); + let elapsed_secs = (*started_at.read()).map(|s| s.elapsed().as_secs()); + let predownload = *predownload_state.read(); + let is_running = *running.read(); + let is_complete = *complete.read(); + + let global = if is_complete { + 100.0 + } else if total == 0 { + 0.0 + } else if total_estimate_now > 0 { + (agg_now.downloaded_bytes() as f32 / total_estimate_now as f32 * 100.0).clamp(0.0, 99.0) + } else { + ((done as f32 + agg_now.fraction()) / total as f32 * 100.0).clamp(0.0, 100.0) + }; + + let has_failures = !failures.read().is_empty(); + let heading = if has_failures && is_complete { + "Download issues found" + } else if predownload { + "Downloading..." + } else { + "Finishing up..." + }; + + let is_leaving = *leaving.read(); + let fade = use_animation_with_dependencies(&is_leaving, |conf, _| { + conf.on_creation(OnCreation::Run); + conf.on_change(OnChange::Rerun); + AnimNum::new(0., 1.) + .time(FADE_DURATION_MS) + .ease(Ease::Out) + .function(Function::Cubic) + }); + let opacity = if is_leaving { + 1.0 - fade.get().value() + } else { + 1.0 + }; + + let tip = use_onboarding_tip(); + + if !*confirmed.read() { + let items = onboarding_bundles_items(&bundles_query).unwrap_or_default(); + let selected = selected_state.read().clone(); + let language = language_state.read().clone(); + let reduce_motion = *reduce_motion_state.read(); + let account_name = try_default_account(&account_query) + .map(|account| account.username.clone()) + .unwrap_or_else(|| "Not signed in".to_string()); + + let import_dispatch = dispatch.clone(); + let import_folder = selection.import_folder; + let import_dedicated = selection.import_dedicated; + let import_detection = migration_detection(&migration_query); + let import_items = items.clone(); + + let migration_summary = import_detection.as_ref().map(|detection| { + let source_name = detection.source.display_name().to_string(); + let is_vanilla = detection.source == MigrationSource::Vanilla; + match import_folder.read().clone() { + Some(folder) => { + let dedicated = !is_vanilla + && *import_dedicated.read() + && detection + .instances + .iter() + .find(|c| c.folder_name == folder) + .and_then(|inst| matching_new_cluster_id(inst, &items)) + .is_some(); + let target = if dedicated { + "This version only" + } else { + "Shared game directory" + }; + let files = if is_vanilla { + "Your Minecraft folder".to_string() + } else { + folder + }; + (source_name, files, target.to_string()) + } + None => (source_name, "No files imported".to_string(), String::new()), + } + }); + + return summary_view( + &items, + &selected, + &versions_metadata(&versions_query).unwrap_or_default(), + &language, + reduce_motion, + settings.dynamic_background_enabled, + account_name, + migration_summary, + predownload_state, + move |_| { + if let (Some(detection), Some(folder)) = + (import_detection.as_ref(), import_folder.peek().clone()) + { + let target = if detection.source != MigrationSource::Vanilla + && *import_dedicated.peek() + { + detection + .instances + .iter() + .find(|c| c.folder_name == folder) + .and_then(|inst| matching_new_cluster_id(inst, &import_items)) + .map(|new_cluster_id| ImportTarget::Dedicated { new_cluster_id }) + .unwrap_or(ImportTarget::Shared) + } else { + ImportTarget::Shared + }; + import_dispatch.import_launcher(detection.source, folder, target); + } + + setup_started.set(true); + confirmed.set(true); + }, + ) + .into_element(); + } + + let dispatch_continue = dispatch.clone(); + let continue_query = bundles_query; + let mut continue_leaving = leaving; + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new(16., 48., 48., 48.)) + .opacity(opacity) + .child( + label() + .text(heading) + .font_size(36.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .maybe_child((has_failures && is_complete).then(|| { + failure_panel( + &failures.read(), + done, + total, + is_running, + move |_| { + let retry_plans: Vec = + failures.peek().iter().map(|f| f.plan.clone()).collect(); + if retry_plans.is_empty() || *running.peek() { + return; + } + let predownload = *predownload_state.peek(); + run_install_batch(retry_plans, predownload, handles); + }, + move |_| { + if *continue_leaving.peek() { + return; + } + continue_leaving.set(true); + finish_onboarding(dispatch_continue.clone(), &continue_query); + }, + ) + .into_element() + })) + .child(rect().width(Size::fill()).height(Size::flex(1.0))) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::End) + .child(progress_panel(ProgressView { + global, + stage: &stage_now, + agg: &agg_now, + activity: &activity_now, + transfer, + total_estimate: total_estimate_now, + elapsed_secs, + done, + total, + predownload, + running: is_running, + })) + .child(rect().width(Size::flex(1.0))) + .child(tip_panel(tip)), + ) + .into_element() + } +} + +fn build_plans( + items: &[ClusterBundles], + selected: &std::collections::HashSet, + versions: &[VersionMetadata], +) -> Vec { + items + .iter() + .map(|cb| { + let kept = kept_package_ids(cb.cluster.id, &cb.archives, selected); + let mut overrides = Vec::new(); + for archive in &cb.archives { + overrides.extend(archive_overrides(cb.cluster.id, archive, selected, &kept)); + } + ClusterPlan { + cluster_id: cb.cluster.id, + mc_version: cb.cluster.mc_version.clone(), + overrides, + predownload: cluster_predownloads(versions, &cb.cluster), + } + }) + .collect() +} + +/// Clusters with no manifest row (e.g. seeded from the bundle catalog alone) are not predownloaded +fn cluster_predownloads(versions: &[VersionMetadata], cluster: &Cluster) -> bool { + let loader = cluster.mc_loader.to_string(); + versions.iter().any(|m| { + m.predownload + && m.mc_version().as_deref() == Some(cluster.mc_version.as_str()) + && m.loader + .as_deref() + .is_none_or(|l| l.eq_ignore_ascii_case(&loader)) + }) +} + +/// True once anything visible in the bundle was taken including one opted-in extra that would otherwise lose its dependencies +fn bundle_taken( + cluster_id: i64, + archive: &BundleArchive, + selected: &std::collections::HashSet, +) -> bool { + archive.manifest.files.iter().any(|file| { + !file.hidden + && selected.contains(&pkg_key( + cluster_id, + &archive.manifest.name, + &file.kind.package_id(), + )) + }) +} + +/// Bundles overlap deciding each archive alone recorded a removal for a package an accepted bundle was about to install +fn kept_package_ids( + cluster_id: i64, + archives: &[BundleArchive], + selected: &std::collections::HashSet, +) -> std::collections::HashSet { + let mut kept = std::collections::HashSet::new(); + + for archive in archives { + let takes_hidden = bundle_taken(cluster_id, archive, selected); + for file in &archive.manifest.files { + let package_id = file.kind.package_id(); + let wanted = if file.hidden { + takes_hidden + } else { + selected.contains(&pkg_key(cluster_id, &archive.manifest.name, &package_id)) + }; + if wanted { + kept.insert(package_id); + } + } + } + + kept +} + +fn archive_overrides( + cluster_id: i64, + archive: &BundleArchive, + selected: &std::collections::HashSet, + kept_elsewhere: &std::collections::HashSet, +) -> Vec<(String, String, OverrideType)> { + let bundle_name = &archive.manifest.name; + let wants = |file: &BundleFile| { + selected.contains(&pkg_key(cluster_id, bundle_name, &file.kind.package_id())) + }; + + // Hidden files follow the bundle kept if anything from it was taken dropped if not + let takes_hidden = bundle_taken(cluster_id, archive, selected); + + let mut overrides = Vec::new(); + for file in &archive.manifest.files { + let wanted = if file.hidden { + takes_hidden + } else { + wants(file) + }; + + let override_type = match (wanted, file.enabled) { + // Matches the manifest default nothing to record + (true, true) | (false, false) => continue, + // Opting into a mod the bundle ships turned off + (true, false) => OverrideType::Enabled, + // Only really declined if no bundle the user took ships the same package + (false, true) => { + if kept_elsewhere.contains(&file.kind.package_id()) { + continue; + } + OverrideType::Removed + } + }; + + overrides.push((bundle_name.clone(), file.kind.package_id(), override_type)); + } + overrides +} + +const GAME_SIZE_GUESS: u64 = 180_000_000; +const JRE_SIZE_GUESS: u64 = 45_000_000; + +fn rough_download_estimate( + items: &[ClusterBundles], + selected: &std::collections::HashSet, + versions: &[VersionMetadata], +) -> u64 { + let mut total: u64 = 0; + for cb in items { + if !cluster_predownloads(versions, &cb.cluster) { + continue; + } + // Counted once per package two bundles shipping the same library is one download + let kept = kept_package_ids(cb.cluster.id, &cb.archives, selected); + let mut counted: std::collections::HashSet = std::collections::HashSet::new(); + for archive in &cb.archives { + for file in &archive.manifest.files { + let package_id = file.kind.package_id(); + if kept.contains(&package_id) && counted.insert(package_id) { + total += file.size; + } + } + } + total += GAME_SIZE_GUESS + JRE_SIZE_GUESS; + } + total +} + +fn run_install_batch(plans: Vec, predownload: bool, handles: InstallHandles) { + let InstallHandles { + mut progress, + mut agg, + mut stage, + mut activity, + mut started_at, + mut meter, + mut total_estimate, + mut failures, + mut running, + mut complete, + } = handles; + + running.set(true); + complete.set(false); + failures.set(Vec::new()); + agg.set(GroupedAgg::default()); + meter.set(None); + total_estimate.set(0); + activity.set("Getting started...".to_string()); + started_at.set(Some(Instant::now())); + let total = plans.len(); + progress.set((0, total)); + + let (events, mut notif_rx) = EventBus::channel(); + + let (ui_tx, mut ui_rx) = mpsc::unbounded_channel::(); + + spawn(async move { + let mut transfer = TransferMeter::default(); + + loop { + tokio::time::sleep(Duration::from_millis(1000)).await; + + let downloaded = agg.peek().downloaded_bytes(); + meter.set(transfer.sample(downloaded, *total_estimate.peek())); + + if !*running.peek() { + break; + } + } + }); + + spawn(async move { + let mut local = GroupedAgg::default(); + loop { + let Some(first) = notif_rx.recv().await else { + break; + }; + if let oneclient_events::Event::Progress(ProgressEvent::Grouped(event)) = first { + apply_grouped(&mut local, event); + } + while let Ok(notification) = notif_rx.try_recv() { + if let oneclient_events::Event::Progress(ProgressEvent::Grouped(event)) = + notification + { + apply_grouped(&mut local, event); + } + } + agg.set(local.clone()); + tokio::time::sleep(Duration::from_millis(50)).await; + } + }); + + spawn(async move { + while let Some(event) = ui_rx.recv().await { + match event { + InstallUiEvent::Progress(done, total) => progress.set((done, total)), + InstallUiEvent::Stage(next) => stage.set(next), + InstallUiEvent::Activity(text) => activity.set(text), + InstallUiEvent::TotalEstimate(bytes) => total_estimate.set(bytes), + InstallUiEvent::Finished(failed) => { + agg.set(GroupedAgg::default()); + activity.set("Wrapping up...".to_string()); + invalidate_cluster_queries().await; + failures.set(failed); + running.set(false); + complete.set(true); + break; + } + } + } + }); + + tokio::spawn(async move { + let mut failed = Vec::new(); + + if predownload { + let _ = ui_tx.send(InstallUiEvent::Activity( + "Calculating download size...".to_string(), + )); + if let Ok(state) = crate::launcher::state() { + let mut sum = 0u64; + for plan in plans.iter().filter(|p| p.predownload) { + match oneclient_core::estimate_cluster_download( + &state, + plan.cluster_id, + state.bundles.as_ref(), + ) + .await + { + Ok(bytes) => sum += bytes, + Err(err) => tracing::warn!( + cluster_id = plan.cluster_id, + "download size estimate failed: {err}" + ), + } + let _ = ui_tx.send(InstallUiEvent::TotalEstimate(sum)); + } + } + } + + for (index, plan) in plans.into_iter().enumerate() { + let _ = ui_tx.send(InstallUiEvent::Stage(DownloadStage { + index, + total, + label: plan.mc_version.clone(), + })); + + let fetch_now = predownload && plan.predownload; + if let Err(err) = install_one(&plan, fetch_now, &events, &ui_tx).await { + // Expected failures (out of disk offline no Java build) are not crashes warn! is a breadcrumb error! reports to Sentry + if err.is_sentry_excluded() { + tracing::warn!( + cluster_id = plan.cluster_id, + mc_version = %plan.mc_version, + "onboarding install failed (expected): {err}" + ); + } else { + tracing::error!( + cluster_id = plan.cluster_id, + mc_version = %plan.mc_version, + "onboarding install failed: {err}" + ); + } + failed.push(InstallFailure { + plan, + reason: err.to_string(), + }); + } + let _ = ui_tx.send(InstallUiEvent::Progress(index + 1, total)); + } + + drop(events); + let _ = ui_tx.send(InstallUiEvent::Finished(failed)); + }); +} + +async fn install_one( + plan: &ClusterPlan, + predownload: bool, + events: &EventBus, + ui_tx: &mpsc::UnboundedSender, +) -> oneclient_core::LauncherResult<()> { + let state = crate::launcher::state()?; + + if !plan.overrides.is_empty() { + let _ = ui_tx.send(InstallUiEvent::Activity( + "Saving your package choices...".to_string(), + )); + } + oneclient_core::set_bundle_package_overrides( + plan.cluster_id, + &plan.overrides, + &state.services.content(), + ) + .await?; + + if !predownload { + return Ok(()); + } + + let session = GroupedProgressSession::start(events, format!("Downloading {}", plan.mc_version)); + + let _ = ui_tx.send(InstallUiEvent::Activity(format!( + "Downloading Minecraft {}...", + plan.mc_version + ))); + + let prepared = oneclient_core::clusters::prepare_cluster_locked( + &state, + plan.cluster_id, + false, + true, + true, + Some(&session), + ) + .await; + + let bundles_result = if prepared.is_ok() { + let _ = ui_tx.send(InstallUiEvent::Activity(format!( + "Installing mods & content for {}...", + plan.mc_version + ))); + oneclient_core::install_cluster_bundles( + plan.cluster_id, + state.bundles.as_ref(), + Some(&session), + &state.services.content(), + ) + .await + } else { + Ok(()) + }; + + session.finish(); + + prepared?; + bundles_result?; + Ok(()) +} + +fn apply_grouped(agg: &mut GroupedAgg, event: GroupedProgressEvent) { + match event { + GroupedProgressEvent::Start { .. } => { + agg.children.clear(); + agg.done_units = 0; + } + GroupedProgressEvent::Expect { .. } => {} + GroupedProgressEvent::AddChild { + child_id, + label, + total, + category, + .. + } => { + agg.children.insert( + child_id, + TaskLine { + label, + phase: "Downloading", + current: 0, + total: total.max(1), + category, + }, + ); + } + GroupedProgressEvent::UpdateChild { + child_id, + current, + total, + .. + } => { + if let Some(task) = agg.children.get_mut(&child_id) { + task.current = current; + task.total = total.max(1); + } + } + GroupedProgressEvent::SetChildPhase { + child_id, phase, .. + } => { + if let Some(task) = agg.children.get_mut(&child_id) { + task.phase = phase.label(); + } + } + GroupedProgressEvent::FinishChild { child_id, .. } => { + if let Some(task) = agg.children.remove(&child_id) { + agg.done_units += task.total; + } + } + GroupedProgressEvent::End { .. } => { + agg.carried += agg.done_units + + agg + .children + .values() + .map(|t| t.current.min(t.total)) + .sum::(); + agg.children.clear(); + agg.done_units = 0; + } + } +} + +fn finish_onboarding( + dispatch: Actions, + bundles_query: &freya::query::UseQuery, +) { + let versions: Vec = onboarding_bundles_items(bundles_query) + .map(|items| { + let mut versions: Vec = items + .iter() + .map(|cb| cb.cluster.mc_version.clone()) + .collect(); + + versions.sort(); + versions.dedup(); + versions + }) + .unwrap_or_default(); + + spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(FADE_DURATION_MS)).await; + + for version in versions { + dispatch.record_seen_version(version); + } + + dispatch.mark_onboarding_seen(); + let _ = RouterContext::get().replace(Route::Home {}); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::{archive, cluster, file}; + use std::collections::HashSet; + + fn skyblock() -> BundleArchive { + archive( + "SkyBlock", + false, + vec![ + file("skyblock-main", true, false), + file("skycubed", false, false), + file("sb-dep", true, true), + ], + ) + } + + fn items(archives: Vec) -> Vec { + vec![ClusterBundles { + cluster: cluster(1), + archives, + }] + } + + fn keys(archive: &BundleArchive, pids: &[&str]) -> HashSet { + pids.iter() + .map(|p| pkg_key(1, &archive.manifest.name, p)) + .collect() + } + + /// Manifest row matching `test_support::cluster` (1.21.11 fabric) + fn version_row(predownload: bool) -> VersionMetadata { + VersionMetadata { + major_version: 21, + minor_version: Some(11), + patch_version: None, + loader: Some("fabric".to_string()), + name: "Mounts of Mayhem".to_string(), + art_url: None, + long_description: None, + tags: Vec::new(), + predownload, + } + } + + fn versions() -> Vec { + vec![version_row(true)] + } + + #[test] + fn declining_a_bundle_removes_its_visible_and_hidden_files() { + let sb = skyblock(); + let plans = build_plans(&items(vec![sb.clone()]), &HashSet::new(), &versions()); + + let ov = &plans[0].overrides; + // The hidden dependency must be dropped too or it installs anyway + assert!(ov.contains(&( + sb.manifest.name.clone(), + "sb-dep".to_string(), + OverrideType::Removed + ))); + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skyblock-main".to_string(), + OverrideType::Removed + ))); + assert!(!ov.iter().any(|(_, pid, _)| pid == "skycubed")); + } + + #[test] + fn accepting_a_bundle_records_nothing_for_its_defaults() { + let sb = skyblock(); + let selected = keys(&sb, &["skyblock-main"]); + let plans = build_plans(&items(vec![sb.clone()]), &selected, &versions()); + + assert!(plans[0].overrides.is_empty()); + } + + #[test] + fn opting_into_an_extra_writes_enabled() { + let sb = skyblock(); + let selected = keys(&sb, &["skyblock-main", "skycubed"]); + let plans = build_plans(&items(vec![sb.clone()]), &selected, &versions()); + + assert_eq!( + plans[0].overrides, + vec![( + sb.manifest.name.clone(), + "skycubed".to_string(), + OverrideType::Enabled + )] + ); + } + + #[test] + fn an_extra_alone_still_keeps_hidden_dependencies() { + let sb = skyblock(); + let selected = keys(&sb, &["skycubed"]); + let plans = build_plans(&items(vec![sb.clone()]), &selected, &versions()); + let ov = &plans[0].overrides; + + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skycubed".to_string(), + OverrideType::Enabled + ))); + assert!(!ov.iter().any(|(_, pid, _)| pid == "sb-dep")); + assert!(ov.contains(&( + sb.manifest.name.clone(), + "skyblock-main".to_string(), + OverrideType::Removed + ))); + } + + #[test] + fn rejections_are_removed_never_disabled() { + let plans = build_plans(&items(vec![skyblock()]), &HashSet::new(), &versions()); + + assert!( + !plans[0] + .overrides + .iter() + .any(|(_, _, ty)| *ty == OverrideType::Disabled) + ); + } + + #[test] + fn per_cluster_choices_are_independent() { + let sb = skyblock(); + let both = vec![ + ClusterBundles { + cluster: cluster(1), + archives: vec![sb.clone()], + }, + ClusterBundles { + cluster: cluster(2), + archives: vec![sb.clone()], + }, + ]; + let selected: HashSet = [pkg_key(1, &sb.manifest.name, "skyblock-main")].into(); + let plans = build_plans(&both, &selected, &versions()); + + assert!(plans[0].overrides.is_empty()); + assert!( + plans[1] + .overrides + .iter() + .any(|(_, pid, ty)| pid == "skyblock-main" && *ty == OverrideType::Removed) + ); + } + + #[test] + fn estimate_skips_declined_bundles_and_counts_opted_in_extras() { + let sb = skyblock(); + let all = items(vec![sb.clone()]); + let baseline = GAME_SIZE_GUESS + JRE_SIZE_GUESS; + + let declined = rough_download_estimate(&all, &HashSet::new(), &versions()); + assert_eq!(declined, baseline, "declined bundle should cost nothing"); + + let accepted = rough_download_estimate(&all, &keys(&sb, &["skyblock-main"]), &versions()); + assert_eq!(accepted, baseline + 2); + + let with_extra = rough_download_estimate( + &all, + &keys(&sb, &["skyblock-main", "skycubed"]), + &versions(), + ); + assert_eq!(with_extra, baseline + 3); + } + + #[test] + fn plans_take_predownload_from_the_manifest() { + let all = items(vec![skyblock()]); + assert!(build_plans(&all, &HashSet::new(), &versions())[0].predownload); + assert!(!build_plans(&all, &HashSet::new(), &[version_row(false)])[0].predownload); + // A cluster with no manifest row at all is never predownloaded + assert!(!build_plans(&all, &HashSet::new(), &[])[0].predownload); + } + + #[test] + fn a_predownload_row_for_another_version_does_not_match() { + let mut other = version_row(true); + other.minor_version = Some(10); + assert!(!build_plans(&items(vec![skyblock()]), &HashSet::new(), &[other])[0].predownload); + } + + #[test] + fn a_predownload_row_for_another_loader_does_not_match() { + let mut other = version_row(true); + other.loader = Some("forge".to_string()); + assert!(!build_plans(&items(vec![skyblock()]), &HashSet::new(), &[other])[0].predownload); + } + + #[test] + fn the_estimate_ignores_versions_that_are_not_predownloaded() { + let all = items(vec![skyblock()]); + let selected = keys(&skyblock(), &["skyblock-main"]); + + assert_eq!(rough_download_estimate(&all, &selected, &[]), 0); + } +} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/progress.rs b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs new file mode 100644 index 00000000..f1cd56db --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs @@ -0,0 +1,390 @@ +use super::*; + +use crate::components::{Button, progress_track}; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::{format_duration_hms, format_size}; + +pub(super) fn failure_panel( + failures: &[InstallFailure], + done: usize, + total: usize, + running: bool, + on_retry: impl FnMut(Event) + 'static, + on_continue: impl FnMut(Event) + 'static, +) -> impl IntoElement { + let lines: Vec = failures + .iter() + .take(8) + .map(|failure| { + label() + .text(format!( + "{} (cluster {}): {}", + failure.plan.mc_version, failure.plan.cluster_id, failure.reason + )) + .font_size(12.) + .color(colors::fg_primary()) + .into_element() + }) + .collect(); + let extra = failures.len().saturating_sub(8); + + rect() + .vertical() + .width(Size::px(640.)) + .margin(Gaps::new(24., 0., 0., 0.)) + .padding(Gaps::new_all(20.)) + .spacing(10.) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::danger().with_a(26)) + .border(border_all_color(1., colors::danger().with_a(128))) + .child( + label() + .text("Some versions could not be prepared.") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!("Processed: {done} / {total}")) + .font_size(12.) + .color(colors::fg_secondary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .padding(Gaps::new_all(12.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(Color::BLACK.with_a(51)) + .children(lines) + .maybe_child((extra > 0).then(|| { + label() + .text(format!("+{extra} more failures")) + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + })), + ) + .child( + rect() + .horizontal() + .spacing(10.) + .child( + Button::new() + .primary() + .enabled(!running) + .on_press(on_retry) + .text("Retry Failed Versions"), + ) + .child( + Button::new() + .secondary() + .on_press(on_continue) + .text("Continue Anyway"), + ), + ) + .into_element() +} + +pub(super) struct ProgressView<'a> { + pub(super) global: f32, + pub(super) stage: &'a DownloadStage, + pub(super) agg: &'a GroupedAgg, + pub(super) activity: &'a str, + /// `None` until the meter has two samples to work from + pub(super) transfer: Option, + pub(super) total_estimate: u64, + pub(super) elapsed_secs: Option, + pub(super) done: usize, + pub(super) total: usize, + pub(super) predownload: bool, + pub(super) running: bool, +} + +pub(super) fn progress_panel(view: ProgressView) -> impl IntoElement { + let ProgressView { + global, + stage, + agg, + activity, + transfer, + total_estimate, + elapsed_secs, + done, + total, + predownload, + running, + } = view; + + let percentage = global.round() as i32; + + let position = if !running && done >= total && total > 0 { + "Done".to_string() + } else if predownload && stage.total > 0 { + format!( + "Version {} of {}: {}", + (stage.index + 1).min(stage.total), + stage.total, + stage.label + ) + } else if total > 0 { + format!("Step {done} of {total}") + } else { + "Preparing...".to_string() + }; + + let tasks = agg.task_list(); + let live_count = tasks.len(); + let shown = live_count.min(MAX_TASK_ROWS); + let overflow = live_count.saturating_sub(shown); + let task_rows: Vec = tasks.into_iter().take(shown).map(task_row).collect(); + + let activity_text = agg.summary().unwrap_or_else(|| { + if activity.is_empty() { + position.clone() + } else { + activity.to_string() + } + }); + + let stats = build_stats(StatsView { + global, + transfer, + total_estimate, + elapsed_secs, + running, + done, + total, + }); + + rect() + .vertical() + .width(Size::px(460.)) + .spacing(10.) + .cross_align(Alignment::Start) + .main_align(Alignment::End) + .maybe_child((!task_rows.is_empty()).then(|| { + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .children(task_rows) + .maybe_child((overflow > 0).then(|| { + label() + .text(format!("+{overflow} more")) + .font_size(11.) + .color(colors::fg_secondary()) + .into_element() + })) + .into_element() + })) + .child( + ActivityIndicator { + running, + text: activity_text, + } + .into_element(), + ) + .maybe_child(stats) + .child( + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .content(Content::Flex) + .child( + label() + .text(format!("{percentage}%")) + .width(Size::flex(1.0)) + .font_size(24.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(position) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .child(progress_track( + global, + 4., + Color::WHITE, + Color::WHITE.with_a(51), + )) + .into_element() +} + +struct StatsView { + global: f32, + transfer: Option, + total_estimate: u64, + elapsed_secs: Option, + running: bool, + done: usize, + total: usize, +} + +fn build_stats(view: StatsView) -> Option { + let StatsView { + global, + transfer, + total_estimate, + elapsed_secs, + running, + done, + total, + } = view; + + let elapsed = elapsed_secs?; + let finished = !running && done >= total && total > 0; + + let mut parts: Vec = vec![format!("Elapsed {}", format_duration_hms(elapsed as i64))]; + + if finished { + parts.push("Complete".to_string()); + } else { + // Both readings come off the same smoothed rate so they always agree + match transfer.and_then(|t| t.eta_secs) { + Some(remaining) => { + parts.push(format!("~{} left", format_duration_hms(remaining as i64))) + } + None => parts.push("~— left".to_string()), + } + + if let Some(speed) = transfer.map(|t| t.speed_bps).filter(|s| *s >= 1.0) { + parts.push(format!("{}/s", format_size(speed as u64))); + } + } + + if total_estimate > 0 { + let downloaded = ((global as f64 / 100.0) * total_estimate as f64) as u64; + parts.push(format!( + "{} / ~{}", + format_size(downloaded), + format_size(total_estimate) + )); + } + + Some( + label() + .text(parts.join(" · ")) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()) + .into_element(), + ) +} + +fn task_row(task: TaskLine) -> Element { + let is_bytes = task.total > 1; + let pct = if task.total > 0 { + (task.current as f32 / task.total as f32 * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + let detail = if is_bytes { + format!( + "{} / {}", + format_size(task.current), + format_size(task.total) + ) + } else { + task.phase.to_string() + }; + + let track_pct = if is_bytes { pct } else { 100.0 }; + let fill = if is_bytes { + colors::brand() + } else { + colors::brand().with_a(120) + }; + + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .child( + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .child( + label() + .text(format!("{} {}", task.phase, task.label)) + .width(Size::flex(1.0)) + .font_size(12.) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(detail) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ) + .child(progress_track(track_pct, 3., fill, Color::WHITE.with_a(28))) + .into_element() +} + +#[derive(PartialEq)] +struct ActivityIndicator { + running: bool, + text: String, +} + +impl Component for ActivityIndicator { + fn render(&self) -> impl IntoElement { + let pulse = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + conf.on_finish(OnFinish::reverse()); + AnimNum::new(0.3, 1.0) + .time(650) + .ease(Ease::InOut) + .function(Function::Cubic) + }); + + let dot_opacity = if self.running { + pulse.read().value() + } else { + 0.9 + }; + let color = if self.running { + colors::brand() + } else { + colors::fg_secondary() + }; + + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .content(Content::Flex) + .child( + rect() + .width(Size::px(8.)) + .height(Size::px(8.)) + .corner_radius(CornerRadius::new_all(4.)) + .background(color) + .opacity(dot_opacity), + ) + .child( + label() + .text(self.text.clone()) + .width(Size::flex(1.0)) + .font_size(12.) + .max_lines(1) + .color(colors::fg_primary()), + ) + } +} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/summary.rs b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs new file mode 100644 index 00000000..d0b44c1a --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/downloading/summary.rs @@ -0,0 +1,254 @@ +use super::*; + +use freya::router::RouterContext; + +use crate::components::{ART_PREVIEW_EDGE, Button, DynamicArt, Icon, IconType, ScrollArea}; +use crate::hooks::ClusterBundles; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::utils::format_size; +use crate::view::onboarding::{onboarding_illustration, predownload_toggle_row, step_heading}; + +#[allow(clippy::too_many_arguments)] +pub(super) fn summary_view( + items: &[ClusterBundles], + selected: &std::collections::HashSet, + versions: &[VersionMetadata], + language: &str, + reduce_motion: bool, + parallax: bool, + account_name: String, + migration: Option<(String, String, String)>, + predownload: State, + on_setup: impl FnMut(Event) + 'static, +) -> impl IntoElement { + let version_rows: Vec = items + .iter() + .map(|cb| { + let prefix = format!("{}|", cb.cluster.id); + let count = selected.iter().filter(|k| k.starts_with(&prefix)).count(); + summary_line( + &cb.cluster.mc_version, + &format!("{count} package{}", if count == 1 { "" } else { "s" }), + ) + }) + .collect(); + + let ready_now = items + .iter() + .filter(|cb| cluster_predownloads(versions, &cb.cluster)) + .count(); + + let packages_note = if *predownload.read() && ready_now > 0 { + let est = rough_download_estimate(items, selected, versions); + let (scope, rest) = if ready_now == items.len() { + ("Your selected content is".to_string(), "") + } else { + ( + format!( + "Content for {ready_now} version{} is", + if ready_now == 1 { "" } else { "s" } + ), + " Everything else is downloaded the first time you launch it.", + ) + }; + format!( + "{scope} downloaded now, during setup. About {} total.{rest}", + format_size(est) + ) + } else { + "Content is downloaded the first time you launch each version.".to_string() + }; + + let migration_section: Element = match migration { + Some((source, folder, target)) => { + let mut rows = vec![ + summary_line("From", &source), + summary_line("Files", &folder), + ]; + if !target.is_empty() { + rows.push(summary_line("Destination", &target)); + } + summary_section("Migration", None, rows).into_element() + } + None => rect().into_element(), + }; + + let content = rect() + .vertical() + .width(Size::fill()) + .height(Size::flex(1.0)) + .content(Content::Flex) + .spacing(20.) + .child(step_heading( + "You're all set", + "Review your setup below, then finish.", + )) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .spacing(20.) + .child(summary_section( + "General", + None, + vec![ + summary_line("Language", language), + summary_line("Account", &account_name), + ], + )) + .child(migration_section) + .child(summary_section( + "Accessibility", + None, + vec![ + summary_line("Animations", if reduce_motion { "Off" } else { "On" }), + summary_line("Parallax background", if parallax { "On" } else { "Off" }), + ], + )) + .child(summary_section( + "Packages per version", + Some(packages_note.as_str()), + if version_rows.is_empty() { + vec![summary_line("No versions", "")] + } else { + version_rows + }, + )) + .child(predownload_toggle_row(predownload)), + ); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::flex(1.0)) + .content(Content::Flex) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .center() + .padding(Gaps::new_all(48.)) + .child(onboarding_illustration(IconType::OnboardingComplete)), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new(48., 80., 24., 24.)) + .child(content), + ), + ) + .child( + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new(0., 40., 32., 40.)) + .child( + Button::new() + .secondary() + .width(Size::px(128.)) + .on_press(move |_| { + let _ = RouterContext::get().replace(Route::OnboardingPreferences {}); + }) + .text("Back"), + ) + .child( + Button::new() + .primary() + .width(Size::px(160.)) + .on_press(on_setup) + .text("Setup") + .child(Icon::new(IconType::Download01).size(16.)), + ), + ) + .child(prefetch_art(items)) +} + +fn prefetch_art(items: &[ClusterBundles]) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .height(Size::px(1.)) + .overflow(Overflow::Clip) + .opacity(0.) + .interactive(false) + .children(items.iter().map(|cb| { + rect() + .width(Size::px(1.)) + .height(Size::px(1.)) + .child(DynamicArt::for_cluster(&cb.cluster).max_edge(ART_PREVIEW_EDGE)) + .into_element() + })) + .into_element() +} + +fn summary_section(title: &str, note: Option<&str>, rows: Vec) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + label() + .text(title.to_string()) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_secondary()), + ) + .maybe_child(note.map(|text| { + label() + .text(text.to_string()) + .font_size(11.) + .color(colors::fg_secondary()) + .into_element() + })) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(2.) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .padding(Gaps::new_symmetric(6., 4.)) + .children(rows), + ) + .into_element() +} + +fn summary_line(label_text: &str, value: &str) -> Element { + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(10., 12.)) + .child( + rect().width(Size::flex(1.0)).child( + label() + .text(label_text.to_string()) + .font_size(14.) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(value.to_string()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/tips.rs b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs new file mode 100644 index 00000000..5e2e9112 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/downloading/tips.rs @@ -0,0 +1,150 @@ +use super::*; + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::backdrop::{next_rotation_delay, rotation_tick}; +use crate::theme::colors; + +const ONBOARDING_TIPS_BACKUP: &[&str] = &[ + "418 I'm a teapot", + "Floors 8 through 10 of the Catacombs were originally planned for release, but portions of it were later merged into Floor 7 and its Master Mode counterpart.", + "At one point, Feather Client was willing to pay $300 USD to add Skytils, a popular SkyBlock mod.", + "Grass is a plant with narrow leaves growing from the base. [...]\nGrasses are monocotyledon, herbaceous plants.", + "In Canada, Santa's postal code is H0H 0H0.", + "Guvf vf abg na vzcbegnag fragrapr.", + "Vs lbh qrpvcure guvf, lbh fubhyq yvfgra gb Mrgihr'f zhfvp.", + "Try to do something creative! It could be music, art, coding...\nYou won't know how much you like it until you try it, so give it a shot :)", + "At one point in Minecraft, there was a rare chance of Snow Golems naturally spawning.", + "W[h]yvest", + "RUMOUR. Open your ears; 9r\"5j5&?OWTY Z0d", + "Make sure to drink water! You never know if you might be dehydrated.", + "People asking you to \"test their mod\" are likely trying to hack you.\nThe only ones you can trust are ones widely trusted by the community.", + "Have a great day!", + "You can sleep through a thunderstorm, even during the day", +]; + +const FUNFACTS_URL: &str = "https://data-v2.polyfrost.org/oneclient/funfacts.txt"; + +fn normalize_line_breaks(value: &str) -> String { + value.replace("/n", "\n").replace("\\n", "\n") +} + +fn parse_funfacts(text: &str) -> Vec { + text.lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(normalize_line_breaks) + .collect() +} + +/// A random tip that rotates in step with the backdrop art +pub(super) fn use_onboarding_tip() -> String { + let mut tips = use_state(|| { + ONBOARDING_TIPS_BACKUP + .iter() + .map(|tip| normalize_line_breaks(tip)) + .collect::>() + }); + + use_hook(move || { + spawn(async move { + let Ok(state) = crate::launcher::state() else { + return; + }; + match state + .services + .requester + .http() + .get(FUNFACTS_URL) + .send() + .await + { + Ok(res) if res.status().is_success() => match res.text().await { + Ok(text) => { + let parsed = parse_funfacts(&text); + if !parsed.is_empty() { + tips.set(parsed); + } + } + Err(err) => { + tracing::warn!("onboarding tips: reading body failed: {err}"); + } + }, + Ok(res) => { + tracing::warn!("onboarding tips: unexpected status {}", res.status()); + } + Err(err) => { + tracing::warn!("onboarding tips: request failed, using backup facts: {err}"); + } + } + }); + }); + + // Random offset against the shared tick so the run does not always open on + // the same fact + let seed = use_hook(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_nanos() as usize) + .unwrap_or(0) + }); + let mut tick = use_state(rotation_tick); + + let stop = use_hook(|| Arc::new(AtomicBool::new(false))); + + use_drop({ + let stop = stop.clone(); + move || stop.store(true, Ordering::Relaxed) + }); + + use_hook({ + let stop = stop.clone(); + move || { + spawn(async move { + loop { + tokio::time::sleep(next_rotation_delay()).await; + + if stop.load(Ordering::Relaxed) { + break; + } + + tick.set(rotation_tick()); + } + }); + } + }); + + // Wrapped at read time the list is swapped out once the remote facts land + let list = tips.read(); + if list.is_empty() { + String::new() + } else { + list[seed.wrapping_add(*tick.read()) % list.len()].clone() + } +} + +pub(super) fn tip_panel(tip: String) -> impl IntoElement { + rect() + .vertical() + .width(Size::px(320.)) + .spacing(4.) + .cross_align(Alignment::End) + .child( + label() + .text("TIP") + .width(Size::fill()) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .text_align(TextAlign::Right) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(tip) + .width(Size::fill()) + .font_size(13.) + .text_align(TextAlign::Right) + .color(colors::fg_secondary()), + ) +} diff --git a/packages/oneclient_app/src/view/onboarding/language.rs b/packages/oneclient_app/src/view/onboarding/language.rs new file mode 100644 index 00000000..c34b0e39 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/language.rs @@ -0,0 +1,129 @@ +use freya::prelude::*; + +use crate::components::IconType; +use crate::hooks::{has_migration_data, use_migration, use_onboarding_selection}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::onboarding::{ + onboarding_illustration, onboarding_nav, onboarding_page, step_heading, +}; + +struct Language { + name: &'static str, + percentage: u32, +} + +const LANGUAGES: [Language; 1] = [Language { + name: "English", + percentage: 100, +}]; + +#[derive(PartialEq)] +pub struct OnboardingLanguage; + +impl Component for OnboardingLanguage { + fn render(&self) -> impl IntoElement { + let selected = use_state(|| 0usize); + let language = use_onboarding_selection().language; + let migration_query = use_migration(); + let back = if has_migration_data(&migration_query) { + Route::OnboardingMigration {} + } else { + Route::OnboardingTerms {} + }; + + let rows: Vec = LANGUAGES + .iter() + .enumerate() + .map(|(index, lang)| language_row(lang, index == *selected.read(), selected, language)) + .collect(); + + let content = rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(step_heading( + "OneClient", + "Let's get you all set-up with the most advanced client.", + )) + .child( + label() + .text("Choose your preferred language") + .font_size(18.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(4.) + .children(rows), + ) + .into_element(); + + onboarding_page( + onboarding_illustration(IconType::OnboardingLanguage), + content, + onboarding_nav(Some(back), Route::OnboardingAccount {}, true), + ) + } +} + +fn language_row( + language: &Language, + active: bool, + mut selected: State, + mut chosen: State, +) -> Element { + let index = LANGUAGES + .iter() + .position(|l| l.name == language.name) + .unwrap_or(0); + let name = language.name; + + let background = if active { + colors::brand() + } else { + Color::TRANSPARENT + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .padding(Gaps::new_symmetric(14., 20.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(background) + .maybe(active, |el| { + el.border(border_all_color(1., colors::brand())) + }) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_press(move |_| { + selected.set(index); + chosen.set(name.to_string()); + }) + .child( + rect().width(Size::flex(1.0)).child( + label() + .text(language.name) + .font_size(16.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ), + ) + .child( + label() + .text(format!("{}% localized", language.percentage)) + .font_size(13.) + .color(if active { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/migration.rs b/packages/oneclient_app/src/view/onboarding/migration.rs new file mode 100644 index 00000000..b09f74c0 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/migration.rs @@ -0,0 +1,432 @@ +use freya::prelude::*; +use freya::router::RouterContext; +use oneclient_core::{MigrationDetection, MigrationSource, SourceInstance}; + +use crate::components::{Button, Icon, IconType, ScrollArea}; +use crate::hooks::{ + ClusterBundles, migration_detection, onboarding_bundles_items, use_migration, + use_onboarding_bundles, use_onboarding_selection, +}; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::onboarding::{ + choice_row, onboarding_illustration, onboarding_slide, step_heading, +}; + +fn migrated_categories(source: &[SourceInstance]) -> Vec { + let mut categories: Vec = Vec::new(); + for instance in source { + for category in &instance.categories { + if !categories.iter().any(|c| c.eq_ignore_ascii_case(category)) { + categories.push(category.clone()); + } + } + } + categories +} + +/// Needed for the "dedicated dir" import option to have a target +pub(crate) fn matching_new_cluster_id( + instance: &SourceInstance, + new: &[ClusterBundles], +) -> Option { + let want = instance.import_version(); + new.iter() + .find(|cb| cb.cluster.mc_version == want && cb.cluster.mc_loader == instance.mc_loader) + .map(|cb| cb.cluster.id) +} + +#[derive(PartialEq)] +pub struct OnboardingMigration; + +impl Component for OnboardingMigration { + fn render(&self) -> impl IntoElement { + let migration_query = use_migration(); + let bundles_query = use_onboarding_bundles(); + let selection = use_onboarding_selection(); + let mut categories = selection.migrated_categories; + // Shared across the flow the file import is dispatched later on Setup + let mut import_folder = selection.import_folder; + let import_dedicated = selection.import_dedicated; + + // The shell only routes here when a source was detected so this is transient + let Some(detection) = migration_detection(&migration_query) else { + return rect() + .width(Size::fill()) + .height(Size::fill()) + .into_element(); + }; + let source_name = detection.source.display_name(); + let is_vanilla = detection.source == MigrationSource::Vanilla; + let new_clusters = onboarding_bundles_items(&bundles_query).unwrap_or_default(); + let bundles_loaded = onboarding_bundles_items(&bundles_query).is_some(); + + let chosen_folder = import_folder.read().clone(); + let any_importable = detection.instances.iter().any(|c| c.has_game_dir); + + let (title, subtitle) = if is_vanilla { + ( + "Bring over your Minecraft files", + "We found a Minecraft installation on this PC. We can copy your worlds, \ + settings, screenshots and servers over. Mods, resource packs and shaders stay \ + behind; OneClient manages those for you." + .to_string(), + ) + } else { + ( + "Bring over your setup", + format!( + "We found data from your previous {source_name} install. Your installed \ + categories are reselected automatically. Optionally, pick one version to \ + copy its files (worlds, config, and packs)." + ), + ) + }; + let heading = step_heading(title, &subtitle); + + let content = rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(heading) + // "Don't import files" is the default choice pinned above the list + .maybe_child(any_importable.then(|| { + import_choice_card( + "Don't import files", + if is_vanilla { + "Start fresh. Nothing is copied over." + } else { + "Start fresh. Only your bundle selection carries over." + }, + None, + chosen_folder.is_none(), + move |_| import_folder.set(None), + ) + })) + .child(import_body( + &detection, + is_vanilla, + chosen_folder.as_deref(), + &new_clusters, + import_folder, + import_dedicated, + )) + .into_element(); + + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child(onboarding_slide( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .center() + .padding(Gaps::new_all(48.)) + .child(onboarding_illustration(if is_vanilla { + IconType::FolderDownload + } else { + IconType::ClockRewind + })), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .content(Content::Flex) + .padding(Gaps::new(48., 80., 24., 24.)) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .child(content), + ), + ), + )) + .child(migration_nav(bundles_loaded, move || { + categories.set(Some(migrated_categories(&detection.instances))); + + let _ = RouterContext::get().replace(Route::OnboardingLanguage {}); + })) + .into_element() + } +} + +fn import_body( + detection: &MigrationDetection, + is_vanilla: bool, + chosen_folder: Option<&str>, + new_clusters: &[ClusterBundles], + mut import_folder: State>, + import_dedicated: State, +) -> Element { + if is_vanilla { + let folder = detection + .instances + .first() + .map(|instance| instance.folder_name.clone()) + .unwrap_or_default(); + + return import_choice_card( + "Copy my Minecraft files", + "Worlds, settings, screenshots and servers move to your shared game directory.", + None, + chosen_folder.is_some(), + move |_| import_folder.set(Some(folder.clone())), + ); + } + + let mut version_cards: Vec = Vec::new(); + for instance in &detection.instances { + let selected_import = chosen_folder == Some(instance.folder_name.as_str()); + let dedicated_available = matching_new_cluster_id(instance, new_clusters).is_some(); + + version_cards.push(version_card( + instance, + selected_import, + *import_dedicated.read(), + dedicated_available, + import_dedicated, + { + let folder = instance.folder_name.clone(); + move |_| import_folder.set(Some(folder.clone())) + }, + )); + } + + rect() + .vertical() + .width(Size::fill()) + .spacing(10.) + .child( + label() + .text("Detected versions") + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .children(version_cards), + ) + .into_element() +} + +/// When the instance has a game dir it also acts as the file-import selector and exposes the shared/dedicated target choice +fn version_card( + instance: &SourceInstance, + selected: bool, + dedicated: bool, + dedicated_available: bool, + mut import_dedicated: State, + on_select: impl FnMut(()) + 'static, +) -> Element { + let importable = instance.has_game_dir; + let mut on_select = on_select; + + let categories = if instance.categories.is_empty() { + "No bundle categories".to_string() + } else { + instance.categories.join(", ") + }; + let subtitle = format!("{} · {}", instance.mc_version, instance.mc_loader); + + let (bg, border_color) = if selected { + (colors::brand(), colors::brand()) + } else { + (colors::page_elevated(), colors::component_border()) + }; + + let header = rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text(instance.folder_name.clone()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child(label().text(subtitle).font_size(11.).color(if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + })) + .child(label().text(categories).font_size(11.).color(if selected { + colors::fg_primary() + } else { + colors::fg_secondary() + })), + ) + // Selection affordance only when the instance actually has files + .maybe_child(importable.then(|| { + if selected { + Icon::new(IconType::Check).size(18.).into_element() + } else { + label() + .text("Import files") + .font_size(12.) + .color(colors::fg_secondary()) + .into_element() + } + })); + + let mut card = rect() + .vertical() + .width(Size::fill()) + .spacing(12.) + .padding(Gaps::new_symmetric(12., 14.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(bg) + .border(border_all_color(1., border_color)) + .child(header); + + if importable { + card = card + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_select(())); + } + + if selected { + let mut targets = rect() + .vertical() + .width(Size::fill()) + .spacing(6.) + .child( + label() + .text("Where should these files go?") + .font_size(12.) + .color(colors::fg_primary()), + ) + .child(choice_row( + "Shared game directory", + "Available to every version.", + !dedicated, + move |_| import_dedicated.set(false), + )); + + if dedicated_available { + targets = targets.child(choice_row( + "This version only", + "Keep the files isolated to the matching version.", + dedicated, + move |_| import_dedicated.set(true), + )); + } + + card = card.child(targets); + } + + card.into_element() +} + +/// A standalone import choice with no associated version (e.g. "Don't import") +fn import_choice_card( + title: &str, + subtitle: &str, + trailing: Option, + active: bool, + on_press: impl FnMut(()) + 'static, +) -> Element { + let mut on_press = on_press; + let (bg, border_color) = if active { + (colors::brand(), colors::brand()) + } else { + (colors::page_elevated(), colors::component_border()) + }; + + rect() + .horizontal() + .width(Size::fill()) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_symmetric(12., 14.)) + .corner_radius(CornerRadius::new_all(10.)) + .background(bg) + .border(border_all_color(1., border_color)) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_press(())) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title.to_string()) + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle.to_string()) + .font_size(11.) + .color(if active { + colors::fg_primary() + } else { + colors::fg_secondary() + }), + ), + ) + .maybe_child( + active + .then(|| Icon::new(IconType::Check).size(16.)) + .or_else(|| trailing.map(|i| Icon::new(i).size(16.))), + ) + .into_element() +} + +/// Like `onboarding_nav` but Next runs `on_next` (apply migration + navigate) instead of a plain route replace +fn migration_nav(next_enabled: bool, on_next: impl FnMut() + 'static) -> impl IntoElement { + let mut on_next = on_next; + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new(0., 40., 32., 40.)) + .child( + Button::new() + .secondary() + .width(Size::px(128.)) + .on_press(move |_| { + let _ = RouterContext::get().replace(Route::OnboardingTerms {}); + }) + .text("Back"), + ) + .child( + Button::new() + .primary() + .width(Size::px(140.)) + .enabled(next_enabled) + .on_press(move |_| on_next()) + .text("Next") + .child(Icon::new(IconType::ArrowRight).size(16.)), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/mod.rs b/packages/oneclient_app/src/view/onboarding/mod.rs new file mode 100644 index 00000000..9abb4f19 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/mod.rs @@ -0,0 +1,349 @@ +mod account; +mod bundles; +mod downloading; +mod language; +mod migration; +mod preferences; +mod selection; +mod terms; +#[cfg(test)] +pub(crate) mod test_support; +mod welcome; + +pub(crate) use selection::{ + archive_selected, default_selection, is_default_bundle, is_optional_file, pkg_key, + set_archive_selected, +}; + +pub use account::OnboardingAccount; +pub use bundles::OnboardingBundles; +pub use downloading::{LoadingBackdrop, OnboardingDownloading}; +pub use language::OnboardingLanguage; +pub use migration::OnboardingMigration; +pub(crate) use migration::matching_new_cluster_id; +pub use preferences::OnboardingPreferences; +pub use terms::OnboardingTerms; +pub use welcome::OnboardingWelcome; + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use freya::animation::*; +use freya::prelude::*; +use freya::router::{RouterContext, use_route}; + +use crate::Route; +use crate::components::{Button, Icon, IconType, toggle}; +use crate::hooks::{has_migration_data, use_migration, use_onboarding_selection}; +use crate::theme::colors; +use crate::ui::{border_all_color, entrance_motion_layer}; + +/// The v1-migration step only exists and is only counted when old launcher data was detected +pub fn onboarding_total(has_migration: bool) -> usize { + if has_migration { 8 } else { 7 } +} + +pub fn onboarding_step_index(route: &Route, has_migration: bool) -> usize { + let shift = if has_migration { 1 } else { 0 }; + + match route { + Route::OnboardingWelcome {} => 0, + Route::OnboardingTerms {} => 1, + Route::OnboardingMigration {} => 2, + Route::OnboardingLanguage {} => 2 + shift, + Route::OnboardingAccount {} => 3 + shift, + Route::OnboardingBundles {} => 4 + shift, + Route::OnboardingPreferences {} => 5 + shift, + Route::OnboardingDownloading {} => 6 + shift, + _ => 0, + } +} + +static LAST_STEP: AtomicUsize = AtomicUsize::new(0); + +const SLIDE_DISTANCE: f32 = 44.; + +pub(crate) fn onboarding_slide(content: impl IntoElement) -> impl IntoElement { + let route = use_route::(); + let migration_query = use_migration(); + let step = onboarding_step_index(&route, has_migration_data(&migration_query)); + let reduce_motion = use_onboarding_selection().reduce_motion; + + let direction = use_hook(|| { + let prev = LAST_STEP.swap(step, Ordering::Relaxed); + if step >= prev { 1.0_f32 } else { -1.0 } + }); + + let anim = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.) + .time(320) + .ease(Ease::Out) + .function(Function::Cubic) + }); + + let anim_finished = *anim.has_run_yet().read() && !*anim.is_running().read(); + let motion = !*reduce_motion.read(); + + use_side_effect_with_deps(&anim_finished, move |&finished| { + if finished { + Platform::get().send(UserEvent::RequestRedraw); + } + }); + + let p = if anim_finished { + 1.0 + } else { + anim.get().value() + }; + let slide_x = if motion { + direction * (1. - p) * SLIDE_DISTANCE + } else { + 0. + }; + let opacity = if motion && !anim_finished { p } else { 1. }; + + rect() + .width(Size::fill()) + .height(Size::flex(1.0)) + .child(entrance_motion_layer(slide_x, 0., opacity, content)) + .into_element() +} + +pub(crate) fn onboarding_page( + illustration: impl IntoElement, + content: impl IntoElement, + nav: impl IntoElement, +) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child(onboarding_slide( + rect() + .horizontal() + .width(Size::fill()) + .height(Size::fill()) + .content(Content::Flex) + .child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .center() + .padding(Gaps::new_all(48.)) + .child(illustration), + ) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .height(Size::fill()) + .main_align(Alignment::Center) + .padding(Gaps::new(48., 80., 24., 24.)) + .spacing(24.) + .child(content), + ), + )) + .child(nav) + .into_element() +} + +pub(crate) fn onboarding_illustration(icon: IconType) -> impl IntoElement { + Icon::new(icon).size(240.).color(Color::WHITE) +} + +pub(crate) fn onboarding_nav( + back: Option, + next: Route, + next_enabled: bool, +) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new(0., 40., 32., 40.)) + .maybe_child(back.map(|route| { + Button::new() + .secondary() + .width(Size::px(128.)) + .on_press(move |_| { + let _ = RouterContext::get().replace(route.clone()); + }) + .text("Back") + .into_element() + })) + .child( + Button::new() + .primary() + .width(Size::px(140.)) + .enabled(next_enabled) + .on_press(move |_| { + let _ = RouterContext::get().replace(next.clone()); + }) + .text("Next") + .child(Icon::new(IconType::ArrowRight).size(16.)), + ) + .into_element() +} + +pub(crate) fn predownload_toggle_row(predownload: State) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(16.) + .content(Content::Flex) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text("Download content now") + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text( + "Downloads Minecraft and your chosen mods now, so you can play as \ + soon as setup finishes. Turn it off to download each version the \ + first time you play it.", + ) + .font_size(11.) + .color(colors::fg_secondary()), + ), + ) + .child(toggle(predownload)) + .into_element() +} + +pub(crate) fn choice_row( + title: &str, + subtitle: &str, + active: bool, + on_press: impl FnMut(()) + 'static, +) -> Element { + choice_row_sized(title, subtitle, active, Size::auto(), on_press) +} + +pub(crate) fn choice_row_sized( + title: &str, + subtitle: &str, + active: bool, + height: Size, + on_press: impl FnMut(()) + 'static, +) -> Element { + let mut on_press = on_press; + let border_color = if active { + colors::fg_primary() + } else { + colors::component_border() + }; + + rect() + .horizontal() + .width(Size::fill()) + .height(height) + .content(Content::Flex) + .cross_align(Alignment::Center) + .spacing(10.) + .padding(Gaps::new_symmetric(8., 12.)) + .corner_radius(CornerRadius::new_all(8.)) + .border(border_all_color(1., border_color)) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_press(())) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text(title.to_string()) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .maybe_child((!subtitle.is_empty()).then(|| { + label() + .text(subtitle.to_string()) + .font_size(11.) + .color(colors::fg_primary()) + .into_element() + })), + ) + .maybe_child(active.then(|| Icon::new(IconType::Check).size(15.))) + .into_element() +} + +pub(crate) fn version_chip( + text: &str, + active: bool, + on_press: impl FnMut(()) + 'static, +) -> Element { + let mut on_press = on_press; + let (bg, border, fg) = if active { + ( + colors::brand().with_a(38), + colors::brand(), + colors::fg_primary(), + ) + } else { + ( + Color::TRANSPARENT, + colors::component_border(), + colors::fg_secondary(), + ) + }; + + rect() + .horizontal() + .height(Size::px(32.)) + .center() + .padding(Gaps::new_symmetric(0., 14.)) + .corner_radius(CornerRadius::new_all(999.)) + .background(bg) + .border(border_all_color(1.5, border)) + .a11y_role(AccessibilityRole::Button) + .cursor(CursorIcon::Pointer) + .on_press(move |_| on_press(())) + .child( + label() + .text(text.to_string()) + .font_size(13.) + .font_weight(FontWeight::MEDIUM) + .color(fg), + ) + .into_element() +} + +pub(crate) fn step_heading(title: &str, subtitle: &str) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child( + label() + .text(title.to_string()) + .font_size(36.) + .font_weight(FontWeight::BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(subtitle.to_string()) + .font_size(16.) + .color(colors::fg_secondary()), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/preferences.rs b/packages/oneclient_app/src/view/onboarding/preferences.rs new file mode 100644 index 00000000..e5c461cd --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/preferences.rs @@ -0,0 +1,85 @@ +use freya::prelude::*; + +use crate::components::{IconType, toggle}; +use crate::hooks::{use_dispatch, use_onboarding_selection, use_settings_snapshot}; +use crate::routes::Route; +use crate::view::app::settings::settings_row; +use crate::view::onboarding::{ + onboarding_illustration, onboarding_nav, onboarding_page, step_heading, +}; + +#[derive(PartialEq)] +pub struct OnboardingPreferences; + +impl Component for OnboardingPreferences { + fn render(&self) -> impl IntoElement { + let settings = use_settings_snapshot().settings; + let dispatch = use_dispatch(); + let mut reduce_motion = use_onboarding_selection().reduce_motion; + + let animations_on = use_state({ + let v = settings.animations_enabled; + move || v + }); + use_side_effect(move || { + reduce_motion.set_if_modified(!*animations_on.read()); + }); + + let parallax_on = use_state({ + let v = settings.dynamic_background_enabled; + move || v + }); + + let mut first = use_state(|| true); + use_side_effect(move || { + let parallax = *parallax_on.read(); + let animations = *animations_on.read(); + if *first.peek() { + first.set(false); + return; + } + let mut next = settings.clone(); + next.dynamic_background_enabled = parallax; + next.animations_enabled = animations; + dispatch.set_settings(next); + }); + + let content = rect() + .vertical() + .width(Size::fill()) + .spacing(20.) + .child(step_heading( + "Accessibility", + "Tune the launcher to your comfort. You can change these later in settings.", + )) + .child( + rect() + .vertical() + .width(Size::fill()) + .spacing(8.) + .child(settings_row( + IconType::Play, + "Animations", + "Disable all launcher animations and transitions.", + toggle(animations_on), + )) + .child(settings_row( + IconType::Eye, + "Parallax background", + "Make the background drift with your cursor. Turning it off disables the motion.", + toggle(parallax_on), + )), + ) + .into_element(); + + onboarding_page( + onboarding_illustration(IconType::OnboardingPreferences), + content, + onboarding_nav( + Some(Route::OnboardingBundles {}), + Route::OnboardingDownloading {}, + true, + ), + ) + } +} diff --git a/packages/oneclient_app/src/view/onboarding/selection.rs b/packages/oneclient_app/src/view/onboarding/selection.rs new file mode 100644 index 00000000..c2b38160 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/selection.rs @@ -0,0 +1,216 @@ +use std::collections::HashSet; + +use oneclient_core::{BundleArchive, BundleFile}; + +use crate::hooks::ClusterBundles; + +pub fn pkg_key(cluster_id: i64, bundle_name: &str, package_id: &str) -> String { + format!("{cluster_id}|{bundle_name}|{package_id}") +} + +pub fn is_default_bundle(archive: &BundleArchive) -> bool { + archive.manifest.enabled +} + +pub fn is_default_file(file: &BundleFile) -> bool { + file.enabled && !file.hidden +} + +pub fn is_optional_file(file: &BundleFile) -> bool { + !file.enabled && !file.hidden +} + +pub fn default_selection( + items: &[ClusterBundles], + opted_in_categories: Option<&[String]>, +) -> HashSet { + let mut selected = HashSet::new(); + + for cb in items { + for archive in &cb.archives { + if !is_default_bundle(archive) && !category_opted_in(archive, opted_in_categories) { + continue; + } + for file in &archive.manifest.files { + if is_default_file(file) { + selected.insert(pkg_key( + cb.cluster.id, + &archive.manifest.name, + &file.kind.package_id(), + )); + } + } + } + } + + selected +} + +fn category_opted_in(archive: &BundleArchive, opted_in: Option<&[String]>) -> bool { + let Some(categories) = opted_in else { + return false; + }; + let category = archive.manifest.category.trim(); + if category.is_empty() { + return false; + } + categories.iter().any(|c| c.eq_ignore_ascii_case(category)) +} + +pub fn archive_selected( + cluster_id: i64, + archive: &BundleArchive, + selected: &HashSet, +) -> bool { + let mut any = false; + for file in archive.manifest.files.iter().filter(|f| is_default_file(f)) { + any = true; + if !selected.contains(&pkg_key( + cluster_id, + &archive.manifest.name, + &file.kind.package_id(), + )) { + return false; + } + } + any +} + +pub fn set_archive_selected( + cluster_id: i64, + archive: &BundleArchive, + on: bool, + selected: &mut HashSet, +) { + for file in archive.manifest.files.iter().filter(|f| is_default_file(f)) { + let key = pkg_key(cluster_id, &archive.manifest.name, &file.kind.package_id()); + if on { + selected.insert(key); + } else { + selected.remove(&key); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::view::onboarding::test_support::{archive, cluster, file}; + + fn catalog() -> Vec { + vec![ClusterBundles { + cluster: cluster(1), + archives: vec![ + archive("QoL", true, vec![file("qol-a", true, false)]), + archive( + "Utility", + true, + vec![file("util-a", true, false), file("util-opt", false, false)], + ), + archive("PvP", false, vec![file("pvp-a", true, false)]), + archive("SkyBlock", false, vec![file("sb-a", true, false)]), + ], + }] + } + + #[test] + fn opt_in_bundles_are_not_selected_by_default() { + let selected = default_selection(&catalog(), None); + + assert!(!selected.iter().any(|k| k.contains("[PvP]"))); + assert!(!selected.iter().any(|k| k.contains("[SkyBlock]"))); + } + + #[test] + fn default_bundles_are_selected_by_default() { + let selected = default_selection(&catalog(), None); + + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [QoL]", "qol-a"))); + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [Utility]", "util-a"))); + } + + #[test] + fn files_the_bundle_ships_off_are_not_selected_by_default() { + let selected = default_selection(&catalog(), None); + + assert!(!selected.contains(&pkg_key( + 1, + "OneClient 1.21.11 Fabric [Utility]", + "util-opt" + ))); + } + + #[test] + fn migrated_categories_opt_into_their_bundles_without_losing_defaults() { + let cats = vec!["SkyBlock".to_string()]; + let selected = default_selection(&catalog(), Some(&cats)); + + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [SkyBlock]", "sb-a"))); + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [QoL]", "qol-a"))); + assert!(!selected.iter().any(|k| k.contains("[PvP]"))); + } + + #[test] + fn migrated_category_match_ignores_case() { + let cats = vec!["skyblock".to_string()]; + let selected = default_selection(&catalog(), Some(&cats)); + + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [SkyBlock]", "sb-a"))); + } + + #[test] + fn hidden_files_are_never_selected() { + let items = vec![ClusterBundles { + cluster: cluster(1), + archives: vec![archive( + "QoL", + true, + vec![file("shown", true, false), file("dep", true, true)], + )], + }]; + let selected = default_selection(&items, None); + + assert!(selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [QoL]", "shown"))); + assert!(!selected.contains(&pkg_key(1, "OneClient 1.21.11 Fabric [QoL]", "dep"))); + } + + #[test] + fn selection_is_per_cluster() { + let items = [ + ClusterBundles { + cluster: cluster(1), + archives: vec![archive("SkyBlock", false, vec![file("sb-a", true, false)])], + }, + ClusterBundles { + cluster: cluster(2), + archives: vec![archive("SkyBlock", false, vec![file("sb-a", true, false)])], + }, + ]; + let mut selected = HashSet::new(); + let sb = &items[0].archives[0]; + set_archive_selected(1, sb, true, &mut selected); + + assert!(archive_selected(1, sb, &selected)); + assert!(!archive_selected(2, &items[1].archives[0], &selected)); + } + + #[test] + fn set_archive_selected_round_trips() { + let items = catalog(); + let pvp = &items[0].archives[2]; + let mut selected = HashSet::new(); + + set_archive_selected(1, pvp, true, &mut selected); + assert!(archive_selected(1, pvp, &selected)); + + set_archive_selected(1, pvp, false, &mut selected); + assert!(!archive_selected(1, pvp, &selected)); + assert!(selected.is_empty()); + } + + #[test] + fn archive_with_no_default_files_is_not_selected() { + let a = archive("Odd", true, vec![file("opt", false, false)]); + assert!(!archive_selected(1, &a, &HashSet::new())); + } +} diff --git a/packages/oneclient_app/src/view/onboarding/terms.rs b/packages/oneclient_app/src/view/onboarding/terms.rs new file mode 100644 index 00000000..b4eb2ecf --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/terms.rs @@ -0,0 +1,304 @@ +use freya::prelude::*; +use freya::query::UseQuery; +use freya::router::RouterContext; + +use crate::components::{Button, Icon, IconType, ScrollArea, Segment, SegmentedControl, toggle}; +use crate::hooks::{ + TermsQuery, has_migration_data, terms_document, terms_error, terms_is_loading, use_dispatch, + use_migration, use_settings_snapshot, use_terms, +}; +use crate::platform::open_url; +use crate::routes::Route; +use crate::theme::colors; +use crate::ui::border_all_color; +use crate::view::onboarding::{onboarding_illustration, onboarding_page, step_heading}; + +#[derive(Clone, Copy, PartialEq)] +enum LegalTab { + Terms, + Privacy, +} + +#[derive(PartialEq)] +pub struct OnboardingTerms; + +impl Component for OnboardingTerms { + fn render(&self) -> impl IntoElement { + let settings = use_settings_snapshot().settings; + let dispatch = use_dispatch(); + let query = use_terms(); + let migration_query = use_migration(); + + let document = terms_document(&query); + let error = terms_error(&query); + let loading = terms_is_loading(&query); + + let accepted = use_state(|| false); + let tab = use_state(|| LegalTab::Terms); + + let returning = settings.seen_onboarding; + let next = if returning { + Route::Home {} + } else if has_migration_data(&migration_query) { + Route::OnboardingMigration {} + } else { + Route::OnboardingLanguage {} + }; + let back = (!returning).then_some(Route::OnboardingWelcome {}); + + let terms_version = document.as_ref().map(|doc| doc.version).unwrap_or(1); + let privacy_version = document + .as_ref() + .map(|doc| doc.privacy_version()) + .unwrap_or(1); + let terms_url = document + .as_ref() + .map(|doc| doc.terms_url().to_string()) + .unwrap_or_else(|| oneclient_common::constants::TOS_URL.to_string()); + let privacy_url = document + .as_ref() + .map(|doc| doc.privacy_url().to_string()) + .unwrap_or_else(|| oneclient_common::constants::PRIVACY_URL.to_string()); + + let privacy_body = document.as_ref().and_then(|doc| doc.privacy_body()); + let tabs = privacy_body.is_some().then(|| tab_switcher(tab)); + + let body = if loading { + loading_body() + } else if let Some(document) = document.as_ref() { + match (*tab.read(), privacy_body) { + (LegalTab::Privacy, Some(privacy)) => markdown_body(privacy), + _ => markdown_body(&document.terms), + } + } else { + fallback_body(error.as_deref(), query) + }; + + let content = rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(step_heading( + "Terms & Privacy", + "Please read and accept these before continuing.", + )) + .maybe_child(tabs) + .child(body) + .child(link_row(terms_url, privacy_url)) + .child(accept_row(accepted)) + .into_element(); + + onboarding_page( + onboarding_illustration(IconType::File02), + content, + terms_nav(back, *accepted.read() && !loading, move || { + dispatch.accept_tos(terms_version, privacy_version); + let _ = RouterContext::get().replace(next.clone()); + }), + ) + } +} + +fn panel(child: impl IntoElement) -> impl IntoElement { + rect() + .vertical() + .width(Size::fill()) + .height(Size::px(220.)) + .content(Content::Flex) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child(child) +} + +fn loading_body() -> Element { + panel( + rect() + .width(Size::fill()) + .height(Size::fill()) + .center() + .child( + label() + .text("Loading the Terms of Service and Privacy Policy...") + .font_size(13.) + .color(colors::fg_secondary()), + ), + ) + .into_element() +} + +fn tab_switcher(tab: State) -> Element { + SegmentedControl::new(tab) + .segment(Segment::new(LegalTab::Terms).label("Terms")) + .segment(Segment::new(LegalTab::Privacy).label("Privacy")) + .into_element() +} + +fn markdown_body(markdown: &str) -> Element { + panel( + ScrollArea::new() + .width(Size::fill()) + .height(Size::flex(1.0)) + .padding(Gaps::new_all(16.)) + .child( + MarkdownViewer::new(markdown.to_string()) + .width(Size::fill()) + .color(colors::fg_primary()) + .color_link(colors::code_info()) + .background_code(colors::component_bg()) + .color_code(colors::fg_primary()) + .background_blockquote(colors::component_bg()) + .border_blockquote(colors::brand()) + .background_divider(colors::component_border()) + .heading_h1(20.) + .heading_h2(17.) + .heading_h3(15.) + .heading_h4(14.) + .heading_h5(13.) + .heading_h6(12.) + .paragraph_size(13.) + .code_font_size(12.), + ), + ) + .into_element() +} + +fn fallback_body(error: Option<&str>, query: UseQuery) -> Element { + panel( + rect() + .vertical() + .width(Size::fill()) + .height(Size::fill()) + .center() + .spacing(10.) + .padding(Gaps::new_all(20.)) + .child( + Icon::new(IconType::AlertTriangle) + .size(22.) + .color(colors::code_warn()), + ) + .child( + label() + .text("Couldn't load the Terms of Service or Privacy Policy.") + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text( + "Open them in your browser using the links below, or try again once \ + you're back online.", + ) + .font_size(12.) + .text_align(TextAlign::Center) + .color(colors::fg_secondary()), + ) + .maybe_child(error.map(|err| { + label() + .text(err.to_string()) + .font_size(11.) + .max_lines(2) + .color(colors::fg_secondary().with_a(160)) + })) + .child( + Button::new() + .secondary() + .small() + .on_press(move |_| { + query.invalidate(); + }) + .text("Retry") + .child(Icon::new(IconType::RefreshCw01).size(14.)), + ), + ) + .into_element() +} + +fn link_row(terms_url: String, privacy_url: String) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .spacing(10.) + .child(external_link_button("Terms of Service", terms_url)) + .child(external_link_button("Privacy Policy", privacy_url)) + .into_element() +} + +fn external_link_button(text: &'static str, url: String) -> impl IntoElement { + Button::new() + .secondary() + .small() + .on_press(move |_| open_url(&url)) + .text(text) + .child(Icon::new(IconType::LinkExternal01).size(14.)) +} + +fn accept_row(accepted: State) -> impl IntoElement { + rect() + .horizontal() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(16.) + .content(Content::Flex) + .padding(Gaps::new_symmetric(12., 16.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(3.) + .child( + label() + .text("I accept the Terms of Service and Privacy Policy") + .font_size(14.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_primary()), + ) + .child( + label() + .text("Required to use OneClient.") + .font_size(11.) + .color(colors::fg_secondary()), + ), + ) + .child(toggle(accepted)) + .into_element() +} + +fn terms_nav( + back: Option, + next_enabled: bool, + on_next: impl FnMut() + 'static, +) -> impl IntoElement { + let mut on_next = on_next; + rect() + .horizontal() + .width(Size::fill()) + .main_align(Alignment::End) + .cross_align(Alignment::Center) + .spacing(12.) + .padding(Gaps::new(0., 40., 32., 40.)) + .maybe_child(back.map(|route| { + Button::new() + .secondary() + .width(Size::px(128.)) + .on_press(move |_| { + let _ = RouterContext::get().replace(route.clone()); + }) + .text("Back") + .into_element() + })) + .child( + Button::new() + .primary() + .width(Size::px(140.)) + .enabled(next_enabled) + .on_press(move |_| on_next()) + .text("Next") + .child(Icon::new(IconType::ArrowRight).size(16.)), + ) + .into_element() +} diff --git a/packages/oneclient_app/src/view/onboarding/test_support.rs b/packages/oneclient_app/src/view/onboarding/test_support.rs new file mode 100644 index 00000000..87f25aa8 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/test_support.rs @@ -0,0 +1,66 @@ +use oneclient_common::domain::{ContentType, GameLoader}; +use oneclient_content::packages::types::ExternalFile; +use oneclient_core::clusters::{Cluster, ClusterStage}; +use oneclient_core::{Bundle, BundleArchive, BundleFile, BundleFileKind, BundleManifest}; +use std::path::PathBuf; + +pub fn file(package_id: &str, enabled: bool, hidden: bool) -> BundleFile { + BundleFile { + enabled, + hidden, + path: format!("mods/{package_id}.jar"), + size: 1, + kind: BundleFileKind::External(ExternalFile { + name: format!("{package_id}.jar"), + url: format!("https://example.invalid/{package_id}.jar"), + sha1: package_id.to_string(), + size: 1, + content_type: ContentType::Mod, + }), + } +} + +pub fn archive(category: &str, enabled: bool, files: Vec) -> BundleArchive { + let name = format!("OneClient 1.21.11 Fabric [{category}]"); + BundleArchive { + bundle: Bundle { + remote_path: format!("/bundles/{category}.mrpack"), + mc_version: "1.21.11".to_string(), + loader: GameLoader::Fabric, + file_name: format!("{category}.mrpack"), + name: name.clone(), + version_id: "1.0.0".to_string(), + category: category.to_string(), + loader_version: "0.16.0".to_string(), + path: PathBuf::from("/tmp/unused.mrpack"), + hidden: false, + }, + manifest: BundleManifest { + name, + version_id: "1.0.0".to_string(), + category: category.to_string(), + mc_version: "1.21.11".to_string(), + loader: GameLoader::Fabric, + loader_version: "0.16.0".to_string(), + enabled, + files, + }, + } +} + +pub fn cluster(id: i64) -> Cluster { + Cluster { + id, + name: format!("1.21.11 Fabric #{id}"), + folder_name: format!("cluster-{id}"), + setting_profile_name: None, + mc_version: "1.21.11".to_string(), + mc_loader: GameLoader::Fabric, + mc_loader_version: None, + stage: ClusterStage::default(), + created_at: None, + last_played: None, + overall_played: std::time::Duration::ZERO, + linked_modpack_hash: None, + } +} diff --git a/packages/oneclient_app/src/view/onboarding/welcome.rs b/packages/oneclient_app/src/view/onboarding/welcome.rs new file mode 100644 index 00000000..37ad66e6 --- /dev/null +++ b/packages/oneclient_app/src/view/onboarding/welcome.rs @@ -0,0 +1,37 @@ +use freya::prelude::*; + +use crate::components::IconType; +use crate::routes::Route; +use crate::theme::colors; +use crate::view::onboarding::{ + onboarding_illustration, onboarding_nav, onboarding_page, step_heading, +}; + +#[derive(PartialEq)] +pub struct OnboardingWelcome; + +impl Component for OnboardingWelcome { + fn render(&self) -> impl IntoElement { + let content = rect() + .vertical() + .width(Size::fill()) + .spacing(16.) + .child(step_heading( + "OneClient", + "Let's get you all set-up with the most advanced client.", + )) + .child( + label() + .text("This quick setup will pick your language, sign you in, and prepare your first versions. It only takes a minute.") + .font_size(15.) + .color(colors::fg_secondary()), + ) + .into_element(); + + onboarding_page( + onboarding_illustration(IconType::OnboardingWelcome), + content, + onboarding_nav(None, Route::OnboardingTerms {}, true), + ) + } +} diff --git a/packages/oneclient_app/src/view/startup.rs b/packages/oneclient_app/src/view/startup.rs new file mode 100644 index 00000000..1acc63b5 --- /dev/null +++ b/packages/oneclient_app/src/view/startup.rs @@ -0,0 +1,257 @@ +use freya::animation::{AnimNum, Ease, Function, OnCreation, OnFinish, use_animation}; +use freya::{prelude::*, router::*}; + +use crate::AppAssets; +use crate::Route; +use crate::components::{Button, progress_track}; +use crate::hooks::use_dispatch; +use crate::hooks::{ + terms_document, terms_is_loading, use_launcher, use_notifications_snapshot, + use_settings_snapshot, use_splash, use_terms, +}; +use crate::theme::colors; + +/// Startup is a sequence of boolean gates, not measurable work, so the bar steps +/// once per gate the launcher clears instead of tracking bytes +const STARTUP_STEPS: u8 = 3; + +#[derive(PartialEq)] +struct RecoveryActions { + snapshots: usize, +} + +impl Component for RecoveryActions { + fn render(&self) -> impl IntoElement { + let actions = use_dispatch(); + let snapshots = self.snapshots; + + let restore = actions.clone(); + + let mut row = rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + Button::new() + .secondary() + .small() + .on_press(|_| crate::recovery::open_data_folder()) + .text("Open data folder"), + ); + + if snapshots > 0 { + row = row.child( + Button::new() + .primary() + .small() + .on_press(move |_| crate::recovery::restore_latest(&restore)) + .text("Restore last backup"), + ); + } + + row.child( + Button::new() + .danger() + .small() + .on_press(move |_| crate::recovery::reset(&actions)) + .text("Reset database"), + ) + } +} + +#[derive(PartialEq)] +pub struct Startup; + +impl Component for Startup { + fn render(&self) -> impl IntoElement { + let launcher = use_launcher(); + let settings = use_settings_snapshot(); + let notifications = use_notifications_snapshot(); + let terms = use_terms(); + let splash = use_splash(); + + let intro = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.) + .time(620) + .ease(Ease::Out) + .function(Function::Cubic) + }); + + let pulse = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + conf.on_finish(OnFinish::reverse()); + AnimNum::new(0.82, 1.0) + .time(1600) + .ease(Ease::InOut) + .function(Function::Sine) + }); + + let logo = use_memo(|| AppAssets::get_bytes("logo.svg").unwrap_or_default()); + + if launcher.ready && !terms_is_loading(&terms) { + let document = terms_document(&terms); + let required_terms = document.as_ref().map(|doc| doc.version).unwrap_or(1); + let required_privacy = document + .as_ref() + .map(|doc| doc.privacy_version()) + .unwrap_or(1); + + let stale = settings.settings.accepted_tos_version < required_terms + || settings.settings.accepted_privacy_version < required_privacy; + + let destination = if !settings.settings.seen_onboarding { + Route::OnboardingWelcome {} + } else if stale { + Route::OnboardingTerms {} + } else { + Route::Home {} + }; + + // Onboarding needs the bundle catalog before it can render its steps so gate it on the fetch + let heading_to_onboarding = matches!( + destination, + Route::OnboardingWelcome { .. } | Route::OnboardingTerms { .. } + ); + if !heading_to_onboarding || !launcher.fetching { + // Raise the splash curtain so the hard route swap is hidden until Home settles onboarding animates itself + if !heading_to_onboarding { + let mut active = splash.active; + let mut home_ready = splash.home_ready; + home_ready.set(false); + active.set(true); + } + let _ = RouterContext::get().replace(destination); + return rect().into_element(); + } + } + + let active = notifications.inbox.iter().find(|entry| entry.is_loading); + + let (message, detail, step): (String, Option, u8) = + if let Some(err) = launcher.error.as_deref() { + let step = if launcher.ready { 2 } else { 1 }; + ( + "Couldn't start OneClient".to_string(), + Some(err.to_string()), + step, + ) + } else if let Some(entry) = active { + let detail = entry + .tasks + .iter() + .find(|t| t.total == 0 || t.current < t.total) + .map(|t| t.label.clone()) + .or_else(|| entry.progress.map(|(c, t)| format!("{c} / {t}"))); + (entry.title.clone(), detail, 3) + } else if launcher.ready { + ("Fetching versions and bundles...".to_string(), None, 2) + } else { + ("Starting OneClient...".to_string(), None, 1) + }; + + let is_error = launcher.error.is_some(); + + let appear = intro.get().value(); + let rise = (1.0 - appear) * 16.; + let breath = pulse.read().value(); + + let dot_color = if is_error { + colors::danger() + } else { + colors::brand() + }; + + let filled = f32::from(step) / f32::from(STARTUP_STEPS + 1) * 100.; + + let progress = rect().width(Size::px(288.)).child(progress_track( + filled, + 4., + dot_color, + colors::fg_primary().with_a(30), + )); + + let loading_row = rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(9.) + .child( + rect() + .width(Size::px(8.)) + .height(Size::px(8.)) + .corner_radius(CornerRadius::new_all(4.)) + .background(dot_color), + ) + .child( + label() + .text(message) + .font_size(14.) + .color(colors::fg_primary().with_a(180)), + ); + + let mut content = rect() + .center() + .vertical() + .spacing(14.) + .child( + SvgViewer::new(("logo.svg", logo.read().cloned())) + .show_loader(false) + .width(Size::px(288.)) + .height(Size::px(60.)) + .color(colors::fg_primary()), + ) + .child( + label() + .text("The only Minecraft launcher you'll need.") + .font_size(15.) + .color(colors::fg_primary().with_a(140)), + ) + .child(rect().height(Size::px(14.))) + .child(loading_row) + .child(progress); + + if let Some(detail) = detail { + content = content.child( + label() + .text(detail) + .font_size(12.) + .max_lines(if is_error { 4 } else { 1 }) + .color(colors::fg_secondary().with_a(180)), + ); + } + + if is_error { + content = content + .child(rect().height(Size::px(6.))) + .child(RecoveryActions { + snapshots: launcher.snapshots, + }) + .child( + label() + .text(format!( + "Your data stays in {}. Nothing here deletes it.", + launcher.data_dir + )) + .font_size(11.) + .max_lines(2) + .color(colors::fg_secondary().with_a(130)), + ); + } + + let content = content + .position(Position::new_absolute()) + .width(Size::fill()) + .height(Size::fill()) + .center() + .opacity(appear * breath) + .margin(Gaps::new(rise, 0., 0., 0.)); + + rect() + .width(Size::fill()) + .height(Size::fill()) + .background(colors::page()) + .window_drag() + .child(content) + .into_element() + } +} diff --git a/packages/oneclient_auth/Cargo.toml b/packages/oneclient_auth/Cargo.toml new file mode 100644 index 00000000..8d404d70 --- /dev/null +++ b/packages/oneclient_auth/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "oneclient_auth" +description = "Microsoft/Xbox/Minecraft authentication and the credentials store" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_events.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +reqwest.workspace = true +tokio.workspace = true +tokio-util.workspace = true + +base64.workspace = true +chrono.workspace = true +# Offline UUIDs are MD5 of "OfflinePlayer:", per Mojang. +md-5.workspace = true +rand.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true } diff --git a/packages/oneclient_auth/src/data.rs b/packages/oneclient_auth/src/data.rs new file mode 100644 index 00000000..63beb128 --- /dev/null +++ b/packages/oneclient_auth/src/data.rs @@ -0,0 +1,80 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountKind { + Microsoft, + Offline, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MinecraftAccount { + pub id: Uuid, + pub username: String, + pub access_token: String, + pub refresh_token: String, + pub expires: DateTime, + #[serde(default = "default_account_kind")] + pub kind: AccountKind, +} + +fn default_account_kind() -> AccountKind { + AccountKind::Microsoft +} + +impl MinecraftAccount { + pub fn is_microsoft(&self) -> bool { + self.kind == AccountKind::Microsoft + } + + pub fn is_offline(&self) -> bool { + self.kind == AccountKind::Offline + } + + pub fn is_expired(&self) -> bool { + self.expires <= Utc::now() + chrono::TimeDelta::seconds(60) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct DeviceCodeLogin { + pub user_code: String, + pub device_code: String, + pub verification_uri: String, + pub expires_in: u64, + pub interval: u64, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct BrowserLogin { + pub auth_url: String, + pub state: String, + pub redirect_uri: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct MicrosoftLoginSession { + pub browser: BrowserLogin, + pub device: DeviceCodeLogin, +} + +impl MicrosoftLoginSession { + pub fn dedupe_key(&self) -> &str { + &self.browser.state + } + + pub fn auth_url(&self) -> &str { + &self.browser.auth_url + } + + pub fn user_code(&self) -> &str { + &self.device.user_code + } + + pub fn verification_uri(&self) -> &str { + &self.device.verification_uri + } +} diff --git a/packages/oneclient_auth/src/diagnostics.rs b/packages/oneclient_auth/src/diagnostics.rs new file mode 100644 index 00000000..820e0b49 --- /dev/null +++ b/packages/oneclient_auth/src/diagnostics.rs @@ -0,0 +1,349 @@ +//! Adapted from Modrinth's `minecraft-auth-errors.ts` +//! (GPL-3.0) + +use oneclient_net::{NetworkFailure, classify_network_failure}; +use reqwest::StatusCode; + +use super::error::{MinecraftAuthError, MinecraftAuthStep, friendly_xbox_error}; + +#[derive(Debug, Clone, serde::Serialize)] +pub struct AuthErrorGuidance { + pub what_happened: String, + pub steps_to_fix: Vec, +} + +impl AuthErrorGuidance { + fn new(what_happened: &str, steps_to_fix: &[&str]) -> Self { + Self { + what_happened: what_happened.to_string(), + steps_to_fix: steps_to_fix.iter().map(|s| (*s).to_string()).collect(), + } + } +} + +#[must_use] +pub fn diagnose_auth_error(err: &MinecraftAuthError) -> Option { + match err { + MinecraftAuthError::XboxError { error_code, .. } => guidance_for_xerr(*error_code), + + MinecraftAuthError::ServiceError { step, status_code } => { + guidance_for_service_error(*step, *status_code) + } + + MinecraftAuthError::DeserializeError { step, .. } => guidance_for_step(*step), + + MinecraftAuthError::RequestError { source, .. } => { + Some(network_guidance(classify_network_failure(source))) + } + + MinecraftAuthError::BrowserAuthorizationExpired + | MinecraftAuthError::BrowserLoginNotFound => Some(AuthErrorGuidance::new( + "The Microsoft sign-in window was closed or expired before sign-in finished.", + &[ + "Start the sign-in again", + "Complete the Microsoft sign-in in your browser without closing the window", + "If your browser blocked the redirect back, allow it and try once more", + ], + )), + + _ => None, + } +} + +fn guidance_for_xerr(code: u64) -> Option { + Some(match code { + 2_148_916_222 => AuthErrorGuidance::new( + "This account requires age verification to comply with UK regulations before it can sign in.", + &[ + "Go to the Minecraft login page and sign in (https://www.minecraft.net/en-us/login)", + "Follow the instructions to verify your age", + "Once verified, try signing in again", + "For more help see UK age verification on Xbox (https://support.xbox.com/en-GB/help/family-online-safety/online-safety/UK-age-verification)", + ], + ), + 2_148_916_227 => AuthErrorGuidance::new( + "This account was suspended for violating Xbox Community Standards.", + &[ + "Visit Xbox Support and review the enforcement details (https://support.xbox.com)", + "Submit an appeal if one is available", + ], + ), + 2_148_916_229 => AuthErrorGuidance::new( + "This account is restricted and does not have permission to play online.", + &[ + "Have a guardian sign in to Microsoft Family (https://account.microsoft.com/family/)", + "Update the online play permissions", + "Once finished, try signing in again", + ], + ), + 2_148_916_233 => AuthErrorGuidance::new( + "This account does not have an Xbox profile set up, or does not own Minecraft.", + &[ + "Make sure Minecraft is purchased on this account", + "Visit the Minecraft login page and sign in (https://www.minecraft.net/en-us/login)", + "Complete Xbox profile setup if prompted", + "Once finished, try signing in again", + ], + ), + 2_148_916_234 => AuthErrorGuidance::new( + "This account has not accepted Xbox's Terms of Service.", + &[ + "Visit Xbox and sign in (https://www.xbox.com)", + "Accept the Terms if prompted", + "Once finished, try signing in again", + ], + ), + 2_148_916_235 => AuthErrorGuidance::new( + "Xbox Live is not available in your region, so sign-in is blocked.", + &[ + "Xbox services must be supported in your country before you can sign in", + "Check Xbox availability for supported regions (https://www.xbox.com/en-US/regions)", + ], + ), + 2_148_916_236 | 2_148_916_237 => AuthErrorGuidance::new( + "This account requires adult verification under South Korean regulations.", + &[ + "Visit Xbox and sign in (https://www.xbox.com)", + "Complete the identity verification process", + "Once finished, try signing in again", + ], + ), + 2_148_916_238 => AuthErrorGuidance::new( + "This account is underage and not linked to a Microsoft family group.", + &[ + "Review the Minecraft Family Setup guide (https://help.minecraft.net/hc/en-us/articles/4408968616077)", + "Join or create a family group as instructed", + "Once finished, try signing in again", + ], + ), + _ => return None, + }) +} + +fn guidance_for_service_error( + step: MinecraftAuthStep, + status: StatusCode, +) -> Option { + if step == MinecraftAuthStep::MinecraftToken { + if status == StatusCode::TOO_MANY_REQUESTS { + return Some(AuthErrorGuidance::new( + "Microsoft or Minecraft temporarily blocked the sign-in because there were too many recent attempts.", + &[ + "Wait about an hour before trying again", + "Restart OneClient after waiting", + "Try signing in once more", + "If it keeps happening, wait longer before retrying so the temporary limit can clear", + ], + )); + } + if status.is_server_error() { + return Some(AuthErrorGuidance::new( + "Minecraft's authentication service is returning a server error, so sign-in cannot finish right now.", + &[ + "Wait a few minutes and try signing in again", + "Check Xbox status for current service issues (https://support.xbox.com/xbox-live-status)", + "Try the official Minecraft Launcher to confirm whether Minecraft sign-in is affected there too (https://www.minecraft.net/en-us/download)", + "If the service is healthy and this keeps happening, contact support with the debug information", + ], + )); + } + } + + guidance_for_step(step) +} + +fn guidance_for_step(step: MinecraftAuthStep) -> Option { + Some(match step { + MinecraftAuthStep::RefreshToken | MinecraftAuthStep::AuthCodeExchange => { + AuthErrorGuidance::new( + "Your saved Microsoft sign-in has expired or was revoked, so your Minecraft session could not be renewed.", + &[ + "Sign out of the affected Minecraft account in OneClient", + "Sign in to the account again", + "Once the new sign-in finishes, try launching Minecraft again", + ], + ) + } + MinecraftAuthStep::XblAuthenticate => AuthErrorGuidance::new( + "Xbox rejected the first sign-in step. This is most often caused by your system clock or time zone being out of sync, or by a temporary Xbox block.", + &[ + "Open your system date and time settings", + "Turn on automatic time zone and automatic time, if available", + "Use the sync option in your settings to synchronise the clock", + "Restart OneClient and try signing in again", + "If it persists, check Xbox status (https://support.xbox.com/xbox-live-status)", + ], + ), + MinecraftAuthStep::XstsAuthorize => AuthErrorGuidance::new( + "Xbox rejected the request to authorize this account for Minecraft, but did not return a specific account restriction we recognise.", + &[ + "Sign in with the official Minecraft Launcher (https://www.minecraft.net/en-us/download)", + "Complete any prompts shown by Microsoft, Xbox, or Minecraft", + "Try signing in to OneClient again", + "If the official launcher also fails, follow the error shown there or contact Xbox Support", + ], + ), + MinecraftAuthStep::MinecraftProfile => AuthErrorGuidance::new( + "Minecraft services could not return a Java Edition profile for this account. This usually means the game was purchased recently, the Java profile is not finished being created, or the wrong Microsoft account is being used.", + &[ + "Sign in with the official Minecraft Launcher and launch Java Edition once (https://www.minecraft.net/en-us/download)", + "Wait up to an hour if the purchase or profile setup was recent", + "Make sure you are using the Microsoft account that owns Minecraft", + "Try signing in to OneClient again", + ], + ), + _ => return None, + }) +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct AuthErrorSample { + pub label: &'static str, + pub message: String, + pub guidance: Option, +} + +#[must_use] +pub fn preview_samples() -> Vec { + use MinecraftAuthStep::{ + MinecraftProfile, MinecraftToken, RefreshToken, XblAuthenticate, XstsAuthorize, + }; + + let errors: Vec<(&'static str, MinecraftAuthError)> = vec![ + ( + "Xbox 403 (RUST-4)", + MinecraftAuthError::ServiceError { + step: XblAuthenticate, + status_code: StatusCode::FORBIDDEN, + }, + ), + ("No Xbox profile", xerr(2_148_916_233)), + ("Region blocked", xerr(2_148_916_235)), + ("Child account", xerr(2_148_916_238)), + ("Account suspended", xerr(2_148_916_227)), + ("Restricted / no online play", xerr(2_148_916_229)), + ("ToS not accepted", xerr(2_148_916_234)), + ("UK age verification", xerr(2_148_916_222)), + ("KR adult verification", xerr(2_148_916_236)), + ( + "Rate limited (429)", + MinecraftAuthError::ServiceError { + step: MinecraftToken, + status_code: StatusCode::TOO_MANY_REQUESTS, + }, + ), + ( + "MC service error (503)", + MinecraftAuthError::ServiceError { + step: MinecraftToken, + status_code: StatusCode::SERVICE_UNAVAILABLE, + }, + ), + ( + "Profile fetch failed", + MinecraftAuthError::ServiceError { + step: MinecraftProfile, + status_code: StatusCode::NOT_FOUND, + }, + ), + ( + "Session expired", + MinecraftAuthError::ServiceError { + step: RefreshToken, + status_code: StatusCode::BAD_REQUEST, + }, + ), + ( + "Generic Xbox reject", + MinecraftAuthError::ServiceError { + step: XstsAuthorize, + status_code: StatusCode::UNAUTHORIZED, + }, + ), + ]; + + let mut out: Vec = errors + .into_iter() + .map(|(label, err)| AuthErrorSample { + label, + message: err.to_string(), + guidance: diagnose_auth_error(&err), + }) + .collect(); + + out.push(AuthErrorSample { + label: "Network unreachable", + message: "error sending request for url (https://user.auth.xboxlive.com/user/authenticate): connection closed".to_string(), + guidance: Some(network_guidance(NetworkFailure::Generic)), + }); + out.push(AuthErrorSample { + label: "TLS certificate rejected", + message: "error sending request for url (https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode): invalid peer certificate: UnknownIssuer".to_string(), + guidance: Some(network_guidance(NetworkFailure::Certificate)), + }); + out.push(AuthErrorSample { + label: "TLS handshake failed", + message: "error sending request for url (https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode): received fatal alert: HandshakeFailure".to_string(), + guidance: Some(network_guidance(NetworkFailure::Handshake)), + }); + out.push(AuthErrorSample { + label: "DNS lookup failed", + message: "error sending request for url (https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode): dns error: failed to lookup address information".to_string(), + guidance: Some(network_guidance(NetworkFailure::Dns)), + }); + out +} + +fn xerr(code: u64) -> MinecraftAuthError { + MinecraftAuthError::XboxError { + step: MinecraftAuthStep::XstsAuthorize, + error_code: code, + message: friendly_xbox_error(code) + .unwrap_or("Xbox rejected sign-in.") + .to_string(), + redirect: None, + } +} + +fn network_guidance(failure: NetworkFailure) -> AuthErrorGuidance { + match failure { + NetworkFailure::Certificate => AuthErrorGuidance::new( + "OneClient reached the Microsoft sign-in service, but could not verify its security certificate. Something on this machine or network is intercepting the encrypted connection — usually antivirus HTTPS scanning, a school or workplace filter, or a VPN.", + &[ + "Turn off HTTPS/SSL scanning in your antivirus (often called \"encrypted connection scanning\", \"web shield\", or \"SSL interception\")", + "Temporarily disable your VPN or proxy and try signing in again", + "If you are on a school or workplace network, try a home network or phone hotspot to confirm", + "Check that your system date, time, and time zone are correct", + ], + ), + NetworkFailure::Handshake => AuthErrorGuidance::new( + "OneClient reached the Microsoft sign-in service, but the two could not agree on a secure connection. This normally means a proxy, filter, or antivirus in between is using outdated encryption settings.", + &[ + "Turn off HTTPS/SSL scanning in your antivirus and try signing in again", + "Temporarily disable your VPN or proxy software", + "If you are on a school or workplace network, try a home network or phone hotspot to confirm", + "Make sure OneClient is up to date", + ], + ), + NetworkFailure::Dns => AuthErrorGuidance::new( + "OneClient could not look up the address of the Microsoft sign-in service. This is a DNS problem on this machine or network, not a problem with your account.", + &[ + "Check that your internet connection is working", + "Temporarily disable your VPN or proxy and try signing in again", + "Check your hosts file for entries blocking Microsoft or Minecraft domains", + "Try a different DNS server, such as Cloudflare (1.1.1.1) or Google (8.8.8.8)", + "Restart OneClient and try signing in again", + ], + ), + NetworkFailure::Generic => AuthErrorGuidance::new( + "OneClient could not connect to a Microsoft, Xbox, or Minecraft service needed for sign-in. This is usually a local network, DNS, proxy, firewall, hosts file, VPN, or antivirus issue.", + &[ + "Restart OneClient and try signing in again", + "Check that your internet connection is working", + "Allow OneClient through your firewall, antivirus, proxy, VPN, and hosts file rules", + "Try a different network, or temporarily disable VPN/proxy software if you use one", + "If routing or DNS is the issue, a service like Cloudflare WARP can sometimes help", + ], + ), + } +} diff --git a/packages/oneclient_auth/src/error.rs b/packages/oneclient_auth/src/error.rs new file mode 100644 index 00000000..a3014884 --- /dev/null +++ b/packages/oneclient_auth/src/error.rs @@ -0,0 +1,139 @@ +use serde::Serialize; + +use oneclient_net::RequestError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub enum MinecraftAuthStep { + DeviceCodeRequest, + DeviceCodePoll, + AuthCodeExchange, + RefreshToken, + XblAuthenticate, + XstsAuthorize, + MinecraftToken, + MinecraftEntitlements, + MinecraftProfile, +} + +#[derive(Debug, thiserror::Error)] +pub enum MinecraftAuthError { + #[error("failed to serialize JSON during MSA step {step:?}: {source}")] + SerializeError { + step: MinecraftAuthStep, + #[source] + source: serde_json::Error, + }, + #[error( + "failed to deserialize JSON during MSA step {step:?}: {source}! status {status_code} - body: {raw}" + )] + DeserializeError { + step: MinecraftAuthStep, + raw: String, + #[source] + source: serde_json::Error, + status_code: reqwest::StatusCode, + }, + // Rendered via `error_chain` reqwest's `Display` names only the URL and the + // cause we need (certificate handshake DNS refused) is under it + #[error( + "failed to request using HTTP during MSA step {step:?}: {}", + oneclient_net::error_chain(source) + )] + RequestError { + step: MinecraftAuthStep, + #[source] + source: reqwest::Error, + }, + #[error("waiting for user to complete device authorization")] + DeviceAuthorizationPending, + #[error("device authorization polling interval increased; retrying")] + DeviceAuthorizationSlowDown, + #[error("device authorization expired before the user signed in")] + DeviceAuthorizationExpired, + #[error("device authorization failed: {error}")] + DeviceAuthorizationFailed { error: String }, + #[error("failed to start the loopback redirect server: {0}")] + LoopbackBind(String), + #[error("browser sign-in was cancelled or timed out before completing")] + BrowserAuthorizationExpired, + #[error("this browser sign-in is no longer active")] + BrowserLoginNotFound, + #[error("browser authorization failed: {error}")] + BrowserAuthorizationFailed { error: String }, + #[error("failed to read user hash from Xbox token")] + HashError, + #[error("{message}")] + XboxError { + step: MinecraftAuthStep, + error_code: u64, + message: String, + redirect: Option, + }, + #[error( + "The sign-in service returned an error (HTTP {status_code}) during step {step:?}. Please wait a moment and try again." + )] + ServiceError { + step: MinecraftAuthStep, + status_code: reqwest::StatusCode, + }, +} + +#[must_use] +pub fn friendly_xbox_error(code: u64) -> Option<&'static str> { + Some(match code { + 2_148_916_227 => "This account has been banned or suspended from Xbox.", + 2_148_916_229 => { + "This account is a child account that must be added to a Family group by an adult before signing in." + } + 2_148_916_233 => { + "This Microsoft account does not have an Xbox profile yet. Create one at xbox.com, then try again." + } + 2_148_916_234 => { + "This account has not accepted the Xbox Terms of Service. Sign in at xbox.com to accept them first." + } + 2_148_916_235 => { + "Xbox Live is not available in your country or region, so sign-in is blocked." + } + 2_148_916_236 | 2_148_916_237 => { + "This account requires adult verification (South Korea) before it can sign in." + } + 2_148_916_238 => { + "This is a child account. An adult must add it to a Microsoft Family group before it can sign in." + } + _ => return None, + }) +} + +pub type AuthResult = Result; + +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error(transparent)] + Minecraft(#[from] MinecraftAuthError), + + #[error(transparent)] + Io(#[from] polyio::IOError), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + + #[error(transparent)] + Request(#[from] RequestError), + + /// An outcome rather than a failure nothing was written by the time this is + /// produced + #[error("sign-in was cancelled")] + LoginCancelled, + + #[error("offline mode requires at least one Microsoft account to be signed in")] + OfflineRequiresMicrosoft, + + #[error("invalid offline username: {reason}")] + InvalidOfflineUsername { reason: String }, + + #[error("account {0} is not registered")] + AccountNotFound(uuid::Uuid), + + #[error("account already exists for username {username:?}")] + DuplicateUsername { username: String }, +} diff --git a/packages/oneclient_auth/src/lib.rs b/packages/oneclient_auth/src/lib.rs new file mode 100644 index 00000000..920dc164 --- /dev/null +++ b/packages/oneclient_auth/src/lib.rs @@ -0,0 +1,21 @@ +//! Construct [`AuthService`] in the composition layer and pass it down nothing +//! in here reaches for a global or a database +//! Accounts persist to `auth.json` + +mod data; +mod diagnostics; +mod error; +mod msa; +mod offline; +mod service; +mod store; + +pub use data::{ + AccountKind, BrowserLogin, DeviceCodeLogin, MicrosoftLoginSession, MinecraftAccount, +}; +pub use diagnostics::{AuthErrorGuidance, AuthErrorSample, diagnose_auth_error, preview_samples}; +pub use error::{AuthError, AuthResult, MinecraftAuthError, MinecraftAuthStep}; +pub use msa::PendingBrowserLogin; +pub use offline::{offline_account, offline_uuid, validate_offline_username}; +pub use service::{AuthService, MICROSOFT_LOGIN_PROGRESS}; +pub use store::CredentialsStore; diff --git a/packages/oneclient_auth/src/msa.rs b/packages/oneclient_auth/src/msa.rs new file mode 100644 index 00000000..b94a721e --- /dev/null +++ b/packages/oneclient_auth/src/msa.rs @@ -0,0 +1,823 @@ +use std::future::Future; +use std::time::Duration; + +use base64::Engine; +use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use chrono::{DateTime, Utc}; +use reqwest::Client; +use serde::Deserialize; +use serde::de::Error as DeError; +use serde_json::json; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use url::Url; +use uuid::Uuid; + +use super::data::{AccountKind, BrowserLogin, DeviceCodeLogin, MinecraftAccount}; +use super::error::{MinecraftAuthError, MinecraftAuthStep, friendly_xbox_error}; +use oneclient_common::constants::{MICROSOFT_CLIENT_ID, MINECRAFT_SCOPES}; + +const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; + +const AUTHORIZE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"; + +const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; + +const AUTH_STEPS: u64 = 5; + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn begin_device_login(client: &Client) -> Result { + request_device_code(client).await +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn finish_dual_login( + client: &Client, + pending: PendingBrowserLogin, + device: &DeviceCodeLogin, + on_progress: impl Fn(&str, u64, u64), +) -> Result { + tracing::info!("waiting for Microsoft sign-in (browser or device code)"); + on_progress("Waiting for sign-in", 0, AUTH_STEPS); + + let deadline = Duration::from_secs(device.expires_in); + let msa = tokio::time::timeout(deadline, race_msa_token(client, &pending, device)) + .await + .map_err(|_| MinecraftAuthError::DeviceAuthorizationExpired)??; + + account_from_msa_token(client, msa, on_progress).await +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn race_msa_token( + client: &Client, + pending: &PendingBrowserLogin, + device: &DeviceCodeLogin, +) -> Result { + let browser = browser_msa_token(client, pending); + let device = poll_device_token(client, device); + tokio::pin!(browser, device); + + let mut browser_done = false; + let mut device_done = false; + let mut last_err: Option = None; + + loop { + tokio::select! { + res = &mut browser, if !browser_done => match res { + Ok(token) => return Ok(token), + Err(err) => { + browser_done = true; + if device_done { + return Err(err); + } + last_err = Some(err); + } + }, + res = &mut device, if !device_done => match res { + Ok(token) => return Ok(token), + Err(err) => { + device_done = true; + if browser_done { + return Err(err); + } + last_err = Some(err); + } + }, + else => return Err(last_err.unwrap_or(MinecraftAuthError::DeviceAuthorizationExpired)), + } + } +} + +pub struct PendingBrowserLogin { + listener: TcpListener, + pkce_verifier: String, + redirect_uri: String, + csrf_state: String, +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn begin_browser_login() -> Result<(BrowserLogin, PendingBrowserLogin), MinecraftAuthError> +{ + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|err| MinecraftAuthError::LoopbackBind(err.to_string()))?; + let port = listener + .local_addr() + .map_err(|err| MinecraftAuthError::LoopbackBind(err.to_string()))? + .port(); + let redirect_uri = format!("http://127.0.0.1:{port}"); + + let pkce_verifier = random_token(); + let code_challenge = pkce_challenge(&pkce_verifier); + let csrf_state = random_token(); + + let mut url = Url::parse(AUTHORIZE_URL).expect("authorize url is valid"); + url.query_pairs_mut() + .append_pair("client_id", MICROSOFT_CLIENT_ID) + .append_pair("response_type", "code") + .append_pair("redirect_uri", &redirect_uri) + .append_pair("response_mode", "query") + .append_pair("scope", MINECRAFT_SCOPES) + .append_pair("state", &csrf_state) + .append_pair("code_challenge", &code_challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("prompt", "select_account"); + + let login = BrowserLogin { + auth_url: url.to_string(), + state: csrf_state.clone(), + redirect_uri: redirect_uri.clone(), + }; + let pending = PendingBrowserLogin { + listener, + pkce_verifier, + redirect_uri, + csrf_state, + }; + + Ok((login, pending)) +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn browser_msa_token( + client: &Client, + pending: &PendingBrowserLogin, +) -> Result { + let code = wait_for_redirect(pending).await?; + exchange_auth_code(client, &code, pending).await +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn wait_for_redirect(pending: &PendingBrowserLogin) -> Result { + loop { + let (mut stream, _) = pending + .listener + .accept() + .await + .map_err(|err| MinecraftAuthError::LoopbackBind(err.to_string()))?; + + let mut buf = [0u8; 2048]; + let read = stream.read(&mut buf).await.unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..read]); + let Some(target) = request.split_whitespace().nth(1) else { + respond(&mut stream, RedirectPage::Waiting).await; + continue; + }; + + if !target.contains('?') { + respond(&mut stream, RedirectPage::Waiting).await; + continue; + } + + let full = format!("http://127.0.0.1{target}"); + let parsed = Url::parse(&full).ok(); + let mut code = None; + let mut state = None; + let mut error = None; + if let Some(parsed) = &parsed { + for (key, value) in parsed.query_pairs() { + match key.as_ref() { + "code" => code = Some(value.into_owned()), + "state" => state = Some(value.into_owned()), + "error" => error = Some(value.into_owned()), + "error_description" => error = Some(value.into_owned()), + _ => {} + } + } + } + + if let Some(error) = error { + tracing::warn!(%error, "browser authorization failed"); + respond(&mut stream, RedirectPage::Failed).await; + return Err(MinecraftAuthError::BrowserAuthorizationFailed { error }); + } + + if state.as_deref() != Some(pending.csrf_state.as_str()) { + respond(&mut stream, RedirectPage::Failed).await; + continue; + } + + if let Some(code) = code { + respond(&mut stream, RedirectPage::Success).await; + return Ok(code); + } + + respond(&mut stream, RedirectPage::Waiting).await; + } +} + +#[derive(Clone, Copy)] +enum RedirectPage { + Waiting, + Success, + Failed, +} + +async fn respond(stream: &mut tokio::net::TcpStream, page: RedirectPage) { + let body = redirect_page_html(page); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.flush().await; +} + +fn redirect_page_html(page: RedirectPage) -> String { + let (accent, glyph, heading, detail) = match page { + RedirectPage::Waiting => ( + "#2567d8", + r#"
"#, + "Signing you in", + "Finish signing in with Microsoft in this window.", + ), + RedirectPage::Success => ( + "#45de2b", + r#""#, + "You're signed in!", + "You can close this tab and return to OneClient.", + ), + RedirectPage::Failed => ( + "#ff0000", + r#""#, + "Sign-in failed", + "Something went wrong. Close this tab and try again from OneClient.", + ), + }; + + format!( + r##" + + +OneClient + + +
+
{glyph}
+

{heading}

+

{detail}

+
+"## + ) +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn exchange_auth_code( + client: &Client, + code: &str, + pending: &PendingBrowserLogin, +) -> Result { + let body = [ + ("client_id", MICROSOFT_CLIENT_ID), + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", pending.redirect_uri.as_str()), + ("code_verifier", pending.pkce_verifier.as_str()), + ("scope", MINECRAFT_SCOPES), + ]; + + let res = auth_retry(|| { + client + .post(TOKEN_URL) + .header("Accept", "application/json") + .form(&body) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::AuthCodeExchange, + source, + })?; + + parse_json_response(res, MinecraftAuthStep::AuthCodeExchange) + .await + .map(MsaToken::from_response) +} + +fn random_token() -> String { + let mut bytes = [0u8; 32]; + bytes[..16].copy_from_slice(Uuid::new_v4().as_bytes()); + bytes[16..].copy_from_slice(Uuid::new_v4().as_bytes()); + BASE64_URL_SAFE_NO_PAD.encode(bytes) +} + +fn pkce_challenge(verifier: &str) -> String { + let digest = Sha256::digest(verifier.as_bytes()); + BASE64_URL_SAFE_NO_PAD.encode(digest) +} + +#[tracing::instrument(level = "debug", skip_all, fields(username = %creds.username))] +pub async fn refresh_microsoft_account( + client: &Client, + creds: &MinecraftAccount, +) -> Result { + tracing::debug!("refreshing Microsoft account token"); + let msa = refresh_msa_token(client, &creds.refresh_token).await?; + let mut account = account_from_msa_token(client, msa, |_, _, _| {}).await?; + account.id = creds.id; + Ok(account) +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn request_device_code(client: &Client) -> Result { + let body = [ + ("client_id", MICROSOFT_CLIENT_ID), + ("scope", MINECRAFT_SCOPES), + ]; + + let res = auth_retry(|| { + client + .post(DEVICE_CODE_URL) + .header("Accept", "application/json") + .form(&body) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::DeviceCodeRequest, + source, + })?; + + parse_json_response(res, MinecraftAuthStep::DeviceCodeRequest).await +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn poll_device_token( + client: &Client, + flow: &DeviceCodeLogin, +) -> Result { + let deadline = Utc::now() + chrono::TimeDelta::seconds(flow.expires_in as i64); + let mut interval_secs = flow.interval.max(5); + + loop { + if Utc::now() >= deadline { + return Err(MinecraftAuthError::DeviceAuthorizationExpired); + } + + let body = [ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("client_id", MICROSOFT_CLIENT_ID), + ("device_code", flow.device_code.as_str()), + ]; + + let res = auth_retry(|| { + client + .post(TOKEN_URL) + .header("Accept", "application/json") + .form(&body) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::DeviceCodePoll, + source, + })?; + + let status = res.status(); + let text = res + .text() + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::DeviceCodePoll, + source, + })?; + + if status.is_success() { + let raw: MsaTokenResponse = serde_json::from_str(&text).map_err(|source| { + MinecraftAuthError::DeserializeError { + step: MinecraftAuthStep::DeviceCodePoll, + raw: text, + source, + status_code: status, + } + })?; + return Ok(MsaToken::from_response(raw)); + } + + if let Ok(err) = serde_json::from_str::(&text) { + match err.error.as_str() { + "authorization_pending" => { + tokio::time::sleep(Duration::from_secs(interval_secs)).await; + continue; + } + "slow_down" => { + interval_secs = interval_secs.saturating_add(5); + tokio::time::sleep(Duration::from_secs(interval_secs)).await; + continue; + } + "expired_token" => return Err(MinecraftAuthError::DeviceAuthorizationExpired), + other => { + return Err(MinecraftAuthError::DeviceAuthorizationFailed { + error: err.error_description.unwrap_or_else(|| other.to_string()), + }); + } + } + } + + return Err(MinecraftAuthError::DeserializeError { + step: MinecraftAuthStep::DeviceCodePoll, + raw: text, + source: DeError::custom("unexpected device code poll response"), + status_code: status, + }); + } +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn refresh_msa_token( + client: &Client, + refresh_token: &str, +) -> Result { + let body = [ + ("client_id", MICROSOFT_CLIENT_ID), + ("refresh_token", refresh_token), + ("grant_type", "refresh_token"), + ("scope", MINECRAFT_SCOPES), + ]; + + let res = auth_retry(|| { + client + .post(TOKEN_URL) + .header("Accept", "application/json") + .form(&body) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::RefreshToken, + source, + })?; + + parse_json_response(res, MinecraftAuthStep::RefreshToken) + .await + .map(MsaToken::from_response) +} + +struct MsaToken { + access_token: String, + refresh_token: String, + expires_in: u64, + obtained_at: DateTime, +} + +impl MsaToken { + fn from_response(raw: MsaTokenResponse) -> Self { + Self { + access_token: raw.access_token, + refresh_token: raw.refresh_token, + expires_in: raw.expires_in, + obtained_at: Utc::now(), + } + } +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn account_from_msa_token( + client: &Client, + msa: MsaToken, + on_progress: impl Fn(&str, u64, u64), +) -> Result { + let rps_ticket = format!("d={}", msa.access_token); + on_progress("Authenticating with Xbox Live", 1, AUTH_STEPS); + let xbl = xbl_authenticate(client, &rps_ticket).await?; + on_progress("Authorizing access (XSTS)", 2, AUTH_STEPS); + let xsts = xsts_authorize(client, &xbl.token).await?; + + let xsts_token = xsts.token.ok_or(MinecraftAuthError::HashError)?; + + let uhs: &str = xsts + .display_claims + .as_ref() + .and_then(|c| c.xui.first()) + .map(|x| x.uhs.as_str()) + .ok_or(MinecraftAuthError::HashError)?; + + on_progress("Requesting Minecraft token", 3, AUTH_STEPS); + let minecraft_token = minecraft_login(client, uhs, &xsts_token).await?; + on_progress("Verifying game ownership", 4, AUTH_STEPS); + minecraft_entitlements(client, &minecraft_token.access_token).await?; + let profile = minecraft_profile(client, &minecraft_token.access_token).await?; + on_progress("Loading profile", AUTH_STEPS, AUTH_STEPS); + + Ok(MinecraftAccount { + id: profile.id.unwrap_or_default(), + username: profile.name, + access_token: minecraft_token.access_token, + refresh_token: msa.refresh_token, + #[allow(clippy::cast_possible_wrap)] + expires: msa.obtained_at + chrono::TimeDelta::seconds(msa.expires_in as i64), + kind: AccountKind::Microsoft, + }) +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn xbl_authenticate( + client: &Client, + rps_ticket: &str, +) -> Result { + let res = auth_retry(|| { + client + .post("https://user.auth.xboxlive.com/user/authenticate") + .header("Accept", "application/json") + .header("x-xbl-contract-version", "1") + .json(&json!({ + "Properties": { + "AuthMethod": "RPS", + "SiteName": "user.auth.xboxlive.com", + "RpsTicket": rps_ticket + }, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" + })) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::XblAuthenticate, + source, + })?; + + let token: XboxTokenResponse = + parse_json_response(res, MinecraftAuthStep::XblAuthenticate).await?; + check_xbox_error(MinecraftAuthStep::XblAuthenticate, &token)?; + + Ok(XblUserToken { + token: token.token.ok_or(MinecraftAuthError::DeserializeError { + step: MinecraftAuthStep::XblAuthenticate, + raw: String::new(), + source: DeError::custom("missing Token"), + status_code: reqwest::StatusCode::OK, + })?, + }) +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn xsts_authorize( + client: &Client, + user_token: &str, +) -> Result { + let res = auth_retry(|| { + client + .post("https://xsts.auth.xboxlive.com/xsts/authorize") + .header("Accept", "application/json") + .header("x-xbl-contract-version", "1") + .json(&json!({ + "Properties": { + "SandboxId": "RETAIL", + "UserTokens": [user_token] + }, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT" + })) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::XstsAuthorize, + source, + })?; + + let token: XboxTokenResponse = + parse_json_response(res, MinecraftAuthStep::XstsAuthorize).await?; + check_xbox_error(MinecraftAuthStep::XstsAuthorize, &token)?; + Ok(token) +} + +#[derive(Deserialize)] +struct MinecraftToken { + access_token: String, +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn minecraft_login( + client: &Client, + uhs: &str, + xsts_token: &str, +) -> Result { + let res = auth_retry(|| { + client + .post("https://api.minecraftservices.com/authentication/login_with_xbox") + .header("Accept", "application/json") + .json(&json!({ + "identityToken": format!("XBL3.0 x={uhs};{xsts_token}"), + })) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::MinecraftToken, + source, + })?; + + parse_json_response(res, MinecraftAuthStep::MinecraftToken).await +} + +#[derive(Deserialize)] +struct MinecraftProfile { + id: Option, + name: String, +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn minecraft_profile( + client: &Client, + token: &str, +) -> Result { + let res = auth_retry(|| { + client + .get("https://api.minecraftservices.com/minecraft/profile") + .header("Accept", "application/json") + .bearer_auth(token) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::MinecraftProfile, + source, + })?; + + parse_json_response(res, MinecraftAuthStep::MinecraftProfile).await +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MinecraftEntitlements {} + +#[tracing::instrument(level = "debug", skip_all)] +async fn minecraft_entitlements( + client: &Client, + token: &str, +) -> Result { + let res = auth_retry(|| { + client + .get("https://api.minecraftservices.com/entitlements/mcstore") + .header("Accept", "application/json") + .bearer_auth(token) + .send() + }) + .await + .map_err(|source| MinecraftAuthError::RequestError { + step: MinecraftAuthStep::MinecraftEntitlements, + source, + })?; + + parse_json_response(res, MinecraftAuthStep::MinecraftEntitlements).await +} + +#[derive(Deserialize)] +struct OAuthErrorResponse { + error: String, + error_description: Option, +} + +#[derive(Deserialize)] +struct MsaTokenResponse { + access_token: String, + refresh_token: String, + expires_in: u64, +} + +struct XblUserToken { + token: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct XboxTokenResponse { + #[serde(default)] + #[serde(rename = "XErr")] + x_err: u64, + #[serde(default)] + #[serde(rename = "Message")] + message: String, + #[serde(rename = "Redirect", default)] + redirect: Option, + #[serde(default)] + token: Option, + #[serde(default)] + display_claims: Option, +} + +#[derive(Debug, Deserialize)] +struct DisplayClaims { + #[serde(default)] + xui: Vec, +} + +#[derive(Debug, Deserialize)] +struct XuiClaim { + uhs: String, +} + +fn check_xbox_error( + step: MinecraftAuthStep, + token: &XboxTokenResponse, +) -> Result<(), MinecraftAuthError> { + if token.x_err == 0 { + return Ok(()); + } + + let message = friendly_xbox_error(token.x_err) + .map(String::from) + .unwrap_or_else(|| { + if token.message.is_empty() { + format!("Xbox rejected sign-in (error {}).", token.x_err) + } else { + token.message.clone() + } + }); + + Err(MinecraftAuthError::XboxError { + step, + error_code: token.x_err, + message, + redirect: token.redirect.clone(), + }) +} + +async fn parse_json_response Deserialize<'de>>( + res: reqwest::Response, + step: MinecraftAuthStep, +) -> Result { + let status = res.status(); + let text = res + .text() + .await + .map_err(|source| MinecraftAuthError::RequestError { step, source })?; + + if !status.is_success() { + tracing::error!(sentry = false, ?step, %status, body = %text, "MSA endpoint returned a non-success status"); + } + + serde_json::from_str(&text).map_err(|source| { + if !status.is_success() { + MinecraftAuthError::ServiceError { + step, + status_code: status, + } + } else { + MinecraftAuthError::DeserializeError { + step, + raw: text, + source, + status_code: status, + } + } + }) +} + +async fn auth_retry(request_fn: impl Fn() -> F) -> Result +where + F: Future>, +{ + const RETRY_COUNT: usize = 5; + const RETRY_WAIT: Duration = Duration::from_millis(250); + let mut resp = request_fn().await; + + for i in 0..RETRY_COUNT { + match &resp { + Ok(_) => break, + Err(err) if (err.is_connect() || err.is_timeout()) && i < RETRY_COUNT - 1 => { + tokio::time::sleep(RETRY_WAIT).await; + resp = request_fn().await; + } + _ => break, + } + } + + resp +} diff --git a/packages/oneclient_auth/src/offline.rs b/packages/oneclient_auth/src/offline.rs new file mode 100644 index 00000000..fe292200 --- /dev/null +++ b/packages/oneclient_auth/src/offline.rs @@ -0,0 +1,52 @@ +use chrono::{Duration, Utc}; +use md5::{Digest, Md5}; +use uuid::Uuid; + +use super::data::{AccountKind, MinecraftAccount}; +use super::error::AuthError; +use crate::error::AuthResult; + +pub fn offline_uuid(username: &str) -> Uuid { + let mut hasher = Md5::new(); + hasher.update(format!("OfflinePlayer:{username}").as_bytes()); + let digest = hasher.finalize(); + + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&digest); + bytes[6] = (bytes[6] & 0x0f) | 0x30; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Uuid::from_bytes(bytes) +} + +pub fn validate_offline_username(username: &str) -> AuthResult<()> { + let len = username.chars().count(); + if !(3..=16).contains(&len) { + return Err(AuthError::InvalidOfflineUsername { + reason: "username must be 3-16 characters".into(), + }); + } + + if !username + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + return Err(AuthError::InvalidOfflineUsername { + reason: "username may only contain letters, digits, and underscores".into(), + }); + } + + Ok(()) +} + +#[tracing::instrument(level = "debug", fields(username = %username))] +pub fn offline_account(username: String) -> MinecraftAccount { + tracing::info!("creating offline account"); + MinecraftAccount { + id: offline_uuid(&username), + username, + access_token: String::new(), + refresh_token: String::new(), + expires: Utc::now() + Duration::days(3650), + kind: AccountKind::Offline, + } +} diff --git a/packages/oneclient_auth/src/service.rs b/packages/oneclient_auth/src/service.rs new file mode 100644 index 00000000..763bc9a2 --- /dev/null +++ b/packages/oneclient_auth/src/service.rs @@ -0,0 +1,428 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex as StdMutex}; + +use oneclient_events::EventBus; +use oneclient_net::RequestClient; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use crate::data::{MicrosoftLoginSession, MinecraftAccount}; +use crate::error::{AuthError, AuthResult, MinecraftAuthError}; +use crate::msa::{self, PendingBrowserLogin}; +use crate::store::{self, CredentialsStore}; + +/// Stable id for the Microsoft login's progress so a front-end can recognise +/// it among everything else on the bus +pub const MICROSOFT_LOGIN_PROGRESS: Uuid = + Uuid::from_u128(0x4D53_4141_5554_4800_0000_0000_0000_0001); + +/// `finish_microsoft_login` takes the listener and runs with it while the +/// token stays here so a cancel from the UI has something to flip +struct PendingLogin { + /// `None` once a flow owns the listener or once a cancel freed the port + /// before any flow got that far + browser: Option, + cancel: CancellationToken, +} + +pub struct AuthService { + store: Mutex, + /// Keyed by CSRF state token + pending_logins: Mutex>, + /// Serialises token renewal per account Microsoft rotates the refresh token + /// on every use so concurrent renewals would sign the account out + refresh_guards: StdMutex>>>, + net: RequestClient, + events: EventBus, +} + +impl AuthService { + /// A missing or unreadable `auth.json` yields an empty store rather than + /// failing so a corrupt file means "sign in again" not "will not start" + pub async fn load(net: RequestClient, events: EventBus) -> AuthResult { + Ok(Self::with_store( + CredentialsStore::load().await?, + net, + events, + )) + } + + #[must_use] + pub fn with_store(store: CredentialsStore, net: RequestClient, events: EventBus) -> Self { + Self { + store: Mutex::new(store), + pending_logins: Mutex::new(HashMap::new()), + refresh_guards: StdMutex::new(HashMap::new()), + net, + events, + } + } + + #[tracing::instrument(skip_all)] + pub async fn begin_microsoft_login(&self) -> AuthResult { + tracing::info!("beginning Microsoft login"); + let client = self.net.http(); + + let (browser, pending) = msa::begin_browser_login().await?; + let device = msa::begin_device_login(client).await?; + + let mut logins = self.pending_logins.lock().await; + // A login cancelled before its flow ever started has nobody left to remove + // it so the next login sweeps it up + logins.retain(|_, login| !login.cancel.is_cancelled()); + logins.insert( + browser.state.clone(), + PendingLogin { + browser: Some(pending), + cancel: CancellationToken::new(), + }, + ); + + Ok(MicrosoftLoginSession { browser, device }) + } + + /// Only one caller can ever claim a login + async fn claim_pending_login( + &self, + state_token: &str, + ) -> AuthResult<(PendingBrowserLogin, CancellationToken)> { + let mut logins = self.pending_logins.lock().await; + + // Cancelled between `begin` and here the listener is already gone and + // there is nothing to run + if logins + .get(state_token) + .is_some_and(|login| login.cancel.is_cancelled()) + { + logins.remove(state_token); + return Err(AuthError::LoginCancelled); + } + + let login = logins.get_mut(state_token).ok_or(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound, + ))?; + let cancel = login.cancel.clone(); + let browser = login.browser.take().ok_or(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound, + ))?; + + Ok((browser, cancel)) + } + + #[tracing::instrument(skip_all)] + pub async fn finish_microsoft_login( + &self, + session: MicrosoftLoginSession, + ) -> AuthResult { + tracing::info!("finishing Microsoft login"); + + let (pending, cancel) = self.claim_pending_login(&session.browser.state).await?; + + let events = self.events.clone(); + let flow = msa::finish_dual_login( + self.net.http(), + pending, + &session.device, + |label, current, total| { + events.progress(MICROSOFT_LOGIN_PROGRESS, label, current, total); + }, + ); + + // Losing the race drops `flow` and with it the device-code poll the + // loopback listener and whichever request was mid-flight + let result = tokio::select! { + biased; + () = cancel.cancelled() => Err(AuthError::LoginCancelled), + res = flow => res.map_err(AuthError::from), + }; + + // Whoever runs the flow owns the cleanup a cancel only flips the token + self.pending_logins + .lock() + .await + .remove(&session.browser.state); + + // Drives the card to 100% so the front-end can clear it a cancelled flow + // needs this as much as a successful one + self.events + .progress(MICROSOFT_LOGIN_PROGRESS, "Signed in", 1, 1); + + // Re-read rather than trusting `result` the last handshake step can + // complete in the same breath as a cancel + if cancel.is_cancelled() { + tracing::info!("Microsoft login cancelled by the user"); + return Err(AuthError::LoginCancelled); + } + + if let Err(err) = &result { + let mut chain = String::new(); + let mut source = std::error::Error::source(err); + while let Some(cause) = source { + chain.push_str(&format!("\n caused by: {cause}")); + source = cause.source(); + } + tracing::warn!("Microsoft login failed: {err}{chain}"); + } + + let account = result?; + tracing::info!(username = %account.username, "Microsoft login succeeded"); + self.store + .lock() + .await + .commit_account(account, &self.events) + .await + } + + /// The entry is deliberately left in the map `finish_microsoft_login` may + /// not have claimed it yet and must find the cancelled token not a hole + #[tracing::instrument(level = "debug", skip_all)] + pub async fn cancel_microsoft_login(&self, state_token: &str) { + let mut logins = self.pending_logins.lock().await; + let Some(login) = logins.get_mut(state_token) else { + return; + }; + + login.cancel.cancel(); + // Frees the port now if no flow ever took the listener once one has the + // flow drops it as it unwinds + login.browser = None; + } + + #[tracing::instrument(skip(self), fields(username = %username))] + pub async fn add_offline_account(&self, username: String) -> AuthResult { + self.store + .lock() + .await + .add_offline_account_and_save(username) + .await + } + + pub async fn list_accounts(&self) -> Vec { + self.store.lock().await.list_accounts() + } + + pub async fn get_account(&self, id: Uuid) -> Option { + self.store.lock().await.get_account(id).cloned() + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn default_account(&self) -> AuthResult> { + self.store.lock().await.default_account().await + } + + #[tracing::instrument(level = "debug", skip(self), fields(?id))] + pub async fn set_default_account(&self, id: Option) -> AuthResult<()> { + self.store.lock().await.set_default_user(id).await + } + + #[tracing::instrument(skip(self), fields(%id))] + pub async fn remove_account(&self, id: Uuid) -> AuthResult<()> { + self.store.lock().await.remove_account(id).await?; + Ok(()) + } + + pub async fn has_microsoft_account(&self) -> bool { + self.store.lock().await.has_microsoft_account() + } + + fn refresh_guard(&self, id: Uuid) -> Arc> { + Arc::clone( + self.refresh_guards + .lock() + .expect("refresh guard registry poisoned") + .entry(id) + .or_default(), + ) + } + + async fn account_snapshot(&self, id: Uuid) -> AuthResult { + self.store + .lock() + .await + .get_account(id) + .cloned() + .ok_or(AuthError::AccountNotFound(id)) + } + + /// The store lock is never held across the Microsoft handshake only around + /// the reads and the final write + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + async fn renew_token(&self, id: Uuid, force: bool) -> AuthResult { + let existing = self.account_snapshot(id).await?; + if !existing.is_microsoft() || (!force && !existing.is_expired()) { + return Ok(existing); + } + + let guard = self.refresh_guard(id); + let _serialised = guard.lock().await; + + // Re-read under the guard whoever held it may have just refreshed + let existing = self.account_snapshot(id).await?; + if !existing.is_microsoft() || (!force && !existing.is_expired()) { + return Ok(existing); + } + + tracing::info!(username = %existing.username, "renewing Microsoft access token"); + match msa::refresh_microsoft_account(self.net.http(), &existing).await { + Ok(refreshed) => { + self.store + .lock() + .await + .commit_refreshed_account(refreshed.clone()) + .await?; + Ok(refreshed) + } + Err(err) => { + let err = AuthError::from(err); + if store::is_transient_auth_error(&err) { + tracing::warn!("keeping existing token after transient renewal failure: {err}"); + Ok(existing) + } else { + Err(err) + } + } + } + } + + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn refresh_account(&self, id: Uuid) -> AuthResult { + self.renew_token(id, true).await + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn refresh_all_accounts(&self) -> AuthResult> { + let ids: Vec = self.store.lock().await.users.keys().copied().collect(); + let mut refreshed = Vec::with_capacity(ids.len()); + + for id in ids { + refreshed.push(self.renew_token(id, true).await?); + } + + Ok(refreshed) + } + + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn account_for_launch(&self, id: Uuid) -> AuthResult { + let account = self.renew_token(id, false).await?; + + Ok(account) + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn default_account_for_launch(&self) -> AuthResult> { + let Some(id) = self.store.lock().await.resolve_default_id().await? else { + return Ok(None); + }; + Ok(Some(self.account_for_launch(id).await?)) + } +} + +#[cfg(test)] +mod tests { + use oneclient_net::NetConfig; + + use super::*; + use crate::data::DeviceCodeLogin; + + fn service(events: EventBus) -> AuthService { + let net = RequestClient::new(NetConfig::default()).expect("net client"); + AuthService::with_store(CredentialsStore::default(), net, events) + } + + /// Registers a login the way `begin_microsoft_login` does minus the device + /// code request which is the only half that needs the network + async fn register_login(service: &AuthService) -> MicrosoftLoginSession { + let (browser, pending) = msa::begin_browser_login().await.expect("loopback bind"); + + service.pending_logins.lock().await.insert( + browser.state.clone(), + PendingLogin { + browser: Some(pending), + cancel: CancellationToken::new(), + }, + ); + + MicrosoftLoginSession { + browser, + device: DeviceCodeLogin { + user_code: "ABCD-EFGH".to_string(), + device_code: "device-code".to_string(), + verification_uri: "https://microsoft.com/link".to_string(), + expires_in: 900, + interval: 5, + message: String::new(), + }, + } + } + + #[tokio::test] + async fn a_cancel_that_beats_the_flow_is_still_a_cancel() { + // A quick cancel can land before the flow claims the login + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; + + service.cancel_microsoft_login(session.dedupe_key()).await; + let err = service.finish_microsoft_login(session).await.unwrap_err(); + + assert!(matches!(err, AuthError::LoginCancelled), "{err}"); + assert!(service.pending_logins.lock().await.is_empty()); + assert!(service.store.lock().await.list_accounts().is_empty()); + } + + #[tokio::test] + async fn a_cancel_frees_the_loopback_port_it_was_holding() { + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; + + service.cancel_microsoft_login(session.dedupe_key()).await; + + let logins = service.pending_logins.lock().await; + let login = logins + .get(session.dedupe_key()) + .expect("the entry is what a flow starting late reads the cancel from"); + assert!(login.cancel.is_cancelled()); + assert!( + login.browser.is_none(), + "the listener should be dropped rather than left bound" + ); + } + + #[tokio::test] + async fn a_cancel_reaches_a_flow_that_has_already_claimed_the_login() { + // Once claimed the entry is the only channel a cancel has to the flow + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; + + let (_listener, cancel) = service + .claim_pending_login(session.dedupe_key()) + .await + .expect("claim"); + service.cancel_microsoft_login(session.dedupe_key()).await; + + assert!(cancel.is_cancelled()); + } + + #[tokio::test] + async fn a_login_can_only_be_claimed_once() { + let (events, _rx) = EventBus::channel(); + let service = service(events); + let session = register_login(&service).await; + + service + .claim_pending_login(session.dedupe_key()) + .await + .expect("claim"); + let claimed_again = service.claim_pending_login(session.dedupe_key()).await; + + assert!(matches!( + claimed_again, + Err(AuthError::Minecraft( + MinecraftAuthError::BrowserLoginNotFound + )) + )); + } +} diff --git a/packages/oneclient_auth/src/store.rs b/packages/oneclient_auth/src/store.rs new file mode 100644 index 00000000..8e2e1738 --- /dev/null +++ b/packages/oneclient_auth/src/store.rs @@ -0,0 +1,191 @@ +use std::collections::HashMap; + +use uuid::Uuid; + +use oneclient_common::paths; +use oneclient_events::EventBus; + +use crate::data::{AccountKind, MinecraftAccount}; +use crate::error::{AuthError, AuthResult}; +use crate::offline::{offline_account, validate_offline_username}; + +#[derive(Debug, serde::Serialize, serde::Deserialize, Default)] +pub struct CredentialsStore { + pub users: HashMap, + pub default_user: Option, +} + +impl CredentialsStore { + #[tracing::instrument(level = "debug")] + pub async fn load() -> AuthResult { + let path = paths::auth_file()?; + if !path.exists() { + return Ok(Self::default()); + } + + match polyio::read_json(&path).await { + Ok(store) => Ok(store), + Err(err) => { + tracing::warn!("failed to read auth file: {err}"); + Ok(Self::default()) + } + } + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn save(&self) -> AuthResult<()> { + let path = paths::auth_file()?; + + polyio::write_json_atomic(&path, self).await?; + Ok(()) + } + + pub fn has_microsoft_account(&self) -> bool { + self.users + .values() + .any(|account| account.kind == AccountKind::Microsoft) + } + + pub fn list_accounts(&self) -> Vec { + self.users.values().cloned().collect() + } + + pub fn get_account(&self, id: Uuid) -> Option<&MinecraftAccount> { + self.users.get(&id) + } + + #[tracing::instrument(level = "debug", skip_all, fields(username = %account.username))] + pub async fn commit_account( + &mut self, + account: MinecraftAccount, + events: &EventBus, + ) -> AuthResult { + self.users.insert(account.id, account.clone()); + + if self.default_user.is_none() { + self.default_user = Some(account.id); + } + + self.save().await?; + tracing::debug!("committed account to credentials store"); + events + .notify("Account added") + .body(format!("Signed in as {}", account.username)) + .send(); + Ok(account) + } + + pub fn add_offline_account(&mut self, username: String) -> AuthResult { + let account = self.insert_offline_account(username)?; + Ok(account) + } + + #[tracing::instrument(level = "debug", skip(self), fields(username = %username))] + pub async fn add_offline_account_and_save( + &mut self, + username: String, + ) -> AuthResult { + let account = self.insert_offline_account(username)?; + self.save().await?; + Ok(account) + } + + fn insert_offline_account(&mut self, username: String) -> AuthResult { + if self.default_user.is_none() { + // will be set after inserting account + } + + validate_offline_username(&username)?; + + if self + .users + .values() + .any(|u| u.username.eq_ignore_ascii_case(&username)) + { + return Err(AuthError::DuplicateUsername { username }); + } + + let account = offline_account(username); + self.users.insert(account.id, account.clone()); + Ok(account) + } + + #[tracing::instrument(level = "debug", skip_all, fields(%account.id))] + pub async fn commit_refreshed_account(&mut self, account: MinecraftAccount) -> AuthResult<()> { + self.users.insert(account.id, account); + self.save().await?; + tracing::debug!("stored refreshed Microsoft account"); + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self), fields(%id))] + pub async fn remove_account(&mut self, id: Uuid) -> AuthResult> { + let removed = self.users.remove(&id); + + if self.default_user == Some(id) { + self.default_user = self.users.keys().copied().next(); + } + + self.save().await?; + Ok(removed) + } + + #[tracing::instrument(level = "debug", skip(self), fields(?id))] + pub async fn set_default_user(&mut self, id: Option) -> AuthResult<()> { + if let Some(id) = id + && !self.users.contains_key(&id) + { + return Err(AuthError::AccountNotFound(id)); + } + + self.default_user = id; + self.save().await?; + Ok(()) + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn resolve_default_id(&mut self) -> AuthResult> { + let id = self + .default_user + .or_else(|| self.users.keys().copied().next()); + + let Some(id) = id else { + return Ok(None); + }; + + if !self.users.contains_key(&id) { + return Ok(None); + } + + if self.default_user != Some(id) { + self.default_user = Some(id); + self.save().await?; + } + + Ok(Some(id)) + } + + /// Never touches the network callers that need a usable token go through + /// [`crate::AuthService::account_for_launch`] + #[tracing::instrument(level = "debug", skip_all)] + pub async fn default_account(&mut self) -> AuthResult> { + let Some(id) = self.resolve_default_id().await? else { + return Ok(None); + }; + let Some(account) = self.users.get(&id).cloned() else { + return Ok(None); + }; + + Ok(Some(account)) + } +} + +/// A transient failure must keep the existing token discarding it because +/// Wi-Fi dropped would sign the user out of a working account +pub(crate) fn is_transient_auth_error(err: &AuthError) -> bool { + matches!( + err, + AuthError::Minecraft(crate::error::MinecraftAuthError::RequestError { source, .. }) + if source.is_connect() || source.is_timeout() + ) +} diff --git a/packages/oneclient_auth/tests/offline.rs b/packages/oneclient_auth/tests/offline.rs new file mode 100644 index 00000000..21325eec --- /dev/null +++ b/packages/oneclient_auth/tests/offline.rs @@ -0,0 +1,107 @@ +use chrono::{TimeDelta, Utc}; +use oneclient_auth::{AccountKind, CredentialsStore, offline_account, offline_uuid}; +use uuid::Uuid; + +fn isolate_launcher_dir() { + oneclient_common::paths::set_launcher_dir( + std::env::temp_dir().join(format!("oneclient-auth-test-{}", Uuid::new_v4())), + ); +} + +fn fake_microsoft_account(username: &str) -> oneclient_auth::MinecraftAccount { + let mut account = offline_account(username.to_string()); + account.kind = AccountKind::Microsoft; + account.access_token = "access".into(); + account.refresh_token = "refresh".into(); + account +} + +#[test] +fn offline_uuid_matches_vanilla_algorithm() { + let id = offline_uuid("Notch"); + + assert_eq!( + id, + Uuid::parse_str("b50ad385-829d-3141-a216-7e7d7539ba7f").unwrap() + ); + assert_ne!(id, offline_uuid("Steve")); +} + +#[tokio::test] +async fn offline_account_blocked_without_microsoft() { + isolate_launcher_dir(); + + let mut store = CredentialsStore::default(); + let err = store.add_offline_account("Steve".into()).unwrap_err(); + + assert!(err.to_string().contains("Microsoft")); +} + +#[tokio::test] +async fn offline_account_allowed_when_microsoft_exists() { + isolate_launcher_dir(); + + let mut store = CredentialsStore::default(); + let msa = fake_microsoft_account("MsaUser"); + store.users.insert(msa.id, msa); + + let offline = store.add_offline_account("Steve".into()).unwrap(); + assert_eq!(offline.kind, AccountKind::Offline); + assert_eq!(offline.username, "Steve"); +} + +#[tokio::test] +async fn default_offline_fails_when_last_microsoft_removed() { + isolate_launcher_dir(); + + let mut store = CredentialsStore::default(); + let msa = fake_microsoft_account("MsaUser"); + let msa_id = msa.id; + store.users.insert(msa_id, msa); + + let offline = store.add_offline_account("Steve".into()).unwrap(); + store.default_user = Some(offline.id); + store.users.remove(&msa_id); + + let err = store.default_account().await.unwrap_err(); + assert!(err.to_string().contains("Microsoft")); +} + +#[test] +fn token_expiring_imminently_counts_as_expired() { + let mut account = fake_microsoft_account("MsaUser"); + + account.expires = Utc::now() + TimeDelta::hours(1); + assert!(!account.is_expired(), "a token with an hour left is usable"); + + account.expires = Utc::now() + TimeDelta::seconds(5); + assert!( + account.is_expired(), + "a token about to lapse must be renewed before launching" + ); + + account.expires = Utc::now() - TimeDelta::hours(1); + assert!(account.is_expired()); +} + +#[tokio::test] +async fn default_account_returns_expired_token_without_refreshing() { + isolate_launcher_dir(); + + let mut store = CredentialsStore::default(); + let mut msa = fake_microsoft_account("MsaUser"); + msa.expires = Utc::now() - TimeDelta::hours(5); + let msa_id = msa.id; + store.users.insert(msa_id, msa); + + store.default_user = Some(msa_id); + + let account = store + .default_account() + .await + .expect("an expired token must not fail the read path") + .expect("the only account should be the default"); + + assert_eq!(account.id, msa_id); + assert!(account.is_expired()); +} diff --git a/packages/oneclient_cluster/Cargo.toml b/packages/oneclient_cluster/Cargo.toml new file mode 100644 index 00000000..9643c8f3 --- /dev/null +++ b/packages/oneclient_cluster/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "oneclient_cluster" +description = "Cluster records, their settings profiles, and their on-disk files" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_db.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +tokio.workspace = true + +bytes.workspace = true +chrono.workspace = true +image.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_repr.workspace = true +strum.workspace = true +serde_json.workspace = true +sqlx.workspace = true +thiserror.workspace = true +tracing.workspace = true +trash.workspace = true diff --git a/packages/oneclient_cluster/src/cluster.rs b/packages/oneclient_cluster/src/cluster.rs new file mode 100644 index 00000000..acfafca0 --- /dev/null +++ b/packages/oneclient_cluster/src/cluster.rs @@ -0,0 +1,117 @@ +use std::path::PathBuf; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use oneclient_db::models::ClusterId; +use oneclient_db::models::ClusterRow; +use serde::{Deserialize, Serialize}; + +use crate::error::ClusterResult; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; + +use crate::error::ClusterError; +use crate::stage::ClusterStage; + +pub use oneclient_common::paths::DEDICATED_MARKER; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Cluster { + pub id: ClusterId, + pub name: String, + pub folder_name: String, + pub setting_profile_name: Option, + pub mc_version: String, + pub mc_loader: GameLoader, + pub mc_loader_version: Option, + pub stage: ClusterStage, + pub created_at: Option>, + pub last_played: Option>, + #[serde(with = "serde_duration_secs")] + pub overall_played: Duration, + pub linked_modpack_hash: Option, +} + +impl PartialEq for Cluster { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +mod serde_duration_secs { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use std::time::Duration; + + pub fn serialize(duration: &Duration, serializer: S) -> Result { + duration.as_secs().serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + Ok(Duration::from_secs(u64::deserialize(deserializer)?)) + } +} + +impl Cluster { + pub fn try_from_row(row: ClusterRow) -> Result { + let mc_loader = GameLoader::from_repr(row.mc_loader as u8) + .ok_or(ClusterError::InvalidLoader(row.mc_loader))?; + let stage = + ClusterStage::from_repr(row.stage).ok_or(ClusterError::InvalidStage(row.stage))?; + + Ok(Self { + id: row.id, + name: row.name, + folder_name: row.folder_name, + setting_profile_name: row.setting_profile_name, + mc_version: row.mc_version, + mc_loader, + mc_loader_version: row.mc_loader_version, + stage, + created_at: parse_timestamp(row.created_at), + last_played: parse_timestamp(row.last_played), + overall_played: Duration::from_secs(row.overall_played.unwrap_or(0).max(0) as u64), + linked_modpack_hash: row.linked_modpack_hash, + }) + } + + pub fn dir(&self) -> ClusterResult { + Ok(paths::cluster_dir(&self.folder_name)?) + } + + pub fn dedicated_marker(&self) -> ClusterResult { + Ok(self.dir()?.join(DEDICATED_MARKER)) + } + + pub fn uses_dedicated_dir(&self) -> bool { + paths::cluster_uses_dedicated_dir(&self.folder_name) + } + + pub fn game_dir(&self) -> ClusterResult { + Ok(paths::cluster_game_dir(&self.folder_name)?) + } + + pub fn as_link_target(&self) -> ClusterLinkTarget<'_> { + ClusterLinkTarget { + id: self.id, + folder_name: &self.folder_name, + mc_version: &self.mc_version, + mc_loader: self.mc_loader, + } + } +} + +#[derive(Debug, Clone)] +pub struct ClusterLinkTarget<'a> { + pub id: ClusterId, + pub folder_name: &'a str, + pub mc_version: &'a str, + pub mc_loader: GameLoader, +} + +fn parse_timestamp(value: Option) -> Option> { + value.and_then(|text| { + DateTime::parse_from_rfc3339(&text) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + }) +} diff --git a/packages/oneclient_cluster/src/error.rs b/packages/oneclient_cluster/src/error.rs new file mode 100644 index 00000000..b160b90f --- /dev/null +++ b/packages/oneclient_cluster/src/error.rs @@ -0,0 +1,75 @@ +use thiserror::Error; + +use crate::stage::ClusterStage; +use oneclient_common::domain::GameLoader; + +pub type ClusterResult = Result; + +#[derive(Debug, Error)] +pub enum ClusterError { + #[error(transparent)] + Io(#[from] polyio::IOError), + + #[error(transparent)] + StdIo(#[from] std::io::Error), + + #[error(transparent)] + Db(#[from] oneclient_db::DbError), + + #[error("Database execution failed: {0}")] + Sql(#[from] sqlx::Error), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + + #[error("Unable to parse JSON: {0}")] + Json(#[from] serde_json::Error), + + #[error("invalid settings profile: {reason}")] + InvalidProfile { reason: String }, + + #[error(transparent)] + Logs(#[from] crate::logs::LogsError), + + #[error(transparent)] + Screenshots(#[from] crate::screenshots::ScreenshotsError), + + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), + + #[error("cluster {0} not found")] + NotFound(i64), + + #[error("cluster folder '{0}' not found on disk")] + FolderNotFound(String), + + #[error("cluster is in {0} stage")] + Busy(ClusterStage), + + #[error("invalid Minecraft version '{0}'")] + InvalidVersion(String), + + #[error("cluster is missing a required Java version")] + MissingJavaVersion, + + #[error("loader {0} is not compatible with {1}")] + MismatchedLoader(GameLoader, GameLoader), + + #[error("setting profile '{0}' not found")] + ProfileNotFound(String), + + #[error("cluster has no settings profile assigned")] + NoProfile, + + #[error("cluster {0} is already running")] + AlreadyRunning(i64), + + #[error("cluster name is empty after sanitization")] + EmptyName, + + #[error("unknown loader id {0} in database")] + InvalidLoader(i64), + + #[error("unknown stage id {0} in database")] + InvalidStage(i64), +} diff --git a/packages/oneclient_cluster/src/lib.rs b/packages/oneclient_cluster/src/lib.rs new file mode 100644 index 00000000..825353fb --- /dev/null +++ b/packages/oneclient_cluster/src/lib.rs @@ -0,0 +1,22 @@ +//! Launch prep catalog provisioning and version migrations stay in +//! `oneclient_core` they need Java MC metadata and the package store +//! No event bus here the network client exists only for mclo.gs uploads + +mod cluster; +mod error; +mod manager; +mod options; +mod profile; +mod stage; + +pub mod logs; +pub mod profiles; +pub mod screenshots; + +pub use cluster::{Cluster, ClusterLinkTarget}; +pub use error::{ClusterError, ClusterResult}; +pub use manager::ClusterManager; +pub use options::{ClusterUpdate, CreateClusterOptions}; +pub use profile::{GameSettingsProfile, PackageUpdateMode, SettingsOsExtra}; +pub use profiles::ProfileUpdate; +pub use stage::ClusterStage; diff --git a/packages/oneclient_cluster/src/logs/error.rs b/packages/oneclient_cluster/src/logs/error.rs new file mode 100644 index 00000000..838b9a53 --- /dev/null +++ b/packages/oneclient_cluster/src/logs/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum LogsError { + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error("invalid log file name: {0}")] + InvalidName(String), + + #[error("log file is too large to upload")] + TooLarge, + + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), + + #[error("mclo.gs upload failed: {0}")] + Upload(String), +} diff --git a/packages/oneclient_cluster/src/logs/manage.rs b/packages/oneclient_cluster/src/logs/manage.rs new file mode 100644 index 00000000..c2ad8558 --- /dev/null +++ b/packages/oneclient_cluster/src/logs/manage.rs @@ -0,0 +1,157 @@ +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Utc}; + +use crate::cluster::Cluster; +use crate::error::ClusterResult; +use oneclient_common::paths; + +use super::parse::parse_level; +use super::{LogFileInfo, LogKind, LogLevel, LogLine, LogsError, ReadOptions}; + +/// Restricted to cluster dirs the launcher's own logs belong to the live +/// console and must not be readable deletable or uploadable from a cluster page +pub(super) fn ensure_allowed(path: &Path) -> ClusterResult { + polyio::ensure_under(path, [paths::clusters_dir()?])? + .ok_or_else(|| LogsError::InvalidName(path.display().to_string()).into()) +} + +fn push_file(path: PathBuf, name: String, kind: LogKind, out: &mut Vec) { + let Ok(meta) = std::fs::metadata(&path) else { + return; + }; + if !meta.is_file() { + return; + } + let modified = meta + .modified() + .map(DateTime::::from) + .unwrap_or_else(|_| Utc::now()); + + out.push(LogFileInfo { + name, + kind, + size_bytes: meta.len(), + modified, + path, + }); +} + +fn collect_dir( + dir: &Path, + kind: LogKind, + accept: impl Fn(&str) -> bool, + out: &mut Vec, +) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + if !accept(name) { + continue; + } + push_file(path.clone(), name.to_string(), kind, out); + } +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub fn list_cluster_logs(cluster: &Cluster) -> ClusterResult> { + let mut out = Vec::new(); + + let dir = cluster.dir()?; + + collect_dir( + &dir.join("logs"), + LogKind::Minecraft, + |name| name.ends_with(".log") || name.ends_with(".log.gz"), + &mut out, + ); + + collect_dir( + &dir.join("crash-reports"), + LogKind::CrashReport, + |name| name.ends_with(".txt") || name.ends_with(".log"), + &mut out, + ); + + push_file( + cluster_output_log(cluster)?, + "Game output".to_string(), + LogKind::Game { + cluster_id: cluster.id, + }, + &mut out, + ); + + out.sort_by_key(|info| std::cmp::Reverse(info.modified)); + Ok(out) +} + +pub fn cluster_output_log(cluster: &Cluster) -> ClusterResult { + Ok(cluster.dir()?.join("cluster-output.log")) +} + +pub(super) fn lines_from(content: &str, opts: &ReadOptions) -> Vec { + let mut prev = LogLevel::Unknown; + let mut lines: Vec = Vec::new(); + for (idx, raw) in content.lines().enumerate() { + let level = match parse_level(raw) { + Some(level) => { + prev = level; + level + } + None => prev, + }; + lines.push(LogLine { + number: idx + 1, + level, + text: raw.to_string(), + }); + } + + if let Some(filter) = opts.level_filter { + lines.retain(|l| l.level == filter); + } + if let Some(query) = opts.search.as_ref().filter(|q| !q.is_empty()) { + let query = query.to_lowercase(); + lines.retain(|l| l.text.to_lowercase().contains(&query)); + } + if let Some(max) = opts.max_lines + && lines.len() > max + { + lines.drain(0..lines.len() - max); + } + + lines +} + +pub(super) async fn read_file_string(path: &Path) -> ClusterResult { + let is_gz = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("gz")); + + if is_gz { + Ok(polyio::read_gz_to_string(path).await?) + } else { + Ok(polyio::read_to_string(path).await?) + } +} + +#[tracing::instrument(level = "debug", skip(opts))] +pub async fn read_log_at(path: &Path, opts: &ReadOptions) -> ClusterResult> { + let path = ensure_allowed(path)?; + let content = read_file_string(&path).await?; + Ok(lines_from(&content, opts)) +} + +#[tracing::instrument(level = "debug")] +pub async fn delete_log_at(path: &Path) -> ClusterResult<()> { + let path = ensure_allowed(path)?; + + Ok(polyio::remove_file(&path).await?) +} diff --git a/packages/oneclient_cluster/src/logs/mclogs.rs b/packages/oneclient_cluster/src/logs/mclogs.rs new file mode 100644 index 00000000..1dc22673 --- /dev/null +++ b/packages/oneclient_cluster/src/logs/mclogs.rs @@ -0,0 +1,80 @@ +use std::path::Path; + +use serde::Deserialize; + +use crate::error::ClusterResult; +use oneclient_net::RequestError; + +use super::manage::{ensure_allowed, read_file_string}; +use super::{LogsError, MclogsUploadResponse}; + +const MCLOGS_URL: &str = "https://api.mclo.gs/1/log"; + +const MAX_LINES: usize = 25_000; +const MAX_BYTES: usize = 10 * 1024 * 1024; + +#[derive(Deserialize)] +struct MclogsResponse { + success: bool, + #[serde(default)] + id: Option, + #[serde(default)] + url: Option, + #[serde(default)] + raw: Option, + #[serde(default)] + error: Option, +} + +#[tracing::instrument(skip(net))] +pub async fn upload_log_at( + net: &oneclient_net::RequestClient, + path: &Path, +) -> ClusterResult { + let path = ensure_allowed(path)?; + let mut content = read_file_string(&path).await?; + + let line_count = content.lines().count(); + if line_count > MAX_LINES { + content = content + .lines() + .skip(line_count - MAX_LINES) + .collect::>() + .join("\n"); + } + if content.len() > MAX_BYTES { + let mut cut = content.len() - MAX_BYTES; + while cut < content.len() && !content.is_char_boundary(cut) { + cut += 1; + } + content = content[cut..].to_string(); + } + + let response = net + .http() + .post(MCLOGS_URL) + .form(&[("content", content.as_str())]) + .send() + .await + .map_err(RequestError::ReqwestError)?; + + let bytes = response.bytes().await.map_err(RequestError::ReqwestError)?; + let parsed: MclogsResponse = serde_json::from_slice(&bytes)?; + + if !parsed.success { + let reason = parsed.error.unwrap_or_else(|| "unknown error".into()); + tracing::warn!(reason = %reason, "mclogs upload failed"); + return Err(LogsError::Upload(reason).into()); + } + + tracing::info!( + url = parsed.url.as_deref().unwrap_or(""), + "uploaded log to mclo.gs" + ); + + Ok(MclogsUploadResponse { + id: parsed.id.unwrap_or_default(), + url: parsed.url.unwrap_or_default(), + raw: parsed.raw.unwrap_or_default(), + }) +} diff --git a/packages/oneclient_cluster/src/logs/mod.rs b/packages/oneclient_cluster/src/logs/mod.rs new file mode 100644 index 00000000..c4483dbb --- /dev/null +++ b/packages/oneclient_cluster/src/logs/mod.rs @@ -0,0 +1,131 @@ +mod error; +mod manage; +mod mclogs; +mod parse; + +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; + +pub use error::LogsError; +pub use manage::{cluster_output_log, delete_log_at, list_cluster_logs, read_log_at}; +pub use mclogs::upload_log_at; +pub use parse::parse_level; + +/// No launcher variant on purpose the launcher's own logs live in the log console +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum LogKind { + Game { cluster_id: i64 }, + Minecraft, + CrashReport, + Other, +} + +impl LogKind { + pub fn is_crash_report(self) -> bool { + matches!(self, Self::CrashReport) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LogFileInfo { + pub name: String, + pub kind: LogKind, + pub size_bytes: u64, + pub modified: DateTime, + pub path: PathBuf, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, + Fatal, + Unknown, +} + +impl LogLevel { + pub fn from_token(token: &str) -> Option { + match token.to_ascii_uppercase().as_str() { + "TRACE" => Some(Self::Trace), + "DEBUG" => Some(Self::Debug), + "INFO" => Some(Self::Info), + "WARN" | "WARNING" => Some(Self::Warn), + "ERROR" => Some(Self::Error), + "FATAL" => Some(Self::Fatal), + _ => None, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Trace => "TRACE", + Self::Debug => "DEBUG", + Self::Info => "INFO", + Self::Warn => "WARN", + Self::Error => "ERROR", + Self::Fatal => "FATAL", + Self::Unknown => "", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LogLine { + pub number: usize, + pub level: LogLevel, + pub text: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct ReadOptions { + pub level_filter: Option, + pub search: Option, + pub max_lines: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct MclogsUploadResponse { + pub id: String, + pub url: String, + pub raw: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_bracket_levels() { + assert_eq!( + parse_level("[12:00:00] [Render thread/INFO]: hello"), + Some(LogLevel::Info) + ); + assert_eq!( + parse_level("[12:00:00] [Server thread/WARN]: uh oh"), + Some(LogLevel::Warn) + ); + assert_eq!(parse_level("[ERROR] boom"), Some(LogLevel::Error)); + assert_eq!(parse_level("[main/FATAL]"), Some(LogLevel::Fatal)); + + assert_eq!(parse_level("\tat java.base/java.lang.Thread.run"), None); + assert_eq!(parse_level("[12:34:56] just a timestamp"), None); + } + + #[test] + fn carries_level_forward() { + let opts = ReadOptions::default(); + + let lines = super::manage::lines_from( + "[12:00:00] [Server thread/ERROR]: boom\n\tat Foo.bar\nplain line", + &opts, + ); + + assert_eq!(lines[0].level, LogLevel::Error); + assert_eq!(lines[1].level, LogLevel::Error); + assert_eq!(lines[2].level, LogLevel::Error); + } +} diff --git a/packages/oneclient_cluster/src/logs/parse.rs b/packages/oneclient_cluster/src/logs/parse.rs new file mode 100644 index 00000000..089afb32 --- /dev/null +++ b/packages/oneclient_cluster/src/logs/parse.rs @@ -0,0 +1,25 @@ +use super::LogLevel; + +pub fn parse_level(line: &str) -> Option { + let bytes = line.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'[' + && let Some(rel_end) = line[i + 1..].find(']') + { + let seg = &line[i + 1..i + 1 + rel_end]; + if let Some(level) = level_from_segment(seg) { + return Some(level); + } + i = i + 1 + rel_end + 1; + continue; + } + i += 1; + } + None +} + +fn level_from_segment(seg: &str) -> Option { + let token = seg.rsplit('/').next().unwrap_or(seg).trim(); + LogLevel::from_token(token) +} diff --git a/packages/oneclient_cluster/src/manager.rs b/packages/oneclient_cluster/src/manager.rs new file mode 100644 index 00000000..20fc90ef --- /dev/null +++ b/packages/oneclient_cluster/src/manager.rs @@ -0,0 +1,347 @@ +use oneclient_db::dao::{cluster as cluster_dao, setting_profile as profile_dao}; +use oneclient_db::models::{ClusterId, ClusterPatch, NewCluster}; + +use crate::error::ClusterResult; +use crate::profile::GameSettingsProfile; +use crate::profiles::ProfileUpdate; +use crate::profiles::{create_profile_from_global, resolve_cluster_profile, update_named_profile}; +use oneclient_common::domain::ContentType; +use oneclient_common::patch::Patch; + +use oneclient_db::DbPool; +use tokio::sync::Mutex; + +use crate::cluster::Cluster; +use crate::error::ClusterError; +use crate::options::{ClusterUpdate, CreateClusterOptions}; +use crate::stage::ClusterStage; + +pub struct ClusterManager { + db: DbPool, + /// Serialises creation so two concurrent creates cannot resolve to the same + /// folder name and race each other onto disk + provisioning: Mutex<()>, +} + +impl ClusterManager { + #[must_use] + pub fn new(db: DbPool) -> Self { + Self { + db, + provisioning: Mutex::new(()), + } + } + + /// Orphan-folder recovery must hold this across its whole scan or a + /// concurrent create's folder is adopted into a duplicate row + pub async fn provisioning_guard(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.provisioning.lock().await + } + + pub fn sanitize_name(name: &str) -> String { + let mut name = name.to_string(); + name.retain(|c| { + c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | ' ' | '.' | '(' | ')') + }); + name.trim().to_string() + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn get(&self, cluster_id: ClusterId) -> ClusterResult { + let row = cluster_dao::get_by_id(&self.db, cluster_id) + .await? + .ok_or(ClusterError::NotFound(cluster_id))?; + Cluster::try_from_row(row) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn list(&self) -> ClusterResult> { + let rows = cluster_dao::list_all(&self.db).await?; + rows.into_iter() + .map(Cluster::try_from_row) + .collect::, _>>() + } + + #[tracing::instrument(skip(self))] + pub async fn create( + &self, + global: &GameSettingsProfile, + options: CreateClusterOptions, + ) -> ClusterResult { + let _guard = self.provisioning.lock().await; + self.create_core(global, options).await + } + + /// Returns `None` if a cluster for this version/loader already exists + #[tracing::instrument(level = "debug", skip(self))] + pub async fn create_provisioned( + &self, + global: &GameSettingsProfile, + options: CreateClusterOptions, + ) -> ClusterResult> { + let _guard = self.provisioning.lock().await; + if cluster_dao::find_by_version_loader( + &self.db, + &options.mc_version, + options.mc_loader as i64, + ) + .await? + .is_some() + { + return Ok(None); + } + self.create_core(global, options).await.map(Some) + } + + /// Callers MUST hold `self.provisioning` this does not lock + #[tracing::instrument(level = "debug", skip(self, global))] + async fn create_core( + &self, + global: &GameSettingsProfile, + options: CreateClusterOptions, + ) -> ClusterResult { + let name = Self::sanitize_name(&options.name); + if name.is_empty() { + return Err(ClusterError::EmptyName); + } + + let folder_name = resolve_unique_folder_name(&name).await?; + let cluster_path = oneclient_common::paths::clusters_dir()?.join(&folder_name); + + match create_inner( + &self.db, + global, + &options, + &name, + &folder_name, + &cluster_path, + ) + .await + { + Ok(cluster) => { + tracing::info!(cluster_id = cluster.id, name = %cluster.name, "created cluster"); + Ok(cluster) + } + Err(err) => { + tracing::warn!(name = %name, error = %err, "cluster creation failed, cleaning up directory"); + let _ = polyio::remove_dir_all(&cluster_path).await; + Err(err) + } + } + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn update( + &self, + cluster_id: ClusterId, + update: ClusterUpdate, + ) -> ClusterResult { + let _ = self.get(cluster_id).await?; + + if let Patch::Set(ref profile_name) = update.setting_profile_name { + ensure_profile_exists(&self.db, profile_name).await?; + } + + let name = update.name.as_deref().map(Self::sanitize_name); + if name.as_deref().is_some_and(str::is_empty) { + return Err(ClusterError::EmptyName); + } + + let patch = ClusterPatch { + name, + setting_profile_name: update.setting_profile_name.into_db_patch(), + mc_loader_version: update.mc_loader_version.into_db_patch(), + linked_modpack_hash: update.linked_modpack_hash.into_db_patch(), + }; + + let row = cluster_dao::update(&self.db, cluster_id, &patch).await?; + Cluster::try_from_row(row) + } + + #[tracing::instrument(skip(self))] + pub async fn delete(&self, cluster_id: ClusterId, remove_files: bool) -> ClusterResult<()> { + let cluster = self.get(cluster_id).await?; + + if !cluster_dao::delete_by_id(&self.db, cluster_id).await? { + return Err(ClusterError::NotFound(cluster_id)); + } + + if remove_files { + let path = cluster.dir()?; + if path.exists() { + polyio::remove_dir_all(&path).await?; + } + } + + tracing::info!(cluster_id, remove_files, "deleted cluster"); + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn set_stage( + &self, + cluster_id: ClusterId, + stage: ClusterStage, + ) -> ClusterResult { + let row = cluster_dao::set_stage(&self.db, cluster_id, stage as i64).await?; + Cluster::try_from_row(row) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn uses_dedicated_dir(&self, cluster_id: ClusterId) -> ClusterResult { + Ok(self.get(cluster_id).await?.uses_dedicated_dir()) + } + + #[tracing::instrument(level = "debug", skip(self))] + /// `is_running` is passed in because process liveness belongs to the game + /// lifecycle not to cluster records + pub async fn set_dedicated_dir( + &self, + cluster_id: ClusterId, + dedicated: bool, + is_running: bool, + ) -> ClusterResult<()> { + if is_running { + return Err(ClusterError::AlreadyRunning(cluster_id)); + } + + let cluster = self.get(cluster_id).await?; + let marker = cluster.dedicated_marker()?; + if dedicated { + polyio::create_dir_all(cluster.dir()?).await?; + polyio::write(&marker, b"").await.ok(); + } else if marker.exists() { + polyio::remove_file(&marker).await.ok(); + } + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn add_playtime( + &self, + cluster_id: ClusterId, + duration: std::time::Duration, + ) -> ClusterResult { + let seconds = i64::try_from(duration.as_secs()).unwrap_or(i64::MAX); + let row = cluster_dao::add_playtime(&self.db, cluster_id, seconds).await?; + + Cluster::try_from_row(row) + } + + #[tracing::instrument(level = "debug", skip(self, global, cluster), fields(cluster_id = cluster.id))] + pub async fn resolve_settings( + &self, + global: &GameSettingsProfile, + cluster: &Cluster, + ) -> ClusterResult { + resolve_cluster_profile(&self.db, global, cluster.setting_profile_name.as_deref()).await + } + + #[tracing::instrument(level = "debug", skip(self, update))] + pub async fn update_profile( + &self, + cluster_id: ClusterId, + update: ProfileUpdate, + ) -> ClusterResult { + let cluster = self.get(cluster_id).await?; + let profile_name = cluster + .setting_profile_name + .ok_or(ClusterError::NoProfile)?; + + update_named_profile(&self.db, &profile_name, update).await + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn create_and_assign_profile( + &self, + global: &GameSettingsProfile, + cluster_id: ClusterId, + profile_name: &str, + ) -> ClusterResult { + let profile = + create_profile_from_global(&self.db, global, profile_name, None, None).await?; + + self.update( + cluster_id, + ClusterUpdate::default().setting_profile(&profile.name), + ) + .await?; + + Ok(profile) + } +} + +#[tracing::instrument(level = "debug", skip(db, global, options))] +async fn create_inner( + db: &DbPool, + global: &GameSettingsProfile, + options: &CreateClusterOptions, + name: &str, + folder_name: &str, + cluster_path: &std::path::Path, +) -> ClusterResult { + polyio::create_dir_all(cluster_path).await?; + ensure_content_dirs(cluster_path).await?; + + let profile = create_profile_from_global(db, global, name, options.mem_max, None).await?; + + let row = cluster_dao::insert( + db, + &NewCluster { + name, + folder_name, + mc_version: &options.mc_version, + mc_loader: options.mc_loader as i64, + mc_loader_version: options.mc_loader_version.as_deref(), + setting_profile_name: Some(&profile.name), + stage: ClusterStage::NotReady as i64, + }, + ) + .await?; + + Cluster::try_from_row(row) +} + +#[tracing::instrument(level = "debug", skip(pool))] +async fn ensure_profile_exists(pool: &oneclient_db::DbPool, name: &str) -> ClusterResult<()> { + if profile_dao::get_by_name(pool, name).await?.is_none() { + return Err(ClusterError::ProfileNotFound(name.to_string())); + } + Ok(()) +} + +#[tracing::instrument(level = "debug")] +async fn resolve_unique_folder_name(name: &str) -> ClusterResult { + let cluster_dir = oneclient_common::paths::clusters_dir()?; + let mut folder_name = name.to_string(); + let mut path = cluster_dir.join(&folder_name); + + if path.exists() { + let mut which = 1; + loop { + let candidate = format!("{folder_name} ({which})"); + path = cluster_dir.join(&candidate); + if !path.exists() { + folder_name = candidate; + break; + } + which += 1; + } + } + + Ok(folder_name) +} + +#[tracing::instrument(level = "debug")] +async fn ensure_content_dirs(cluster_path: &std::path::Path) -> ClusterResult<()> { + for content_type in [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, + ContentType::World, + ] { + polyio::create_dir_all(cluster_path.join(content_type.folder_name())).await?; + } + Ok(()) +} diff --git a/packages/oneclient_cluster/src/options.rs b/packages/oneclient_cluster/src/options.rs new file mode 100644 index 00000000..3b9ced50 --- /dev/null +++ b/packages/oneclient_cluster/src/options.rs @@ -0,0 +1,59 @@ +use serde::{Deserialize, Serialize}; + +use oneclient_common::domain::GameLoader; +use oneclient_common::patch::Patch; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateClusterOptions { + pub name: String, + pub mc_version: String, + pub mc_loader: GameLoader, + pub mc_loader_version: Option, + pub mem_max: Option, +} + +impl CreateClusterOptions { + pub fn new( + name: impl Into, + mc_version: impl Into, + mc_loader: GameLoader, + ) -> Self { + Self { + name: name.into(), + mc_version: mc_version.into(), + mc_loader, + mc_loader_version: None, + mem_max: None, + } + } + + pub fn mem_max(mut self, megabytes: u32) -> Self { + self.mem_max = Some(megabytes); + self + } + + pub fn loader_version(mut self, version: impl Into) -> Self { + self.mc_loader_version = Some(version.into()); + self + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ClusterUpdate { + pub name: Option, + pub setting_profile_name: Patch, + pub mc_loader_version: Patch, + pub linked_modpack_hash: Patch, +} + +impl ClusterUpdate { + pub fn setting_profile(mut self, name: impl Into) -> Self { + self.setting_profile_name = Patch::Set(name.into()); + self + } + + pub fn loader_version(mut self, version: impl Into) -> Self { + self.mc_loader_version = Patch::Set(version.into()); + self + } +} diff --git a/packages/oneclient_cluster/src/profile.rs b/packages/oneclient_cluster/src/profile.rs new file mode 100644 index 00000000..5c4a2488 --- /dev/null +++ b/packages/oneclient_cluster/src/profile.rs @@ -0,0 +1,165 @@ +use serde::{Deserialize, Serialize}; + +use oneclient_db::models::SettingProfileRow; + +pub const GLOBAL_PROFILE_NAME: &str = "Global"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GameSettingsProfile { + pub name: String, + pub java_path: Option, + pub resolution: Option, + pub force_fullscreen: Option, + pub mem_max: Option, + pub launch_args: Option, + pub launch_env: Option, + pub hook_pre: Option, + pub hook_wrapper: Option, + pub hook_post: Option, + pub os_extra: Option, + /// Never applies to bundle content + pub browser_update_mode: Option, +} + +// `Resolution` lives in oneclient_common the launch-argument builder needs it +// and cannot depend on the launcher settings +pub use oneclient_common::Resolution; +pub use oneclient_common::domain::PackageUpdateMode; + +cfg_select! { + target_os = "windows" => { + #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct SettingsOsExtra {} + } + target_os = "macos" => { + #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct SettingsOsExtra {} + } + not(any(target_os = "windows", target_os = "macos")) => { + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + pub struct SettingsOsExtra { + pub enable_gamemode: Option, + } + + impl Default for SettingsOsExtra { + fn default() -> Self { + Self { + enable_gamemode: Some(true), + } + } + } + } +} + +impl GameSettingsProfile { + pub fn default_global_profile() -> Self { + Self { + name: GLOBAL_PROFILE_NAME.into(), + java_path: None, + resolution: None, + force_fullscreen: Some(false), + mem_max: Some(4096), + launch_args: None, + launch_env: None, + hook_pre: None, + hook_wrapper: None, + hook_post: None, + os_extra: Some(SettingsOsExtra::default()), + browser_update_mode: Some(PackageUpdateMode::default()), + } + } + + pub fn is_global(&self) -> bool { + self.name == GLOBAL_PROFILE_NAME + } + + pub fn merge_global(&mut self, global: &Self) { + if self.java_path.is_none() { + self.java_path = global.java_path.clone(); + } + if self.resolution.is_none() { + self.resolution = global.resolution; + } + if self.force_fullscreen.is_none() { + self.force_fullscreen = global.force_fullscreen; + } + if self.mem_max.is_none() { + self.mem_max = global.mem_max; + } + if self.launch_args.is_none() { + self.launch_args = global.launch_args.clone(); + } + if self.launch_env.is_none() { + self.launch_env = global.launch_env.clone(); + } + if self.hook_pre.is_none() { + self.hook_pre = global.hook_pre.clone(); + } + if self.hook_wrapper.is_none() { + self.hook_wrapper = global.hook_wrapper.clone(); + } + if self.hook_post.is_none() { + self.hook_post = global.hook_post.clone(); + } + if self.os_extra.is_none() { + self.os_extra = global.os_extra.clone(); + } + if self.browser_update_mode.is_none() { + self.browser_update_mode = global.browser_update_mode; + } + } + + pub fn from_row(row: SettingProfileRow) -> crate::ClusterResult { + Ok(Self { + name: row.name, + java_path: row.java_path, + resolution: row + .resolution + .map(|json| serde_json::from_str(&json)) + .transpose()?, + force_fullscreen: row.force_fullscreen.map(|v| v != 0), + mem_max: row.mem_max.map(|v| v as u32), + launch_args: row.launch_args, + launch_env: row.launch_env, + hook_pre: row.hook_pre, + hook_wrapper: row.hook_wrapper, + hook_post: row.hook_post, + os_extra: row + .os_extra + .map(|json| serde_json::from_str(&json)) + .transpose()?, + // Unrecognised values inherit rather than error so a profile from a + // newer build cannot make the cluster unloadable + browser_update_mode: row + .browser_update_mode + .as_deref() + .and_then(PackageUpdateMode::parse), + }) + } + + pub fn into_row(&self) -> crate::ClusterResult { + Ok(SettingProfileRow { + name: self.name.clone(), + java_path: self.java_path.clone(), + resolution: self + .resolution + .map(|res| serde_json::to_string(&res)) + .transpose()?, + force_fullscreen: self.force_fullscreen.map(i64::from), + mem_max: self.mem_max.map(i64::from), + launch_args: self.launch_args.clone(), + launch_env: self.launch_env.clone(), + hook_pre: self.hook_pre.clone(), + hook_wrapper: self.hook_wrapper.clone(), + hook_post: self.hook_post.clone(), + os_extra: self + .os_extra + .as_ref() + .map(serde_json::to_string) + .transpose()?, + browser_update_mode: self + .browser_update_mode + .map(|mode| mode.as_str().to_string()), + }) + } +} diff --git a/packages/oneclient_cluster/src/profiles.rs b/packages/oneclient_cluster/src/profiles.rs new file mode 100644 index 00000000..10126b71 --- /dev/null +++ b/packages/oneclient_cluster/src/profiles.rs @@ -0,0 +1,166 @@ +//! A profile is a sparse override on the global baseline a `None` field +//! inherits so `Patch::Clear` means "inherit again" not "set to nothing" +//! The baseline is passed in so this crate needn't depend on launcher config + +use oneclient_common::patch::Patch; +use oneclient_db::DbPool; + +use crate::error::{ClusterError, ClusterResult}; +use crate::profile::{GameSettingsProfile, SettingsOsExtra}; +use oneclient_common::Resolution; +use oneclient_common::domain::PackageUpdateMode; + +#[tracing::instrument(level = "debug", skip(pool, global))] +pub async fn get_profile_or_default( + pool: &DbPool, + global: &GameSettingsProfile, + name: Option<&str>, +) -> ClusterResult { + if let Some(name) = name + && let Some(row) = oneclient_db::dao::setting_profile::get_by_name(pool, name).await? + { + return GameSettingsProfile::from_row(row); + } + + Ok(global.clone()) +} + +#[tracing::instrument(level = "debug", skip(pool, global))] +/// Falls back to the whole baseline when the name is missing or unknown so a +/// deleted profile cannot brick a cluster +pub async fn resolve_cluster_profile( + pool: &DbPool, + global: &GameSettingsProfile, + profile_name: Option<&str>, +) -> ClusterResult { + let Some(name) = profile_name else { + return Ok(global.clone()); + }; + + let Some(row) = oneclient_db::dao::setting_profile::get_by_name(pool, name).await? else { + return Ok(global.clone()); + }; + + let mut profile = GameSettingsProfile::from_row(row)?; + if !profile.is_global() { + profile.merge_global(global); + } + + Ok(profile) +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn upsert_named_profile( + pool: &DbPool, + profile: &GameSettingsProfile, +) -> ClusterResult { + if profile.is_global() { + return Err(ClusterError::InvalidProfile { + reason: "the Global profile is stored in settings.json".into(), + }); + } + + let row = profile.into_row()?; + let saved = oneclient_db::dao::setting_profile::upsert(pool, &row).await?; + GameSettingsProfile::from_row(saved) +} + +#[tracing::instrument(level = "debug", skip(pool, global))] +pub async fn create_profile_from_global( + pool: &DbPool, + global: &GameSettingsProfile, + name: &str, + mem_max: Option, + force_fullscreen: Option, +) -> ClusterResult { + let mut profile = global.clone(); + + profile.name = name.to_string(); + if let Some(mem) = mem_max { + profile.mem_max = Some(mem); + } + + if let Some(fullscreen) = force_fullscreen { + profile.force_fullscreen = Some(fullscreen); + } + + upsert_named_profile(pool, &profile).await +} + +#[tracing::instrument(level = "debug", skip(pool, global))] +pub async fn create_settings_profile( + pool: &DbPool, + global: &GameSettingsProfile, + name: &str, +) -> ClusterResult { + create_profile_from_global(pool, global, name, None, None).await +} + +#[tracing::instrument(level = "debug", skip(pool))] +pub async fn list_named_profiles(pool: &DbPool) -> ClusterResult> { + let rows = oneclient_db::dao::setting_profile::list_all(pool).await?; + rows.into_iter() + .map(GameSettingsProfile::from_row) + .collect() +} + +#[tracing::instrument(level = "debug", skip(pool))] +pub async fn delete_named_profile(pool: &DbPool, name: &str) -> ClusterResult<()> { + oneclient_db::dao::setting_profile::delete_by_name(pool, name).await?; + Ok(()) +} + +#[derive(Debug, Clone, Default)] +pub struct ProfileUpdate { + pub java_path: Patch, + pub resolution: Patch, + pub force_fullscreen: Patch, + pub mem_max: Patch, + pub launch_args: Patch, + pub launch_env: Patch, + pub hook_pre: Patch, + pub hook_wrapper: Patch, + pub hook_post: Patch, + pub os_extra: Patch, + pub browser_update_mode: Patch, +} + +impl ProfileUpdate { + pub fn apply(&self, profile: &mut GameSettingsProfile) { + self.java_path.apply_to_option(&mut profile.java_path); + self.resolution.apply_to_option(&mut profile.resolution); + self.force_fullscreen + .apply_to_option(&mut profile.force_fullscreen); + self.mem_max.apply_to_option(&mut profile.mem_max); + self.launch_args + .apply_to_command_option(&mut profile.launch_args); + self.launch_env + .apply_to_command_option(&mut profile.launch_env); + self.hook_pre.apply_to_command_option(&mut profile.hook_pre); + self.hook_wrapper + .apply_to_command_option(&mut profile.hook_wrapper); + self.hook_post + .apply_to_command_option(&mut profile.hook_post); + self.os_extra.apply_to_option(&mut profile.os_extra); + self.browser_update_mode + .apply_to_option(&mut profile.browser_update_mode); + } +} + +#[tracing::instrument(level = "debug", skip(pool, update))] +pub async fn update_named_profile( + pool: &DbPool, + name: &str, + update: ProfileUpdate, +) -> ClusterResult { + let Some(row) = oneclient_db::dao::setting_profile::get_by_name(pool, name).await? else { + return Err(ClusterError::InvalidProfile { + reason: format!("profile '{name}' not found"), + }); + }; + + let mut profile = GameSettingsProfile::from_row(row)?; + update.apply(&mut profile); + + upsert_named_profile(pool, &profile).await +} diff --git a/packages/oneclient_cluster/src/screenshots/mod.rs b/packages/oneclient_cluster/src/screenshots/mod.rs new file mode 100644 index 00000000..c2a2fcf4 --- /dev/null +++ b/packages/oneclient_cluster/src/screenshots/mod.rs @@ -0,0 +1,126 @@ +use std::path::{Path, PathBuf}; + +use bytes::Bytes; +use chrono::{DateTime, Utc}; +use thiserror::Error; + +use crate::cluster::Cluster; +use crate::error::ClusterResult; +use oneclient_common::paths; + +#[derive(Debug, Error)] +pub enum ScreenshotsError { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error("path is not inside a cluster directory: {0}")] + InvalidPath(String), + #[error("failed to move screenshot to trash: {0}")] + Trash(String), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ScreenshotInfo { + pub name: String, + pub size_bytes: u64, + pub created: DateTime, + pub resolution: Option<(u32, u32)>, + pub path: PathBuf, +} + +fn is_image(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") +} + +fn ensure_in_clusters(path: &Path) -> ClusterResult { + polyio::ensure_under( + path, + [paths::clusters_dir()?, paths::shared_minecraft_dir()?], + )? + .ok_or_else(|| ScreenshotsError::InvalidPath(path.display().to_string()).into()) +} + +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub fn list_cluster_screenshots(cluster: &Cluster) -> ClusterResult> { + let dir = cluster.game_dir()?.join("screenshots"); + let mut out = Vec::new(); + + let Ok(entries) = std::fs::read_dir(&dir) else { + return Ok(out); + }; + + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + + if !is_image(name) { + continue; + } + + let Ok(meta) = std::fs::metadata(&path) else { + continue; + }; + + if !meta.is_file() { + continue; + } + + let created = meta + .created() + .or_else(|_| meta.modified()) + .map(DateTime::::from) + .unwrap_or_else(|_| Utc::now()); + + let resolution = image::image_dimensions(&path).ok(); + + out.push(ScreenshotInfo { + name: name.to_string(), + size_bytes: meta.len(), + created, + resolution, + path, + }); + } + + out.sort_by_key(|s| std::cmp::Reverse(s.created)); + Ok(out) +} + +#[tracing::instrument(level = "debug")] +pub fn load_screenshot(path: &Path, max_edge: Option) -> ClusterResult { + let path = ensure_in_clusters(path)?; + let raw = std::fs::read(&path).map_err(ScreenshotsError::Io)?; + let bytes = match max_edge { + Some(edge) => thumbnail(&raw, edge).unwrap_or_else(|| Bytes::from(raw)), + None => Bytes::from(raw), + }; + Ok(bytes) +} + +fn thumbnail(raw: &[u8], max_edge: u32) -> Option { + let img = image::load_from_memory(raw).ok()?; + if img.width().max(img.height()) <= max_edge { + return None; + } + let resized = img.resize(max_edge, max_edge, image::imageops::FilterType::Triangle); + + let mut out = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut out); + resized + .write_to(&mut cursor, image::ImageFormat::Png) + .ok()?; + Some(Bytes::from(out)) +} + +#[tracing::instrument(level = "debug")] +pub fn delete_screenshot(path: &Path) -> ClusterResult<()> { + let path = ensure_in_clusters(path)?; + + // TODO maybe async? + match trash::delete(&path) { + Ok(()) => Ok(()), + Err(err) => Err(ScreenshotsError::Trash(err.to_string()).into()), + } +} diff --git a/packages/oneclient_cluster/src/stage.rs b/packages/oneclient_cluster/src/stage.rs new file mode 100644 index 00000000..fc605ef5 --- /dev/null +++ b/packages/oneclient_cluster/src/stage.rs @@ -0,0 +1,30 @@ +use serde_repr::{Deserialize_repr, Serialize_repr}; +use strum::{Display, FromRepr}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize_repr, + Deserialize_repr, + FromRepr, + Display, + Default, +)] +#[repr(i64)] +pub enum ClusterStage { + #[default] + NotReady = 0, + Downloading = 1, + Repairing = 2, + Ready = 3, +} + +impl ClusterStage { + pub fn is_busy(self) -> bool { + matches!(self, Self::Downloading | Self::Repairing) + } +} diff --git a/packages/oneclient_common/Cargo.toml b/packages/oneclient_common/Cargo.toml new file mode 100644 index 00000000..7ba3aa7d --- /dev/null +++ b/packages/oneclient_common/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "oneclient_common" +description = "Shared vocabulary for OneClient: paths, constants, platform and content types" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +# Minecraft/modded metadata vocabulary (Os, loader format versions) +interfrost.workspace = true + +directories.workspace = true +serde.workspace = true +strum.workspace = true +thiserror.workspace = true diff --git a/packages/oneclient_common/src/constants.rs b/packages/oneclient_common/src/constants.rs new file mode 100644 index 00000000..ed35f8c8 --- /dev/null +++ b/packages/oneclient_common/src/constants.rs @@ -0,0 +1,49 @@ +pub const DUMMY_REPLACE_NEWLINE: &str = "\n"; + +pub const MICROSOFT_CLIENT_ID: &str = "9419b7ee-1448-4d1b-b52a-550d8f36ab56"; +pub const MINECRAFT_SCOPES: &str = "XboxLive.SignIn XboxLive.offline_access"; +pub const CURSEFORGE_API_KEY: &str = "$2a$10$6utA1UNSmFPrE/Lh7b7ndeeGmiOkjKNY8kpFB0fsmE/d42ZAfFgCe"; +pub const DISCORD_CLIENT_ID: &str = "1426999264633946334"; + +pub const MODRINTH_API_URL: &str = "https://api.modrinth.com"; +pub const MODRINTH_CDN_PREFIX: &str = "https://cdn.modrinth.com/data/"; +pub const CURSEFORGE_API_URL: &str = "https://api.curseforge.com/v1"; +pub const CURSEFORGE_GAME_ID: u32 = 432; +pub const METADATA_API_URL: &str = "https://meta.polyfrost.org"; +pub const MCLOGS_API_URL: &str = "https://api.mclo.gs/1"; +pub const SKYCLIENT_BASE_URL: &str = + "https://raw.githubusercontent.com/SkyblockClient/SkyblockClient-REPO/refs/heads/main/v1"; +pub const META_URL_BASE: &str = "https://data-v2.polyfrost.org"; +pub const TOS_URL: &str = "https://polyfrost.org/legal/terms"; +pub const PRIVACY_URL: &str = "https://polyfrost.org/legal/privacy"; +pub const PLUS_BACKEND_URL: &str = "https://plus.polyfrost.org"; + +pub const SENTRY_DSN: &str = match option_env!("ONECLIENT_SENTRY_DSN") { + Some(dsn) => dsn, + None => { + "https://e7dff7e07427e1a28b9212cfcc8ddc1e@o4511714343124992.ingest.us.sentry.io/4511714354135040" + } +}; + +pub const TARGET_OS: &str = cfg_select! { + target_os = "windows" => "windows", + target_os = "macos" => "osx", + target_os = "linux" => "linux" +}; + +pub const NATIVE_ARCH: &str = cfg_select! { + target_arch = "x86" => "32", + target_arch = "x86_64" => "64", + _ => "64" +}; + +pub const JAVA_BIN: &str = if cfg!(windows) { "javaw.exe" } else { "java" }; + +pub const ARCH_WIDTH: &str = if cfg!(target_pointer_width = "64") { + "64" +} else { + "32" +}; + +pub const LINE_ENDING: &str = if cfg!(windows) { "\r\n" } else { "\n" }; +pub const CLASSPATH_SEPARATOR: &str = if cfg!(windows) { ";" } else { ":" }; diff --git a/packages/oneclient_common/src/domain.rs b/packages/oneclient_common/src/domain.rs new file mode 100644 index 00000000..bdf68e81 --- /dev/null +++ b/packages/oneclient_common/src/domain.rs @@ -0,0 +1,289 @@ +use std::fmt::Display; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use strum::{Display as StrumDisplay, EnumIter, FromRepr}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + EnumIter, + FromRepr, + StrumDisplay, +)] +#[repr(u8)] +pub enum ContentType { + #[serde(rename = "mod")] + Mod = 0, + #[serde(rename = "resourcepack")] + ResourcePack = 1, + #[serde(rename = "shader")] + Shader = 2, + #[serde(rename = "datapack")] + DataPack = 3, + #[serde(rename = "world")] + World = 4, + #[serde(rename = "modpack")] + Modpack = 5, +} + +impl ContentType { + pub fn modrinth_type(self) -> &'static str { + match self { + Self::Mod => "mod", + Self::ResourcePack => "resourcepack", + Self::Shader => "shader", + Self::DataPack => "datapack", + Self::World => "world", + Self::Modpack => "modpack", + } + } + + pub fn folder_name(self) -> &'static str { + match self { + Self::Mod => "mods", + Self::ResourcePack => "resourcepacks", + Self::Shader => "shaderpacks", + Self::DataPack => "datapacks", + Self::World => "worlds", + Self::Modpack => "modpacks", + } + } + + pub fn from_folder_name(name: &str) -> Option { + match name.to_lowercase().as_str() { + "mods" | "mod" => Some(Self::Mod), + "resourcepacks" | "resourcepack" => Some(Self::ResourcePack), + "shaderpacks" | "shaders" | "shader" => Some(Self::Shader), + "datapacks" | "datapack" => Some(Self::DataPack), + "worlds" | "world" | "saves" => Some(Self::World), + "modpacks" | "modpack" => Some(Self::Modpack), + _ => None, + } + } +} + +impl From<&str> for ContentType { + fn from(value: &str) -> Self { + Self::from_folder_name(value).unwrap_or(Self::Mod) + } +} + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + EnumIter, + FromRepr, + StrumDisplay, +)] +#[repr(u8)] +pub enum ProviderId { + Modrinth = 0, + CurseForge = 1, + Local = 2, +} + +impl ProviderId { + pub const REMOTE_PROVIDERS: &[Self] = &[Self::Modrinth, Self::CurseForge]; + pub const REMOTE_PROVIDERS_STR: &[&str] = &["Modrinth", "CurseForge"]; + + pub fn dir_name(self) -> &'static str { + match self { + Self::Modrinth => "modrinth", + Self::CurseForge => "curseforge", + Self::Local => "local", + } + } + + pub fn website(self) -> &'static str { + match self { + Self::Modrinth => "https://modrinth.com/", + Self::CurseForge => "https://www.curseforge.com/", + Self::Local => "", + } + } + + pub const fn remote_providers() -> &'static [Self] { + &[Self::Modrinth, Self::CurseForge] + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HashAlgorithm { + Sha1, +} + +/// Applies only to browser-installed packages bundles have their own update flow +/// The check always runs regardless of variant so "out of date" markers stay populated +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, EnumIter, StrumDisplay, +)] +#[serde(rename_all = "snake_case")] +pub enum PackageUpdateMode { + Automatic, + Skip, + #[default] + Prompt, +} + +impl PackageUpdateMode { + /// Display order defaulting variant first + pub const ALL: &[Self] = &[Self::Prompt, Self::Automatic, Self::Skip]; + + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Automatic => "Automatically update", + Self::Skip => "Always skip auto updates", + Self::Prompt => "Show the modal", + } + } + + /// The value stored in the `setting_profiles.browser_update_mode` column + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Automatic => "automatic", + Self::Skip => "skip", + Self::Prompt => "prompt", + } + } + + #[must_use] + pub fn parse(value: &str) -> Option { + match value { + "automatic" => Some(Self::Automatic), + "skip" => Some(Self::Skip), + "prompt" => Some(Self::Prompt), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, FromRepr)] +#[repr(u8)] +pub enum GameLoader { + #[default] + #[serde(rename = "minecraft")] + Vanilla = 0, + Forge = 1, + NeoForge = 2, + Quilt = 3, + Fabric = 4, + LegacyFabric = 5, +} + +impl GameLoader { + pub const fn is_modded(self) -> bool { + !matches!(self, Self::Vanilla) + } + + pub const fn get_format_version(self) -> usize { + match self { + Self::Vanilla => interfrost::api::minecraft::CURRENT_FORMAT_VERSION, + Self::Forge => interfrost::api::modded::CURRENT_FORGE_FORMAT_VERSION, + Self::NeoForge => interfrost::api::modded::CURRENT_NEOFORGE_FORMAT_VERSION, + Self::Quilt => interfrost::api::modded::CURRENT_QUILT_FORMAT_VERSION, + Self::Fabric => interfrost::api::modded::CURRENT_FABRIC_FORMAT_VERSION, + Self::LegacyFabric => interfrost::api::modded::CURRENT_LEGACY_FABRIC_FORMAT_VERSION, + } + } + + pub fn get_format_name(self) -> String { + match self { + Self::Vanilla => String::from("minecraft"), + Self::NeoForge => String::from("neo"), + _ => self.to_string().to_lowercase().replace(['_', '.', ' '], ""), + } + } + + pub const fn modded_loaders() -> &'static [Self] { + &[Self::Forge, Self::NeoForge, Self::Quilt, Self::Fabric] + } + + pub fn compatible_with(self, other: Self) -> bool { + match self { + Self::Vanilla => other == Self::Vanilla, + Self::Forge => other == Self::Forge, + Self::NeoForge => other == Self::NeoForge, + Self::Quilt => matches!(other, Self::Quilt | Self::Fabric), + Self::Fabric => other == Self::Fabric, + Self::LegacyFabric => other == Self::LegacyFabric, + } + } + + pub fn modrinth_name(self) -> &'static str { + match self { + Self::Vanilla => "minecraft", + Self::Forge => "forge", + Self::NeoForge => "neoforge", + Self::Quilt => "quilt", + Self::Fabric => "fabric", + Self::LegacyFabric => "legacy-fabric", + } + } +} + +impl FromStr for GameLoader { + type Err = String; + + fn from_str(s: &str) -> Result { + Ok( + match s.to_lowercase().replace(['_', '.', ' '], "").as_str() { + "vanilla" | "minecraft" => Self::Vanilla, + "forge" => Self::Forge, + "neoforge" | "neo" => Self::NeoForge, + "quilt" | "quiltloader" => Self::Quilt, + "fabric" | "fabricloader" => Self::Fabric, + "legacyfabric" => Self::LegacyFabric, + _ => return Err(format!("'{s}' is not a valid game loader")), + }, + ) + } +} + +impl Display for GameLoader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Vanilla => "Vanilla", + Self::Forge => "Forge", + Self::NeoForge => "NeoForge", + Self::Quilt => "Quilt", + Self::Fabric => "Fabric", + Self::LegacyFabric => "Legacy Fabric", + }) + } +} + +/// Shared by the settings profile (which stores it) and the launch-argument +/// builder (which turns it into `--width`/`--height`) +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct Resolution { + pub width: u32, + pub height: u32, +} + +impl Default for Resolution { + fn default() -> Self { + Self::new(854, 480) + } +} + +impl Resolution { + #[must_use] + pub const fn new(width: u32, height: u32) -> Self { + Self { width, height } + } +} diff --git a/packages/oneclient_common/src/error.rs b/packages/oneclient_common/src/error.rs new file mode 100644 index 00000000..ef1fbcc8 --- /dev/null +++ b/packages/oneclient_common/src/error.rs @@ -0,0 +1,8 @@ +pub type PathsResult = Result; + +/// Kept separate from the launcher-wide error so this crate stays a leaf +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum PathsError { + #[error("Could not resolve launcher data directory")] + DataDirUnavailable, +} diff --git a/packages/oneclient_common/src/lib.rs b/packages/oneclient_common/src/lib.rs new file mode 100644 index 00000000..68674c91 --- /dev/null +++ b/packages/oneclient_common/src/lib.rs @@ -0,0 +1,21 @@ +//! Leaf crate must never depend on the database network stack or any +//! launcher subsystem or the dependency graph cycles back into a monolith + +pub mod constants; +pub mod domain; +pub mod os_ext; +pub mod patch; +pub mod paths; +pub mod search; +pub mod version; + +mod error; + +pub use domain::{ + ContentType, GameLoader, HashAlgorithm, PackageUpdateMode, ProviderId, Resolution, +}; +pub use error::{PathsError, PathsResult}; +pub use os_ext::OsExt; +pub use patch::Patch; +pub use search::{MatchScore, SearchQuery, normalize_query}; +pub use version::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version}; diff --git a/packages/oneclient_common/src/os_ext.rs b/packages/oneclient_common/src/os_ext.rs new file mode 100644 index 00000000..a71f1cdf --- /dev/null +++ b/packages/oneclient_common/src/os_ext.rs @@ -0,0 +1,53 @@ +use interfrost::api::minecraft::Os; + +pub trait OsExt { + fn native() -> Self; + fn native_arch(java_arch: &str) -> Self; + fn get_os(&self) -> Self; +} + +impl OsExt for Os { + fn native() -> Self { + match std::env::consts::OS { + "windows" => Self::Windows, + "macos" => Self::Osx, + "linux" => Self::Linux, + _ => Self::Unknown, + } + } + + fn native_arch(java_arch: &str) -> Self { + if std::env::consts::OS == "windows" { + if java_arch == "aarch64" { + Self::WindowsArm64 + } else { + Self::Windows + } + } else if std::env::consts::OS == "linux" { + if java_arch == "aarch64" { + Self::LinuxArm64 + } else if java_arch == "arm" { + Self::LinuxArm32 + } else { + Self::Linux + } + } else if std::env::consts::OS == "macos" { + if java_arch == "aarch64" { + Self::OsxArm64 + } else { + Self::Osx + } + } else { + Self::Unknown + } + } + + fn get_os(&self) -> Self { + match self { + Self::OsxArm64 => Self::Osx, + Self::LinuxArm32 | Self::LinuxArm64 => Self::Linux, + Self::WindowsArm64 => Self::Windows, + _ => self.clone(), + } + } +} diff --git a/packages/oneclient_common/src/patch.rs b/packages/oneclient_common/src/patch.rs new file mode 100644 index 00000000..5108fe38 --- /dev/null +++ b/packages/oneclient_common/src/patch.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum Patch { + #[default] + Unchanged, + Clear, + Set(T), +} + +impl Patch { + pub fn is_unchanged(&self) -> bool { + matches!(self, Self::Unchanged) + } + + pub fn apply_to_option(&self, slot: &mut Option) + where + T: Clone, + { + match self { + Self::Unchanged => {} + Self::Clear => *slot = None, + Self::Set(value) => *slot = Some(value.clone()), + } + } + + pub fn into_db_patch(self) -> Option> { + match self { + Self::Unchanged => None, + Self::Clear => Some(None), + Self::Set(value) => Some(Some(value)), + } + } +} + +impl Patch { + pub fn apply_to_command_option(&self, slot: &mut Option) { + match self { + Self::Unchanged => {} + Self::Clear => *slot = None, + Self::Set(value) => *slot = Some(value.trim().to_owned()), + } + } +} + +impl Patch<&str> { + pub fn apply_to_command_option(&self, slot: &mut Option) { + match self { + Self::Unchanged => {} + Self::Clear => *slot = None, + Self::Set(value) => *slot = Some(value.trim().to_owned()), + } + } +} diff --git a/packages/oneclient_common/src/paths.rs b/packages/oneclient_common/src/paths.rs new file mode 100644 index 00000000..09de0163 --- /dev/null +++ b/packages/oneclient_common/src/paths.rs @@ -0,0 +1,152 @@ +use directories::ProjectDirs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use crate::domain::{ContentType, ProviderId}; +use crate::error::{PathsError, PathsResult}; + +const QUALIFIER: &str = "org"; +const ORGANIZATION: &str = "Polyfrost"; + +#[cfg(not(debug_assertions))] +const APPLICATION: &str = "OneClient"; +// Separate dir so a running dev environment never touches prod data +#[cfg(debug_assertions)] +const APPLICATION: &str = "OneClient-dev"; + +static PROJECT_DIRS: OnceLock = OnceLock::new(); +static LAUNCHER_DIR_OVERRIDE: OnceLock = OnceLock::new(); + +pub fn set_launcher_dir(dir: PathBuf) { + let _ = LAUNCHER_DIR_OVERRIDE.set(dir); +} + +fn project_dirs() -> PathsResult<&'static ProjectDirs> { + if let Some(dirs) = PROJECT_DIRS.get() { + return Ok(dirs); + } + + let dirs = ProjectDirs::from(QUALIFIER, ORGANIZATION, APPLICATION) + .ok_or(PathsError::DataDirUnavailable)?; + + let _ = PROJECT_DIRS.set(dirs); + PROJECT_DIRS.get().ok_or(PathsError::DataDirUnavailable) +} + +pub fn launcher_dir() -> PathsResult<&'static Path> { + if let Some(dir) = LAUNCHER_DIR_OVERRIDE.get() { + return Ok(dir); + } + + Ok(project_dirs()?.data_local_dir()) +} + +pub fn database_file() -> PathsResult { + Ok(launcher_dir()?.join("user_data.db")) +} + +pub fn settings_file() -> PathsResult { + Ok(launcher_dir()?.join("settings.json")) +} + +pub fn auth_file() -> PathsResult { + Ok(launcher_dir()?.join("auth.json")) +} + +pub fn logs_dir() -> PathsResult { + Ok(launcher_dir()?.join("logs")) +} + +pub fn java_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("java")) +} + +pub fn clusters_dir() -> PathsResult { + Ok(launcher_dir()?.join("clusters")) +} + +pub fn shared_minecraft_dir() -> PathsResult { + Ok(launcher_dir()?.join(".minecraft")) +} + +/// Presence marks a cluster folder as its own game dir instead of the shared `.minecraft` +pub const DEDICATED_MARKER: &str = ".dedicated_directory"; + +pub fn cluster_dir(folder_name: &str) -> PathsResult { + Ok(clusters_dir()?.join(folder_name)) +} + +pub fn cluster_uses_dedicated_dir(folder_name: &str) -> bool { + cluster_dir(folder_name).is_ok_and(|dir| dir.join(DEDICATED_MARKER).exists()) +} + +/// Lives here not on `Cluster` so the content layer can resolve it from a bare +/// `ClusterRow` without duplicating the marker-file rule +pub fn cluster_game_dir(folder_name: &str) -> PathsResult { + if cluster_uses_dedicated_dir(folder_name) { + cluster_dir(folder_name) + } else { + shared_minecraft_dir() + } +} + +pub fn packages_cache_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("packages")) +} + +pub fn caches_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("caches")) +} + +pub fn bundles_dir() -> PathsResult { + Ok(caches_dir()?.join("bundles")) +} + +pub fn images_cache_dir() -> PathsResult { + Ok(caches_dir()?.join("images")) +} + +pub fn profiles_cache_dir() -> PathsResult { + Ok(caches_dir()?.join("profiles")) +} + +pub fn versions_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("versions")) +} + +pub fn libraries_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("libraries")) +} + +pub fn natives_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("natives")) +} + +pub fn assets_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("assets")) +} + +pub fn assets_index_dir() -> PathsResult { + Ok(assets_dir()?.join("indexes")) +} + +pub fn assets_object_dir() -> PathsResult { + Ok(assets_dir()?.join("objects")) +} + +pub fn legacy_assets_dir() -> PathsResult { + Ok(launcher_dir()?.join("metadata").join("resources")) +} + +pub fn package_version_dir( + content_type: ContentType, + provider: ProviderId, + project_id: &str, + version_id: &str, +) -> PathsResult { + Ok(packages_cache_dir()? + .join(content_type.folder_name()) + .join(provider.dir_name()) + .join(project_id) + .join(version_id)) +} diff --git a/packages/oneclient_common/src/search.rs b/packages/oneclient_common/src/search.rs new file mode 100644 index 00000000..0c496e5e --- /dev/null +++ b/packages/oneclient_common/src/search.rs @@ -0,0 +1,352 @@ +//! Local surfaces use the scoring here surfaces backed by a remote provider +//! take only the normalised text since ranking their results is their job + +/// Trims and collapses internal whitespace runs so spacing variants share one +/// provider request cache key and filter result +#[must_use] +pub fn normalize_query(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for word in raw.split_whitespace() { + if !out.is_empty() { + out.push(' '); + } + out.push_str(word); + } + out +} + +/// Ordered worst to best +/// The tier dominates the within-tier bonus can never lift a weaker kind of +/// match above a stronger one +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct MatchScore(u32); + +impl MatchScore { + /// Awarded to every candidate by an empty query preserving the caller's order + pub const NEUTRAL: Self = Self(0); +} + +/// Tier floors +/// The gap between them is wider than any bonus below can reach +const TIER_EXACT: u32 = 5_000; +const TIER_PREFIX: u32 = 4_000; +const TIER_WORD_PREFIX: u32 = 3_000; +const TIER_SUBSTRING: u32 = 2_000; +const TIER_SUBSEQUENCE: u32 = 1_000; +const TIER_TYPO: u32 = 100; + +/// Ceiling for the within-tier bonus comfortably below the 1000-wide tier gap +const BONUS_MAX: u32 = 900; + +/// Below this many characters a typo budget would match almost anything so +/// short terms are held to the exact tiers +const MIN_TYPO_LEN: usize = 4; + +/// Normalised once so matching a list of candidates doesn't redo the work per row +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SearchQuery { + text: String, + terms: Vec, +} + +impl SearchQuery { + #[must_use] + pub fn new(raw: &str) -> Self { + let text = normalize_query(raw).to_lowercase(); + let terms = text + .split(' ') + .filter(|t| !t.is_empty()) + .map(str::to_string) + .collect(); + Self { text, terms } + } + + /// Safe as a cache key inputs differing only in spacing produce the same text + #[must_use] + pub fn as_str(&self) -> &str { + &self.text + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + + /// `None` if it doesn't match + /// An empty query matches everything at [`MatchScore::NEUTRAL`] + #[must_use] + pub fn score(&self, haystack: &str) -> Option { + if self.is_empty() { + return Some(MatchScore::NEUTRAL); + } + + let hay = normalize_query(haystack).to_lowercase(); + if hay.is_empty() { + return None; + } + + if let Some(score) = score_whole(&hay, &self.text) { + return Some(score); + } + + // Per-word fallback lets word order differ keeps one typo from sinking + // the query and gives the typo budget a word rather than a whole name + let mut worst = u32::MAX; + for term in &self.terms { + let best = hay + .split(' ') + .filter_map(|word| score_whole(word, term)) + .max() + .or_else(|| score_whole(&hay, term))?; + worst = worst.min(best.0); + } + // A per-word match must never outrank the tier it matched at + Some(MatchScore(worst.saturating_sub(1))) + } + + #[must_use] + pub fn best_score<'a>(&self, fields: impl IntoIterator) -> Option { + fields.into_iter().filter_map(|f| self.score(f)).max() + } + + #[must_use] + pub fn matches(&self, haystack: &str) -> bool { + self.score(haystack).is_some() + } +} + +/// Both inputs must already be normalised and lowercased +fn score_whole(hay: &str, needle: &str) -> Option { + if hay == needle { + return Some(MatchScore(TIER_EXACT)); + } + if hay.starts_with(needle) { + return Some(MatchScore(TIER_PREFIX + coverage(hay, needle))); + } + if let Some(at) = hay.find(needle) { + // A word-boundary hit reads as intentional mid-word is closer to coincidence + let word_start = hay[..at].ends_with(' '); + let tier = if word_start { + TIER_WORD_PREFIX + } else { + TIER_SUBSTRING + }; + return Some(MatchScore(tier + coverage(hay, needle))); + } + if let Some(span) = subsequence_span(hay, needle) { + // Tightness not just length sets the bonus a scattered subsequence is weaker + let tightness = scale(needle.chars().count(), span); + return Some(MatchScore(TIER_SUBSEQUENCE + tightness)); + } + + let needle_len = needle.chars().count(); + if needle_len < MIN_TYPO_LEN { + return None; + } + let budget = typo_budget(needle_len); + let distance = osa_distance(hay, needle, budget)?; + // Fewer typos forgiven ranks higher + let bonus = BONUS_MAX.saturating_sub((distance as u32) * (BONUS_MAX / (budget as u32 + 1))); + Some(MatchScore(TIER_TYPO + bonus)) +} + +/// Within-tier bonus for how much of the candidate the query accounts for +fn coverage(hay: &str, needle: &str) -> u32 { + scale(needle.chars().count(), hay.chars().count()) +} + +fn scale(part: usize, whole: usize) -> u32 { + if whole == 0 { + return 0; + } + let ratio = (part.min(whole) as u64 * u64::from(BONUS_MAX)) / whole as u64; + ratio as u32 +} + +/// Letters a term of this length may get wrong +/// Deliberately stingy a generous budget makes every query match everything +fn typo_budget(len: usize) -> usize { + if len >= 8 { 2 } else { 1 } +} + +/// Width of the tightest window of `hay` holding `needle`'s chars in order or +/// `None` if not a subsequence +/// Covers dropped/doubled-letter typos +fn subsequence_span(hay: &str, needle: &str) -> Option { + let hay: Vec = hay.chars().collect(); + let needle: Vec = needle.chars().collect(); + if needle.is_empty() || needle.len() > hay.len() { + return None; + } + + // Earliest end then latest start walking back from it the tightest window + let mut n = 0; + let mut end = None; + for (i, c) in hay.iter().enumerate() { + if *c == needle[n] { + n += 1; + if n == needle.len() { + end = Some(i); + break; + } + } + } + let end = end?; + + let mut n = needle.len(); + let mut start = 0; + for i in (0..=end).rev() { + if hay[i] == needle[n - 1] { + n -= 1; + if n == 0 { + start = i; + break; + } + } + } + Some(end - start + 1) +} + +/// Optimal string alignment distance giving up once it exceeds `max` +/// Unlike Levenshtein a transposition costs one edit +fn osa_distance(a: &str, b: &str, max: usize) -> Option { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + if a.len().abs_diff(b.len()) > max { + return None; + } + + // Three rows rotate through these buffers `two_ago` is only read from row + // two onwards by which point it holds real values + let mut two_ago: Vec = vec![0; b.len() + 1]; + let mut prev: Vec = (0..=b.len()).collect(); + let mut cur: Vec = vec![0; b.len() + 1]; + + for i in 1..=a.len() { + cur[0] = i; + let mut row_min = cur[0]; + for j in 1..=b.len() { + let cost = usize::from(a[i - 1] != b[j - 1]); + let mut best = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost); + if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] { + best = best.min(two_ago[j - 2] + 1); + } + cur[j] = best; + row_min = row_min.min(best); + } + // Every later row is at least this large so the budget is unrecoverable + if row_min > max { + return None; + } + std::mem::swap(&mut two_ago, &mut prev); + std::mem::swap(&mut prev, &mut cur); + } + + (prev[b.len()] <= max).then_some(prev[b.len()]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn best(query: &str, candidates: &[&str]) -> Vec { + let q = SearchQuery::new(query); + let mut scored: Vec<(MatchScore, &str)> = candidates + .iter() + .filter_map(|c| q.score(c).map(|s| (s, *c))) + .collect(); + scored.sort_by_key(|(s, c)| (std::cmp::Reverse(*s), *c)); + scored.into_iter().map(|(_, c)| c.to_string()).collect() + } + + #[test] + fn whitespace_never_changes_a_query() { + let canonical = SearchQuery::new("sodium extra"); + for raw in [ + " sodium extra", + "sodium extra ", + "sodium extra", + "\tsodium\nextra ", + ] { + assert_eq!(SearchQuery::new(raw), canonical, "{raw:?}"); + } + assert_eq!(canonical.as_str(), "sodium extra"); + } + + #[test] + fn a_blank_query_is_empty() { + assert!(SearchQuery::new(" ").is_empty()); + assert_eq!( + SearchQuery::new("").score("anything"), + Some(MatchScore::NEUTRAL) + ); + } + + #[test] + fn common_typos_still_find_the_package() { + for typo in ["sodum", "soduim", "sodiun", "sodiium"] { + assert!(SearchQuery::new(typo).matches("Sodium"), "{typo}"); + } + } + + #[test] + fn short_terms_are_not_fuzzed() { + assert!(!SearchQuery::new("abc").matches("Sodium")); + } + + #[test] + fn unrelated_names_do_not_match() { + assert!(!SearchQuery::new("sodium").matches("Journeymap")); + } + + #[test] + fn better_kinds_of_match_rank_first() { + assert_eq!( + best( + "sodium", + &[ + "Sodium", + "Sodium Extra", + "Indium (Sodium addon)", + "Sodum Fork", + "Sodiun" + ] + ), + [ + "Sodium", + "Sodium Extra", + "Indium (Sodium addon)", + "Sodiun", + "Sodum Fork", + ] + ); + } + + #[test] + fn a_tight_subsequence_beats_a_scattered_one() { + assert_eq!( + best("srr", &["Sodium Rendering Regression", "Sorry"]), + ["Sorry", "Sodium Rendering Regression"] + ); + } + + #[test] + fn multi_word_queries_match_out_of_order() { + let q = SearchQuery::new("extra sodium"); + assert!(q.matches("Sodium Extra")); + assert!(!q.matches("Sodium")); + } + + #[test] + fn a_typo_in_one_word_does_not_sink_the_query() { + assert!(SearchQuery::new("sodum extra").matches("Sodium Extra")); + } + + #[test] + fn best_score_picks_the_strongest_field() { + let q = SearchQuery::new("sodium"); + let whole = q.score("Sodium").unwrap(); + assert_eq!(q.best_score(["Fabric API", "Sodium"]), Some(whole)); + assert_eq!(q.best_score(["Fabric API", "fabric-api.jar"]), None); + } +} diff --git a/packages/oneclient_common/src/version.rs b/packages/oneclient_common/src/version.rs new file mode 100644 index 00000000..291f70b6 --- /dev/null +++ b/packages/oneclient_common/src/version.rs @@ -0,0 +1,132 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParsedMcVersion { + pub major: u32, + pub minor: Option, + pub patch: Option, +} + +impl ParsedMcVersion { + #[must_use] + pub fn key(&self) -> Option { + Some((self.minor?, self.patch)) + } +} + +pub type VersionKey = (u32, Option); + +pub fn parse_mc_version(version: &str) -> Option { + let parts: Vec<&str> = version.split('.').collect(); + + if parts.first() != Some(&"1") { + if parts.len() < 2 { + return None; + } + let major = parts[0].parse().ok()?; + let minor = parts[1].parse().ok()?; + let patch = match parts.get(2) { + Some(p) => Some(p.parse().ok()?), + None => None, + }; + return Some(ParsedMcVersion { + major, + minor: Some(minor), + patch, + }); + } + + if parts.len() <= 1 { + return None; + } + + let major = parts[1].parse().ok()?; + let minor = parts.get(2).and_then(|p| p.parse().ok()); + + Some(ParsedMcVersion { + major, + minor, + patch: None, + }) +} + +#[must_use] +pub fn format_mc_version(major: u32, minor: u32, patch: Option) -> String { + let base = if major >= 26 { + format!("{major}.{minor}") + } else { + format!("1.{major}.{minor}") + }; + + match patch { + Some(patch) => format!("{base}.{patch}"), + None => base, + } +} + +#[must_use] +pub fn normalize_mc_version_input(version: &str) -> String { + version.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_modern_versions() { + let v = parse_mc_version("26.1").unwrap(); + assert_eq!(v.major, 26); + assert_eq!(v.minor, Some(1)); + assert_eq!(v.patch, None); + + let v = parse_mc_version("26.2").unwrap(); + assert_eq!(v.major, 26); + assert_eq!(v.minor, Some(2)); + assert_eq!(v.patch, None); + } + + #[test] + fn parse_modern_patch_versions() { + let v = parse_mc_version("26.1.2").unwrap(); + assert_eq!(v.major, 26); + assert_eq!(v.minor, Some(1)); + assert_eq!(v.patch, Some(2)); + } + + #[test] + fn parse_legacy_versions() { + let v = parse_mc_version("1.21.5").unwrap(); + assert_eq!(v.major, 21); + assert_eq!(v.minor, Some(5)); + assert_eq!(v.patch, None); + } + + #[test] + fn reject_garbage_patch_component() { + assert!(parse_mc_version("26.1.x").is_none()); + } + + #[test] + fn format_versions() { + assert_eq!(format_mc_version(26, 1, None), "26.1"); + assert_eq!(format_mc_version(26, 1, Some(2)), "26.1.2"); + assert_eq!(format_mc_version(21, 5, None), "1.21.5"); + } + + #[test] + fn format_round_trips_through_parse() { + for (major, minor, patch) in [(26, 1, None), (26, 1, Some(2)), (21, 5, None)] { + let text = format_mc_version(major, minor, patch); + let parsed = parse_mc_version(&text).unwrap(); + assert_eq!(parsed.major, major); + assert_eq!(parsed.minor, Some(minor)); + assert_eq!(parsed.patch, patch); + } + } + + #[test] + fn patch_sorts_after_bare_minor() { + let bare = parse_mc_version("26.1").unwrap().key().unwrap(); + let patched = parse_mc_version("26.1.2").unwrap().key().unwrap(); + assert!(bare < patched); + } +} diff --git a/packages/oneclient_content/Cargo.toml b/packages/oneclient_content/Cargo.toml new file mode 100644 index 00000000..ae88b929 --- /dev/null +++ b/packages/oneclient_content/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "oneclient_content" +description = "Mod providers, the artifact store, and Polyfrost bundles" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_db.workspace = true +oneclient_events.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +async-trait.workspace = true +async_zip.workspace = true +futures-lite.workspace = true +futures-util.workspace = true +tokio.workspace = true + +# HTML package descriptions -> markdown +htmd.workspace = true + +chrono.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true } +polyio = { workspace = true, features = ["testing"] } diff --git a/packages/oneclient_content/src/bundles/error.rs b/packages/oneclient_content/src/bundles/error.rs new file mode 100644 index 00000000..7a785d74 --- /dev/null +++ b/packages/oneclient_content/src/bundles/error.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum BundleError { + #[error("unknown loader '{0}' in bundles manifest")] + UnknownLoader(String), + + #[error("invalid loader value '{0}' in bundles manifest")] + InvalidLoader(i64), + + #[error("bundle path has no file name: {0}")] + InvalidPath(String), + + #[error("failed to parse polymrpack manifest")] + InvalidManifest, + + #[error("bundle file missing at {0}")] + MissingFile(String), + + #[error("bundle not found: {0}")] + NotFound(String), +} diff --git a/packages/oneclient_content/src/bundles/install.rs b/packages/oneclient_content/src/bundles/install.rs new file mode 100644 index 00000000..3f43b4ce --- /dev/null +++ b/packages/oneclient_content/src/bundles/install.rs @@ -0,0 +1,885 @@ +use futures_util::StreamExt; +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::cluster_bundle as bundle_dao; +use oneclient_db::models::ClusterRow; +use oneclient_db::models::OverrideType; + +use crate::bundles::error::BundleError; +use crate::bundles::manager::BundlesManager; +use crate::bundles::overrides; +use crate::bundles::types::{BundleArchive, BundleFile, BundleFileKind}; +use crate::ctx::ContentCtx; +use crate::error::ContentError; +use crate::error::ContentResult; +use crate::packages::store::{PackageStore, evict_if_unused, try_unlink_materialized}; +use crate::packages::types::ExternalFile; +use oneclient_common::domain::{ContentType, GameLoader}; +use oneclient_events::{GroupedProgressChild, GroupedProgressSession, TaskCategory, TaskPhase}; + +fn is_base62(s: &str) -> bool { + !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()) +} + +pub fn effective_enabled(file: &BundleFile, user_override: Option) -> bool { + match user_override { + Some(OverrideType::Removed | OverrideType::Disabled) => false, + Some(OverrideType::Enabled) => true, + None => file.enabled, + } +} + +fn find_override( + overrides: &[oneclient_db::models::ClusterBundleOverrideRow], + bundle_name: &str, + package_id: &str, +) -> Option { + overrides + .iter() + .find(|o| o.bundle_name == bundle_name && o.package_id == package_id) + .and_then(|o| OverrideType::parse(&o.override_type)) +} + +/// Searches all bundles +/// a package's `bundle_name` is rewritten on every install so a choice filed +/// under its old bundle still counts +/// `Removed` outranks `Disabled` +/// `Enabled` never suppresses +pub(crate) fn find_user_suppression( + overrides: &[oneclient_db::models::ClusterBundleOverrideRow], + package_id: &str, +) -> Option { + let mut found = None; + + for row in overrides.iter().filter(|o| o.package_id == package_id) { + match OverrideType::parse(&row.override_type) { + Some(OverrideType::Removed) => return Some(OverrideType::Removed), + Some(OverrideType::Disabled) => found = Some(OverrideType::Disabled), + Some(OverrideType::Enabled) | None => {} + } + } + + found +} + +/// Clears across all bundles +/// a row left behind under another bundle would keep answering "off" forever +async fn clear_suppressing_overrides( + cluster_id: i64, + package_id: &str, + ctx: &ContentCtx, +) -> ContentResult<()> { + bundle_dao::clear_suppressing_overrides(&ctx.db, cluster_id, package_id).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(file, child, ctx), fields(package = %file.display_name()))] +pub async fn install_package_from_bundle( + file: &BundleFile, + cluster_id: i64, + bundle_name: &str, + skip_compatibility: bool, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let hash = match &file.kind { + BundleFileKind::Managed { + provider, + project_id, + version_id, + sha1, + } => { + let project = crate::packages::cached_project_detail( + ctx, + *provider, + project_id, + file.content_type(), + ) + .await; + + let version = if is_base62(version_id) { + crate::packages::get_version_cached(ctx, *provider, project_id, version_id).await? + } else if let Ok(Some((_, version))) = ctx.providers.lookup_version(sha1, ctx).await { + version + } else { + crate::packages::get_version_cached(ctx, *provider, project_id, version_id).await? + }; + + let artifact = PackageStore::install_to_cluster( + *provider, + &project, + &version, + cluster_id, + skip_compatibility, + false, + child, + ctx, + ) + .await?; + artifact.hash + } + BundleFileKind::External(ext) => { + install_external(ext, &cluster, skip_compatibility, child, ctx).await? + } + }; + + bundle_dao::track_bundle_artifact( + &ctx.db, + cluster_id, + &hash, + bundle_name, + &file.kind.bundle_version_id(), + &file.kind.package_id(), + ) + .await?; + + Ok(hash) +} + +#[tracing::instrument(level = "debug", skip(ext, cluster, child, ctx), fields(file = %ext.name))] +async fn install_external( + ext: &ExternalFile, + cluster: &ClusterRow, + skip_compatibility: bool, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + let artifact = crate::packages::store::download_external(ext, false, child, ctx).await?; + PackageStore::link_artifact(&artifact, cluster, Some(&ext.name), ctx).await?; + + let _ = skip_compatibility; + Ok(artifact.hash) +} + +#[tracing::instrument(level = "debug", skip(archive, ctx), fields(bundle = %archive.manifest.name))] +pub async fn extract_bundle_overrides_for_cluster( + archive: &BundleArchive, + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult<()> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + overrides::sync_bundle_overrides(&archive.bundle.path, &archive.manifest.name, &cluster, None) + .await?; + Ok(()) +} + +#[tracing::instrument(skip(bundles, ctx))] +pub async fn install_bundle( + cluster_id: i64, + bundle_name: &str, + skip_compatibility: bool, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { + ContentError::InvalidData { + reason: format!("unknown loader {}", cluster.mc_loader), + } + })?; + + let archive = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await? + .into_iter() + .find(|a| a.manifest.name == bundle_name) + .ok_or(BundleError::NotFound(bundle_name.to_string()))?; + + install_enabled_bundle_files(&archive, cluster_id, skip_compatibility, None, ctx).await +} + +/// `suppression` comes from [`find_user_suppression`] so a choice filed under a +/// bundle the file has since left still counts +pub(crate) fn disable_was_deliberate(hidden: bool, suppression: Option) -> bool { + match suppression { + Some(OverrideType::Removed) => true, + // Hidden files are never-shown dependencies + // they follow the bundle so only an outright removal keeps one off + Some(OverrideType::Disabled) => !hidden, + Some(OverrideType::Enabled) | None => false, + } +} + +#[tracing::instrument(level = "debug", skip(archives, ctx))] +pub async fn heal_bundle_activity( + cluster_id: i64, + archives: &[BundleArchive], + ctx: &ContentCtx, +) -> ContentResult<()> { + let tracked = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + if tracked.iter().all(|row| row.enabled != 0) { + return Ok(()); + } + + let mut hidden: std::collections::HashMap<(&str, String), bool> = + std::collections::HashMap::new(); + for archive in archives { + for file in &archive.manifest.files { + hidden.insert( + (archive.manifest.name.as_str(), file.kind.package_id()), + file.hidden, + ); + } + } + + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + + for row in tracked.iter().filter(|row| row.enabled == 0) { + let (Some(bundle_name), Some(package_id)) = (&row.bundle_name, &row.package_id) else { + continue; + }; + let Some(is_hidden) = hidden + .get(&(bundle_name.as_str(), package_id.clone())) + .copied() + else { + continue; + }; + + // Across bundles + // a package that moved keeps its old override row and reading only its + // current bundle would switch it back on + let suppression = find_user_suppression(&overrides, package_id); + if disable_was_deliberate(is_hidden, suppression) { + continue; + } + + tracing::info!( + cluster_id, + bundle = %bundle_name, + package_id, + hidden = is_hidden, + ?suppression, + "re-enabling bundle content that was switched off with nothing recording the choice" + ); + + // One unrepairable row must not fail the whole install + if let Err(err) = + PackageStore::set_artifact_enabled_to(cluster_id, &row.hash, true, ctx).await + { + tracing::warn!(hash = %row.hash, error = %err, "failed to re-enable bundle content"); + continue; + } + + // Drop the stale override everywhere or a copy under another bundle + // keeps answering "off" and the two records never settle + if suppression == Some(OverrideType::Disabled) { + clear_suppressing_overrides(cluster_id, package_id, ctx).await?; + } + } + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(archive, progress, ctx), fields(bundle = %archive.manifest.name))] +pub async fn install_enabled_bundle_files( + archive: &BundleArchive, + cluster_id: i64, + skip_compatibility: bool, + progress: Option<&GroupedProgressSession>, + ctx: &ContentCtx, +) -> ContentResult> { + extract_bundle_overrides_for_cluster(archive, cluster_id, ctx).await?; + heal_bundle_activity(cluster_id, std::slice::from_ref(archive), ctx).await?; + + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let bundle_name = archive.manifest.name.clone(); + let mut installed = Vec::new(); + + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let mut linked_projects: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut linked_hashes: std::collections::HashSet<&str> = std::collections::HashSet::new(); + // "Already installed" means the database not disk + // content lives in the cache between sessions so probing the folder would + // reinstall everything + for info in &linked { + if let Some(pid) = &info.project_id { + linked_projects.insert(pid.as_str()); + } + linked_hashes.insert(info.hash.as_str()); + } + + let to_install: Vec = archive + .manifest + .files + .iter() + .filter(|file| { + let package_id = file.kind.package_id(); + if !effective_enabled(file, find_override(&overrides, &bundle_name, &package_id)) { + return false; + } + let already_installed = match &file.kind { + BundleFileKind::Managed { project_id, .. } => { + linked_projects.contains(project_id.as_str()) + } + BundleFileKind::External(ext) => linked_hashes.contains(ext.sha1.as_str()), + }; + !already_installed + }) + .cloned() + .collect(); + + tracing::info!( + cluster_id, + bundle = %bundle_name, + to_install = to_install.len(), + "installing enabled bundle files" + ); + + if let Some(p) = progress { + let reserved_bytes: u64 = to_install.iter().map(|f| f.size.max(1)).sum(); + p.expect( + TaskCategory::Packages, + to_install.len() as u64, + reserved_bytes, + ); + } + + let bundle_name = &bundle_name; + let results = futures_util::stream::iter(to_install.into_iter().map(|file| async move { + let child = progress.map(|p| { + let c = p.child( + format!("Mod {}", file.display_name()), + file.size.max(1), + oneclient_events::TaskCategory::Packages, + ); + c.set_phase(TaskPhase::Downloading); + c + }); + + let result = install_package_from_bundle( + &file, + cluster_id, + bundle_name, + skip_compatibility, + child.as_ref(), + ctx, + ) + .await; + + if let Some(child) = child { + child.set_phase(TaskPhase::Installing); + child.finish(); + } + (file.display_name(), result) + })) + .buffer_unordered(BUNDLE_INSTALL_CONCURRENCY) + .collect::>() + .await; + + for (name, result) in results { + match result { + Ok(hash) => installed.push(hash), + Err(err) => { + tracing::warn!(file = %name, error = %err, "failed to install bundle file"); + } + } + } + + Ok(installed) +} + +/// Kept modest each fetch also costs a provider API call rate limited per-minute +pub(crate) const BUNDLE_INSTALL_CONCURRENCY: usize = 6; + +#[tracing::instrument(level = "debug", skip(bundles, ctx))] +pub async fn enabled_bundle_bytes( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { + ContentError::InvalidData { + reason: format!("unknown loader {}", cluster.mc_loader), + } + })?; + + let archives = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await?; + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + + let mut linked_projects: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut linked_hashes: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for info in &linked { + if let Some(pid) = &info.project_id { + linked_projects.insert(pid.as_str()); + } + linked_hashes.insert(info.hash.as_str()); + } + + let mut total = 0u64; + for archive in &archives { + let bundle_name = &archive.manifest.name; + for file in &archive.manifest.files { + let package_id = file.kind.package_id(); + if !effective_enabled(file, find_override(&overrides, bundle_name, &package_id)) { + continue; + } + let already_installed = match &file.kind { + BundleFileKind::Managed { project_id, .. } => { + linked_projects.contains(project_id.as_str()) + } + BundleFileKind::External(ext) => linked_hashes.contains(ext.sha1.as_str()), + }; + if already_installed { + continue; + } + total += file.size; + } + } + + Ok(total) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn set_bundle_package_override( + cluster_id: i64, + bundle_name: &str, + package_id: &str, + override_type: Option, + ctx: &ContentCtx, +) -> ContentResult<()> { + match override_type { + // The UI shows one row per package across bundles so an objection left + // under another bundle would let the next pass undo this switch + Some(ty @ OverrideType::Enabled) => { + clear_suppressing_overrides(cluster_id, package_id, ctx).await?; + bundle_dao::save_override(&ctx.db, cluster_id, bundle_name, package_id, ty).await?; + } + Some(ty) => { + bundle_dao::save_override(&ctx.db, cluster_id, bundle_name, package_id, ty).await?; + } + None => { + bundle_dao::remove_override(&ctx.db, cluster_id, bundle_name, package_id).await?; + } + } + + Ok(()) +} + +/// For bundle files the cluster has not installed +/// installed ones go through [`toggle_artifact_enabled`] +/// Matching the manifest default clears the override +/// switching *on* also drops objections filed under other bundles +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn set_bundle_package_enabled( + cluster_id: i64, + bundle_name: &str, + package_id: &str, + enabled: bool, + manifest_default: bool, + ctx: &ContentCtx, +) -> ContentResult<()> { + let override_type = match (enabled, manifest_default) { + (true, true) | (false, false) => None, + (true, false) => Some(OverrideType::Enabled), + (false, true) => Some(OverrideType::Disabled), + }; + + set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, ctx).await?; + + if enabled { + clear_suppressing_overrides(cluster_id, package_id, ctx).await?; + } + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(overrides, ctx), fields(count = overrides.len()))] +pub async fn set_bundle_package_overrides( + cluster_id: i64, + overrides: &[(String, String, OverrideType)], + ctx: &ContentCtx, +) -> ContentResult<()> { + bundle_dao::save_overrides(&ctx.db, cluster_id, overrides).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn set_bundle_package_opt_in( + cluster_id: i64, + bundle_name: &str, + package_id: &str, + opted_in: bool, + ctx: &ContentCtx, +) -> ContentResult<()> { + let override_type = if opted_in { + Some(OverrideType::Enabled) + } else { + None + }; + set_bundle_package_override(cluster_id, bundle_name, package_id, override_type, ctx).await +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn list_cluster_bundle_overrides( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + let rows = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + Ok(rows + .into_iter() + .map(|o| (o.bundle_name, o.package_id, o.override_type)) + .collect()) +} + +#[tracing::instrument(skip(bundles, progress, ctx))] +pub async fn install_cluster_bundles( + cluster_id: i64, + bundles: &BundlesManager, + progress: Option<&GroupedProgressSession>, + ctx: &ContentCtx, +) -> ContentResult<()> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { + ContentError::InvalidData { + reason: format!("unknown loader {}", cluster.mc_loader), + } + })?; + + let archives = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await?; + tracing::info!( + cluster_id, + mc_version = %cluster.mc_version, + bundles = archives.len(), + "installing enabled bundle content" + ); + for archive in &archives { + let installed = + install_enabled_bundle_files(archive, cluster_id, true, progress, ctx).await?; + tracing::info!( + cluster_id, + bundle = %archive.manifest.name, + installed = installed.len(), + "installed bundle files" + ); + } + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn on_user_remove_artifact( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult<()> { + handle_user_artifact_action(cluster_id, hash, ctx, OverrideType::Removed).await +} + +#[tracing::instrument(level = "debug", skip(ctx))] +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn toggle_artifact_enabled( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult { + let enabled = PackageStore::set_artifact_enabled(cluster_id, hash, ctx).await?; + + if enabled { + on_user_enable_artifact(cluster_id, hash, ctx).await?; + } else { + on_user_disable_artifact(cluster_id, hash, ctx).await?; + } + + Ok(enabled) +} + +/// Prefer this over [`toggle_artifact_enabled`] unless the current value is +/// known to be the opposite +/// a relinked artifact keeps its old `enabled` so a flip on an already-correct +/// row puts it wrong +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn set_artifact_enabled_to( + cluster_id: i64, + hash: &str, + enabled: bool, + ctx: &ContentCtx, +) -> ContentResult<()> { + PackageStore::set_artifact_enabled_to(cluster_id, hash, enabled, ctx).await?; + + if enabled { + on_user_enable_artifact(cluster_id, hash, ctx).await + } else { + on_user_disable_artifact(cluster_id, hash, ctx).await + } +} + +/// Writes the override alongside the flag +/// without it the losing bundle copy looks disabled-by-nobody and +/// heal_bundle_activity re-enables it every launch +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn reconcile_duplicate_activity(cluster_id: i64, ctx: &ContentCtx) -> ContentResult<()> { + for hash in crate::packages::reconcile_duplicate_activity(cluster_id, ctx).await? { + on_user_disable_artifact(cluster_id, &hash, ctx).await?; + } + + Ok(()) +} + +pub async fn on_user_disable_artifact( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult<()> { + handle_user_artifact_action(cluster_id, hash, ctx, OverrideType::Disabled).await +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn on_user_enable_artifact( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult<()> { + if let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await? + && let Some(package_id) = tracked.package_id + { + clear_suppressing_overrides(cluster_id, &package_id, ctx).await?; + } + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +async fn handle_user_artifact_action( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, + override_type: OverrideType, +) -> ContentResult<()> { + let Some(tracked) = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await? else { + return Ok(()); + }; + + let (Some(bundle_name), Some(package_id)) = (tracked.bundle_name, tracked.package_id) else { + return Ok(()); + }; + + bundle_dao::save_override( + &ctx.db, + cluster_id, + &bundle_name, + &package_id, + override_type, + ) + .await?; + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn remove_artifact_from_cluster( + cluster_id: i64, + hash: &str, + record_override: bool, + ctx: &ContentCtx, +) -> ContentResult<()> { + let bundle_data = bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, hash).await?; + + // Looked up first but never allowed to block removal + // a package whose artifact row has gone missing must still be removable + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let target = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .and_then(|artifact| ContentType::from_repr(artifact.content_type as u8)); + + let link = artifact_dao::list_cluster_artifacts(&ctx.db, cluster_id) + .await? + .into_iter() + .find(|l| l.hash == hash); + + // Database first unconditionally + // on Windows a jar held open by a running game blocks deleting every hard + // link which used to fail the whole removal + // The folder is rebuilt from the database at the next launch + artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; + + // Best-effort folder cleanup failure here is not an error + if let (Some(content_type), Some(link)) = (target, link) { + try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; + } + + // The package actually lives in the cache + // `evict_if_unused` drops it only once no other cluster still needs it + if let Err(err) = evict_if_unused(hash, ctx).await { + tracing::warn!(hash, error = %err, "failed to evict unused artifact from the cache"); + } + + if let Some(tracked) = bundle_data + && let (Some(bundle_name), Some(package_id)) = + (tracked.bundle_name.clone(), tracked.package_id.clone()) + { + if record_override { + bundle_dao::save_override( + &ctx.db, + cluster_id, + &bundle_name, + &package_id, + OverrideType::Removed, + ) + .await?; + } else { + let replacement_exists = + bundle_dao::has_bundle_mapping_for_package(&ctx.db, cluster_id, &package_id) + .await?; + if !replacement_exists { + clear_suppressing_overrides(cluster_id, &package_id, ctx).await?; + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use oneclient_common::domain::ContentType; + use oneclient_db::models::ClusterBundleOverrideRow; + + fn file(enabled: bool) -> BundleFile { + BundleFile { + enabled, + hidden: false, + path: "mods/example.jar".to_string(), + size: 1, + kind: BundleFileKind::External(ExternalFile { + name: "example.jar".to_string(), + url: "https://example.invalid/example.jar".to_string(), + sha1: "abc123".to_string(), + size: 1, + content_type: ContentType::Mod, + }), + } + } + + #[test] + fn override_none_falls_back_to_manifest_default() { + assert!(effective_enabled(&file(true), None)); + assert!(!effective_enabled(&file(false), None)); + } + + #[test] + fn suppressing_overrides_win_over_enabled_manifest() { + assert!(!effective_enabled( + &file(true), + Some(OverrideType::Disabled) + )); + assert!(!effective_enabled(&file(true), Some(OverrideType::Removed))); + } + + #[test] + fn enabled_override_wins_over_disabled_manifest() { + assert!(effective_enabled(&file(false), Some(OverrideType::Enabled))); + } + + #[test] + fn enabled_override_round_trips_through_the_db_string() { + let parsed = OverrideType::parse(OverrideType::Enabled.as_str()); + assert_eq!(parsed, Some(OverrideType::Enabled)); + } + + #[test] + fn unknown_override_string_is_ignored() { + assert_eq!(OverrideType::parse("something-new"), None); + assert!(!effective_enabled(&file(false), None)); + } + + fn row(bundle: &str, pid: &str, ty: OverrideType) -> ClusterBundleOverrideRow { + ClusterBundleOverrideRow { + id: 1, + cluster_id: 1, + bundle_name: bundle.to_string(), + package_id: pid.to_string(), + override_type: ty.as_str().to_string(), + } + } + + #[test] + fn a_disable_with_no_override_behind_it_is_an_accident() { + assert!(!disable_was_deliberate(false, None)); + assert!(!disable_was_deliberate(true, None)); + } + + #[test] + fn a_users_own_disable_is_respected() { + assert!(disable_was_deliberate(false, Some(OverrideType::Disabled))); + assert!(disable_was_deliberate(false, Some(OverrideType::Removed))); + } + + #[test] + fn a_hidden_dependency_cannot_be_disabled_on_its_own() { + assert!( + !disable_was_deliberate(true, Some(OverrideType::Disabled)), + "a hidden file follows its bundle; only removing it keeps it off" + ); + assert!(disable_was_deliberate(true, Some(OverrideType::Removed))); + } + + #[test] + fn an_opt_in_override_never_reads_as_a_disable() { + assert!(!disable_was_deliberate(false, Some(OverrideType::Enabled))); + assert!(!disable_was_deliberate(true, Some(OverrideType::Enabled))); + } + + #[test] + fn suppression_is_found_under_a_bundle_the_package_has_since_left() { + let rows = vec![row("Bundle B", "fabric-api", OverrideType::Disabled)]; + + assert_eq!( + find_override(&rows, "Bundle C", "fabric-api"), + None, + "the per-bundle question is still answered per bundle" + ); + assert_eq!( + find_user_suppression(&rows, "fabric-api"), + Some(OverrideType::Disabled), + "the user's choice follows the package, not the bundle it was filed under" + ); + } + + #[test] + fn removal_outranks_a_disable_filed_elsewhere() { + let rows = vec![ + row("Bundle A", "yacl", OverrideType::Disabled), + row("Bundle B", "yacl", OverrideType::Removed), + ]; + + assert_eq!( + find_user_suppression(&rows, "yacl"), + Some(OverrideType::Removed) + ); + } + + #[test] + fn an_opt_in_is_not_an_objection() { + let rows = vec![ + row("Bundle A", "sodium", OverrideType::Enabled), + row("Bundle B", "lithium", OverrideType::Disabled), + ]; + + assert_eq!(find_user_suppression(&rows, "sodium"), None); + assert_eq!(find_user_suppression(&rows, "unheard-of"), None); + } + + #[test] + fn find_override_matches_on_bundle_and_package() { + let rows = vec![ + row("Bundle A", "pkg-1", OverrideType::Enabled), + row("Bundle B", "pkg-2", OverrideType::Disabled), + ]; + assert_eq!( + find_override(&rows, "Bundle A", "pkg-1"), + Some(OverrideType::Enabled) + ); + assert_eq!( + find_override(&rows, "Bundle B", "pkg-2"), + Some(OverrideType::Disabled) + ); + assert_eq!(find_override(&rows, "Bundle B", "pkg-1"), None); + assert_eq!(find_override(&rows, "Bundle A", "pkg-3"), None); + } +} diff --git a/packages/oneclient_content/src/bundles/manager.rs b/packages/oneclient_content/src/bundles/manager.rs new file mode 100644 index 00000000..cef1b9a0 --- /dev/null +++ b/packages/oneclient_content/src/bundles/manager.rs @@ -0,0 +1,318 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use chrono::Utc; +use oneclient_db::dao::bundle as bundle_dao; +use oneclient_db::models::{BundleRow, NewBundle}; +use tokio::sync::RwLock; + +use crate::bundles::error::BundleError; +use crate::bundles::manifest::{BundleManifest as RemoteBundleManifest, RemoteBundleRef}; +use crate::bundles::polymrpack; +use crate::bundles::types::BundleArchive; +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; +use oneclient_net::RequestError; +use oneclient_net::{EtagPolicy, fetch_cached}; + +#[derive(Debug, Clone)] +pub struct Bundle { + pub remote_path: String, + pub mc_version: String, + pub loader: GameLoader, + pub file_name: String, + pub name: String, + pub version_id: String, + pub category: String, + pub loader_version: String, + pub path: PathBuf, + pub hidden: bool, +} + +impl Bundle { + fn try_from_row(row: BundleRow) -> ContentResult { + let loader = GameLoader::from_repr(row.mc_loader as u8) + .ok_or(BundleError::InvalidLoader(row.mc_loader))?; + + let launcher_dir = paths::launcher_dir()?; + let path = launcher_dir.join(&row.disk_path); + + Ok(Self { + remote_path: row.remote_path, + mc_version: row.mc_version, + loader, + file_name: row.file_name, + name: row.name.unwrap_or_default(), + version_id: row.version_id.unwrap_or_default(), + category: row.category.unwrap_or_default(), + loader_version: row.loader_version.unwrap_or_default(), + path, + hidden: row.hidden != 0, + }) + } +} + +pub struct BundlesManager { + manifest: RwLock, + pub(crate) archive_cache: RwLock>, +} + +impl BundlesManager { + pub fn new() -> Self { + Self { + manifest: RwLock::new(RemoteBundleManifest::default()), + archive_cache: RwLock::new(HashMap::new()), + } + } + + #[tracing::instrument(skip_all)] + pub async fn sync(&self, ctx: &ContentCtx) -> ContentResult { + let Some(fetched) = Self::fetch_manifest(ctx).await? else { + tracing::debug!( + "skipping bundle sync because no remote or cached manifest is available" + ); + return Ok(false); + }; + let FetchedManifest { + manifest, + changed: manifest_changed, + etag, + } = fetched; + *self.manifest.write().await = manifest.clone(); + self.archive_cache.write().await.clear(); + + if !manifest_changed { + tracing::debug!("bundles manifest unchanged; skipping per-bundle remote checks"); + return Ok(false); + } + + let remote_paths: Vec = manifest + .remote_paths() + .into_iter() + .map(|entry| entry.remote_path) + .collect(); + + bundle_dao::hide_bundles_not_in(&ctx.db, &remote_paths).await?; + + let bundles_root = paths::bundles_dir()?; + polyio::create_dir_all(&bundles_root.join("bundles")).await?; + + let mut synced_all = true; + for entry in manifest.remote_paths() { + if let Err(err) = self.sync_bundle(&entry, ctx, &bundles_root).await { + synced_all = false; + tracing::warn!( + remote_path = %entry.remote_path, + error = %err, + "failed to sync bundle" + ); + } + } + + if synced_all { + if let Some(etag) = etag { + let manifest_path = paths::bundles_dir()?.join("metadata.json"); + oneclient_net::commit_etag(&manifest_path, &etag).await; + } + } else { + tracing::warn!( + "bundle catalog incomplete; not caching the manifest etag so the next \ + sync retries the missing bundles" + ); + } + + Ok(true) + } + + pub async fn cache_archive_manifest( + &self, + path: std::path::PathBuf, + manifest: crate::bundles::types::BundleManifest, + ) { + self.archive_cache.write().await.insert(path, manifest); + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn archives_for( + &self, + ctx: &ContentCtx, + mc_version: &str, + loader: GameLoader, + ) -> ContentResult> { + let mut archives = Vec::new(); + for bundle in self.list_for(ctx, mc_version, loader).await? { + let manifest = self.manifest_for_archive(&bundle.path).await?; + archives.push(BundleArchive { bundle, manifest }); + } + Ok(archives) + } + + #[tracing::instrument(level = "debug", skip(self))] + async fn manifest_for_archive( + &self, + path: &Path, + ) -> ContentResult { + if let Some(manifest) = self.archive_cache.read().await.get(path) { + return Ok(manifest.clone()); + } + let manifest = polymrpack::read_manifest_from_archive(path).await?; + self.archive_cache + .write() + .await + .insert(path.to_path_buf(), manifest.clone()); + Ok(manifest) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn list_for( + &self, + ctx: &ContentCtx, + mc_version: &str, + loader: GameLoader, + ) -> ContentResult> { + let rows = + bundle_dao::list_visible_for_version_loader(&ctx.db, mc_version, loader as i64).await?; + + rows.into_iter() + .map(Bundle::try_from_row) + .collect::, _>>() + } + + #[tracing::instrument(level = "debug", skip(self, entry, ctx, bundles_root), fields(remote_path = %entry.remote_path))] + async fn sync_bundle( + &self, + entry: &RemoteBundleRef, + ctx: &ContentCtx, + bundles_root: &Path, + ) -> ContentResult<()> { + let loader = GameLoader::from_str(&entry.loader) + .map_err(|_| BundleError::UnknownLoader(entry.loader.clone()))?; + + let file_name = entry + .remote_path + .split('/') + .next_back() + .ok_or_else(|| BundleError::InvalidPath(entry.remote_path.clone()))? + .to_string(); + + let disk_path = bundles_root.join("bundles").join(&file_name); + let remote_url = format!("{}{}", ctx.net.config().meta_url_base, entry.remote_path); + + download_bundle_if_needed(ctx, &remote_url, &disk_path, &entry.sha1).await?; + + let meta = polymrpack::read_meta_from_archive(&disk_path).await?; + + let relative_disk_path = disk_path + .strip_prefix(paths::launcher_dir()?) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| disk_path.to_string_lossy().into_owned()); + + let synced_at = Utc::now().to_rfc3339(); + + bundle_dao::upsert_bundle( + &ctx.db, + NewBundle { + remote_path: &entry.remote_path, + mc_version: &entry.mc_version, + mc_loader: loader as i64, + file_name: &file_name, + name: Some(&meta.name), + version_id: Some(&meta.version_id), + category: Some(&meta.category), + loader_version: Some(&meta.loader_version), + disk_path: &relative_disk_path, + hidden: false, + etag: Some(&entry.sha1), + synced_at: Some(&synced_at), + }, + ) + .await?; + + Ok(()) + } + + /// Defers the ETag commit + /// bundles download after the catalog so an early ETag would 304 past + /// bundles that never arrived + /// `sync` commits it once all land + #[tracing::instrument(level = "debug", skip(ctx))] + async fn fetch_manifest(ctx: &ContentCtx) -> ContentResult> { + let manifest_path = paths::bundles_dir()?.join("metadata.json"); + let url = format!( + "{}/oneclient/bundles/metadata.json", + ctx.net.config().meta_url_base + ); + + let Some(fetched) = fetch_cached(&ctx.net, &url, &manifest_path, EtagPolicy::Defer).await? + else { + return Ok(None); + }; + + Ok(Some(FetchedManifest { + manifest: fetched.json()?, + changed: fetched.changed, + etag: fetched.etag, + })) + } +} + +impl Default for BundlesManager { + fn default() -> Self { + Self::new() + } +} + +struct FetchedManifest { + manifest: RemoteBundleManifest, + changed: bool, + etag: Option, +} + +#[tracing::instrument(level = "debug", skip(ctx))] +async fn download_bundle_if_needed( + ctx: &ContentCtx, + url: &str, + disk_path: &Path, + expected_sha1: &str, +) -> ContentResult<()> { + if oneclient_net::matches_on_disk(disk_path, expected_sha1).await { + tracing::debug!("bundle cache hit via SHA1: {url}"); + return Ok(()); + } + + tracing::debug!("downloading bundle from remote: {url}"); + let expected = polyio::Checksum::sha1(expected_sha1); + oneclient_net::download_verified( + &ctx.net, + &ctx.events, + url, + disk_path, + Some(&expected), + 0, + None, + ) + .await + .map_err(map_request_error) +} + +fn map_request_error(err: RequestError) -> ContentError { + match err { + RequestError::DeserializeError { + source, + type_name, + url, + status, + snippet, + } => ContentError::InvalidData { + reason: format!( + "failed to parse {type_name} from {url} (HTTP {status}): {source}; body: {snippet}" + ), + }, + other => ContentError::InvalidData { + reason: other.to_string(), + }, + } +} diff --git a/packages/oneclient_content/src/bundles/manifest.rs b/packages/oneclient_content/src/bundles/manifest.rs new file mode 100644 index 00000000..030f7c29 --- /dev/null +++ b/packages/oneclient_content/src/bundles/manifest.rs @@ -0,0 +1,43 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemoteBundleEntry { + pub path: String, + pub sha1: String, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleManifest { + pub versions: HashMap>>, +} + +impl BundleManifest { + pub fn remote_paths(&self) -> Vec { + let mut refs = Vec::new(); + + for (mc_version, loaders) in &self.versions { + for (loader, entries) in loaders { + for entry in entries { + refs.push(RemoteBundleRef { + mc_version: mc_version.clone(), + loader: loader.clone(), + remote_path: entry.path.clone(), + sha1: entry.sha1.clone(), + }); + } + } + } + + refs + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteBundleRef { + pub mc_version: String, + pub loader: String, + pub remote_path: String, + pub sha1: String, +} diff --git a/packages/oneclient_content/src/bundles/mod.rs b/packages/oneclient_content/src/bundles/mod.rs new file mode 100644 index 00000000..1c235b77 --- /dev/null +++ b/packages/oneclient_content/src/bundles/mod.rs @@ -0,0 +1,29 @@ +mod error; +mod install; +mod manager; +mod manifest; +mod overrides; +mod polymrpack; +mod runtime; +mod types; +mod updates; + +pub use error::BundleError; +pub use install::{ + effective_enabled, enabled_bundle_bytes, extract_bundle_overrides_for_cluster, + heal_bundle_activity, install_bundle, install_cluster_bundles, install_enabled_bundle_files, + install_package_from_bundle, list_cluster_bundle_overrides, on_user_disable_artifact, + on_user_enable_artifact, on_user_remove_artifact, reconcile_duplicate_activity, + remove_artifact_from_cluster, set_artifact_enabled_to, set_bundle_package_enabled, + set_bundle_package_opt_in, set_bundle_package_override, set_bundle_package_overrides, + toggle_artifact_enabled, +}; +pub use manager::{Bundle, BundlesManager}; +pub use manifest::BundleManifest as RemoteBundleManifest; +pub use runtime::{is_bundle_syncing, sync_all_cluster_bundles}; +pub use types::{ + ApplyBundleUpdatesResult, BundleArchive, BundleFile, BundleFileKind, BundleManifest, + BundlePackageAddition, BundlePackageRemoval, BundlePackageUpdate, BundleUpdateCheckResult, + BundleWithUpdateStatus, FileUpdateStatus, +}; +pub use updates::{apply_bundle_updates, check_bundle_updates, get_bundles_with_update_status}; diff --git a/packages/oneclient_content/src/bundles/overrides.rs b/packages/oneclient_content/src/bundles/overrides.rs new file mode 100644 index 00000000..1a7dae13 --- /dev/null +++ b/packages/oneclient_content/src/bundles/overrides.rs @@ -0,0 +1,504 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use oneclient_db::models::ClusterRow; +use serde::{Deserialize, Serialize}; + +use crate::error::ContentResult; +use oneclient_events::EventBus; +use polyio::{sha1_bytes, sha1_file}; + +const ALWAYS_UPDATE_GLOBS: &[&str] = &["config/fabric_loader_dependencies.json"]; + +const OVERRIDES_PREFIX: &str = "overrides/"; +const LOCK_REL_PATH: &str = ".oneclient/bundle_overrides.json"; + +fn cluster_root(cluster: &ClusterRow) -> ContentResult { + Ok(oneclient_common::paths::clusters_dir()?.join(&cluster.folder_name)) +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +struct OverrideLock { + #[serde(default)] + bundles: HashMap>, +} + +impl OverrideLock { + async fn load(root: &Path) -> Self { + let path = root.join(LOCK_REL_PATH); + match polyio::read(&path).await { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|err| { + tracing::warn!(error = %err, "corrupt bundle override lock; starting fresh"); + Self::default() + }), + Err(_) => Self::default(), + } + } + + async fn save(&self, root: &Path) -> ContentResult<()> { + let path = root.join(LOCK_REL_PATH); + if let Some(parent) = path.parent() { + polyio::create_dir_all(parent).await.ok(); + } + let bytes = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?; + polyio::write(&path, bytes).await?; + Ok(()) + } +} + +#[derive(Debug, Default)] +pub struct OverrideSyncReport { + pub written: Vec, + pub conflicts: Vec, + pub deleted: Vec, +} + +#[tracing::instrument(level = "debug", skip(cluster, events))] +pub async fn sync_bundle_overrides( + archive_path: &Path, + bundle_name: &str, + cluster: &ClusterRow, + events: Option<&EventBus>, +) -> ContentResult { + let root = cluster_root(cluster)?; + sync_bundle_overrides_at(archive_path, bundle_name, &root, events).await +} + +#[tracing::instrument(level = "debug", skip(events))] +async fn sync_bundle_overrides_at( + archive_path: &Path, + bundle_name: &str, + root: &Path, + events: Option<&EventBus>, +) -> ContentResult { + let entries = + polyio::read_zip_file_entries(archive_path, |name| name.starts_with(OVERRIDES_PREFIX)) + .await?; + + let mut lock = OverrideLock::load(root).await; + let previous = lock.bundles.remove(bundle_name).unwrap_or_default(); + let mut next: HashMap = HashMap::new(); + let mut report = OverrideSyncReport::default(); + + for (name, bytes) in entries { + let rel = name.trim_start_matches(OVERRIDES_PREFIX); + if rel.is_empty() { + continue; + } + + let new_sha1 = sha1_bytes(&bytes); + let dest = root.join(polyio::sanitize_path(rel)); + let disk_sha1 = current_sha1(&dest).await; + let base = previous.get(rel).cloned(); + + if matches_always_update(rel) { + let wrote = write_override(&dest, &bytes, rel).await; + if wrote { + if disk_sha1.as_deref() != Some(new_sha1.as_str()) { + report.written.push(rel.to_string()); + } + next.insert(rel.to_string(), new_sha1); + } else if let Some(b) = base { + next.insert(rel.to_string(), b); + } + continue; + } + + match disk_sha1 { + None => { + if write_override(&dest, &bytes, rel).await { + report.written.push(rel.to_string()); + next.insert(rel.to_string(), new_sha1); + } + } + Some(disk) if disk == new_sha1 => { + next.insert(rel.to_string(), new_sha1); + } + Some(disk) if base.as_deref() == Some(disk.as_str()) => { + if write_override(&dest, &bytes, rel).await { + report.written.push(rel.to_string()); + next.insert(rel.to_string(), new_sha1); + } else { + next.insert(rel.to_string(), disk); + } + } + Some(_) => { + if let Some(b) = base { + if b != new_sha1 { + report.conflicts.push(rel.to_string()); + } + next.insert(rel.to_string(), b); + } + } + } + } + + for (rel, base_sha1) in &previous { + if next.contains_key(rel) { + continue; + } + let dest = root.join(polyio::sanitize_path(rel)); + + if current_sha1(&dest).await.as_deref() == Some(base_sha1.as_str()) + && polyio::remove_file(&dest).await.is_ok() + { + report.deleted.push(rel.clone()); + } + } + + lock.bundles.insert(bundle_name.to_string(), next); + if let Err(err) = lock.save(root).await { + tracing::warn!(error = %err, "failed to persist bundle override lock"); + } + + tracing::debug!( + bundle = bundle_name, + written = report.written.len(), + conflicts = report.conflicts.len(), + deleted = report.deleted.len(), + "synced bundle overrides" + ); + + if !report.conflicts.is_empty() { + notify_conflicts(events, bundle_name, &report.conflicts); + } + + Ok(report) +} + +async fn write_override(dest: &Path, bytes: &[u8], rel: &str) -> bool { + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await.ok(); + } + match polyio::write(dest, bytes).await { + Ok(()) => true, + Err(err) => { + tracing::warn!(path = rel, error = %err, "failed to write bundle override"); + false + } + } +} + +async fn current_sha1(path: &Path) -> Option { + if polyio::try_exists(path).await.unwrap_or(false) { + sha1_file(path).await.ok() + } else { + None + } +} + +fn notify_conflicts(events: Option<&EventBus>, bundle_name: &str, conflicts: &[String]) { + let Some(events) = events else { + return; + }; + + const MAX_LISTED: usize = 5; + let listed = conflicts + .iter() + .take(MAX_LISTED) + .cloned() + .collect::>() + .join(", "); + let extra = conflicts.len().saturating_sub(MAX_LISTED); + let suffix = if extra > 0 { + format!(" (+{extra} more)") + } else { + String::new() + }; + + events.notify("Kept your config edits") + .body(format!( + "{bundle_name}: {} config file(s) you edited were left untouched by the update: {listed}{suffix}", + conflicts.len() + )) + .send(); +} + +fn matches_always_update(rel: &str) -> bool { + ALWAYS_UPDATE_GLOBS + .iter() + .any(|pattern| glob_match(pattern, rel)) +} + +fn glob_match(pattern: &str, path: &str) -> bool { + let pat: Vec<&str> = pattern.split('/').collect(); + let seg: Vec<&str> = path.split('/').collect(); + glob_segments(&pat, &seg) +} + +fn glob_segments(pat: &[&str], path: &[&str]) -> bool { + match pat.split_first() { + None => path.is_empty(), + Some((&"**", rest)) => (0..=path.len()).any(|i| glob_segments(rest, &path[i..])), + Some((first, rest)) => { + if let Some((head, tail)) = path.split_first() { + segment_match(first, head) && glob_segments(rest, tail) + } else { + false + } + } + } +} + +fn segment_match(pattern: &str, segment: &str) -> bool { + if !pattern.contains('*') { + return pattern == segment; + } + + let parts: Vec<&str> = pattern.split('*').collect(); + let mut pos = 0usize; + for (index, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + if index == 0 { + if !segment[pos..].starts_with(part) { + return false; + } + pos += part.len(); + } else if index == parts.len() - 1 { + if !segment[pos..].ends_with(part) { + return false; + } + } else if let Some(found) = segment[pos..].find(part) { + pos += found + part.len(); + } else { + return false; + } + } + true +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn glob_exact_and_wildcards() { + assert!(glob_match( + "config/fabric_loader_dependencies.json", + "config/fabric_loader_dependencies.json" + )); + assert!(!glob_match( + "config/fabric_loader_dependencies.json", + "config/other.json" + )); + assert!(glob_match("config/*.json", "config/anything.json")); + assert!(!glob_match("config/*.json", "config/nested/anything.json")); + assert!(glob_match("config/**", "config/nested/deep/file.toml")); + assert!(glob_match("**/options.txt", "a/b/options.txt")); + assert!(glob_match("**/options.txt", "options.txt")); + assert!(glob_match("config/mod-*.cfg", "config/mod-foo.cfg")); + assert!(!glob_match("config/mod-*.cfg", "config/other-foo.cfg")); + } + + async fn write_bundle(path: &Path, entries: &[(&str, &[u8])]) { + let file = tokio::fs::File::create(path).await.unwrap(); + let mut writer = async_zip::tokio::write::ZipFileWriter::with_tokio(file); + for (name, data) in entries { + let builder = async_zip::ZipEntryBuilder::new( + (*name).to_string().into(), + async_zip::Compression::Stored, + ); + writer.write_entry_whole(builder, data).await.unwrap(); + } + writer.close().await.unwrap(); + } + + async fn read(path: &Path) -> Option> { + polyio::read(path).await.ok() + } + + async fn sync(zip: &Path, root: &Path) -> OverrideSyncReport { + sync_bundle_overrides_at(zip, "test-bundle", root, None) + .await + .unwrap() + } + + #[tokio::test] + async fn fresh_install_writes_all() { + let root = polyio::testing::ScratchDir::new("fresh"); + let zip = root.join("v1.mrpack"); + write_bundle( + &zip, + &[ + ("overrides/config/a.toml", b"alpha"), + ("overrides/config/nested/b.cfg", b"beta"), + ("overrides/", b""), + ], + ) + .await; + + let report = sync(&zip, root.path()).await; + assert_eq!(report.written.len(), 2); + assert!(report.conflicts.is_empty()); + assert_eq!(read(&root.join("config/a.toml")).await.unwrap(), b"alpha"); + assert_eq!( + read(&root.join("config/nested/b.cfg")).await.unwrap(), + b"beta" + ); + } + + #[tokio::test] + async fn rerun_same_version_is_noop() { + let root = polyio::testing::ScratchDir::new("noop"); + let zip = root.join("v1.mrpack"); + write_bundle(&zip, &[("overrides/config/a.toml", b"alpha")]).await; + sync(&zip, root.path()).await; + + let report = sync(&zip, root.path()).await; + assert!(report.written.is_empty()); + assert!(report.conflicts.is_empty()); + assert!(report.deleted.is_empty()); + } + + #[tokio::test] + async fn untouched_file_updates() { + let root = polyio::testing::ScratchDir::new("update"); + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await; + sync(&v1, root.path()).await; + + let v2 = root.join("v2.mrpack"); + write_bundle(&v2, &[("overrides/config/a.toml", b"alpha-v2")]).await; + let report = sync(&v2, root.path()).await; + + assert_eq!(report.written, vec!["config/a.toml".to_string()]); + assert!(report.conflicts.is_empty()); + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"alpha-v2" + ); + } + + #[tokio::test] + async fn user_edit_with_bundle_unchanged_is_kept_silently() { + let root = polyio::testing::ScratchDir::new("edit_noconflict"); + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await; + sync(&v1, root.path()).await; + + polyio::write(root.join("config/a.toml"), b"user-edit") + .await + .unwrap(); + + let report = sync(&v1, root.path()).await; + assert!(report.written.is_empty()); + assert!(report.conflicts.is_empty()); + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"user-edit" + ); + } + + #[tokio::test] + async fn user_edit_and_bundle_changed_is_conflict_kept() { + let root = polyio::testing::ScratchDir::new("conflict"); + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[("overrides/config/a.toml", b"alpha")]).await; + sync(&v1, root.path()).await; + + polyio::write(root.join("config/a.toml"), b"user-edit") + .await + .unwrap(); + + let v2 = root.join("v2.mrpack"); + write_bundle(&v2, &[("overrides/config/a.toml", b"alpha-v2")]).await; + let report = sync(&v2, root.path()).await; + + assert_eq!(report.conflicts, vec!["config/a.toml".to_string()]); + assert!(report.written.is_empty()); + + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"user-edit" + ); + + let report2 = sync(&v2, root.path()).await; + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"user-edit" + ); + assert_eq!(report2.conflicts, vec!["config/a.toml".to_string()]); + } + + #[tokio::test] + async fn always_update_overwrites_user_edit() { + let root = polyio::testing::ScratchDir::new("always"); + let path = "config/fabric_loader_dependencies.json"; + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[(&format!("overrides/{path}"), b"deps-v1")]).await; + sync(&v1, root.path()).await; + + polyio::write(root.join(path), b"runtime-mutated") + .await + .unwrap(); + + let v2 = root.join("v2.mrpack"); + write_bundle(&v2, &[(&format!("overrides/{path}"), b"deps-v2")]).await; + let report = sync(&v2, root.path()).await; + + assert!(report.conflicts.is_empty()); + assert_eq!(read(&root.join(path)).await.unwrap(), b"deps-v2"); + } + + #[tokio::test] + async fn removed_file_deleted_only_when_unmodified() { + let root = polyio::testing::ScratchDir::new("removed"); + let v1 = root.join("v1.mrpack"); + write_bundle( + &v1, + &[ + ("overrides/config/gone.toml", b"g"), + ("overrides/config/kept.toml", b"k"), + ], + ) + .await; + sync(&v1, root.path()).await; + + polyio::write(root.join("config/kept.toml"), b"edited") + .await + .unwrap(); + + let v2 = root.join("v2.mrpack"); + write_bundle(&v2, &[("overrides/config/a.toml", b"a")]).await; + let report = sync(&v2, root.path()).await; + + assert_eq!(report.deleted, vec!["config/gone.toml".to_string()]); + assert!(read(&root.join("config/gone.toml")).await.is_none()); + + assert_eq!( + read(&root.join("config/kept.toml")).await.unwrap(), + b"edited" + ); + } + + #[tokio::test] + async fn bootstrap_pre_existing_edit_is_never_overwritten() { + let root = polyio::testing::ScratchDir::new("bootstrap"); + + polyio::create_dir_all(root.join("config")).await.unwrap(); + polyio::write(root.join("config/a.toml"), b"pre-existing") + .await + .unwrap(); + + let v1 = root.join("v1.mrpack"); + write_bundle(&v1, &[("overrides/config/a.toml", b"bundle-version")]).await; + let report = sync(&v1, root.path()).await; + + assert!(report.written.is_empty()); + assert!(report.conflicts.is_empty()); + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"pre-existing" + ); + + let report2 = sync(&v1, root.path()).await; + assert!(report2.written.is_empty()); + assert_eq!( + read(&root.join("config/a.toml")).await.unwrap(), + b"pre-existing" + ); + } +} diff --git a/packages/oneclient_content/src/bundles/polymrpack.rs b/packages/oneclient_content/src/bundles/polymrpack.rs new file mode 100644 index 00000000..f5245983 --- /dev/null +++ b/packages/oneclient_content/src/bundles/polymrpack.rs @@ -0,0 +1,176 @@ +use std::collections::HashMap; +use std::path::Path; +use std::str::FromStr; + +use serde::Deserialize; + +use crate::bundles::error::BundleError; +use crate::bundles::types::{ + BundleFile, BundleFileKind, BundleManifest, content_type_from_bundle_path, +}; +use crate::error::ContentResult; +use crate::packages::types::ExternalFile; +use oneclient_common::constants::MODRINTH_CDN_PREFIX; +use oneclient_common::domain::{GameLoader, ProviderId}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PolyMrpackMeta { + pub name: String, + pub version_id: String, + pub category: String, + pub enabled: bool, + pub mc_version: String, + pub loader: GameLoader, + pub loader_version: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PolyMrpackManifest { + pub category: String, + pub enabled: bool, + pub version_id: String, + pub name: String, + #[serde(default)] + pub dependencies: HashMap, + #[serde(default)] + pub files: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PolyMrpackFile { + pub path: String, + pub hashes: PolyMrpackHashes, + #[serde(default)] + pub downloads: Vec, + #[serde(rename = "fileSize", default)] + pub file_size: u64, + pub enabled: bool, + #[serde(default)] + pub hidden: bool, +} + +#[derive(Debug, Deserialize)] +struct PolyMrpackHashes { + pub sha1: String, +} + +#[tracing::instrument(level = "debug")] +pub async fn read_meta_from_archive(path: &Path) -> ContentResult { + let manifest = read_manifest_from_archive(path).await?; + Ok(PolyMrpackMeta { + name: manifest.name, + version_id: manifest.version_id, + category: manifest.category, + enabled: manifest.enabled, + mc_version: manifest.mc_version, + loader: manifest.loader, + loader_version: manifest.loader_version, + }) +} + +#[tracing::instrument(level = "debug")] +pub async fn read_manifest_from_archive(path: &Path) -> ContentResult { + let file = tokio::fs::File::open(path).await?; + let reader = tokio::io::BufReader::new(file); + let manifest_bytes = polyio::try_read_zip_entry_bytes(reader, "modrinth.index.json").await?; + parse_manifest_bytes(&manifest_bytes) +} + +fn parse_manifest_bytes(bytes: &[u8]) -> ContentResult { + let manifest: PolyMrpackManifest = + serde_json::from_slice(bytes).map_err(|_| BundleError::InvalidManifest)?; + + let (mc_version, loader, loader_version) = parse_dependencies(&manifest.dependencies)?; + let files = manifest + .files + .iter() + .filter_map(parse_bundle_file) + .collect(); + + Ok(BundleManifest { + name: manifest.name, + version_id: manifest.version_id, + category: manifest.category, + mc_version, + loader, + loader_version, + enabled: manifest.enabled, + files, + }) +} + +fn parse_dependencies( + deps: &HashMap, +) -> ContentResult<(String, GameLoader, String)> { + let mut mc_version = None; + let mut loader = None; + let mut loader_version = None; + + for (key, value) in deps { + let normalized = key.to_lowercase().replace(['_', '.', ' ', '-'], ""); + if normalized == "minecraft" { + mc_version = Some(value.clone()); + } else if let Ok(parsed) = GameLoader::from_str(&normalized) { + loader = Some(parsed); + loader_version = Some(value.clone()); + } + } + + Ok(( + mc_version.ok_or(BundleError::InvalidManifest)?, + loader.ok_or(BundleError::InvalidManifest)?, + loader_version.ok_or(BundleError::InvalidManifest)?, + )) +} + +fn parse_bundle_file(file: &PolyMrpackFile) -> Option { + if let Some(url) = file + .downloads + .iter() + .find(|url| url.starts_with(MODRINTH_CDN_PREFIX)) + { + let paths = url[MODRINTH_CDN_PREFIX.len()..] + .split('/') + .collect::>(); + if paths.len() >= 4 { + return Some(BundleFile { + enabled: file.enabled, + hidden: file.hidden, + path: file.path.clone(), + size: file.file_size, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: paths[0].to_string(), + version_id: paths[2].to_string(), + sha1: file.hashes.sha1.to_ascii_lowercase(), + }, + }); + } + tracing::error!("invalid modrinth file URL in bundle: '{url}'"); + return None; + } + + let download_url = file.downloads.first().cloned()?; + let file_name = file + .path + .split('/') + .next_back() + .unwrap_or(&file.path) + .to_string(); + + Some(BundleFile { + enabled: file.enabled, + hidden: file.hidden, + path: file.path.clone(), + size: file.file_size, + kind: BundleFileKind::External(ExternalFile { + name: file_name, + url: download_url, + sha1: file.hashes.sha1.to_ascii_lowercase(), + size: file.file_size, + content_type: content_type_from_bundle_path(&file.path), + }), + }) +} diff --git a/packages/oneclient_content/src/bundles/runtime.rs b/packages/oneclient_content/src/bundles/runtime.rs new file mode 100644 index 00000000..0982a3db --- /dev/null +++ b/packages/oneclient_content/src/bundles/runtime.rs @@ -0,0 +1,33 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::bundles::ApplyBundleUpdatesResult; +use crate::bundles::manager::BundlesManager; +use crate::bundles::updates::apply_bundle_updates_for_all_clusters; +use crate::ctx::ContentCtx; +use oneclient_events::GroupedProgressSession; + +static BUNDLE_SYNCING: AtomicBool = AtomicBool::new(false); + +pub fn is_bundle_syncing() -> bool { + BUNDLE_SYNCING.load(Ordering::Relaxed) +} + +/// `session` when given groups every cluster's downloads into one notification +#[tracing::instrument(skip_all)] +pub async fn sync_all_cluster_bundles( + bundles: &BundlesManager, + ctx: &ContentCtx, + session: Option<&GroupedProgressSession>, +) -> Vec<(i64, ApplyBundleUpdatesResult)> { + tracing::info!("syncing all cluster bundles"); + BUNDLE_SYNCING.store(true, Ordering::Relaxed); + let changed = match apply_bundle_updates_for_all_clusters(bundles, ctx, session).await { + Ok(changed) => changed, + Err(err) => { + tracing::error!("failed to apply bundle updates for clusters: {err:#}"); + Vec::new() + } + }; + BUNDLE_SYNCING.store(false, Ordering::Relaxed); + changed +} diff --git a/packages/oneclient_content/src/bundles/types.rs b/packages/oneclient_content/src/bundles/types.rs new file mode 100644 index 00000000..36028cf9 --- /dev/null +++ b/packages/oneclient_content/src/bundles/types.rs @@ -0,0 +1,161 @@ +use serde::{Deserialize, Serialize}; + +use crate::bundles::Bundle; +use crate::packages::types::ExternalFile; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; + +#[derive(Debug, Clone)] +pub struct BundleArchive { + pub bundle: Bundle, + pub manifest: BundleManifest, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BundleManifest { + pub name: String, + pub version_id: String, + pub category: String, + pub mc_version: String, + pub loader: GameLoader, + pub loader_version: String, + pub enabled: bool, + pub files: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleFile { + pub enabled: bool, + pub hidden: bool, + #[serde(default)] + pub path: String, + #[serde(default)] + pub size: u64, + pub kind: BundleFileKind, +} + +impl BundleFile { + pub fn content_type(&self) -> ContentType { + if let BundleFileKind::External(ext) = &self.kind { + return ext.content_type; + } + content_type_from_bundle_path(&self.path) + } + + pub fn display_name(&self) -> String { + let from_path = self.path.rsplit('/').next().filter(|s| !s.is_empty()); + if let Some(name) = from_path { + return name.to_string(); + } + match &self.kind { + BundleFileKind::External(ext) => ext.name.clone(), + BundleFileKind::Managed { project_id, .. } => project_id.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum BundleFileKind { + Managed { + provider: ProviderId, + project_id: String, + version_id: String, + sha1: String, + }, + External(ExternalFile), +} + +impl BundleFileKind { + pub fn package_id(&self) -> String { + match self { + Self::Managed { project_id, .. } => project_id.clone(), + Self::External(ext) => ext.sha1.clone(), + } + } + + pub fn bundle_version_id(&self) -> String { + match self { + Self::Managed { version_id, .. } => version_id.clone(), + Self::External(ext) => ext.sha1.clone(), + } + } +} + +pub fn managed_bundle_key(provider: ProviderId, package_id: &str) -> String { + format!("m:{}:{package_id}", provider.dir_name()) +} + +pub fn external_bundle_key(sha1: &str) -> String { + format!("e:{sha1}") +} + +pub fn content_type_from_bundle_path(path: &str) -> ContentType { + let top = path.split('/').next().unwrap_or(""); + ContentType::from_folder_name(top).unwrap_or(ContentType::Mod) +} + +#[derive(Debug, Clone)] +pub struct BundlePackageUpdate { + pub cluster_id: i64, + pub installed_hash: String, + pub installed_version_id: String, + pub bundle_name: String, + pub new_version_id: String, + pub new_file: BundleFile, +} + +#[derive(Debug, Clone)] +pub struct BundlePackageRemoval { + pub cluster_id: i64, + pub hash: String, + pub package_id: String, + pub bundle_name: String, + /// `None` for external/local files + pub provider: Option, + pub project_id: Option, + /// Captured at check time + /// fallback when the meta cache has no entry + pub display_name: Option, +} + +#[derive(Debug, Clone)] +pub struct BundlePackageAddition { + pub cluster_id: i64, + pub bundle_name: String, + pub new_file: BundleFile, +} + +#[derive(Debug, Clone, Default)] +pub struct BundleUpdateCheckResult { + pub cluster_id: i64, + pub updates_available: Vec, + pub removals_available: Vec, + pub additions_available: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct ApplyBundleUpdatesResult { + pub updates_applied: Vec, + pub removals_applied: Vec, + pub additions_applied: Vec, + pub updates_failed: Vec, + pub removals_failed: Vec, + pub additions_failed: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum FileUpdateStatus { + NotInstalled, + RemovedByUser, + UpToDate, + UpdateAvailable { + installed_version_id: String, + new_version_id: String, + }, +} + +#[derive(Debug, Clone)] +pub struct BundleWithUpdateStatus { + pub archive: BundleArchive, + pub files: Vec<(BundleFile, FileUpdateStatus)>, + pub has_updates: bool, +} diff --git a/packages/oneclient_content/src/bundles/updates.rs b/packages/oneclient_content/src/bundles/updates.rs new file mode 100644 index 00000000..ba9ecf15 --- /dev/null +++ b/packages/oneclient_content/src/bundles/updates.rs @@ -0,0 +1,944 @@ +use std::cmp::Ordering; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, OnceLock}; + +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::cluster as cluster_dao; +use oneclient_db::dao::cluster_bundle as bundle_dao; +use oneclient_db::models::ClusterPatch; +use oneclient_db::models::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType}; +use tokio::sync::Mutex as AsyncMutex; + +use futures_util::StreamExt; + +use crate::bundles::install::{ + BUNDLE_INSTALL_CONCURRENCY, disable_was_deliberate, find_user_suppression, + heal_bundle_activity, install_package_from_bundle, remove_artifact_from_cluster, + set_artifact_enabled_to, +}; +use crate::bundles::manager::BundlesManager; +use crate::bundles::overrides; +use crate::bundles::types::{ + ApplyBundleUpdatesResult, BundleArchive, BundleFileKind, BundlePackageAddition, + BundlePackageRemoval, BundlePackageUpdate, BundleUpdateCheckResult, BundleWithUpdateStatus, + FileUpdateStatus, external_bundle_key, managed_bundle_key, +}; +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; +use crate::packages::store::PackageStore; +use crate::packages::types::LinkedArtifactInfo; +use oneclient_common::domain::{GameLoader, ProviderId}; + +static CLUSTER_UPDATE_LOCKS: OnceLock>>>> = OnceLock::new(); + +fn cluster_lock(cluster_id: i64) -> Arc> { + let locks = CLUSTER_UPDATE_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut map = locks.lock().unwrap(); + map.entry(cluster_id) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone() +} + +#[tracing::instrument(level = "debug", skip(bundles, ctx))] +pub async fn check_bundle_updates( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult { + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + check_bundle_updates_inner(cluster_id, bundles, ctx, &overrides).await +} + +#[tracing::instrument(level = "debug", skip(bundles, ctx, overrides))] +async fn check_bundle_updates_inner( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, + overrides: &[ClusterBundleOverrideRow], +) -> ContentResult { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).ok_or_else(|| { + ContentError::InvalidData { + reason: format!("unknown loader {}", cluster.mc_loader), + } + })?; + + let bundle_packages = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + let all_linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let all_linked_by_hash: HashMap = + all_linked.iter().map(|a| (a.hash.clone(), a)).collect(); + + let (all_installed_managed_keys, all_installed_external_hashes) = + installed_bundle_keys(ctx, &all_linked).await?; + + let archives = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await?; + + let tracked_bundle_names: HashSet = bundle_packages + .iter() + .filter_map(|bp| bp.bundle_name.clone()) + .collect(); + let loaded_bundle_names: HashSet = + archives.iter().map(|a| a.manifest.name.clone()).collect(); + let unavailable_tracked: HashSet = tracked_bundle_names + .into_iter() + .filter(|name| !loaded_bundle_names.contains(name)) + .collect(); + + let overrides_map: HashMap<(String, String), OverrideType> = overrides + .iter() + .filter_map(|o| { + OverrideType::parse(&o.override_type) + .map(|t| ((o.bundle_name.clone(), o.package_id.clone()), t)) + }) + .collect(); + + let mut bundle_versions: HashMap< + String, + HashMap, + > = HashMap::new(); + let mut hidden_dependency_keys_by_bundle: HashMap> = HashMap::new(); + let mut hidden_explicit_keys_by_bundle: HashMap> = HashMap::new(); + let mut shipped_keys_by_bundle: HashMap> = HashMap::new(); + + for archive in &archives { + let mut files_map = HashMap::new(); + let mut hidden_dependency_keys = HashSet::new(); + let mut hidden_explicit_keys = HashSet::new(); + let mut shipped_keys = HashSet::new(); + + for file in &archive.manifest.files { + shipped_keys.insert(match &file.kind { + BundleFileKind::Managed { + provider, + project_id, + .. + } => managed_bundle_key(*provider, project_id), + BundleFileKind::External(ext) => external_bundle_key(&ext.sha1), + }); + + let user_override = overrides_map + .get(&(archive.manifest.name.clone(), file.kind.package_id())) + .copied(); + if !crate::bundles::effective_enabled(file, user_override) { + continue; + } + match &file.kind { + BundleFileKind::Managed { + provider, + project_id, + version_id, + .. + } => { + let key = managed_bundle_key(*provider, project_id); + files_map.insert(key.clone(), (version_id.clone(), file.clone())); + if file.hidden { + hidden_explicit_keys.insert(key); + } + } + BundleFileKind::External(ext) => { + let key = external_bundle_key(&ext.sha1); + files_map.insert(key.clone(), (ext.sha1.clone(), file.clone())); + if file.hidden { + hidden_explicit_keys.insert(key); + } + } + } + } + + for visible_key in files_map.keys() { + hidden_dependency_keys.remove(visible_key); + } + + hidden_dependency_keys_by_bundle + .insert(archive.manifest.name.clone(), hidden_dependency_keys); + hidden_explicit_keys_by_bundle.insert(archive.manifest.name.clone(), hidden_explicit_keys); + shipped_keys_by_bundle.insert(archive.manifest.name.clone(), shipped_keys); + bundle_versions.insert(archive.manifest.name.clone(), files_map); + } + + let mut subscribed_bundles: HashSet = bundle_packages + .iter() + .filter_map(|bp| bp.bundle_name.clone()) + .collect(); + + let mut candidate_keys_by_bundle = HashMap::new(); + for archive in &archives { + let Some(files_map) = bundle_versions.get(&archive.manifest.name) else { + continue; + }; + let hidden_keys = hidden_dependency_keys_by_bundle + .get(&archive.manifest.name) + .cloned() + .unwrap_or_default(); + let hidden_explicit = hidden_explicit_keys_by_bundle + .get(&archive.manifest.name) + .cloned() + .unwrap_or_default(); + candidate_keys_by_bundle.insert( + archive.manifest.name.clone(), + collect_inferable_managed_keys(files_map, &hidden_keys, &hidden_explicit), + ); + } + + for inferred in infer_bundle_names_from_unique_installed_keys( + &candidate_keys_by_bundle, + &all_installed_managed_keys, + ) { + subscribed_bundles.insert(inferred); + } + + let subscribed_bundle_names: Vec<&str> = archives + .iter() + .filter(|a| subscribed_bundles.contains(&a.manifest.name)) + .map(|a| a.manifest.name.as_str()) + .collect(); + + let mut updates_available = Vec::new(); + let mut removals_available = Vec::new(); + + for bundle_pkg in &bundle_packages { + let Some(pkg_id) = &bundle_pkg.package_id else { + continue; + }; + let Some(installed_version_id) = &bundle_pkg.bundle_version_id else { + continue; + }; + + let installed_key = bundle_package_key(bundle_pkg, &all_linked_by_hash, pkg_id); + + let Some(bundle_name) = &bundle_pkg.bundle_name else { + continue; + }; + if unavailable_tracked.contains(bundle_name) { + continue; + } + + let mut matched_target = bundle_versions.get(bundle_name).and_then(|files_map| { + files_map + .get(&installed_key) + .map(|(new_version_id, new_file)| { + ( + bundle_name.clone(), + new_version_id.clone(), + new_file.clone(), + ) + }) + }); + + if matched_target.is_none() { + for candidate in &subscribed_bundle_names { + if *candidate == bundle_name.as_str() { + continue; + } + if let Some((new_version_id, new_file)) = bundle_versions + .get(*candidate) + .and_then(|m| m.get(&installed_key)) + { + matched_target = Some(( + (*candidate).to_string(), + new_version_id.clone(), + new_file.clone(), + )); + break; + } + } + } + + if let Some((resolved_bundle_name, new_version_id, new_file)) = matched_target { + if installed_version_id != &new_version_id { + updates_available.push(BundlePackageUpdate { + cluster_id, + installed_hash: bundle_pkg.hash.clone(), + installed_version_id: installed_version_id.clone(), + bundle_name: resolved_bundle_name, + new_version_id, + new_file, + }); + } + } else { + let user_disabled = matches!( + overrides_map.get(&(bundle_name.clone(), pkg_id.clone())), + Some(OverrideType::Disabled) + ); + let still_shipped = shipped_keys_by_bundle + .get(bundle_name) + .is_some_and(|keys| keys.contains(&installed_key)); + if user_disabled && still_shipped { + continue; + } + + let linked = all_linked_by_hash.get(&bundle_pkg.hash); + removals_available.push(BundlePackageRemoval { + cluster_id, + hash: bundle_pkg.hash.clone(), + package_id: pkg_id.clone(), + bundle_name: bundle_name.clone(), + provider: linked.and_then(|i| i.provider), + project_id: linked + .and_then(|i| i.project_id.clone()) + .or_else(|| Some(pkg_id.clone())), + display_name: linked.map(|i| { + i.display_name + .clone() + .unwrap_or_else(|| i.file_name.clone()) + }), + }); + } + } + + updates_available.retain(|u| { + let file_id = u.new_file.kind.package_id(); + !matches!( + overrides_map.get(&(u.bundle_name.clone(), file_id)), + Some(OverrideType::Removed) + ) + }); + + let addition_eligible_bundles = addition_eligible_bundles( + ctx, + &bundle_packages, + &all_linked, + &candidate_keys_by_bundle, + overrides, + ) + .await?; + + let mut additions_available = Vec::new(); + let mut planned_addition_keys = all_installed_managed_keys.clone(); + for hash in all_installed_external_hashes { + planned_addition_keys.insert(external_bundle_key(&hash)); + } + + for archive in &archives { + if !addition_eligible_bundles.contains(&archive.manifest.name) { + continue; + } + + for file in &archive.manifest.files { + let file_id = file.kind.package_id(); + let user_override = overrides_map + .get(&(archive.manifest.name.clone(), file_id.clone())) + .copied(); + if !crate::bundles::effective_enabled(file, user_override) { + continue; + } + + let file_key = match &file.kind { + BundleFileKind::Managed { + provider, + project_id, + .. + } => managed_bundle_key(*provider, project_id), + BundleFileKind::External(ext) => external_bundle_key(&ext.sha1), + }; + if planned_addition_keys.contains(&file_key) { + continue; + } + + additions_available.push(BundlePackageAddition { + cluster_id, + bundle_name: archive.manifest.name.clone(), + new_file: file.clone(), + }); + planned_addition_keys.insert(file_key); + } + } + + Ok(BundleUpdateCheckResult { + cluster_id, + updates_available, + removals_available, + additions_available, + }) +} + +#[tracing::instrument(skip(bundles, ctx))] +pub async fn apply_bundle_updates( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult { + let session = + oneclient_events::GroupedProgressSession::start(&ctx.events, "Updating bundle content"); + let result = apply_bundle_updates_with(cluster_id, bundles, ctx, Some(&session)).await; + session.finish(); + result +} + +/// The caller owns `session`'s lifetime +/// sharing one across clusters makes a batch surface as a single notification +#[tracing::instrument(skip(bundles, ctx, session))] +pub async fn apply_bundle_updates_with( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, + session: Option<&oneclient_events::GroupedProgressSession>, +) -> ContentResult { + let lock = cluster_lock(cluster_id); + let _guard = lock.lock().await; + + // Must run first + // the check below never consults `enabled` so unrecorded disables would + // read as healthy and be left untouched + { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); + if let Ok(archives) = bundles.archives_for(ctx, &cluster.mc_version, loader).await { + heal_bundle_activity(cluster_id, &archives, ctx).await?; + } + } + + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let check = check_bundle_updates_inner(cluster_id, bundles, ctx, &overrides).await?; + + tracing::info!( + cluster_id, + updates = check.updates_available.len(), + additions = check.additions_available.len(), + removals = check.removals_available.len(), + "applying bundle updates" + ); + + let mut result = ApplyBundleUpdatesResult::default(); + + for removal in check.removals_available { + match remove_artifact_from_cluster(cluster_id, &removal.hash, false, ctx).await { + Ok(()) => result.removals_applied.push(removal), + Err(err) => result + .removals_failed + .push(format!("{}: {err:#}", removal.package_id)), + } + } + + let has_downloads = + !check.updates_available.is_empty() || !check.additions_available.is_empty(); + if let Some(s) = session.filter(|_| has_downloads) { + let count = (check.updates_available.len() + check.additions_available.len()) as u64; + let bytes: u64 = check + .updates_available + .iter() + .map(|u| u.new_file.size.max(1)) + .chain( + check + .additions_available + .iter() + .map(|a| a.new_file.size.max(1)), + ) + .sum(); + s.expect(oneclient_events::TaskCategory::Packages, count, bytes); + } + + // Fetch concurrently reconcile in order + // splitting the two keeps the fan-out safe since no artifact is unlinked + // while another download still needs it + let fetched_updates = futures_util::stream::iter(check.updates_available.into_iter().map( + |update| async move { + let child = session.map(|s| { + let c = s.child( + update.new_file.display_name(), + update.new_file.size.max(1), + oneclient_events::TaskCategory::Packages, + ); + c.set_phase(oneclient_events::TaskPhase::Downloading); + c + }); + let installed = install_package_from_bundle( + &update.new_file, + update.cluster_id, + &update.bundle_name, + true, + child.as_ref(), + ctx, + ) + .await; + if let Some(child) = child { + child.set_phase(oneclient_events::TaskPhase::Installing); + child.finish(); + } + (update, installed) + }, + )) + .buffer_unordered(BUNDLE_INSTALL_CONCURRENCY) + .collect::>() + .await; + + for (update, installed) in fetched_updates { + match installed { + Ok(hash) => match reconcile_update(&update, &hash, &overrides, ctx).await { + Ok(()) => result.updates_applied.push(update), + Err(err) => result.updates_failed.push(err.to_string()), + }, + Err(err) => result.updates_failed.push(err.to_string()), + } + } + + let fetched_additions = futures_util::stream::iter(check.additions_available.into_iter().map( + |addition| async move { + let child = session.map(|s| { + let c = s.child( + addition.new_file.display_name(), + addition.new_file.size.max(1), + oneclient_events::TaskCategory::Packages, + ); + c.set_phase(oneclient_events::TaskPhase::Downloading); + c + }); + let installed = install_package_from_bundle( + &addition.new_file, + addition.cluster_id, + &addition.bundle_name, + true, + child.as_ref(), + ctx, + ) + .await; + if let Some(child) = child { + child.set_phase(oneclient_events::TaskPhase::Installing); + child.finish(); + } + (addition, installed) + }, + )) + .buffer_unordered(BUNDLE_INSTALL_CONCURRENCY) + .collect::>() + .await; + + for (addition, installed) in fetched_additions { + let file_id = addition.new_file.kind.package_id(); + match installed { + Ok(hash) => match reconcile_addition(&addition, &hash, &overrides, ctx).await { + Ok(()) => result.additions_applied.push(addition), + Err(err) => result.additions_failed.push(format!("{file_id}: {err:#}")), + }, + Err(err) => result.additions_failed.push(format!("{file_id}: {err:#}")), + } + } + + { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); + if let Ok(archives) = bundles.archives_for(ctx, &cluster.mc_version, loader).await { + for archive in archives { + if let Err(err) = overrides::sync_bundle_overrides( + &archive.bundle.path, + &archive.manifest.name, + &cluster, + Some(&ctx.events), + ) + .await + { + tracing::warn!( + bundle = %archive.manifest.name, + error = %err, + "failed to sync bundle overrides during update" + ); + } + } + } + } + + let failures = + result.updates_failed.len() + result.removals_failed.len() + result.additions_failed.len(); + if failures == 0 { + sync_cluster_loader_version_from_bundles(cluster_id, bundles, ctx).await?; + } + + Ok(result) +} + +/// `enabled` is set not flipped and overrides are read across all bundles +/// packages get re-resolved between bundles so a per-bundle lookup misses +/// objections filed while the file lived elsewhere +#[tracing::instrument(level = "debug", skip_all, fields(cluster_id = update.cluster_id, bundle = %update.bundle_name, new_version = %update.new_version_id))] +async fn reconcile_update( + update: &BundlePackageUpdate, + hash: &str, + overrides: &[ClusterBundleOverrideRow], + ctx: &ContentCtx, +) -> ContentResult<()> { + let file_id = update.new_file.kind.package_id(); + let suppression = find_user_suppression(overrides, &file_id); + let enabled = !disable_was_deliberate(update.new_file.hidden, suppression); + set_artifact_enabled_to(update.cluster_id, hash, enabled, ctx).await?; + + if hash == update.installed_hash { + return Ok(()); + } + + remove_artifact_from_cluster(update.cluster_id, &update.installed_hash, false, ctx).await +} + +#[tracing::instrument(level = "debug", skip_all, fields(cluster_id = addition.cluster_id, bundle = %addition.bundle_name))] +async fn reconcile_addition( + addition: &BundlePackageAddition, + hash: &str, + overrides: &[ClusterBundleOverrideRow], + ctx: &ContentCtx, +) -> ContentResult<()> { + let file_id = addition.new_file.kind.package_id(); + let suppression = find_user_suppression(overrides, &file_id); + let enabled = !disable_was_deliberate(addition.new_file.hidden, suppression); + set_artifact_enabled_to(addition.cluster_id, hash, enabled, ctx).await +} + +#[tracing::instrument(level = "debug", skip(bundles, ctx))] +async fn sync_cluster_loader_version_from_bundles( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult<()> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); + let bundle_packages = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + let all_linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let archives = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await?; + + let (managed_keys, _) = installed_bundle_keys(ctx, &all_linked).await?; + let subscribed: HashSet = bundle_packages + .iter() + .filter_map(|bp| bp.bundle_name.clone()) + .chain(infer_subscribed_from_archives(&archives, &managed_keys)) + .collect(); + + let target = select_highest_loader_version( + archives + .iter() + .filter(|a| subscribed.contains(&a.manifest.name)) + .map(|a| a.manifest.loader_version.as_str()), + ); + + let Some(target) = target else { + return Ok(()); + }; + + if cluster.mc_loader_version.as_deref() == Some(target.as_str()) { + return Ok(()); + } + + cluster_dao::update( + &ctx.db, + cluster_id, + &ClusterPatch { + mc_loader_version: Some(Some(target)), + ..Default::default() + }, + ) + .await?; + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(bundles, ctx))] +pub async fn get_bundles_with_update_status( + cluster_id: i64, + bundles: &BundlesManager, + ctx: &ContentCtx, +) -> ContentResult> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Fabric); + let bundle_packages = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id).await?; + let all_linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let all_linked_by_hash: HashMap = + all_linked.iter().map(|a| (a.hash.clone(), a)).collect(); + + let mut installed_map: HashMap = HashMap::new(); + for bundle_pkg in &bundle_packages { + let Some(pkg_id) = &bundle_pkg.package_id else { + continue; + }; + let key = bundle_package_key(bundle_pkg, &all_linked_by_hash, pkg_id); + installed_map.insert(key, bundle_pkg); + } + + let overrides = bundle_dao::list_overrides(&ctx.db, cluster_id).await?; + let overrides_map: HashMap<(String, String), OverrideType> = overrides + .iter() + .filter_map(|o| { + OverrideType::parse(&o.override_type) + .map(|t| ((o.bundle_name.clone(), o.package_id.clone()), t)) + }) + .collect(); + + let archives = bundles + .archives_for(ctx, &cluster.mc_version, loader) + .await?; + let mut results = Vec::new(); + + for archive in archives { + let mut files = Vec::new(); + let mut has_updates = false; + + for file in &archive.manifest.files { + let status = match &file.kind { + BundleFileKind::Managed { + provider, + project_id, + version_id, + .. + } => { + let key = managed_bundle_key(*provider, project_id); + if let Some(installed) = installed_map.get(&key) { + let installed_version = + installed.bundle_version_id.as_deref().unwrap_or(""); + if installed_version == version_id.as_str() { + FileUpdateStatus::UpToDate + } else { + has_updates = true; + FileUpdateStatus::UpdateAvailable { + installed_version_id: installed_version.to_string(), + new_version_id: version_id.clone(), + } + } + } else if matches!( + overrides_map.get(&(archive.manifest.name.clone(), project_id.clone())), + Some(OverrideType::Removed) + ) { + FileUpdateStatus::RemovedByUser + } else { + FileUpdateStatus::NotInstalled + } + } + BundleFileKind::External(ext) => { + let key = external_bundle_key(&ext.sha1); + if installed_map.contains_key(&key) { + FileUpdateStatus::UpToDate + } else if matches!( + overrides_map.get(&(archive.manifest.name.clone(), ext.sha1.clone())), + Some(OverrideType::Removed) + ) { + FileUpdateStatus::RemovedByUser + } else { + FileUpdateStatus::NotInstalled + } + } + }; + files.push((file.clone(), status)); + } + + results.push(BundleWithUpdateStatus { + archive, + files, + has_updates, + }); + } + + Ok(results) +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn apply_bundle_updates_for_all_clusters( + bundles: &BundlesManager, + ctx: &ContentCtx, + session: Option<&oneclient_events::GroupedProgressSession>, +) -> ContentResult> { + let mut changed = Vec::new(); + for cluster in cluster_dao::list_all(&ctx.db).await? { + match apply_bundle_updates_with(cluster.id, bundles, ctx, session).await { + Ok(result) => { + if !result.updates_applied.is_empty() + || !result.additions_applied.is_empty() + || !result.removals_applied.is_empty() + { + changed.push((cluster.id, result)); + } + } + Err(err) => tracing::warn!( + cluster_id = cluster.id, + error = %err, + "bundle update apply failed for cluster" + ), + } + } + Ok(changed) +} + +fn bundle_package_key( + bundle_pkg: &BundleTrackedArtifactRow, + linked: &HashMap, + package_id: &str, +) -> String { + if package_id == bundle_pkg.hash { + external_bundle_key(&bundle_pkg.hash) + } else if let Some(info) = linked.get(&bundle_pkg.hash) { + managed_bundle_key(info.provider.unwrap_or(ProviderId::Modrinth), package_id) + } else { + managed_bundle_key(ProviderId::Modrinth, package_id) + } +} + +/// Stricter than subscription +/// emptying a bundle is how a user opts out and an absent override is not +/// consent for a file that did not exist back then +#[tracing::instrument(level = "debug", skip_all)] +async fn addition_eligible_bundles( + ctx: &ContentCtx, + bundle_packages: &[BundleTrackedArtifactRow], + all_linked: &[LinkedArtifactInfo], + candidate_keys_by_bundle: &HashMap>, + overrides: &[ClusterBundleOverrideRow], +) -> ContentResult> { + let (live_managed_keys, _) = + installed_bundle_keys(ctx, all_linked.iter().filter(|item| item.enabled)).await?; + + let mut eligible: HashSet = bundle_packages + .iter() + .filter(|bp| bp.enabled != 0) + .filter_map(|bp| bp.bundle_name.clone()) + .collect(); + + eligible.extend(infer_bundle_names_from_unique_installed_keys( + candidate_keys_by_bundle, + &live_managed_keys, + )); + + eligible.extend( + overrides + .iter() + .filter(|o| OverrideType::parse(&o.override_type) == Some(OverrideType::Enabled)) + .map(|o| o.bundle_name.clone()), + ); + + Ok(eligible) +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn installed_bundle_keys<'a>( + ctx: &ContentCtx, + linked: impl IntoIterator, +) -> ContentResult<(HashSet, HashSet)> { + let mut managed = HashSet::new(); + let mut external = HashSet::new(); + + for item in linked { + if let Some(release) = artifact_dao::get_release_by_hash(&ctx.db, &item.hash).await? { + let provider = + ProviderId::from_repr(release.provider as u8).unwrap_or(ProviderId::Modrinth); + managed.insert(managed_bundle_key(provider, &release.project_id)); + } else { + external.insert(item.hash.clone()); + } + } + + Ok((managed, external)) +} + +fn collect_inferable_managed_keys( + bundle_files_map: &HashMap, + hidden_dependency_keys: &HashSet, + hidden_explicit_keys: &HashSet, +) -> HashSet { + bundle_files_map + .keys() + .filter(|key| { + key.starts_with("m:") + && !hidden_dependency_keys.contains(*key) + && !hidden_explicit_keys.contains(*key) + }) + .cloned() + .collect() +} + +fn infer_bundle_names_from_unique_installed_keys( + candidate_keys_by_bundle: &HashMap>, + all_installed_managed_keys: &HashSet, +) -> HashSet { + let mut key_occurrence_count: HashMap = HashMap::new(); + for candidate_keys in candidate_keys_by_bundle.values() { + for key in candidate_keys { + *key_occurrence_count.entry(key.clone()).or_default() += 1; + } + } + + candidate_keys_by_bundle + .iter() + .filter_map(|(bundle_name, candidate_keys)| { + let has_unique = candidate_keys.iter().any(|key| { + all_installed_managed_keys.contains(key) + && key_occurrence_count.get(key).copied().unwrap_or_default() == 1 + }); + if has_unique { + Some(bundle_name.clone()) + } else { + None + } + }) + .collect() +} + +fn infer_subscribed_from_archives( + archives: &[BundleArchive], + managed_keys: &HashSet, +) -> HashSet { + let mut candidate_keys_by_bundle = HashMap::new(); + for archive in archives { + let mut keys = HashSet::new(); + for file in &archive.manifest.files { + if !file.enabled { + continue; + } + if let BundleFileKind::Managed { + provider, + project_id, + .. + } = &file.kind + && !file.hidden + { + keys.insert(managed_bundle_key(*provider, project_id)); + } + } + candidate_keys_by_bundle.insert(archive.manifest.name.clone(), keys); + } + infer_bundle_names_from_unique_installed_keys(&candidate_keys_by_bundle, managed_keys) +} + +fn compare_version_segment(left: &str, right: &str) -> Ordering { + match (left.parse::(), right.parse::()) { + (Ok(left_num), Ok(right_num)) => left_num.cmp(&right_num), + (Ok(_), Err(_)) => Ordering::Greater, + (Err(_), Ok(_)) => Ordering::Less, + (Err(_), Err(_)) => left.cmp(right), + } +} + +fn compare_version_like(left: &str, right: &str) -> Ordering { + let left_segments: Vec = left + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|s| !s.is_empty()) + .map(str::to_ascii_lowercase) + .collect(); + let right_segments: Vec = right + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|s| !s.is_empty()) + .map(str::to_ascii_lowercase) + .collect(); + + for index in 0..left_segments.len().max(right_segments.len()) { + match (left_segments.get(index), right_segments.get(index)) { + (Some(left_segment), Some(right_segment)) => { + let cmp = compare_version_segment(left_segment, right_segment); + if cmp != Ordering::Equal { + return cmp; + } + } + (Some(_), None) => return Ordering::Greater, + (None, Some(_)) => return Ordering::Less, + (None, None) => break, + } + } + Ordering::Equal +} + +fn select_highest_loader_version<'a>( + versions: impl IntoIterator, +) -> Option { + versions + .into_iter() + .map(str::trim) + .filter(|value| !value.is_empty()) + .max_by(|left, right| compare_version_like(left, right)) + .map(str::to_string) +} diff --git a/packages/oneclient_content/src/ctx.rs b/packages/oneclient_content/src/ctx.rs new file mode 100644 index 00000000..1045ec72 --- /dev/null +++ b/packages/oneclient_content/src/ctx.rs @@ -0,0 +1,33 @@ +use oneclient_db::DbPool; +use oneclient_events::EventBus; +use oneclient_net::RequestClient; + +use crate::packages::provider::PackageProviderRegistry; + +/// `db` is taken directly not behind a port +/// these tables are shared relational state with cross-entity joins used from +/// four subsystems +#[derive(Clone)] +pub struct ContentCtx { + pub db: DbPool, + pub net: RequestClient, + pub events: EventBus, + pub providers: PackageProviderRegistry, +} + +impl ContentCtx { + #[must_use] + pub fn new( + db: DbPool, + net: RequestClient, + events: EventBus, + providers: PackageProviderRegistry, + ) -> Self { + Self { + db, + net, + events, + providers, + } + } +} diff --git a/packages/oneclient_content/src/error.rs b/packages/oneclient_content/src/error.rs new file mode 100644 index 00000000..481a41e1 --- /dev/null +++ b/packages/oneclient_content/src/error.rs @@ -0,0 +1,66 @@ +use crate::bundles::BundleError; +use crate::packages::PackageError; + +pub type ContentResult = Result; + +#[derive(Debug, thiserror::Error)] +pub enum ContentError { + #[error(transparent)] + Package(#[from] PackageError), + + #[error(transparent)] + Bundle(#[from] BundleError), + + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), + + #[error(transparent)] + Db(#[from] oneclient_db::DbError), + + #[error("Database execution failed: {0}")] + Sql(#[from] sqlx::Error), + + #[error(transparent)] + Io(#[from] polyio::IOError), + + #[error(transparent)] + StdIo(#[from] std::io::Error), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + + #[error(transparent)] + Events(#[from] oneclient_events::EventError), + + #[error("Unable to parse JSON: {0}")] + Json(#[from] serde_json::Error), + + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), + + #[error("invalid content data: {reason}")] + InvalidData { reason: String }, +} + +impl ContentError { + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + Self::Request(source) => source.is_transient(), + Self::Io(polyio::IOError::IOError(source)) + | Self::Io(polyio::IOError::PathIOError { source, .. }) + | Self::StdIo(source) => { + use std::io::ErrorKind; + matches!( + source.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) + } + _ => false, + } + } +} diff --git a/packages/oneclient_content/src/lib.rs b/packages/oneclient_content/src/lib.rs new file mode 100644 index 00000000..962c04c0 --- /dev/null +++ b/packages/oneclient_content/src/lib.rs @@ -0,0 +1,8 @@ +pub mod bundles; +pub mod packages; + +mod ctx; +mod error; + +pub use ctx::ContentCtx; +pub use error::{ContentError, ContentResult}; diff --git a/packages/oneclient_content/src/packages/activity.rs b/packages/oneclient_content/src/packages/activity.rs new file mode 100644 index 00000000..f7d3f93b --- /dev/null +++ b/packages/oneclient_content/src/packages/activity.rs @@ -0,0 +1,249 @@ +//! `cluster_artifacts` is unique on `(cluster_id, hash)` so duplicate versions +//! of one package are legal rows +//! the newest stays enabled the rest are switched off rather than unlinked + +use std::collections::HashMap; + +use oneclient_common::domain::ProviderId; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::store::PackageStore; +use crate::packages::types::LinkedArtifactInfo; + +struct Copy { + hash: String, + enabled: bool, + published_at: Option, +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn reconcile_duplicate_activity( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + let mut switched_off = Vec::new(); + + for hash in duplicates_to_disable(&linked) { + tracing::info!( + cluster_id, + %hash, + "resolving duplicate versions of a package to the newest live one" + ); + + PackageStore::set_artifact_enabled_to(cluster_id, &hash, false, ctx).await?; + switched_off.push(hash); + } + + Ok(switched_off) +} + +fn duplicates_to_disable(linked: &[LinkedArtifactInfo]) -> Vec { + let mut out = Vec::new(); + + for (_, copies) in group_duplicates(linked) { + let live: Vec<&Copy> = copies.iter().filter(|copy| copy.enabled).collect(); + if live.len() < 2 { + continue; + } + + let Some(winner) = newest(live.iter().copied()) else { + continue; + }; + + out.extend( + live.iter() + .filter(|copy| copy.hash != winner) + .map(|copy| copy.hash.clone()), + ); + } + + out +} + +/// Local files and a bundle's external files have no project to group on so +/// they are left out +/// two of those are two packages not two copies +fn group_duplicates(linked: &[LinkedArtifactInfo]) -> Vec<((ProviderId, String), Vec)> { + let mut by_project: HashMap<(ProviderId, String), Vec> = HashMap::new(); + + for info in linked { + let (Some(provider), Some(project_id)) = (info.provider, info.project_id.clone()) else { + continue; + }; + + by_project + .entry((provider, project_id)) + .or_default() + .push(Copy { + hash: info.hash.clone(), + enabled: info.enabled, + published_at: info.published_at.clone(), + }); + } + + by_project.retain(|_, copies| copies.len() > 1); + by_project.into_iter().collect() +} + +/// `published_at` is RFC 3339 so string order matches time order +/// undated copies lose to dated ones and an all-undated group still picks one +fn newest<'a>(copies: impl IntoIterator) -> Option { + copies + .into_iter() + .max_by(|a, b| a.published_at.cmp(&b.published_at)) + .map(|copy| copy.hash.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use oneclient_common::domain::ContentType; + + fn info( + project_id: Option<&str>, + hash: &str, + enabled: bool, + published_at: Option<&str>, + ) -> LinkedArtifactInfo { + LinkedArtifactInfo { + hash: hash.into(), + cluster_file_name: format!("{hash}.jar"), + enabled, + content_type: ContentType::Mod, + file_name: format!("{hash}.jar"), + project_id: project_id.map(Into::into), + version_id: Some(format!("v-{hash}")), + display_name: None, + display_version: None, + provider: project_id.map(|_| ProviderId::Modrinth), + published_at: published_at.map(Into::into), + } + } + + fn copies(specs: &[(&str, bool, Option<&str>)]) -> Vec { + specs + .iter() + .map(|(hash, enabled, published)| Copy { + hash: (*hash).into(), + enabled: *enabled, + published_at: published.map(Into::into), + }) + .collect() + } + + #[test] + fn the_newest_publication_wins() { + let picked = newest(&copies(&[ + ("old", true, Some("2026-01-01T00:00:00Z")), + ("new", false, Some("2026-06-01T00:00:00Z")), + ("middle", false, Some("2026-03-01T00:00:00Z")), + ])); + + assert_eq!( + picked.as_deref(), + Some("new"), + "being enabled does not win it" + ); + } + + #[test] + fn a_dated_copy_beats_an_undated_one() { + let picked = newest(&copies(&[ + ("undated", true, None), + ("dated", false, Some("2026-01-01T00:00:00Z")), + ])); + + assert_eq!(picked.as_deref(), Some("dated")); + } + + #[test] + fn an_undated_group_still_picks_one() { + let picked = newest(&copies(&[("a", false, None), ("b", false, None)])); + + assert!( + picked.is_some(), + "a group with no dates must not go unresolved" + ); + } + + #[test] + fn only_projects_with_several_copies_are_reconciled() { + let groups = group_duplicates(&[ + info(Some("sodium"), "s1", true, None), + info(Some("sodium"), "s2", true, None), + info(Some("lithium"), "l1", true, None), + ]); + + assert_eq!(groups.len(), 1, "a single copy is nothing to resolve"); + assert_eq!(groups[0].0.1, "sodium"); + assert_eq!(groups[0].1.len(), 2); + } + + #[test] + fn the_newest_live_copy_survives_and_the_rest_go_quiet() { + let mut disable = duplicates_to_disable(&[ + info(Some("sodium"), "old", true, Some("2026-01-01T00:00:00Z")), + info(Some("sodium"), "new", true, Some("2026-06-01T00:00:00Z")), + info(Some("sodium"), "middle", true, Some("2026-03-01T00:00:00Z")), + ]); + disable.sort(); + assert_eq!(disable, vec!["middle".to_string(), "old".to_string()]); + } + + #[test] + fn a_copy_that_is_already_off_is_not_counted_as_a_conflict() { + let disable = duplicates_to_disable(&[ + info(Some("sodium"), "live", true, Some("2026-01-01T00:00:00Z")), + info(Some("sodium"), "off", false, Some("2026-06-01T00:00:00Z")), + ]); + + assert!( + disable.is_empty(), + "one live copy is not a duplicate, however many switched-off copies sit beside it" + ); + } + + #[test] + fn the_user_disabling_the_newest_copy_is_left_standing() { + let disable = duplicates_to_disable(&[ + info(Some("sodium"), "chosen", true, Some("2026-01-01T00:00:00Z")), + info( + Some("sodium"), + "newest", + false, + Some("2026-06-01T00:00:00Z"), + ), + ]); + + assert!( + disable.is_empty(), + "picking the older version on purpose must not be undone, and the newest must not be \ + switched back on" + ); + } + + #[test] + fn a_project_with_no_live_copy_at_all_is_left_alone() { + let disable = duplicates_to_disable(&[ + info(Some("sodium"), "a", false, Some("2026-01-01T00:00:00Z")), + info(Some("sodium"), "b", false, Some("2026-06-01T00:00:00Z")), + ]); + + assert!(disable.is_empty()); + } + + #[test] + fn content_with_no_project_is_left_alone() { + let groups = group_duplicates(&[ + info(None, "local-1", true, None), + info(None, "local-2", true, None), + ]); + + assert!( + groups.is_empty(), + "two local files are two packages, not two copies of one" + ); + } +} diff --git a/packages/oneclient_content/src/packages/dependencies.rs b/packages/oneclient_content/src/packages/dependencies.rs new file mode 100644 index 00000000..b387ab45 --- /dev/null +++ b/packages/oneclient_content/src/packages/dependencies.rs @@ -0,0 +1,401 @@ +use std::collections::{HashSet, VecDeque}; + +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_db::models::ClusterRow; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::provider::PackageProvider; +use crate::packages::store::PackageStore; +use crate::packages::types::{ + DependencyKind, ProjectDetail, ReleaseType, VersionDependency, VersionDetail, VersionSummary, +}; + +/// Caps the transitive walk so a provider serving a cycle can't spin forever +const MAX_DEPTH: usize = 4; + +const VERSION_WINDOW: usize = 50; + +#[derive(Debug, Clone)] +pub struct ResolvedDependency { + pub project: ProjectDetail, + pub version: VersionDetail, +} + +#[derive(Debug, Clone, Default)] +pub struct DependencyResolution { + /// Ordered breadth-first from the root + /// already-present ones are filtered out + pub install: Vec, + pub unresolved: Vec, +} + +impl DependencyResolution { + pub fn is_empty(&self) -> bool { + self.install.is_empty() && self.unresolved.is_empty() + } +} + +#[tracing::instrument(level = "debug", skip(root, ctx), fields(project_id = %root.project_id, version_id = %root.version_id))] +pub async fn resolve_required( + provider_id: ProviderId, + root: &VersionDetail, + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult { + let mut resolution = DependencyResolution::default(); + if !root.dependencies.iter().any(is_required) { + return Ok(resolution); + } + + let provider = ctx.providers.get(provider_id)?; + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + + // Seeded with installed projects and the root against refetches and self-referencing graphs + let mut seen = installed_project_ids(provider_id, cluster_id, ctx).await?; + seen.insert(root.project_id.clone()); + + let mut queue: VecDeque<(VersionDependency, usize)> = root + .dependencies + .iter() + .filter(|dep| is_required(dep)) + .map(|dep| (dep.clone(), 1)) + .collect(); + + while let Some((dep, depth)) = queue.pop_front() { + if let Some(project_id) = &dep.project_id + && seen.contains(project_id) + { + continue; + } + + let resolved = match resolve_one(provider, &dep, &cluster, ctx).await { + Ok(Some(resolved)) => resolved, + Ok(None) => { + tracing::warn!(?dep, "no compatible version for dependency"); + resolution.unresolved.push(dependency_label(&dep)); + continue; + } + Err(err) => { + tracing::warn!(?dep, %err, "failed to resolve dependency"); + resolution.unresolved.push(dependency_label(&dep)); + continue; + } + }; + + // A version-pinned dependency only reveals its project once fetched so dedupe again here + if !seen.insert(resolved.version.project_id.clone()) { + continue; + } + + if depth < MAX_DEPTH { + for next in resolved + .version + .dependencies + .iter() + .filter(|d| is_required(d)) + { + queue.push_back((next.clone(), depth + 1)); + } + } + + resolution.install.push(resolved); + } + + Ok(resolution) +} + +fn is_required(dep: &VersionDependency) -> bool { + dep.kind == DependencyKind::Required +} + +fn dependency_label(dep: &VersionDependency) -> String { + dep.project_id + .clone() + .or_else(|| dep.version_id.clone()) + .unwrap_or_else(|| "unknown".to_string()) +} + +async fn resolve_one( + provider: &dyn PackageProvider, + dep: &VersionDependency, + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> ContentResult> { + // A pinned version wins even when newer exists + // providers ignore the project id once the version id is known + let version = match &dep.version_id { + Some(version_id) => { + provider + .get_version( + dep.project_id.as_deref().unwrap_or_default(), + version_id, + ctx, + ) + .await? + } + None => { + let Some(project_id) = dep.project_id.as_deref() else { + return Ok(None); + }; + let Some(pick) = pick_version(provider, project_id, cluster, ctx).await? else { + return Ok(None); + }; + provider + .get_version(project_id, &pick.version_id, ctx) + .await? + } + }; + + let project = provider.get_project(&version.project_id, ctx).await?; + Ok(Some(ResolvedDependency { project, version })) +} + +/// Falls back to the newest prerelease when no release fits the cluster +pub(crate) async fn pick_version( + provider: &dyn PackageProvider, + project_id: &str, + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> ContentResult> { + let loader = GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla); + let loader_filter = loader.is_modded().then_some(loader); + + let mut candidates = provider + .list_versions( + project_id, + Some(&cluster.mc_version), + loader_filter, + 0, + VERSION_WINDOW, + ctx, + ) + .await? + .items; + + // Resource packs shaders and datapacks declare no loader so a loader-filtered query is empty + if candidates.is_empty() && loader_filter.is_some() { + candidates = provider + .list_versions( + project_id, + Some(&cluster.mc_version), + None, + 0, + VERSION_WINDOW, + ctx, + ) + .await? + .items; + } + + Ok(choose_version(candidates, cluster, loader)) +} + +fn choose_version( + mut candidates: Vec, + cluster: &ClusterRow, + loader: GameLoader, +) -> Option { + candidates.retain(|version| fits_cluster(version, cluster, loader)); + // Neither provider promises an order so sort rather than trust the page + candidates.sort_by_key(|version| std::cmp::Reverse(version.published)); + + candidates + .iter() + .find(|version| matches!(version.release_type, ReleaseType::Release)) + .or_else(|| candidates.first()) + .cloned() +} + +/// Mirrors the store's install-time compatibility check +/// must stay in sync with it +fn fits_cluster(version: &VersionSummary, cluster: &ClusterRow, loader: GameLoader) -> bool { + if !version.game_versions.is_empty() + && !version + .game_versions + .iter() + .any(|v| cluster.mc_version.contains(v)) + { + return false; + } + + version.loaders.is_empty() || version.loaders.iter().any(|l| loader.compatible_with(*l)) +} + +async fn installed_project_ids( + provider_id: ProviderId, + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + Ok(PackageStore::list_linked_artifacts(cluster_id, ctx) + .await? + .into_iter() + .filter(|linked| linked.provider == Some(provider_id)) + .filter_map(|linked| linked.project_id) + .collect()) +} + +/// False for modpacks they bring their own file list +pub fn resolves_dependencies(content_type: ContentType) -> bool { + !matches!(content_type, ContentType::Modpack) +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + + use super::*; + + fn summary( + version_id: &str, + release_type: ReleaseType, + day: u32, + loaders: Vec, + game_versions: Vec<&str>, + ) -> VersionSummary { + VersionSummary { + version_id: version_id.into(), + project_id: "p".into(), + name: version_id.into(), + version_number: version_id.into(), + published: Utc.with_ymd_and_hms(2025, 1, day, 0, 0, 0).unwrap(), + release_type, + game_versions: game_versions.into_iter().map(Into::into).collect(), + loaders, + downloads: 0, + file_size: 0, + } + } + + fn cluster(loader: GameLoader, mc_version: &str) -> ClusterRow { + ClusterRow { + id: 1, + name: "c".into(), + folder_name: "c".into(), + setting_profile_name: None, + mc_version: mc_version.into(), + mc_loader: loader as i64, + stage: 0, + mc_loader_version: None, + created_at: None, + last_played: None, + overall_played: None, + linked_modpack_hash: None, + } + } + + #[test] + fn prefers_the_newest_release_over_a_newer_prerelease() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![ + summary( + "old", + ReleaseType::Release, + 1, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + summary( + "beta", + ReleaseType::Beta, + 3, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + summary( + "new", + ReleaseType::Release, + 2, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + ]; + + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "new"); + } + + #[test] + fn falls_back_to_a_prerelease_when_no_release_fits() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![ + summary( + "alpha", + ReleaseType::Alpha, + 1, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + summary( + "beta", + ReleaseType::Beta, + 2, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + ]; + + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "beta"); + } + + #[test] + fn drops_versions_the_store_would_reject() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![ + summary( + "forge", + ReleaseType::Release, + 3, + vec![GameLoader::Forge], + vec!["1.21.4"], + ), + summary( + "old-mc", + ReleaseType::Release, + 2, + vec![GameLoader::Fabric], + vec!["1.20.1"], + ), + summary( + "fits", + ReleaseType::Release, + 1, + vec![GameLoader::Fabric], + vec!["1.21.4"], + ), + ]; + + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "fits"); + } + + #[test] + fn keeps_loaderless_content_for_a_modded_cluster() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![summary( + "pack", + ReleaseType::Release, + 1, + Vec::new(), + vec!["1.21.4"], + )]; + + let pick = choose_version(candidates, &cluster, GameLoader::Fabric); + assert_eq!(pick.unwrap().version_id, "pack"); + } + + #[test] + fn no_compatible_version_resolves_to_nothing() { + let cluster = cluster(GameLoader::Fabric, "1.21.4"); + let candidates = vec![summary( + "forge", + ReleaseType::Release, + 1, + vec![GameLoader::Forge], + vec!["1.21.4"], + )]; + + assert!(choose_version(candidates, &cluster, GameLoader::Fabric).is_none()); + } +} diff --git a/packages/oneclient_content/src/packages/error.rs b/packages/oneclient_content/src/packages/error.rs new file mode 100644 index 00000000..cbbabd27 --- /dev/null +++ b/packages/oneclient_content/src/packages/error.rs @@ -0,0 +1,38 @@ +use thiserror::Error; + +use super::types::PackageBody; +use oneclient_common::domain::ProviderId; + +#[derive(Debug, Error)] +pub enum PackageError { + #[error("no primary download file on version")] + NoPrimaryFile, + #[error("package is not a modpack")] + NotModpack, + #[error("unsupported modpack format")] + UnsupportedModpackFormat, + #[error("modpack install had {failed} of {total} file failures")] + PartialModpackInstall { failed: u64, total: u64 }, + #[error("missing API key for provider {0:?}")] + MissingApiKey(ProviderId), + #[error("unsupported package body type")] + UnsupportedBody(PackageBody), + #[error("package is not compatible with cluster Minecraft version")] + IncompatibleMcVersion, + #[error("package is not compatible with cluster loader")] + IncompatibleLoader, + #[error("cluster {0} not found")] + ClusterNotFound(i64), + #[error("artifact file missing at {0}")] + ArtifactMissing(String), + #[error("hash mismatch: expected {expected}, got {actual}")] + HashMismatch { expected: String, actual: String }, + #[error("provider {0:?} does not support this operation")] + UnsupportedProvider(ProviderId), + #[error("package provider {0:?} is not registered")] + ProviderNotRegistered(ProviderId), + #[error("invalid local file: {0}")] + InvalidLocalFile(String), +} + +pub type PackageResult = Result; diff --git a/packages/oneclient_content/src/packages/file_identity.rs b/packages/oneclient_content/src/packages/file_identity.rs new file mode 100644 index 00000000..cdf50cec --- /dev/null +++ b/packages/oneclient_content/src/packages/file_identity.rs @@ -0,0 +1,75 @@ +use std::path::Path; + +use crate::error::ContentResult; +use polyio::{normalize_hash, sha1_bytes}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileIdentity { + pub sha1: String, + pub cf_fingerprint: Option, +} + +impl FileIdentity { + pub fn from_bytes(bytes: &[u8]) -> Self { + Self { + sha1: sha1_bytes(bytes), + cf_fingerprint: Some(curseforge_fingerprint(bytes)), + } + } + + #[tracing::instrument(level = "debug", skip(path))] + pub async fn from_path(path: impl AsRef) -> ContentResult { + let bytes = polyio::read(path).await?; + Ok(Self::from_bytes(&bytes)) + } + + pub fn from_sha1(sha1: impl AsRef) -> Self { + Self { + sha1: normalize_hash(sha1.as_ref()), + cf_fingerprint: None, + } + } + + pub fn with_curseforge_fingerprint(mut self, fingerprint: u32) -> Self { + self.cf_fingerprint = Some(fingerprint); + self + } +} + +pub fn curseforge_fingerprint(bytes: &[u8]) -> u32 { + const MULTIPLIER: u32 = 1540483477; + + let normalized_len = bytes.iter().filter(|&&b| !is_cf_whitespace(b)).count(); + let mut hash = 1u32 ^ normalized_len as u32; + let mut chunk = 0u32; + let mut chunk_bits = 0u32; + + for &byte in bytes { + if is_cf_whitespace(byte) { + continue; + } + + chunk |= u32::from(byte) << chunk_bits; + chunk_bits += 8; + + if chunk_bits == 32 { + let mixed = chunk.wrapping_mul(MULTIPLIER); + let folded = (mixed ^ (mixed >> 24)).wrapping_mul(MULTIPLIER); + hash = hash.wrapping_mul(MULTIPLIER) ^ folded; + chunk = 0; + chunk_bits = 0; + } + } + + if chunk_bits > 0 { + hash = (hash ^ chunk).wrapping_mul(MULTIPLIER); + } + + let mixed = (hash ^ (hash >> 13)).wrapping_mul(MULTIPLIER); + mixed ^ (mixed >> 15) +} + +#[inline] +fn is_cf_whitespace(byte: u8) -> bool { + matches!(byte, b' ' | b'\t' | b'\n' | b'\r') +} diff --git a/packages/oneclient_content/src/packages/metadata_cache.rs b/packages/oneclient_content/src/packages/metadata_cache.rs new file mode 100644 index 00000000..02aadc53 --- /dev/null +++ b/packages/oneclient_content/src/packages/metadata_cache.rs @@ -0,0 +1,236 @@ +use std::collections::HashMap; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::package_metadata as meta_dao; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::ProviderId; +use crate::packages::store::artifact_absolute_path; +use crate::packages::types::{PackageBody, ProjectDetail, VersionDetail, VersionFile}; +use oneclient_common::domain::{ContentType, GameLoader}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CachedPackageMeta { + pub provider: ProviderId, + pub project_id: String, + pub name: String, + pub summary: String, + pub author: String, + pub icon_url: Option, +} + +#[tracing::instrument(level = "debug", skip(ctx, project_ids))] +pub async fn read_cached_package_meta( + ctx: &ContentCtx, + provider: ProviderId, + project_ids: &[String], +) -> HashMap { + if project_ids.is_empty() { + return HashMap::new(); + } + + let rows = meta_dao::get_package_metadata_batch(&ctx.db, provider as i64, project_ids) + .await + .unwrap_or_default(); + + rows.into_iter() + .map(|row| { + ( + row.project_id.clone(), + CachedPackageMeta { + provider, + project_id: row.project_id, + name: row.name, + summary: row.summary, + author: row.author, + icon_url: row.icon_url, + }, + ) + }) + .collect() +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn cached_project_detail( + ctx: &ContentCtx, + provider: ProviderId, + project_id: &str, + content_type: ContentType, +) -> ProjectDetail { + let cached = + read_cached_package_meta(ctx, provider, std::slice::from_ref(&project_id.to_string())) + .await; + let meta = cached.get(project_id); + + ProjectDetail { + id: project_id.to_string(), + slug: String::new(), + provider, + content_type, + name: meta + .map(|m| m.name.clone()) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| project_id.to_string()), + summary: meta.map(|m| m.summary.clone()).unwrap_or_default(), + author: meta.map(|m| m.author.clone()).unwrap_or_default(), + members: Vec::new(), + gallery: Vec::new(), + body: PackageBody::Raw(String::new()), + license: None, + links: Vec::new(), + version_ids: Vec::new(), + game_versions: Vec::new(), + loaders: Vec::new(), + icon_url: meta.and_then(|m| m.icon_url.clone()), + created: Utc::now(), + updated: Utc::now(), + downloads: 0, + } +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn get_version_cached( + ctx: &ContentCtx, + provider: ProviderId, + project_id: &str, + version_id: &str, +) -> ContentResult { + if let Some(cached) = cached_version_detail(ctx, provider, project_id, version_id).await { + return Ok(cached); + } + + ctx.providers + .get(provider)? + .get_version(project_id, version_id, ctx) + .await +} + +#[tracing::instrument(level = "debug", skip(ctx))] +async fn cached_version_detail( + ctx: &ContentCtx, + provider: ProviderId, + project_id: &str, + version_id: &str, +) -> Option { + let release = + artifact_dao::get_provider_release(&ctx.db, provider as i64, project_id, version_id) + .await + .ok() + .flatten()?; + let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, &release.hash) + .await + .ok() + .flatten()?; + + let path = artifact_absolute_path(&artifact.path).ok()?; + if !path.exists() { + return None; + } + + let game_versions: Vec = serde_json::from_str(&release.mc_versions).unwrap_or_default(); + let loaders: Vec = serde_json::from_str(&release.mc_loaders).unwrap_or_default(); + let published = release + .published_at + .as_deref() + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(Utc::now); + + Some(VersionDetail { + version_id: version_id.to_string(), + project_id: project_id.to_string(), + name: release.display_name, + version_number: release.display_version, + changelog: None, + game_versions, + loaders, + published, + downloads: 0, + files: vec![VersionFile { + sha1: release.hash, + url: String::new(), + file_name: artifact.file_name, + primary: true, + size: artifact.size_bytes.unwrap_or(0) as u64, + fingerprint: None, + }], + // Not persisted with the release row callers needing the graph go to the provider + dependencies: Vec::new(), + }) +} + +#[tracing::instrument(level = "debug", skip(ctx, project_ids))] +pub async fn fetch_package_meta( + ctx: &ContentCtx, + provider: ProviderId, + project_ids: &[String], +) -> ContentResult> { + if project_ids.is_empty() { + return Ok(HashMap::new()); + } + + let provider_repr = provider as i64; + let mut out: HashMap = HashMap::new(); + + for row in meta_dao::get_package_metadata_batch(&ctx.db, provider_repr, project_ids).await? { + out.insert( + row.project_id.clone(), + CachedPackageMeta { + provider, + project_id: row.project_id, + name: row.name, + summary: row.summary, + author: row.author, + icon_url: row.icon_url, + }, + ); + } + + let missing: Vec = project_ids + .iter() + .filter(|id| out.get(*id).is_none_or(|m| m.author.is_empty())) + .cloned() + .collect(); + if missing.is_empty() { + return Ok(out); + } + + tracing::debug!( + count = missing.len(), + "fetching missing package metadata from provider" + ); + + let details = ctx + .providers + .get(provider)? + .get_projects(&missing, ctx) + .await?; + + for detail in details { + let meta = CachedPackageMeta { + provider, + project_id: detail.id, + name: detail.name, + summary: detail.summary, + author: detail.author, + icon_url: detail.icon_url, + }; + meta_dao::upsert_package_metadata( + &ctx.db, + provider_repr, + &meta.project_id, + &meta.name, + &meta.summary, + &meta.author, + meta.icon_url.as_deref(), + ) + .await?; + out.insert(meta.project_id.clone(), meta); + } + + Ok(out) +} diff --git a/packages/oneclient_content/src/packages/mod.rs b/packages/oneclient_content/src/packages/mod.rs new file mode 100644 index 00000000..d9c713ff --- /dev/null +++ b/packages/oneclient_content/src/packages/mod.rs @@ -0,0 +1,32 @@ +pub mod activity; +pub mod dependencies; +pub mod error; +pub mod metadata_cache; +pub mod modpack; +pub mod provider; +pub mod store; +pub mod types; +pub mod updates; + +mod file_identity; + +// Re-exported so `oneclient_core::packages::ContentType` keeps working +pub use activity::reconcile_duplicate_activity; +pub use dependencies::{ + DependencyResolution, ResolvedDependency, resolve_required, resolves_dependencies, +}; +pub use error::{PackageError, PackageResult}; +pub use file_identity::{FileIdentity, curseforge_fingerprint}; +pub use metadata_cache::{ + CachedPackageMeta, cached_project_detail, fetch_package_meta, get_version_cached, + read_cached_package_meta, +}; +pub use oneclient_common::domain::{ContentType, GameLoader, HashAlgorithm, ProviderId}; +pub use provider::{PackageProvider, PackageProviderRegistry}; +pub use store::PackageStore; +pub use types::*; +pub use updates::{ + BrowserPackageUpdate, BrowserUpdateCheck, apply_browser_package_update, + cached_browser_package_updates, check_browser_package_updates, refresh_browser_package_updates, + skip_browser_package_update, +}; diff --git a/packages/oneclient_content/src/packages/modpack/mod.rs b/packages/oneclient_content/src/packages/modpack/mod.rs new file mode 100644 index 00000000..30753904 --- /dev/null +++ b/packages/oneclient_content/src/packages/modpack/mod.rs @@ -0,0 +1,17 @@ +mod mrpack; + +use std::path::Path; + +pub use mrpack::{MrpackInstaller, install_mrpack_to_cluster}; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; + +#[tracing::instrument(level = "debug", skip(archive_path, ctx))] +pub async fn install_modpack_archive( + archive_path: impl AsRef, + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult<()> { + mrpack::install_mrpack_to_cluster(archive_path.as_ref().to_path_buf(), cluster_id, ctx).await +} diff --git a/packages/oneclient_content/src/packages/modpack/mrpack.rs b/packages/oneclient_content/src/packages/modpack/mrpack.rs new file mode 100644 index 00000000..2b71889a --- /dev/null +++ b/packages/oneclient_content/src/packages/modpack/mrpack.rs @@ -0,0 +1,200 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use futures_lite::AsyncReadExt; +use oneclient_db::models::ClusterRow; +use serde::Deserialize; +use uuid::Uuid; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::error::PackageError; +use crate::packages::store::{self, PackageStore}; +use crate::packages::types::ExternalFile; +use oneclient_common::domain::ContentType; + +pub struct MrpackInstaller; + +#[derive(Deserialize)] +#[allow(dead_code)] +struct MrpackManifest { + #[serde(default)] + files: Vec, + #[serde(default)] + dependencies: HashMap, +} + +#[derive(Deserialize)] +struct MrpackFileEntry { + path: String, + hashes: MrpackHashes, + #[serde(default)] + downloads: Vec, + #[serde(rename = "fileSize")] + file_size: u64, +} + +#[derive(Deserialize)] +struct MrpackHashes { + sha1: String, +} + +impl MrpackInstaller { + #[tracing::instrument(skip(ctx))] + pub async fn install_archive( + archive_path: PathBuf, + cluster_id: i64, + ctx: &ContentCtx, + ) -> ContentResult<()> { + tracing::info!("installing mrpack archive to cluster"); + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let cluster_root = oneclient_common::paths::clusters_dir()?.join(&cluster.folder_name); + + let bytes = Arc::new(polyio::read(archive_path).await?); + + let mut manifest_bytes: Option> = None; + + polyio::read_zip_entries_bytes(bytes.to_vec(), async |_, entry, reader| { + let entry_name = entry.filename().as_str().map_err(io_err)?; + + if entry_name == "modrinth.index.json" { + let mut buf = Vec::new(); + reader.read_to_end(&mut buf).await.map_err(io_err)?; + manifest_bytes = Some(buf); + } + Ok(()) + }) + .await?; + + let manifest_bytes = manifest_bytes.ok_or(PackageError::UnsupportedModpackFormat)?; + let manifest: MrpackManifest = serde_json::from_slice(&manifest_bytes)?; + + let mut failed = 0u64; + let total = manifest.files.len() as u64; + let progress_id = Uuid::new_v4(); + + for (index, entry) in manifest.files.into_iter().enumerate() { + ctx.events + .progress(progress_id, "Installing Modpack Files", index as u64, total); + + let content_type = content_type_from_path(&entry.path); + let hash = entry.hashes.sha1.to_ascii_lowercase(); + let file_name = Path::new(&entry.path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&entry.path) + .to_string(); + + let path_str = entry.path.clone(); + + if let Err(err) = + install_mrpack_file(entry, content_type, hash, file_name, &cluster, ctx).await + { + failed += 1; + tracing::warn!(path = %path_str, error = %err, "modpack file install failed"); + } + } + + ctx.events + .progress(progress_id, "Installing Modpack Files", total, total); + + polyio::read_zip_entries_bytes(bytes.to_vec(), async |_, entry, reader| { + let name = entry.filename().as_str().map_err(io_err)?; + + let Some(rest) = name.strip_prefix("overrides/") else { + return Ok(()); + }; + if rest.is_empty() || name.ends_with('/') { + return Ok(()); + } + + let dest = cluster_root.join(rest); + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await.map_err(io_err)?; + } + + let mut file_bytes = Vec::new(); + reader.read_to_end(&mut file_bytes).await.map_err(io_err)?; + polyio::write(&dest, &file_bytes).await.map_err(io_err)?; + Ok(()) + }) + .await?; + + if failed > 0 { + tracing::error!(failed, total, "modpack install completed with failures"); + return Err(PackageError::PartialModpackInstall { failed, total }.into()); + } + + tracing::info!(total, "modpack install complete"); + Ok(()) + } +} + +#[tracing::instrument(level = "debug", skip(entry, cluster, ctx))] +async fn install_mrpack_file( + entry: MrpackFileEntry, + content_type: ContentType, + hash: String, + file_name: String, + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> ContentResult<()> { + if let Some(row) = oneclient_db::dao::artifact::get_artifact_by_hash(&ctx.db, &hash).await? { + let path = store::artifact_absolute_path(&row.path)?; + if path.exists() { + PackageStore::link_artifact(&row, cluster, Some(&file_name), ctx).await?; + return Ok(()); + } + } + + if let Some((provider_id, version)) = ctx.providers.lookup_version(&hash, ctx).await? { + let provider = ctx.providers.get(provider_id)?; + let project = provider.get_project(&version.project_id, ctx).await?; + let artifact = + PackageStore::download_and_cache(provider_id, &project, &version, false, None, ctx) + .await?; + PackageStore::link_artifact(&artifact, cluster, Some(&file_name), ctx).await?; + return Ok(()); + } + + let url = entry + .downloads + .first() + .cloned() + .ok_or(PackageError::NoPrimaryFile)?; + + let external = ExternalFile { + name: file_name.clone(), + url, + sha1: hash, + size: entry.file_size, + content_type, + }; + + let artifact = store::download_external(&external, false, None, ctx).await?; + PackageStore::link_artifact(&artifact, cluster, Some(&file_name), ctx).await?; + + Ok(()) +} + +fn content_type_from_path(path: &str) -> ContentType { + let top = path.split('/').next().unwrap_or(""); + ContentType::from_folder_name(top).unwrap_or(ContentType::Mod) +} + +fn io_err(err: impl std::error::Error + Send + Sync + 'static) -> polyio::IOError { + polyio::IOError::PathIOError { + source: std::io::Error::other(err), + path: String::new(), + } +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn install_mrpack_to_cluster( + archive_path: PathBuf, + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult<()> { + MrpackInstaller::install_archive(archive_path, cluster_id, ctx).await +} diff --git a/packages/oneclient_content/src/packages/provider/curseforge.rs b/packages/oneclient_content/src/packages/provider/curseforge.rs new file mode 100644 index 00000000..2d03decc --- /dev/null +++ b/packages/oneclient_content/src/packages/provider/curseforge.rs @@ -0,0 +1,746 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use reqwest::Method; +use serde::Deserialize; +use url::Url; + +use super::PackageProvider; +use super::http::fetch_json; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::file_identity::FileIdentity; +use crate::packages::types::{ + DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary, + ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup, + VersionSummary, +}; +use oneclient_common::constants::{CURSEFORGE_API_URL, CURSEFORGE_GAME_ID}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; + +pub struct CurseForgeProvider; + +#[inline(always)] +fn api_url(path: &str) -> String { + format!("{CURSEFORGE_API_URL}{path}") +} + +#[async_trait::async_trait] +impl PackageProvider for CurseForgeProvider { + fn id(&self) -> ProviderId { + ProviderId::CurseForge + } + + #[tracing::instrument(level = "debug", skip(self, filters, ctx))] + async fn search( + &self, + filters: &SearchFilters, + ctx: &ContentCtx, + ) -> ContentResult> { + tracing::debug!(query = ?filters.query, "searching curseforge"); + let mut url = Url::parse(&api_url("/mods/search"))?; + + { + let mut params = url.query_pairs_mut(); + params.append_pair("gameId", &CURSEFORGE_GAME_ID.to_string()); + let class_id = filters + .content_type + .map(cf_class_id) + .unwrap_or(CfClass::Mod as u32); + params.append_pair("classId", &class_id.to_string()); + params.append_pair( + "pageSize", + &filters + .limit + .unwrap_or(super::super::types::DEFAULT_PAGE_SIZE) + .to_string(), + ); + params.append_pair("index", &filters.offset.unwrap_or(0).to_string()); + params.append_pair("sortField", "6"); + params.append_pair("sortOrder", "desc"); + let query = filters.normalized_query(); + if !query.is_empty() { + params.append_pair("searchFilter", &query); + } + if let Some(v) = filters.game_versions.as_ref().and_then(|v| v.first()) { + params.append_pair("gameVersion", v); + } + if let Some(loader) = filters + .loaders + .as_ref() + .and_then(|l| l.first()) + .and_then(|l| cf_loader_type(*l)) + { + params.append_pair("modLoaderType", &loader.to_string()); + } + } + + let response: CfPaged> = + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; + + Ok(Page { + offset: response.pagination.index, + limit: response.pagination.page_size, + total: response.pagination.total_count, + items: response + .data + .into_iter() + .map(ProjectSummary::from) + .collect(), + }) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn get_project( + &self, + project_id: &str, + ctx: &ContentCtx, + ) -> ContentResult { + let response: CfData = fetch_json( + &ctx.net, + Method::GET, + &api_url(&format!("/mods/{project_id}")), + None, + ) + .await?; + Ok(response.data.into_detail()) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn get_project_with_body( + &self, + project_id: &str, + ctx: &ContentCtx, + ) -> ContentResult { + let mut detail = self.get_project(project_id, ctx).await?; + detail.body = fetch_body(project_id, &detail, ctx).await; + Ok(detail) + } + + #[tracing::instrument(level = "debug", skip(self, project_ids, ctx))] + async fn get_projects( + &self, + project_ids: &[String], + ctx: &ContentCtx, + ) -> ContentResult> { + let mod_ids: Vec = project_ids.iter().filter_map(|s| s.parse().ok()).collect(); + let body = serde_json::json!({ "modIds": mod_ids }); + let response: CfData> = + fetch_json(&ctx.net, Method::POST, &api_url("/mods"), Some(body)).await?; + Ok(response.data.into_iter().map(CfMod::into_detail).collect()) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn list_versions( + &self, + project_id: &str, + game_version: Option<&str>, + loader: Option, + offset: usize, + limit: usize, + ctx: &ContentCtx, + ) -> ContentResult> { + let mut url = Url::parse(&api_url(&format!("/mods/{project_id}/files")))?; + { + let mut params = url.query_pairs_mut(); + if let Some(v) = game_version { + params.append_pair("gameVersion", v); + } + if let Some(t) = loader.and_then(cf_loader_type) { + params.append_pair("modLoaderType", &t.to_string()); + } + } + let response: CfPaged> = + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; + + let total = response.data.len(); + let items = response + .data + .into_iter() + .skip(offset) + .take(limit) + .map(VersionSummary::from) + .collect(); + + Ok(Page { + offset, + limit, + total, + items, + }) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn list_categories( + &self, + content_type: ContentType, + ctx: &ContentCtx, + ) -> ContentResult> { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct CfCategory { + name: String, + } + let mut url = Url::parse(&api_url("/categories"))?; + url.query_pairs_mut() + .append_pair("gameId", &CURSEFORGE_GAME_ID.to_string()) + .append_pair("classId", &cf_class_id(content_type).to_string()); + let response: CfData> = + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; + Ok(response.data.into_iter().map(|c| c.name).collect()) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn get_version( + &self, + project_id: &str, + version_id: &str, + ctx: &ContentCtx, + ) -> ContentResult { + let response: CfData = fetch_json( + &ctx.net, + Method::GET, + &api_url(&format!("/mods/{project_id}/files/{version_id}")), + None, + ) + .await?; + Ok(response.data.into()) + } + + #[tracing::instrument(level = "debug", skip(self, version_ids, ctx))] + async fn get_versions( + &self, + version_ids: &[String], + ctx: &ContentCtx, + ) -> ContentResult> { + let file_ids: Vec = version_ids.iter().filter_map(|s| s.parse().ok()).collect(); + let body = serde_json::json!({ "fileIds": file_ids }); + let response: CfData> = + fetch_json(&ctx.net, Method::POST, &api_url("/mods/files"), Some(body)).await?; + Ok(response.data.into_iter().map(Into::into).collect()) + } + + #[tracing::instrument(level = "debug", skip_all)] + async fn lookup_versions( + &self, + identities: &[FileIdentity], + ctx: &ContentCtx, + ) -> ContentResult { + let mut out = HashMap::new(); + + let with_fingerprint: Vec<(&FileIdentity, u32)> = identities + .iter() + .filter_map(|id| id.cf_fingerprint.map(|fp| (id, fp))) + .collect(); + + if with_fingerprint.is_empty() { + return Ok(out); + } + + let nums: Vec = with_fingerprint.iter().map(|(_, fp)| *fp).collect(); + let body = serde_json::json!({ "fingerprints": nums }); + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Response { + exact_matches: Vec, + } + #[derive(Deserialize)] + struct FingerprintMatch { + file: CfFile, + } + + let response: CfData = fetch_json( + &ctx.net, + Method::POST, + &api_url(&format!("/mods/fingerprints/{CURSEFORGE_GAME_ID}")), + Some(body), + ) + .await?; + + for m in response.data.exact_matches { + let version: VersionDetail = m.file.clone().into(); + let file_sha1 = version + .files + .first() + .map(|f| polyio::normalize_hash(&f.sha1)); + + let fp = m.file.file_fingerprint; + + for (identity, expected_fp) in &with_fingerprint { + if *expected_fp == fp || file_sha1.as_deref() == Some(identity.sha1.as_str()) { + out.insert(identity.sha1.clone(), version.clone()); + } + } + } + + Ok(out) + } +} + +#[derive(Deserialize)] +struct CfData { + data: T, +} + +#[derive(Deserialize)] +struct CfPaged { + data: T, + pagination: CfPagination, +} + +#[tracing::instrument(level = "debug", skip(detail, ctx))] +async fn fetch_body(project_id: &str, detail: &ProjectDetail, ctx: &ContentCtx) -> PackageBody { + let fallback = || { + detail + .links + .iter() + .find(|(label, _)| label == "Website") + .map(|(_, url)| PackageBody::Url(url.clone())) + .unwrap_or_else(|| PackageBody::Raw(String::new())) + }; + + let response: Result, _> = fetch_json( + &ctx.net, + Method::GET, + &api_url(&format!("/mods/{project_id}/description")), + None, + ) + .await; + + match response { + Ok(response) => match html_to_markdown(&response.data) { + Some(markdown) => PackageBody::Raw(markdown), + None => fallback(), + }, + Err(err) => { + tracing::warn!(project_id, %err, "failed to fetch curseforge description"); + fallback() + } + } +} + +fn html_to_markdown(html: &str) -> Option { + let markdown = htmd::convert(html).ok()?; + let markdown = markdown.trim(); + + (!markdown.is_empty()).then(|| absolutize_links(markdown)) +} + +fn absolutize_links(markdown: &str) -> String { + markdown + .replace( + "](/linkout?remoteUrl=", + "](https://www.curseforge.com/linkout?remoteUrl=", + ) + .replace("](/", "](https://www.curseforge.com/") +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfPagination { + index: usize, + page_size: usize, + total_count: usize, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfMod { + id: u32, + name: String, + slug: String, + summary: String, + download_count: u64, + date_created: DateTime, + date_modified: DateTime, + #[serde(default)] + logo: Option, + #[serde(default)] + authors: Vec, + #[serde(default)] + links: Option, + #[serde(default)] + screenshots: Vec, + class_id: u32, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfScreenshot { + url: String, + #[serde(default)] + title: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfLogo { + #[serde(default)] + thumbnail_url: Option, + #[serde(default)] + url: Option, +} + +#[derive(Deserialize)] +struct CfAuthor { + name: String, + #[serde(default)] + url: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfLinks { + #[serde(default)] + website_url: Option, + #[serde(default)] + source_url: Option, + #[serde(default)] + issues_url: Option, + #[serde(default)] + wiki_url: Option, +} + +fn cf_links(links: &CfLinks) -> Vec<(String, String)> { + let mut out = Vec::new(); + for (label, url) in [ + ("Website", &links.website_url), + ("Source", &links.source_url), + ("Issues", &links.issues_url), + ("Wiki", &links.wiki_url), + ] { + if let Some(url) = url.as_ref().filter(|u| !u.is_empty()) { + out.push((label.to_string(), url.clone())); + } + } + out +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfFile { + id: u32, + mod_id: u32, + display_name: String, + file_name: String, + release_type: u8, + file_date: DateTime, + download_count: u64, + #[serde(default)] + game_versions: Vec, + #[serde(default)] + mod_loaders: Vec, + #[serde(default)] + hashes: Vec, + file_fingerprint: u32, + download_url: String, + file_length: u64, + #[serde(default)] + dependencies: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfDependency { + mod_id: u32, + relation_type: u8, +} + +impl From for VersionDependency { + fn from(d: CfDependency) -> Self { + Self { + project_id: Some(d.mod_id.to_string()), + version_id: None, + // CF relationType 1 embedded 2 optional 3 required 4 tool 5 incompatible 6 include + kind: match d.relation_type { + 3 => DependencyKind::Required, + 5 => DependencyKind::Incompatible, + 1 | 6 => DependencyKind::Embedded, + _ => DependencyKind::Optional, + }, + } + } +} + +#[derive(Deserialize, Clone, Copy)] +#[serde(from = "u8")] +enum CfLoader { + Forge = 1, + Cauldron = 2, + #[allow(dead_code)] + LiteLoader = 3, + Fabric = 4, + Quilt = 5, + NeoForge = 6, +} + +#[derive(Clone, Deserialize)] +struct CfHash { + value: String, + #[serde(rename = "algo")] + algorithm: u8, +} + +#[derive(Deserialize)] +#[serde(from = "u32")] +enum CfClass { + Mod = 6, + ResourcePack = 12, + DataPack = 4472, + Shader = 6552, + Modpack = 4471, +} + +impl From for ProjectSummary { + fn from(m: CfMod) -> Self { + Self { + id: m.id.to_string(), + slug: m.slug, + provider: ProviderId::CurseForge, + content_type: cf_class_to_type(m.class_id), + name: m.name, + summary: m.summary, + author: m + .authors + .first() + .map(|a| a.name.clone()) + .unwrap_or_default(), + icon_url: m + .logo + .and_then(|l| l.thumbnail_url.or(l.url)) + .filter(|s| !s.is_empty()), + downloads: m.download_count, + created: m.date_created, + updated: m.date_modified, + loaders: Vec::new(), + game_versions: Vec::new(), + } + } +} + +impl CfMod { + fn into_detail(self) -> ProjectDetail { + ProjectDetail { + id: self.id.to_string(), + slug: self.slug, + provider: ProviderId::CurseForge, + content_type: cf_class_to_type(self.class_id), + name: self.name.clone(), + summary: self.summary, + author: self + .authors + .first() + .map(|a| a.name.clone()) + .unwrap_or_default(), + members: self + .authors + .iter() + .map(|a| ProjectMember { + name: a.name.clone(), + role: "Author".to_string(), + url: a.url.clone(), + avatar_url: None, + }) + .collect(), + gallery: self + .screenshots + .into_iter() + .map(|s| GalleryImage { + url: s.url, + title: s.title.filter(|t| !t.is_empty()), + }) + .collect(), + license: None, + links: self.links.as_ref().map(cf_links).unwrap_or_default(), + body: PackageBody::Raw(String::new()), + version_ids: Vec::new(), + game_versions: Vec::new(), + loaders: Vec::new(), + icon_url: self + .logo + .and_then(|l| l.thumbnail_url.or(l.url)) + .filter(|s| !s.is_empty()), + created: self.date_created, + updated: self.date_modified, + downloads: self.download_count, + } + } +} + +impl From for VersionSummary { + fn from(f: CfFile) -> Self { + VersionSummary { + version_id: f.id.to_string(), + project_id: f.mod_id.to_string(), + name: f.display_name, + version_number: f.file_name, + published: f.file_date, + release_type: match f.release_type { + 2 => ReleaseType::Beta, + 3 => ReleaseType::Alpha, + _ => ReleaseType::Release, + }, + game_versions: f.game_versions, + loaders: f + .mod_loaders + .iter() + .filter_map(|l| cf_loader_to_game(*l)) + .collect(), + downloads: f.download_count, + file_size: f.file_length, + } + } +} + +impl From for VersionDetail { + fn from(f: CfFile) -> Self { + let sha1 = f + .hashes + .iter() + .find(|h| h.algorithm == 1) + .map(|h| h.value.clone()) + .unwrap_or_default(); + + VersionDetail { + version_id: f.id.to_string(), + project_id: f.mod_id.to_string(), + name: f.display_name, + version_number: f.file_name.clone(), + changelog: None, + game_versions: f.game_versions, + loaders: f + .mod_loaders + .iter() + .filter_map(|l| cf_loader_to_game(*l)) + .collect(), + published: f.file_date, + downloads: f.download_count, + files: vec![VersionFile { + sha1, + url: f.download_url, + file_name: f.file_name, + primary: true, + size: f.file_length, + fingerprint: Some(f.file_fingerprint.to_string()), + }], + dependencies: f.dependencies.into_iter().map(Into::into).collect(), + } + } +} + +fn cf_class_id(t: ContentType) -> u32 { + match t { + ContentType::Mod => 6, + ContentType::ResourcePack => 12, + ContentType::DataPack => 4472, + ContentType::Shader => 6552, + ContentType::Modpack => 4471, + ContentType::World => 6, + } +} + +fn cf_class_to_type(class_id: u32) -> ContentType { + match CfClass::from(class_id) { + CfClass::ResourcePack => ContentType::ResourcePack, + CfClass::DataPack => ContentType::DataPack, + CfClass::Shader => ContentType::Shader, + CfClass::Modpack => ContentType::Modpack, + CfClass::Mod => ContentType::Mod, + } +} + +impl From for CfLoader { + fn from(value: u8) -> Self { + match value { + 1 => Self::Forge, + 2 => Self::Cauldron, + 3 => Self::LiteLoader, + 4 => Self::Fabric, + 5 => Self::Quilt, + 6 => Self::NeoForge, + _ => Self::Cauldron, + } + } +} + +impl From for CfClass { + fn from(value: u32) -> Self { + match value { + 6 => Self::Mod, + 12 => Self::ResourcePack, + 4472 => Self::DataPack, + 6552 => Self::Shader, + 4471 => Self::Modpack, + _ => Self::Mod, + } + } +} + +fn cf_loader_to_game(loader: CfLoader) -> Option { + match loader { + CfLoader::Forge => Some(GameLoader::Forge), + CfLoader::Fabric => Some(GameLoader::Fabric), + CfLoader::Quilt => Some(GameLoader::Quilt), + CfLoader::NeoForge => Some(GameLoader::NeoForge), + _ => None, + } +} + +fn cf_loader_type(loader: GameLoader) -> Option { + match loader { + GameLoader::Forge => Some(1), + GameLoader::Fabric => Some(4), + GameLoader::Quilt => Some(5), + GameLoader::NeoForge => Some(6), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// relationType is a bare number + /// a mismap quietly turns required libraries into optional ones + #[test] + fn file_carries_its_dependencies() { + let raw = serde_json::json!({ + "id": 1, + "modId": 2, + "displayName": "Sodium", + "fileName": "sodium.jar", + "releaseType": 1, + "fileDate": "2025-01-01T00:00:00Z", + "downloadCount": 10, + "gameVersions": ["1.21.4"], + "modLoaders": [], + "hashes": [], + "fileFingerprint": 123, + "downloadUrl": "https://example.invalid/sodium.jar", + "fileLength": 100, + "dependencies": [ + {"modId": 10, "relationType": 3}, + {"modId": 11, "relationType": 2}, + {"modId": 12, "relationType": 5}, + {"modId": 13, "relationType": 1} + ] + }); + + let version: VersionDetail = serde_json::from_value::(raw) + .expect("curseforge file") + .into(); + + assert_eq!( + version.dependencies[0], + VersionDependency { + project_id: Some("10".into()), + version_id: None, + kind: DependencyKind::Required, + } + ); + assert_eq!(version.dependencies[1].kind, DependencyKind::Optional); + assert_eq!(version.dependencies[2].kind, DependencyKind::Incompatible); + assert_eq!(version.dependencies[3].kind, DependencyKind::Embedded); + } +} diff --git a/packages/oneclient_content/src/packages/provider/http.rs b/packages/oneclient_content/src/packages/provider/http.rs new file mode 100644 index 00000000..5b67bc78 --- /dev/null +++ b/packages/oneclient_content/src/packages/provider/http.rs @@ -0,0 +1,50 @@ +use reqwest::Method; +use serde::de::DeserializeOwned; + +use crate::ctx::ContentCtx; +use oneclient_events::GroupedProgressChild; +use oneclient_net::{RequestClient, RequestError, ResponseNotifyOptions, ResponseOptions}; + +#[tracing::instrument(level = "debug", skip(client, url, body))] +pub async fn fetch_json( + client: &RequestClient, + method: Method, + url: impl reqwest::IntoUrl, + body: Option, +) -> Result { + let url = url.into_url()?; + client.send_json(method, url, body, &[]).await +} + +#[tracing::instrument(level = "debug", skip(client, url, body, headers))] +pub async fn fetch_json_with_headers( + client: &RequestClient, + method: Method, + url: impl reqwest::IntoUrl, + body: Option, + headers: &[(String, String)], +) -> Result { + let url = url.into_url()?; + let borrowed: Vec<(&str, &str)> = headers + .iter() + .map(|(name, value)| (name.as_str(), value.as_str())) + .collect(); + client.send_json(method, url, body, &borrowed).await +} + +#[tracing::instrument(level = "debug", skip(client, dest, child, ctx))] +pub async fn download_url( + client: &RequestClient, + url: &str, + dest: impl AsRef + Send, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> Result<(), RequestError> { + let request = reqwest::Request::new(Method::GET, url.parse()?); + let options = ResponseOptions { + notify: child.map(|c| ResponseNotifyOptions::grouped(c.clone())), + }; + client + .download_file(request, dest, options, &ctx.events) + .await +} diff --git a/packages/oneclient_content/src/packages/provider/mod.rs b/packages/oneclient_content/src/packages/provider/mod.rs new file mode 100644 index 00000000..48367712 --- /dev/null +++ b/packages/oneclient_content/src/packages/provider/mod.rs @@ -0,0 +1,85 @@ +mod curseforge; +pub mod http; +mod modrinth; +mod registry; + +pub use curseforge::CurseForgeProvider; +pub use modrinth::ModrinthProvider; +pub use registry::PackageProviderRegistry; + +use super::types::{ + Page, ProjectDetail, ProjectSummary, SearchFilters, VersionDetail, VersionLookup, + VersionSummary, +}; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::file_identity::FileIdentity; +use oneclient_common::domain::ProviderId; + +#[async_trait::async_trait] +pub trait PackageProvider: Send + Sync { + fn id(&self) -> ProviderId; + + async fn search( + &self, + filters: &SearchFilters, + ctx: &ContentCtx, + ) -> ContentResult>; + + async fn get_project(&self, project_id: &str, ctx: &ContentCtx) + -> ContentResult; + + /// Populates [`ProjectDetail::body`] + /// Costs an extra request on providers that serve descriptions separately + /// so install paths should stay on `get_project` + async fn get_project_with_body( + &self, + project_id: &str, + ctx: &ContentCtx, + ) -> ContentResult { + self.get_project(project_id, ctx).await + } + + async fn get_projects( + &self, + project_ids: &[String], + ctx: &ContentCtx, + ) -> ContentResult>; + + async fn list_versions( + &self, + project_id: &str, + game_version: Option<&str>, + loader: Option, + offset: usize, + limit: usize, + ctx: &ContentCtx, + ) -> ContentResult>; + + async fn get_version( + &self, + project_id: &str, + version_id: &str, + ctx: &ContentCtx, + ) -> ContentResult; + + async fn get_versions( + &self, + version_ids: &[String], + ctx: &ContentCtx, + ) -> ContentResult>; + + async fn lookup_versions( + &self, + identities: &[FileIdentity], + ctx: &ContentCtx, + ) -> ContentResult; + + async fn list_categories( + &self, + _content_type: oneclient_common::domain::ContentType, + _ctx: &ContentCtx, + ) -> ContentResult> { + Ok(Vec::new()) + } +} diff --git a/packages/oneclient_content/src/packages/provider/modrinth.rs b/packages/oneclient_content/src/packages/provider/modrinth.rs new file mode 100644 index 00000000..b2bb82f9 --- /dev/null +++ b/packages/oneclient_content/src/packages/provider/modrinth.rs @@ -0,0 +1,744 @@ +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; + +use chrono::{DateTime, Utc}; +use reqwest::Method; +use serde::Deserialize; +use url::Url; + +use serde::de::DeserializeOwned; + +use super::PackageProvider; +use super::http::fetch_json_with_headers; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::file_identity::FileIdentity; +use crate::packages::types::{ + DependencyKind, GalleryImage, PackageBody, Page, ProjectDetail, ProjectMember, ProjectSummary, + ReleaseType, SearchFilters, VersionDependency, VersionDetail, VersionFile, VersionLookup, + VersionSummary, +}; +use oneclient_common::constants::MODRINTH_API_URL; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_net::{RequestClient, RequestError}; + +pub struct ModrinthProvider; + +fn v2(path: &str) -> String { + format!("{MODRINTH_API_URL}/v2{path}") +} + +async fn fetch_json( + client: &RequestClient, + method: Method, + url: impl reqwest::IntoUrl, + body: Option, +) -> Result { + fetch_json_with_headers( + client, + method, + url, + body, + &client.config().modrinth_headers(), + ) + .await +} + +#[async_trait::async_trait] +impl PackageProvider for ModrinthProvider { + fn id(&self) -> ProviderId { + ProviderId::Modrinth + } + + #[tracing::instrument(level = "debug", skip(self, filters, ctx))] + async fn search( + &self, + filters: &SearchFilters, + ctx: &ContentCtx, + ) -> ContentResult> { + tracing::debug!(query = ?filters.query, "searching modrinth"); + let mut url = Url::parse(&v2("/search"))?; + { + let limit = filters + .limit + .unwrap_or(super::super::types::DEFAULT_PAGE_SIZE); + + let offset = filters.offset.unwrap_or(0); + let mut params = url.query_pairs_mut(); + params.append_pair("limit", &limit.to_string()); + params.append_pair("offset", &offset.to_string()); + params.append_pair("query", &filters.normalized_query()); + + let mut groups: Vec> = Vec::new(); + if let Some(content_type) = filters.content_type { + groups.push(vec![format!( + "project_type:{}", + content_type.modrinth_type() + )]); + } + if let Some(versions) = &filters.game_versions { + let group: Vec = versions.iter().map(|v| format!("versions:{v}")).collect(); + if !group.is_empty() { + groups.push(group); + } + } + if let Some(loaders) = &filters.loaders { + let group: Vec = loaders + .iter() + .map(|l| format!("categories:{}", l.modrinth_name())) + .collect(); + if !group.is_empty() { + groups.push(group); + } + } + if let Some(categories) = &filters.categories { + for category in categories { + groups.push(vec![format!("categories:{category}")]); + } + } + if !groups.is_empty() { + params.append_pair( + "facets", + &serde_json::to_string(&groups).unwrap_or_default(), + ); + } + + if let Some(sort) = filters.sort { + params.append_pair("index", sort.modrinth_index()); + } + } + + #[derive(Deserialize)] + struct Response { + hits: Vec, + offset: usize, + limit: usize, + total_hits: usize, + } + + #[derive(Deserialize)] + struct Hit { + project_id: String, + project_type: String, + slug: String, + author: String, + title: String, + description: String, + #[serde(default)] + categories: Vec, + #[serde(default, alias = "versions")] + game_versions: Vec, + downloads: u64, + icon_url: Option, + date_created: DateTime, + date_modified: DateTime, + } + + let response: Response = fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; + + Ok(Page { + offset: response.offset, + limit: response.limit, + total: response.total_hits, + items: response + .hits + .into_iter() + .map(|h| ProjectSummary { + id: h.project_id, + slug: h.slug, + provider: ProviderId::Modrinth, + content_type: parse_content_type(&h.project_type), + name: h.title, + summary: h.description, + author: h.author, + icon_url: h.icon_url, + downloads: h.downloads, + created: h.date_created, + updated: h.date_modified, + loaders: h + .categories + .iter() + .filter_map(|c| GameLoader::from_str(c).ok()) + .collect(), + game_versions: h.game_versions, + }) + .collect(), + }) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn get_project( + &self, + project_id: &str, + ctx: &ContentCtx, + ) -> ContentResult { + let project_url = v2(&format!("/project/{project_id}")); + let members_url = v2(&format!("/project/{project_id}/members")); + let project_fut = fetch_json::(&ctx.net, Method::GET, &project_url, None); + let members_fut = + fetch_json::>(&ctx.net, Method::GET, &members_url, None); + let (raw, members) = tokio::join!(project_fut, members_fut); + + let mut detail = raw?.into_detail(); + if let Ok(members) = members { + apply_modrinth_members(&mut detail, members); + } + Ok(detail) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn list_categories( + &self, + content_type: ContentType, + ctx: &ContentCtx, + ) -> ContentResult> { + #[derive(Deserialize)] + struct Tag { + name: String, + project_type: String, + } + let tags: Vec = fetch_json(&ctx.net, Method::GET, &v2("/tag/category"), None).await?; + let want = content_type.modrinth_type(); + Ok(tags + .into_iter() + .filter(|t| t.project_type == want) + .map(|t| t.name) + .collect()) + } + + #[tracing::instrument(level = "debug", skip(self, project_ids, ctx))] + async fn get_projects( + &self, + project_ids: &[String], + ctx: &ContentCtx, + ) -> ContentResult> { + if project_ids.is_empty() { + return Ok(Vec::new()); + } + let ids = project_ids + .iter() + .map(|id| format!("\"{id}\"")) + .collect::>() + .join(","); + + let raw: Vec = fetch_json( + &ctx.net, + Method::GET, + &v2(&format!("/projects?ids=[{ids}]")), + None, + ) + .await?; + + let team_ids: Vec = raw + .iter() + .map(|p| p.team.clone()) + .collect::>() + .into_iter() + .collect(); + + let mut members_by_team = fetch_modrinth_team_members(&ctx.net, &team_ids) + .await + .unwrap_or_default(); + + Ok(raw + .into_iter() + .map(|project| { + let team_id = project.team.clone(); + let mut detail = project.into_detail(); + if let Some(members) = members_by_team.remove(&team_id) { + apply_modrinth_members(&mut detail, members); + } + detail + }) + .collect()) + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + async fn list_versions( + &self, + project_id: &str, + game_version: Option<&str>, + loader: Option, + offset: usize, + limit: usize, + ctx: &ContentCtx, + ) -> ContentResult> { + let mut url = Url::parse(&v2(&format!("/project/{project_id}/version")))?; + if let Some(v) = game_version { + url.query_pairs_mut() + .append_pair("game_versions", &format!("[\"{v}\"]")); + } + if let Some(loader) = loader { + url.query_pairs_mut() + .append_pair("loaders", &format!("[\"{}\"]", loader.modrinth_name())); + } + + let versions: Vec = + fetch_json(&ctx.net, Method::GET, url.as_str(), None).await?; + + let total = versions.len(); + let items = versions + .into_iter() + .skip(offset) + .take(limit) + .map(VersionSummary::from) + .collect(); + + Ok(Page { + offset, + limit, + total, + items, + }) + } + + #[tracing::instrument(level = "debug", skip(self, _project_id, ctx))] + async fn get_version( + &self, + _project_id: &str, + version_id: &str, + ctx: &ContentCtx, + ) -> ContentResult { + let raw: ModrinthVersion = fetch_json( + &ctx.net, + Method::GET, + &v2(&format!("/version/{version_id}")), + None, + ) + .await?; + Ok(raw.into()) + } + + #[tracing::instrument(level = "debug", skip(self, version_ids, ctx))] + async fn get_versions( + &self, + version_ids: &[String], + ctx: &ContentCtx, + ) -> ContentResult> { + if version_ids.is_empty() { + return Ok(Vec::new()); + } + let ids = version_ids + .iter() + .map(|id| format!("\"{id}\"")) + .collect::>() + .join(","); + let raw: Vec = fetch_json( + &ctx.net, + Method::GET, + &v2(&format!("/versions?ids=[{ids}]")), + None, + ) + .await?; + Ok(raw.into_iter().map(Into::into).collect()) + } + + #[tracing::instrument(level = "debug", skip_all)] + async fn lookup_versions( + &self, + identities: &[FileIdentity], + ctx: &ContentCtx, + ) -> ContentResult { + let sha1_hashes: Vec = identities + .iter() + .map(|id| id.sha1.clone()) + .collect::>() + .into_iter() + .collect(); + + if sha1_hashes.is_empty() { + return Ok(HashMap::new()); + } + + let body = serde_json::json!({ + "hashes": sha1_hashes, + "algorithm": "sha1" + }); + + let fetched: HashMap = + fetch_json(&ctx.net, Method::POST, &v2("/version_files"), Some(body)).await?; + + let mut out = HashMap::new(); + for identity in identities { + if let Some(version) = fetched.get(&identity.sha1) { + out.insert(identity.sha1.clone(), version.clone().into()); + } + } + + Ok(out) + } +} + +#[tracing::instrument(level = "debug", skip(client, team_ids))] +async fn fetch_modrinth_team_members( + client: &RequestClient, + team_ids: &[String], +) -> Result>, RequestError> { + if team_ids.is_empty() { + return Ok(HashMap::new()); + } + let ids = team_ids + .iter() + .map(|id| format!("\"{id}\"")) + .collect::>() + .join(","); + let teams: Vec> = fetch_json( + client, + Method::GET, + &v2(&format!("/teams?ids=[{ids}]")), + None, + ) + .await?; + Ok(teams + .into_iter() + .filter_map(|members| { + let team_id = members.first()?.team_id.clone(); + Some((team_id, members)) + }) + .collect()) +} + +#[derive(Deserialize)] +struct ModrinthProject { + id: String, + slug: String, + team: String, + project_type: String, + title: String, + description: String, + body: Option, + body_url: Option, + #[serde(default)] + game_versions: Vec, + #[serde(default)] + loaders: Vec, + #[serde(default)] + icon_url: Option, + published: DateTime, + updated: DateTime, + downloads: u64, + license: Option, + source_url: Option, + issues_url: Option, + wiki_url: Option, + discord_url: Option, + #[serde(default)] + gallery: Vec, +} + +#[derive(Deserialize)] +struct ModrinthGalleryItem { + url: String, + /// Original upload + /// `url` is Modrinth's re-encoded copy and looks soft when scaled up + #[serde(default)] + raw_url: Option, + #[serde(default)] + title: Option, +} + +#[derive(Deserialize)] +struct ModrinthMember { + #[serde(default)] + team_id: String, + role: String, + user: ModrinthUser, +} + +#[derive(Deserialize)] +struct ModrinthUser { + username: String, + #[serde(default)] + avatar_url: Option, +} + +impl ModrinthMember { + fn into_member(self) -> ProjectMember { + ProjectMember { + url: Some(format!("https://modrinth.com/user/{}", self.user.username)), + name: self.user.username, + role: self.role, + avatar_url: self.user.avatar_url, + } + } +} + +fn apply_modrinth_members(detail: &mut ProjectDetail, members: Vec) { + detail.members = members + .into_iter() + .map(ModrinthMember::into_member) + .collect(); + if detail.author.is_empty() { + detail.author = detail + .members + .iter() + .find(|m| m.role.eq_ignore_ascii_case("owner")) + .or_else(|| detail.members.first()) + .map(|m| m.name.clone()) + .unwrap_or_default(); + } +} + +#[derive(Deserialize)] +struct ModrinthLicense { + #[serde(default)] + name: Option, + id: Option, +} + +impl ModrinthProject { + fn into_detail(self) -> ProjectDetail { + let body = if let Some(raw) = self.body { + PackageBody::Raw(raw) + } else if let Some(url) = self.body_url { + PackageBody::Url(url) + } else { + PackageBody::Raw(String::new()) + }; + + let license = self + .license + .and_then(|l| l.name.or(l.id)) + .filter(|s| !s.is_empty()); + + let mut links = Vec::new(); + for (label, url) in [ + ("Source", self.source_url), + ("Issues", self.issues_url), + ("Wiki", self.wiki_url), + ("Discord", self.discord_url), + ] { + if let Some(url) = url.filter(|u| !u.is_empty()) { + links.push((label.to_string(), url)); + } + } + + ProjectDetail { + id: self.id.clone(), + slug: self.slug, + provider: ProviderId::Modrinth, + content_type: parse_content_type(&self.project_type), + name: self.title, + summary: self.description, + author: String::new(), + members: Vec::new(), + gallery: self + .gallery + .into_iter() + .map(|g| GalleryImage { + url: g.raw_url.filter(|url| !url.is_empty()).unwrap_or(g.url), + title: g.title.filter(|t| !t.is_empty()), + }) + .collect(), + license, + links, + body, + version_ids: Vec::new(), + game_versions: self.game_versions, + loaders: self + .loaders + .iter() + .filter_map(|l| GameLoader::from_str(l).ok()) + .collect(), + icon_url: self.icon_url, + created: self.published, + updated: self.updated, + downloads: self.downloads, + } + } +} + +#[derive(Clone, Deserialize)] +struct ModrinthVersion { + id: String, + project_id: String, + name: String, + version_number: String, + changelog: Option, + game_versions: Vec, + loaders: Vec, + date_published: DateTime, + downloads: u64, + #[serde(default)] + files: Vec, + #[serde(default)] + version_type: String, + #[serde(default)] + dependencies: Vec, +} + +#[derive(Clone, Deserialize)] +struct ModrinthDependency { + #[serde(default)] + project_id: Option, + #[serde(default)] + version_id: Option, + #[serde(default)] + dependency_type: String, +} + +impl From for VersionDependency { + fn from(d: ModrinthDependency) -> Self { + Self { + project_id: d.project_id.filter(|id| !id.is_empty()), + version_id: d.version_id.filter(|id| !id.is_empty()), + kind: match d.dependency_type.as_str() { + "required" => DependencyKind::Required, + "incompatible" => DependencyKind::Incompatible, + "embedded" => DependencyKind::Embedded, + _ => DependencyKind::Optional, + }, + } + } +} + +#[derive(Clone, Deserialize)] +struct ModrinthFile { + hashes: ModrinthHashes, + url: String, + filename: String, + #[serde(default)] + primary: bool, + #[serde(default)] + size: u64, +} + +#[derive(Clone, Deserialize)] +struct ModrinthHashes { + sha1: String, +} + +impl From for VersionDetail { + fn from(v: ModrinthVersion) -> Self { + Self { + version_id: v.id, + project_id: v.project_id, + name: v.name.clone(), + version_number: v.version_number, + changelog: v.changelog, + game_versions: v.game_versions, + loaders: v + .loaders + .iter() + .filter_map(|l| GameLoader::from_str(l).ok()) + .collect(), + published: v.date_published, + downloads: v.downloads, + files: v.files.into_iter().map(Into::into).collect(), + dependencies: v.dependencies.into_iter().map(Into::into).collect(), + } + } +} + +impl From for VersionSummary { + fn from(v: ModrinthVersion) -> Self { + let file_size = v + .files + .iter() + .find(|f| f.primary) + .or_else(|| v.files.first()) + .map(|f| f.size) + .unwrap_or(0); + VersionSummary { + version_id: v.id, + project_id: v.project_id, + name: v.name, + version_number: v.version_number, + published: v.date_published, + release_type: parse_release_type(&v.version_type), + game_versions: v.game_versions, + loaders: v + .loaders + .iter() + .filter_map(|l| GameLoader::from_str(l).ok()) + .collect(), + downloads: v.downloads, + file_size, + } + } +} + +impl From for VersionFile { + fn from(f: ModrinthFile) -> Self { + Self { + sha1: f.hashes.sha1, + url: f.url, + file_name: f.filename, + primary: f.primary, + size: f.size, + fingerprint: None, + } + } +} + +fn parse_content_type(s: &str) -> ContentType { + match s { + "mod" => ContentType::Mod, + "resourcepack" => ContentType::ResourcePack, + "shader" => ContentType::Shader, + "datapack" => ContentType::DataPack, + "modpack" => ContentType::Modpack, + _ => ContentType::Mod, + } +} + +fn parse_release_type(s: &str) -> ReleaseType { + match s { + "beta" => ReleaseType::Beta, + "alpha" => ReleaseType::Alpha, + _ => ReleaseType::Release, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A field rename here silently empties the dependency list + #[test] + fn version_carries_its_dependencies() { + let raw = serde_json::json!({ + "id": "abc", + "project_id": "sodium", + "name": "Sodium 0.6", + "version_number": "0.6", + "changelog": null, + "game_versions": ["1.21.4"], + "loaders": ["fabric"], + "date_published": "2025-01-01T00:00:00Z", + "downloads": 10, + "version_type": "release", + "files": [], + "dependencies": [ + {"project_id": "fabric-api", "version_id": null, "dependency_type": "required"}, + {"project_id": null, "version_id": "pinned", "dependency_type": "required"}, + {"project_id": "sodium-extra", "version_id": null, "dependency_type": "optional"}, + {"project_id": "optifine", "version_id": null, "dependency_type": "incompatible"}, + ], + }); + + let version: VersionDetail = serde_json::from_value::(raw) + .expect("modrinth version") + .into(); + + assert_eq!(version.dependencies.len(), 4); + assert_eq!( + version.dependencies[0], + VersionDependency { + project_id: Some("fabric-api".into()), + version_id: None, + kind: DependencyKind::Required, + } + ); + assert_eq!( + version.dependencies[1], + VersionDependency { + project_id: None, + version_id: Some("pinned".into()), + kind: DependencyKind::Required, + } + ); + assert_eq!(version.dependencies[2].kind, DependencyKind::Optional); + assert_eq!(version.dependencies[3].kind, DependencyKind::Incompatible); + } +} diff --git a/packages/oneclient_content/src/packages/provider/registry.rs b/packages/oneclient_content/src/packages/provider/registry.rs new file mode 100644 index 00000000..cc7dadcd --- /dev/null +++ b/packages/oneclient_content/src/packages/provider/registry.rs @@ -0,0 +1,154 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use super::{CurseForgeProvider, ModrinthProvider, PackageProvider}; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::error::PackageError; +use crate::packages::file_identity::FileIdentity; +use crate::packages::store::artifact_absolute_path; +use crate::packages::types::{VersionDetail, VersionLookup}; +use oneclient_common::domain::ProviderId; + +#[derive(Clone)] +pub struct PackageProviderRegistry { + providers: HashMap>, +} + +impl PackageProviderRegistry { + pub fn new() -> Self { + let mut registry = Self { + providers: HashMap::new(), + }; + registry.register(Arc::new(ModrinthProvider)); + registry.register(Arc::new(CurseForgeProvider)); + registry + } + + pub fn register(&mut self, provider: Arc) { + self.providers.insert(provider.id(), provider); + } + + pub fn get(&self, id: ProviderId) -> ContentResult<&dyn PackageProvider> { + self.providers + .get(&id) + .map(|p| p.as_ref()) + .ok_or(PackageError::ProviderNotRegistered(id).into()) + } + + pub fn remote_ids(&self) -> Vec { + ProviderId::remote_providers() + .iter() + .copied() + .filter(|id| self.providers.contains_key(id)) + .collect() + } + + pub fn remote(&self) -> Vec<&dyn PackageProvider> { + self.remote_ids() + .into_iter() + .filter_map(|id| self.providers.get(&id).map(|p| p.as_ref())) + .collect() + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn lookup_versions( + &self, + identities: &[FileIdentity], + ctx: &ContentCtx, + ) -> ContentResult { + if identities.is_empty() { + return Ok(HashMap::new()); + } + + let mut enriched: Vec = identities.to_vec(); + for identity in &mut enriched { + enrich_curseforge_fingerprint(identity, ctx).await?; + } + + let mut merged = HashMap::new(); + for id in self.remote_ids() { + let provider = self.get(id)?; + let found = provider.lookup_versions(&enriched, ctx).await?; + for (sha1, version) in found { + merged.entry(sha1).or_insert(version); + } + } + Ok(merged) + } + + #[tracing::instrument(level = "debug", skip(self, sha1, ctx))] + pub async fn lookup_version( + &self, + sha1: impl AsRef, + ctx: &ContentCtx, + ) -> ContentResult> { + let mut identity = FileIdentity::from_sha1(sha1); + enrich_curseforge_fingerprint(&mut identity, ctx).await?; + + for id in self.remote_ids() { + let provider = self.get(id)?; + let mut found = provider + .lookup_versions(std::slice::from_ref(&identity), ctx) + .await?; + if let Some(version) = found.remove(&identity.sha1) { + return Ok(Some((id, version))); + } + } + Ok(None) + } + + #[tracing::instrument(level = "debug", skip(self, ctx), fields(sha1 = %identity.sha1))] + pub async fn lookup_version_identity( + &self, + identity: &FileIdentity, + ctx: &ContentCtx, + ) -> ContentResult> { + let mut identity = identity.clone(); + enrich_curseforge_fingerprint(&mut identity, ctx).await?; + + for id in self.remote_ids() { + let provider = self.get(id)?; + let mut found = provider + .lookup_versions(std::slice::from_ref(&identity), ctx) + .await?; + if let Some(version) = found.remove(&identity.sha1) { + return Ok(Some((id, version))); + } + } + Ok(None) + } +} + +impl Default for PackageProviderRegistry { + fn default() -> Self { + Self::new() + } +} + +#[tracing::instrument(level = "debug", skip(identity, ctx), fields(sha1 = %identity.sha1))] +async fn enrich_curseforge_fingerprint( + identity: &mut FileIdentity, + ctx: &ContentCtx, +) -> ContentResult<()> { + if identity.cf_fingerprint.is_some() { + return Ok(()); + } + + let Some(row) = + oneclient_db::dao::artifact::get_artifact_by_hash(&ctx.db, &identity.sha1).await? + else { + return Ok(()); + }; + + let path = artifact_absolute_path(&row.path)?; + if !path.exists() { + return Ok(()); + } + + let bytes = polyio::read(&path).await?; + identity.cf_fingerprint = Some(crate::packages::file_identity::curseforge_fingerprint( + &bytes, + )); + Ok(()) +} diff --git a/packages/oneclient_content/src/packages/store/download.rs b/packages/oneclient_content/src/packages/store/download.rs new file mode 100644 index 00000000..8c61ebac --- /dev/null +++ b/packages/oneclient_content/src/packages/store/download.rs @@ -0,0 +1,141 @@ +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::models::ArtifactRow; + +use super::paths::{artifact_absolute_path, cache_file_path, relative_cache_path}; +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use crate::packages::types::{ExternalFile, VersionDetail, VersionFile}; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_events::GroupedProgressChild; +use polyio::{normalize_hash, sha1_file}; + +#[tracing::instrument(level = "debug", skip(child, ctx))] +pub async fn ensure_artifact_file( + hash: &str, + url: &str, + dest: &std::path::Path, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + if oneclient_net::matches_on_disk(dest, hash).await { + return Ok(polyio::stat(dest).await?.len()); + } + + tracing::debug!("downloading artifact file"); + // Modrinth and CurseForge both index by SHA-1 + let expected = polyio::Checksum::sha1(hash); + oneclient_net::download_verified( + &ctx.net, + &ctx.events, + url, + dest, + Some(&expected), + 0, + child + .cloned() + .map(oneclient_net::ResponseNotifyOptions::grouped), + ) + .await?; + + Ok(polyio::stat(dest).await?.len()) +} + +#[allow(clippy::too_many_arguments)] +#[tracing::instrument(level = "debug", skip(version, file, child, ctx))] +pub async fn download_version_file( + provider: ProviderId, + project_id: &str, + version: &VersionDetail, + content_type: ContentType, + file: &VersionFile, + force: bool, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + let hash = normalize_hash(&file.sha1); + if !force && let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, &hash).await? { + let path = artifact_absolute_path(&row.path)?; + if path.exists() { + let disk = sha1_file(&path).await?; + if disk == hash { + return Ok(row); + } + } + } + + let dest = cache_file_path( + content_type, + provider, + project_id, + &version.version_id, + &file.file_name, + )?; + + let size = ensure_artifact_file(&hash, &file.url, &dest, child, ctx).await?; + let stored_path = relative_cache_path(&dest)?; + + let published_at = version.published.to_rfc3339(); + + let row = artifact_dao::insert_artifact( + &ctx.db, + &hash, + content_type as i64, + &stored_path, + &file.file_name, + Some(size as i64), + ) + .await?; + + artifact_dao::upsert_provider_release( + &ctx.db, + provider as i64, + project_id, + &version.version_id, + &hash, + &version.name, + &version.version_number, + Some(published_at.as_str()), + &serde_json::to_string(&version.game_versions)?, + &serde_json::to_string(&version.loaders)?, + ) + .await?; + + Ok(row) +} + +#[tracing::instrument(level = "debug", skip(file, child, ctx), fields(name = %file.name))] +pub async fn download_external( + file: &ExternalFile, + force: bool, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + let hash = normalize_hash(&file.sha1); + if !force && let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, &hash).await? { + let path = artifact_absolute_path(&row.path)?; + if path.exists() { + return Ok(row); + } + } + + let dest = cache_file_path( + file.content_type, + ProviderId::Local, + "external", + &hash[..hash.len().min(16)], + &file.name, + )?; + let size = ensure_artifact_file(&hash, &file.url, &dest, child, ctx).await?; + let stored_path = relative_cache_path(&dest)?; + + artifact_dao::insert_artifact( + &ctx.db, + &hash, + file.content_type as i64, + &stored_path, + &file.name, + Some(size as i64), + ) + .await + .map_err(Into::into) +} diff --git a/packages/oneclient_content/src/packages/store/gc.rs b/packages/oneclient_content/src/packages/store/gc.rs new file mode 100644 index 00000000..f3b73a76 --- /dev/null +++ b/packages/oneclient_content/src/packages/store/gc.rs @@ -0,0 +1,284 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use oneclient_db::dao::artifact as artifact_dao; + +use crate::ctx::ContentCtx; +use crate::error::ContentResult; +use oneclient_common::paths; + +use super::paths::{artifact_absolute_path, relative_cache_path}; + +/// A download lands in the cache before its row is inserted so an in-flight fetch looks exactly like an orphan +const RECENT_GRACE: Duration = Duration::from_secs(60 * 60); + +#[derive(Debug, Default, Clone, Copy)] +pub struct GcReport { + pub artifacts_removed: usize, + pub files_removed: usize, + pub bytes_freed: u64, +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn evict_if_unused(hash: &str, ctx: &ContentCtx) -> ContentResult { + // Read the row first once it is deleted there is no way back to the file + let row = artifact_dao::get_artifact_by_hash(&ctx.db, hash).await?; + + if !artifact_dao::delete_artifact_if_unused(&ctx.db, hash).await? { + return Ok(false); + } + + if let Some(row) = row + && let Ok(path) = artifact_absolute_path(&row.path) + { + remove_cached_file(&path).await; + } + + Ok(true) +} + +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn collect_unused_artifacts(ctx: &ContentCtx) -> ContentResult { + let mut report = GcReport::default(); + + for row in artifact_dao::list_unused_artifacts(&ctx.db).await? { + let path = artifact_absolute_path(&row.path).ok(); + + // An install inserts the row then links it + // in between it looks unused + if let Some(path) = &path + && is_recent(path).await + { + continue; + } + + if !artifact_dao::delete_artifact_if_unused(&ctx.db, &row.hash).await? { + continue; + } + report.artifacts_removed += 1; + + if let Some(path) = path { + report.bytes_freed += file_len(&path).await; + remove_cached_file(&path).await; + } + } + + if report.artifacts_removed > 0 { + tracing::info!( + artifacts = report.artifacts_removed, + mb_freed = report.bytes_freed / 1_000_000, + "reclaimed unused packages" + ); + } + + Ok(report) +} + +pub async fn find_unreferenced_files(ctx: &ContentCtx) -> ContentResult> { + let cache_root = paths::packages_cache_dir()?; + if !polyio::try_exists(&cache_root).await.unwrap_or(false) { + return Ok(Vec::new()); + } + + let known: HashSet = artifact_dao::list_artifact_paths(&ctx.db) + .await? + .into_iter() + .collect(); + + // An empty artifacts table means a lost index not a cache full of garbage + // the other reading would offer to wipe every package the user has + if known.is_empty() { + tracing::debug!("no artifacts indexed; treating the package cache as fully referenced"); + return Ok(Vec::new()); + } + + let mut found = Vec::new(); + for file in walk_files(&cache_root).await { + let Ok(relative) = relative_cache_path(&file) else { + continue; + }; + if known.contains(&relative) || is_recent(&file).await { + continue; + } + + let size = file_len(&file).await; + found.push((file, size)); + } + + Ok(found) +} + +/// Reasons from the filesystem not the database so it can misread an install whose database was reset +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn remove_unreferenced_files(ctx: &ContentCtx) -> ContentResult { + let cache_root = paths::packages_cache_dir()?; + let mut report = GcReport::default(); + + for (file, size) in find_unreferenced_files(ctx).await? { + if polyio::remove_file(&file).await.is_ok() { + report.files_removed += 1; + report.bytes_freed += size; + prune_empty_parents(&file, &cache_root).await; + } + } + + if report.files_removed > 0 { + tracing::info!( + files = report.files_removed, + mb_freed = report.bytes_freed / 1_000_000, + "removed unreferenced cache files" + ); + } + + Ok(report) +} + +async fn remove_cached_file(path: &Path) { + if polyio::remove_file(path).await.is_err() { + return; + } + + if let Ok(root) = paths::packages_cache_dir() { + prune_empty_parents(path, &root).await; + } +} + +async fn prune_empty_parents(file: &Path, root: &Path) { + let mut dir = file.parent().map(Path::to_path_buf); + + while let Some(current) = dir { + if current == root || !current.starts_with(root) { + return; + } + + // `remove_dir` refuses a non-empty directory which is exactly the stop condition + if polyio::remove_dir(¤t).await.is_err() { + return; + } + + dir = current.parent().map(Path::to_path_buf); + } +} + +async fn is_recent(path: &Path) -> bool { + let Ok(meta) = polyio::stat(path).await else { + // Cannot tell how old it is so leave it be + return true; + }; + + meta.modified() + .ok() + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .is_none_or(|age| age < RECENT_GRACE) +} + +async fn file_len(path: &Path) -> u64 { + polyio::stat(path).await.map(|m| m.len()).unwrap_or(0) +} + +async fn walk_files(root: &Path) -> Vec { + let mut files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + + while let Some(dir) = stack.pop() { + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + if file_type.is_dir() { + stack.push(entry.path()); + } else if file_type.is_file() { + files.push(entry.path()); + } + } + } + + files +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn empty_parents_go_but_the_root_stays() { + let root = polyio::testing::ScratchDir::new("gc_prune"); + let cache = root.join("packages"); + let nested = cache.join("mod").join("modrinth").join("proj").join("v1"); + polyio::create_dir_all(&nested).await.unwrap(); + + let file = nested.join("a.jar"); + polyio::write(&file, b"jar".as_slice()).await.unwrap(); + polyio::remove_file(&file).await.unwrap(); + + prune_empty_parents(&file, &cache).await; + + assert!(!cache.join("mod").exists(), "empty chain should be gone"); + assert!(cache.exists(), "the cache root itself always stays"); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn a_sibling_stops_the_prune() { + let root = polyio::testing::ScratchDir::new("gc_prune_sibling"); + let cache = root.join("packages"); + let version = cache.join("mod").join("modrinth").join("proj").join("v1"); + polyio::create_dir_all(&version).await.unwrap(); + + let gone = version.join("a.jar"); + polyio::write(&gone, b"jar".as_slice()).await.unwrap(); + polyio::write(version.join("b.jar"), b"jar".as_slice()) + .await + .unwrap(); + polyio::remove_file(&gone).await.unwrap(); + + prune_empty_parents(&gone, &cache).await; + + assert!(version.exists(), "a directory with another file survives"); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn fresh_files_are_spared() { + let root = polyio::testing::ScratchDir::new("gc_recent"); + polyio::create_dir_all(root.path()).await.unwrap(); + + let file = root.join("downloading.jar"); + polyio::write(&file, b"partial".as_slice()).await.unwrap(); + + assert!(is_recent(&file).await, "a just-written file is not garbage"); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn walk_finds_files_at_every_depth() { + let root = polyio::testing::ScratchDir::new("gc_walk"); + polyio::create_dir_all(root.join("a").join("b")) + .await + .unwrap(); + polyio::write(root.join("top.jar"), b"1".as_slice()) + .await + .unwrap(); + polyio::write(root.join("a").join("mid.jar"), b"2".as_slice()) + .await + .unwrap(); + polyio::write(root.join("a").join("b").join("deep.jar"), b"3".as_slice()) + .await + .unwrap(); + + let found = walk_files(root.path()).await; + + assert_eq!(found.len(), 3, "found: {found:?}"); + + std::fs::remove_dir_all(root.path()).ok(); + } +} diff --git a/packages/oneclient_content/src/packages/store/link.rs b/packages/oneclient_content/src/packages/store/link.rs new file mode 100644 index 00000000..f0a222ac --- /dev/null +++ b/packages/oneclient_content/src/packages/store/link.rs @@ -0,0 +1,114 @@ +use std::path::Path; + +use oneclient_db::models::ClusterRow; + +use crate::error::ContentResult; +use oneclient_common::domain::ContentType; +use oneclient_common::paths; + +use super::manifest; + +#[tracing::instrument(level = "debug")] +pub async fn link_or_copy(src: &Path, dest: &Path) -> ContentResult<()> { + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await?; + } + + remove_entry(dest).await?; + + if polyio::symlink_file(src, dest).await.is_ok() { + return Ok(()); + } + + polyio::copy(src, dest).await?; + Ok(()) +} + +/// `Path::exists` resolves symlinks so a link to an evicted artifact reads as absent and survives every unlink +pub async fn remove_entry(path: &Path) -> ContentResult<()> { + if polyio::symlink_metadata(path).await.is_err() { + return Ok(()); + } + + polyio::remove_file(path).await?; + Ok(()) +} + +/// Best-effort only the folder is reconciled at the next launch regardless +/// A running game holds its jars open which on Windows blocks deletion so failure here is expected +#[tracing::instrument(level = "debug", skip(cluster), fields(cluster_id = cluster.id))] +pub async fn try_unlink_materialized( + cluster: &ClusterRow, + content_type: ContentType, + file_name: &str, +) -> bool { + let Ok(game_dir) = paths::cluster_game_dir(&cluster.folder_name) else { + return false; + }; + + let Some(mut loaded) = manifest::load(&game_dir).await else { + return false; + }; + + // The shared game dir belongs to whichever cluster played last + // touching a file we did not put there would delete another cluster's or + // the user's content + let relative = manifest::entry_path(content_type.folder_name(), file_name); + if !loaded.owns(cluster.id, &relative) { + return false; + } + + let path = game_dir.join(content_type.folder_name()).join(file_name); + if let Err(err) = remove_entry(&path).await { + tracing::debug!( + file = file_name, + error = %err, + "could not drop package from the game folder now; it goes at the next launch" + ); + return false; + } + + loaded.entries.retain(|entry| entry.path != relative); + manifest::save(&game_dir, &loaded).await; + + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn remove_entry_clears_a_dangling_link() { + let root = polyio::testing::ScratchDir::new("dangling_link"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + + let target = dir.join("target.jar"); + let link = dir.join("link.jar"); + polyio::write(&target, b"jar".as_slice()).await.unwrap(); + polyio::symlink_file(&target, &link).await.unwrap(); + polyio::remove_file(&target).await.unwrap(); + + assert!(!link.exists(), "the link resolves to nothing"); + assert!( + polyio::symlink_metadata(&link).await.is_ok(), + "but the link itself is still there" + ); + + remove_entry(&link).await.unwrap(); + assert!(polyio::symlink_metadata(&link).await.is_err()); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn remove_entry_is_fine_with_nothing_there() { + let root = polyio::testing::ScratchDir::new("remove_missing"); + polyio::create_dir_all(root.path()).await.unwrap(); + + remove_entry(&root.join("never_existed.jar")).await.unwrap(); + + std::fs::remove_dir_all(root.path()).ok(); + } +} diff --git a/packages/oneclient_content/src/packages/store/manifest.rs b/packages/oneclient_content/src/packages/store/manifest.rs new file mode 100644 index 00000000..44460d0e --- /dev/null +++ b/packages/oneclient_content/src/packages/store/manifest.rs @@ -0,0 +1,156 @@ +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +pub const MANIFEST_NAME: &str = ".oneclient-materialized.json"; + +const MANIFEST_VERSION: u32 = 1; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestEntry { + /// Slash-separated relative to the game directory e.g. `mods/sodium.jar` + pub path: String, + pub hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MaterializedManifest { + pub version: u32, + /// The shared `.minecraft` is used by every non-dedicated cluster in turn so this often is not us + pub cluster_id: i64, + pub entries: Vec, +} + +impl MaterializedManifest { + #[must_use] + pub fn new(cluster_id: i64, entries: Vec) -> Self { + Self { + version: MANIFEST_VERSION, + cluster_id, + entries, + } + } + + #[must_use] + pub fn paths(&self) -> HashSet<&str> { + self.entries.iter().map(|e| e.path.as_str()).collect() + } + + #[must_use] + pub fn contains(&self, relative_path: &str) -> bool { + self.entries.iter().any(|e| e.path == relative_path) + } + + /// The cluster check is what makes deleting out of the shared game directory safe + #[must_use] + pub fn owns(&self, cluster_id: i64, relative_path: &str) -> bool { + self.cluster_id == cluster_id && self.contains(relative_path) + } +} + +#[must_use] +pub fn manifest_path(game_dir: &Path) -> PathBuf { + game_dir.join(MANIFEST_NAME) +} + +/// An unparseable manifest reads as `None` not an error +/// stashing links as user content is recoverable refusing to launch is not +pub async fn load(game_dir: &Path) -> Option { + let raw = polyio::read_to_string(manifest_path(game_dir)).await.ok()?; + + match serde_json::from_str::(&raw) { + Ok(manifest) if manifest.version == MANIFEST_VERSION => Some(manifest), + Ok(manifest) => { + tracing::warn!( + version = manifest.version, + "ignoring materialized manifest written by a different launcher version" + ); + None + } + Err(err) => { + tracing::warn!(error = %err, "materialized manifest is unreadable; ignoring"); + None + } + } +} + +pub async fn save(game_dir: &Path, manifest: &MaterializedManifest) { + let body = match serde_json::to_vec_pretty(manifest) { + Ok(body) => body, + Err(err) => { + tracing::warn!(error = %err, "failed to encode materialized manifest"); + return; + } + }; + + if let Err(err) = polyio::write(manifest_path(game_dir), body).await { + tracing::warn!(error = %err, "failed to write materialized manifest"); + } +} + +pub async fn clear(game_dir: &Path) { + polyio::remove_file(manifest_path(game_dir)).await.ok(); +} + +#[must_use] +pub fn entry_path(content_folder: &str, file_name: &str) -> String { + format!("{content_folder}/{file_name}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest() -> MaterializedManifest { + MaterializedManifest::new( + 7, + vec![ManifestEntry { + path: "mods/sodium.jar".into(), + hash: "abc".into(), + }], + ) + } + + #[test] + fn ownership_is_scoped_to_the_cluster_that_wrote_it() { + let manifest = manifest(); + + assert!(manifest.owns(7, "mods/sodium.jar")); + assert!(!manifest.owns(8, "mods/sodium.jar")); + assert!(!manifest.owns(7, "mods/iris.jar")); + } + + #[tokio::test] + async fn round_trips_through_the_game_dir() { + let root = polyio::testing::ScratchDir::new("manifest_round_trip"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + + assert!(load(dir).await.is_none(), "no manifest yet"); + + save(dir, &manifest()).await; + let loaded = load(dir).await.expect("manifest should load"); + assert_eq!(loaded.cluster_id, 7); + assert!(loaded.contains("mods/sodium.jar")); + + clear(dir).await; + assert!(load(dir).await.is_none(), "cleared manifest should be gone"); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn garbage_manifest_reads_as_absent() { + let root = polyio::testing::ScratchDir::new("manifest_garbage"); + let dir = root.path(); + polyio::create_dir_all(dir).await.unwrap(); + polyio::write(manifest_path(dir), b"not json".as_slice()) + .await + .unwrap(); + + assert!(load(dir).await.is_none()); + + std::fs::remove_dir_all(root.path()).ok(); + } +} diff --git a/packages/oneclient_content/src/packages/store/mod.rs b/packages/oneclient_content/src/packages/store/mod.rs new file mode 100644 index 00000000..69eb9e65 --- /dev/null +++ b/packages/oneclient_content/src/packages/store/mod.rs @@ -0,0 +1,475 @@ +mod download; +mod gc; +mod link; +pub mod manifest; +mod paths; + +pub use download::{download_external, download_version_file, ensure_artifact_file}; +pub use gc::{ + GcReport, collect_unused_artifacts, evict_if_unused, find_unreferenced_files, + remove_unreferenced_files, +}; +pub use link::{link_or_copy, remove_entry, try_unlink_materialized}; +pub use paths::{artifact_absolute_path, cache_file_path, relative_cache_path}; + +use oneclient_db::dao::{artifact as artifact_dao, cluster as cluster_dao}; +use oneclient_db::models::{ArtifactRow, ClusterRow}; + +use super::error::PackageError; +use super::types::{ + CachedArtifact, LinkedArtifactInfo, ProjectDetail, ProviderReleaseInfo, VersionDetail, +}; +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_events::GroupedProgressChild; +use polyio::{normalize_hash, sha1_file}; +use std::path::Path; + +pub struct PackageStore; + +impl PackageStore { + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn get_cluster(cluster_id: i64, ctx: &ContentCtx) -> ContentResult { + cluster_dao::get_by_id(&ctx.db, cluster_id) + .await? + .ok_or(PackageError::ClusterNotFound(cluster_id).into()) + } + + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn cached_artifact( + hash: &str, + ctx: &ContentCtx, + ) -> ContentResult> { + let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, hash).await? else { + return Ok(None); + }; + + Ok(Some(row_to_cached(row, ctx).await?)) + } + + #[tracing::instrument(level = "debug", skip(project, version, child, ctx), fields(project_id = %project.id, version_id = %version.version_id))] + pub async fn download_and_cache( + provider_id: ProviderId, + project: &ProjectDetail, + version: &VersionDetail, + force: bool, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, + ) -> ContentResult { + let file = version.primary_file().ok_or(PackageError::NoPrimaryFile)?; + + download::download_version_file( + provider_id, + &project.id, + version, + project.content_type, + file, + force, + child, + ctx, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + #[tracing::instrument(skip(project, version, child, ctx), fields(project_id = %project.id, version_id = %version.version_id))] + pub async fn install_to_cluster( + provider_id: ProviderId, + project: &ProjectDetail, + version: &VersionDetail, + cluster_id: i64, + skip_compatibility: bool, + force_download: bool, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, + ) -> ContentResult { + tracing::info!("installing package to cluster"); + let cluster = Self::get_cluster(cluster_id, ctx).await?; + + if !skip_compatibility { + ensure_compatible(project, version, &cluster)?; + } + + let artifact = + Self::download_and_cache(provider_id, project, version, force_download, child, ctx) + .await?; + + Self::link_artifact(&artifact, &cluster, None, ctx).await?; + Ok(artifact) + } + + /// Writes nothing to disk + /// the artifact is materialized into the game directory at launch the only + /// moment no game is holding the files open + #[tracing::instrument(level = "debug", skip(artifact, cluster, ctx))] + pub async fn link_artifact( + artifact: &ArtifactRow, + cluster: &ClusterRow, + cluster_file_name: Option<&str>, + ctx: &ContentCtx, + ) -> ContentResult<()> { + let name = cluster_file_name.unwrap_or(&artifact.file_name); + + artifact_dao::link_cluster_artifact(&ctx.db, cluster.id, &artifact.hash, name).await?; + + Ok(()) + } + + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn link_by_hash( + hash: &str, + cluster_id: i64, + cluster_file_name: Option<&str>, + ctx: &ContentCtx, + ) -> ContentResult<()> { + let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .ok_or(PackageError::ArtifactMissing(hash.to_string()))?; + + let cluster = Self::get_cluster(cluster_id, ctx).await?; + + Self::link_artifact(&artifact, &cluster, cluster_file_name, ctx).await + } + + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn list_linked_artifacts( + cluster_id: i64, + ctx: &ContentCtx, + ) -> ContentResult> { + let links = artifact_dao::list_cluster_artifacts(&ctx.db, cluster_id).await?; + let mut items = Vec::with_capacity(links.len()); + + for link in links { + let Some(artifact) = artifact_dao::get_artifact_by_hash(&ctx.db, &link.hash).await? + else { + continue; + }; + + let content_type = + ContentType::from_repr(artifact.content_type as u8).unwrap_or(ContentType::Mod); + let release = artifact_dao::get_release_by_hash(&ctx.db, &link.hash).await?; + + items.push(LinkedArtifactInfo { + hash: link.hash, + cluster_file_name: link.cluster_file_name, + enabled: link.enabled != 0, + content_type, + file_name: artifact.file_name, + project_id: release.as_ref().map(|r| r.project_id.clone()), + version_id: release.as_ref().map(|r| r.version_id.clone()), + display_name: release.as_ref().map(|r| r.display_name.clone()), + display_version: release.as_ref().map(|r| r.display_version.clone()), + provider: release + .as_ref() + .and_then(|r| ProviderId::from_repr(r.provider as u8)), + published_at: release.as_ref().and_then(|r| r.published_at.clone()), + }); + } + + Ok(items) + } + + #[tracing::instrument(level = "debug", skip(ctx))] + /// Only the flag is recorded + /// the folder is rewritten at the next launch since a running game holds + /// its jars open + /// Storage only bundle override bookkeeping lives in + /// [`crate::bundles::toggle_artifact_enabled`] to avoid mutual recursion + pub async fn set_artifact_enabled( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, + ) -> ContentResult { + Self::write_artifact_enabled(cluster_id, hash, None, ctx).await + } + + /// Returns the state it ended in which is the requested one unless the link was already there + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn set_artifact_enabled_to( + cluster_id: i64, + hash: &str, + enabled: bool, + ctx: &ContentCtx, + ) -> ContentResult { + Self::write_artifact_enabled(cluster_id, hash, Some(enabled), ctx).await + } + + /// `target` of `None` means "the opposite of whatever it is now" + async fn write_artifact_enabled( + cluster_id: i64, + hash: &str, + target: Option, + ctx: &ContentCtx, + ) -> ContentResult { + let cluster = Self::get_cluster(cluster_id, ctx).await?; + let artifact = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .ok_or(PackageError::ArtifactMissing(hash.to_string()))?; + + let link = artifact_dao::get_cluster_artifact(&ctx.db, cluster_id, hash) + .await? + .ok_or(PackageError::ArtifactMissing(hash.to_string()))?; + + let content_type = + ContentType::from_repr(artifact.content_type as u8).ok_or_else(|| { + ContentError::InvalidData { + reason: format!("unknown content type {}", artifact.content_type), + } + })?; + + let enabled = target.unwrap_or(link.enabled == 0); + let file_name = link + .cluster_file_name + .trim_end_matches(".disabled") + .to_string(); + + artifact_dao::update_cluster_artifact( + &ctx.db, + cluster_id, + hash, + &file_name, + i64::from(enabled), + ) + .await?; + + if !enabled { + link::try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; + if link.cluster_file_name != file_name { + link::try_unlink_materialized(&cluster, content_type, &file_name).await; + } + } + + Ok(enabled) + } + + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn import_local_file( + path: &Path, + content_type: ContentType, + cluster_id: i64, + ctx: &ContentCtx, + ) -> ContentResult { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| PackageError::InvalidLocalFile(path.display().to_string()))? + .to_string(); + + let hash = normalize_hash(&sha1_file(path).await?); + + if let Some(row) = artifact_dao::get_artifact_by_hash(&ctx.db, &hash).await? { + let cluster = Self::get_cluster(cluster_id, ctx).await?; + Self::link_artifact(&row, &cluster, None, ctx).await?; + return Ok(row); + } + + let dest = cache_file_path( + content_type, + ProviderId::Local, + "imported", + &hash[..hash.len().min(16)], + &file_name, + )?; + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await?; + } + polyio::copy(path, &dest).await?; + + let size = polyio::stat(&dest).await?.len(); + let stored_path = relative_cache_path(&dest)?; + + let row = artifact_dao::insert_artifact( + &ctx.db, + &hash, + content_type as i64, + &stored_path, + &file_name, + Some(size as i64), + ) + .await?; + + let cluster = Self::get_cluster(cluster_id, ctx).await?; + Self::link_artifact(&row, &cluster, None, ctx).await?; + Ok(row) + } + + #[tracing::instrument(level = "debug", skip(ctx))] + pub async fn resolve_or_download( + provider_id: ProviderId, + project_id: &str, + version_id: &str, + ctx: &ContentCtx, + ) -> ContentResult { + let provider = ctx.providers.get(provider_id)?; + + let version = provider.get_version(project_id, version_id, ctx).await?; + + let project = provider.get_project(project_id, ctx).await?; + + Self::download_and_cache(provider_id, &project, &version, false, None, ctx).await + } +} + +#[tracing::instrument(level = "debug", skip(row, ctx), fields(hash = %row.hash))] +async fn row_to_cached(row: ArtifactRow, ctx: &ContentCtx) -> ContentResult { + let path = artifact_absolute_path(&row.path)?; + let release = artifact_dao::get_release_by_hash(&ctx.db, &row.hash) + .await? + .map(|r| ProviderReleaseInfo { + provider: ProviderId::from_repr(r.provider as u8).unwrap_or(ProviderId::Local), + project_id: r.project_id, + version_id: r.version_id, + display_name: r.display_name, + display_version: r.display_version, + mc_versions: serde_json::from_str(&r.mc_versions).unwrap_or_default(), + loaders: serde_json::from_str(&r.mc_loaders).unwrap_or_default(), + }); + + Ok(CachedArtifact { + hash: row.hash, + content_type: ContentType::from_repr(row.content_type as u8).unwrap_or(ContentType::Mod), + path, + file_name: row.file_name, + size_bytes: row.size_bytes.map(|s| s as u64), + release, + }) +} + +fn ensure_compatible( + project: &ProjectDetail, + version: &VersionDetail, + cluster: &ClusterRow, +) -> ContentResult<()> { + if project.provider == ProviderId::Local { + return Ok(()); + } + + if project.content_type == ContentType::Mod { + let cluster_loader = + GameLoader::from_repr(cluster.mc_loader as u8).unwrap_or(GameLoader::Vanilla); + + if !version.loaders.is_empty() + && !version + .loaders + .iter() + .any(|l| cluster_loader.compatible_with(*l)) + { + return Err(PackageError::IncompatibleLoader.into()); + } + } + + if !version.game_versions.is_empty() + && !version + .game_versions + .iter() + .any(|v| cluster.mc_version.contains(v)) + { + return Err(PackageError::IncompatibleMcVersion.into()); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::packages::types::PackageBody; + use chrono::Utc; + + fn project(content_type: ContentType) -> ProjectDetail { + ProjectDetail { + id: "p".into(), + slug: "p".into(), + provider: ProviderId::Modrinth, + content_type, + name: "p".into(), + summary: String::new(), + author: String::new(), + members: Vec::new(), + gallery: Vec::new(), + body: PackageBody::Raw(String::new()), + license: None, + links: Vec::new(), + version_ids: Vec::new(), + game_versions: Vec::new(), + loaders: Vec::new(), + icon_url: None, + created: Utc::now(), + updated: Utc::now(), + downloads: 0, + } + } + + fn version(loaders: Vec, game_versions: Vec<&str>) -> VersionDetail { + VersionDetail { + version_id: "v".into(), + project_id: "p".into(), + name: "v".into(), + version_number: "1".into(), + changelog: None, + game_versions: game_versions.into_iter().map(Into::into).collect(), + loaders, + published: Utc::now(), + downloads: 0, + files: Vec::new(), + dependencies: Vec::new(), + } + } + + fn cluster(loader: GameLoader, mc_version: &str) -> ClusterRow { + ClusterRow { + id: 1, + name: "c".into(), + folder_name: "c".into(), + setting_profile_name: None, + mc_version: mc_version.into(), + mc_loader: loader as i64, + stage: 0, + mc_loader_version: None, + created_at: None, + last_played: None, + overall_played: None, + linked_modpack_hash: None, + } + } + + #[test] + fn resource_pack_installs_into_modded_cluster() { + let result = ensure_compatible( + &project(ContentType::ResourcePack), + &version(vec![GameLoader::Vanilla], vec!["1.21.4"]), + &cluster(GameLoader::Fabric, "1.21.4"), + ); + + assert!(result.is_ok(), "resource packs are not bound to the loader"); + } + + #[test] + fn mod_still_rejects_incompatible_loader() { + let result = ensure_compatible( + &project(ContentType::Mod), + &version(vec![GameLoader::Forge], vec!["1.21.4"]), + &cluster(GameLoader::Fabric, "1.21.4"), + ); + + assert!(matches!( + result, + Err(ContentError::Package(PackageError::IncompatibleLoader)) + )); + } + + #[test] + fn resource_pack_still_rejects_incompatible_mc_version() { + let result = ensure_compatible( + &project(ContentType::ResourcePack), + &version(vec![GameLoader::Vanilla], vec!["1.7.10"]), + &cluster(GameLoader::Fabric, "1.21.4"), + ); + + assert!(matches!( + result, + Err(ContentError::Package(PackageError::IncompatibleMcVersion)) + )); + } +} diff --git a/packages/oneclient_content/src/packages/store/paths.rs b/packages/oneclient_content/src/packages/store/paths.rs new file mode 100644 index 00000000..a6ae50a2 --- /dev/null +++ b/packages/oneclient_content/src/packages/store/paths.rs @@ -0,0 +1,31 @@ +use std::path::{Path, PathBuf}; + +use crate::error::ContentResult; +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_common::paths; + +pub fn cache_file_path( + content_type: ContentType, + provider: ProviderId, + project_id: &str, + version_id: &str, + file_name: &str, +) -> ContentResult { + Ok(paths::package_version_dir(content_type, provider, project_id, version_id)?.join(file_name)) +} + +pub fn artifact_absolute_path(stored_path: &str) -> ContentResult { + let root = paths::launcher_dir()?; + Ok(root.join(stored_path)) +} + +pub fn relative_cache_path(abs: &Path) -> ContentResult { + let root = paths::launcher_dir()?; + + let rel = abs + .strip_prefix(root) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|_| abs.to_path_buf()); + + Ok(rel.to_string_lossy().replace('\\', "/")) +} diff --git a/packages/oneclient_content/src/packages/types.rs b/packages/oneclient_content/src/packages/types.rs new file mode 100644 index 00000000..90aeadcd --- /dev/null +++ b/packages/oneclient_content/src/packages/types.rs @@ -0,0 +1,241 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_common::search::normalize_query; + +pub const DEFAULT_PAGE_SIZE: usize = 24; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SearchFilters { + pub query: Option, + pub offset: Option, + pub limit: Option, + pub content_type: Option, + pub game_versions: Option>, + pub loaders: Option>, + pub categories: Option>, + pub sort: Option, +} + +impl SearchFilters { + /// Providers rank the raw string so " sodium " and "sodium" score differently + #[must_use] + pub fn normalized_query(&self) -> String { + self.query + .as_deref() + .map(normalize_query) + .unwrap_or_default() + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SearchSort { + #[default] + Relevance, + Downloads, + Newest, + Updated, +} + +impl SearchSort { + pub fn modrinth_index(self) -> &'static str { + match self { + Self::Relevance => "relevance", + Self::Downloads => "downloads", + Self::Newest => "newest", + Self::Updated => "updated", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Page { + pub offset: usize, + pub limit: usize, + pub total: usize, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProjectSummary { + pub id: String, + pub slug: String, + pub provider: ProviderId, + pub content_type: ContentType, + pub name: String, + pub summary: String, + pub author: String, + pub icon_url: Option, + pub downloads: u64, + pub created: DateTime, + pub updated: DateTime, + pub loaders: Vec, + pub game_versions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PackageBody { + Url(String), + Raw(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectMember { + pub name: String, + pub role: String, + pub url: Option, + pub avatar_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GalleryImage { + pub url: String, + pub title: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProjectDetail { + pub id: String, + pub slug: String, + pub provider: ProviderId, + pub content_type: ContentType, + pub name: String, + pub summary: String, + pub author: String, + pub members: Vec, + pub gallery: Vec, + pub body: PackageBody, + pub license: Option, + pub links: Vec<(String, String)>, + pub version_ids: Vec, + pub game_versions: Vec, + pub loaders: Vec, + pub icon_url: Option, + pub created: DateTime, + pub updated: DateTime, + pub downloads: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionSummary { + pub version_id: String, + pub project_id: String, + pub name: String, + pub version_number: String, + pub published: DateTime, + pub release_type: ReleaseType, + pub game_versions: Vec, + pub loaders: Vec, + pub downloads: u64, + pub file_size: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ReleaseType { + #[default] + Release, + Beta, + Alpha, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionDetail { + pub version_id: String, + pub project_id: String, + pub name: String, + pub version_number: String, + pub changelog: Option, + pub game_versions: Vec, + pub loaders: Vec, + pub published: DateTime, + pub downloads: u64, + pub files: Vec, + /// Only [`DependencyKind::Required`] entries are pulled in automatically + #[serde(default)] + pub dependencies: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DependencyKind { + Required, + Optional, + Incompatible, + Embedded, +} + +/// At least one id is set Modrinth may pin a version CurseForge only names the project +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VersionDependency { + pub project_id: Option, + pub version_id: Option, + pub kind: DependencyKind, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionFile { + pub sha1: String, + pub url: String, + pub file_name: String, + pub primary: bool, + pub size: u64, + pub fingerprint: Option, +} + +impl VersionDetail { + pub fn primary_file(&self) -> Option<&VersionFile> { + self.files.iter().find(|f| f.primary).or(self.files.first()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExternalFile { + pub name: String, + pub url: String, + pub sha1: String, + pub size: u64, + pub content_type: ContentType, +} + +#[derive(Debug, Clone)] +pub struct CachedArtifact { + pub hash: String, + pub content_type: ContentType, + pub path: std::path::PathBuf, + pub file_name: String, + pub size_bytes: Option, + pub release: Option, +} + +#[derive(Debug, Clone)] +pub struct ProviderReleaseInfo { + pub provider: ProviderId, + pub project_id: String, + pub version_id: String, + pub display_name: String, + pub display_version: String, + pub mc_versions: Vec, + pub loaders: Vec, +} + +pub type VersionLookup = HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LinkedArtifactInfo { + pub hash: String, + pub cluster_file_name: String, + pub enabled: bool, + pub content_type: ContentType, + pub file_name: String, + pub project_id: Option, + pub version_id: Option, + pub display_name: Option, + pub display_version: Option, + pub provider: Option, + /// Provider publish time RFC 3339 + pub published_at: Option, +} diff --git a/packages/oneclient_content/src/packages/updates.rs b/packages/oneclient_content/src/packages/updates.rs new file mode 100644 index 00000000..27ac83c4 --- /dev/null +++ b/packages/oneclient_content/src/packages/updates.rs @@ -0,0 +1,552 @@ +//! Bundle-tracked artifacts are skipped +//! [`crate::bundles::updates`] owns those files and the two flows would fight +//! over the same artifact +//! +//! The `browser_package_updates` cache is only narrowed by a check that reached +//! the provider so an offline run is a no-op rather than "all up to date" + +use std::collections::HashSet; + +use futures_util::StreamExt; + +use oneclient_common::domain::ProviderId; +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_db::dao::browser_package_update as update_dao; +use oneclient_db::dao::cluster_bundle as bundle_dao; +use oneclient_db::models::{BrowserPackageUpdateRow, ClusterRow}; +use oneclient_events::GroupedProgressChild; + +use crate::ctx::ContentCtx; +use crate::error::{ContentError, ContentResult}; +use crate::packages::dependencies::pick_version; +use crate::packages::store::{PackageStore, evict_if_unused, try_unlink_materialized}; +use crate::packages::types::LinkedArtifactInfo; + +/// Matches the bundle installer's fan-out +/// the providers are the bottleneck +const UPDATE_CHECK_CONCURRENCY: usize = 6; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BrowserPackageUpdate { + pub cluster_id: i64, + /// The artifact currently linked into the cluster + pub hash: String, + pub provider: ProviderId, + pub project_id: String, + pub installed_version_id: String, + pub installed_version_name: String, + pub latest_version_id: String, + pub latest_version_name: String, + /// Local fallback for a UI with no meta cache entry + pub display_name: String, + /// The user has already declined this exact newer version + pub skipped: bool, +} + +impl BrowserPackageUpdate { + fn from_row(row: BrowserPackageUpdateRow) -> Self { + Self { + cluster_id: row.cluster_id, + hash: row.hash, + provider: ProviderId::from_repr(row.provider as u8).unwrap_or(ProviderId::Modrinth), + project_id: row.project_id, + installed_version_id: row.installed_version_id, + installed_version_name: row.installed_version_name, + latest_version_id: row.latest_version_id, + latest_version_name: row.latest_version_name, + display_name: row.display_name, + skipped: row.skipped != 0, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct BrowserUpdateCheck { + pub cluster_id: i64, + pub updates: Vec, + /// Unreachable this run + /// their cached rows are preserved not cleared + pub unchecked: Vec, +} + +impl BrowserUpdateCheck { + #[must_use] + pub fn pending(&self) -> Vec { + self.updates + .iter() + .filter(|u| !u.skipped) + .cloned() + .collect() + } +} + +struct Candidate { + hash: String, + provider: ProviderId, + project_id: String, + version_id: String, + display_name: String, + display_version: String, +} + +/// A missing bundle tracking row is the marker for "the user added this themselves" +fn browser_installed( + linked: &[LinkedArtifactInfo], + bundle_hashes: &HashSet, +) -> Vec { + linked + .iter() + .filter(|info| !bundle_hashes.contains(&info.hash)) + .filter_map(|info| { + let provider = info.provider?; + if provider == ProviderId::Local { + return None; + } + Some(Candidate { + hash: info.hash.clone(), + provider, + project_id: info.project_id.clone()?, + version_id: info.version_id.clone()?, + display_name: info + .display_name + .clone() + .unwrap_or_else(|| info.file_name.clone()), + display_version: info.display_version.clone().unwrap_or_default(), + }) + }) + .collect() +} + +/// An unreachable project lands in [`BrowserUpdateCheck::unchecked`] +/// only an unreadable cluster errors +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn check_browser_package_updates( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let linked = PackageStore::list_linked_artifacts(cluster_id, ctx).await?; + + let bundle_hashes: HashSet = bundle_dao::list_bundle_tracked(&ctx.db, cluster_id) + .await? + .into_iter() + .map(|row| row.hash) + .collect(); + + let candidates = browser_installed(&linked, &bundle_hashes); + if candidates.is_empty() { + return Ok(BrowserUpdateCheck { + cluster_id, + ..Default::default() + }); + } + + let skipped: HashSet = update_dao::list_for_cluster(&ctx.db, cluster_id) + .await? + .into_iter() + .filter(|row| row.skipped != 0) + .map(|row| format!("{}:{}", row.hash, row.latest_version_id)) + .collect(); + + let results = futures_util::stream::iter(candidates.into_iter().map(|candidate| { + let cluster = &cluster; + async move { + let outcome = latest_for(&candidate, cluster, ctx).await; + (candidate, outcome) + } + })) + .buffer_unordered(UPDATE_CHECK_CONCURRENCY) + .collect::>() + .await; + + let mut check = BrowserUpdateCheck { + cluster_id, + ..Default::default() + }; + + for (candidate, outcome) in results { + match outcome { + Ok(Some((latest_version_id, latest_version_name))) => { + let key = format!("{}:{latest_version_id}", candidate.hash); + check.updates.push(BrowserPackageUpdate { + cluster_id, + hash: candidate.hash, + provider: candidate.provider, + project_id: candidate.project_id, + installed_version_id: candidate.version_id, + installed_version_name: candidate.display_version, + latest_version_id, + latest_version_name, + display_name: candidate.display_name, + skipped: skipped.contains(&key), + }); + } + Ok(None) => {} + Err(err) => { + // Offline is the common case and says nothing about the launcher + if err.is_transient() { + tracing::debug!( + project_id = %candidate.project_id, + error = %err, + "update check skipped, provider unreachable" + ); + } else { + tracing::warn!( + project_id = %candidate.project_id, + error = %err, + "update check failed for package" + ); + } + check.unchecked.push(candidate.hash); + } + } + } + + Ok(check) +} + +/// `None` when the installed version is already the newest compatible one +async fn latest_for( + candidate: &Candidate, + cluster: &ClusterRow, + ctx: &ContentCtx, +) -> ContentResult> { + let provider = ctx.providers.get(candidate.provider)?; + let Some(latest) = pick_version(provider, &candidate.project_id, cluster, ctx).await? else { + return Ok(None); + }; + + if latest.version_id == candidate.version_id { + return Ok(None); + } + + let name = if latest.version_number.is_empty() { + latest.name + } else { + latest.version_number + }; + + Ok(Some((latest.version_id, name))) +} + +/// Unreported rows are dropped except unreachable ones which keep their last +/// known state +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn refresh_browser_package_updates( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult { + let check = check_browser_package_updates(cluster_id, ctx).await?; + + let mut keep: Vec = check.unchecked.clone(); + let mut stored = Vec::with_capacity(check.updates.len()); + + for update in &check.updates { + let row = update_dao::upsert( + &ctx.db, + cluster_id, + &update.hash, + update.provider as i64, + &update.project_id, + &update.installed_version_id, + &update.installed_version_name, + &update.latest_version_id, + &update.latest_version_name, + &update.display_name, + ) + .await?; + keep.push(row.hash.clone()); + // The row is the authority on `skipped` for this exact version + stored.push(BrowserPackageUpdate::from_row(row)); + } + + update_dao::retain_hashes(&ctx.db, cluster_id, &keep).await?; + + Ok(BrowserUpdateCheck { + cluster_id, + updates: stored, + unchecked: check.unchecked, + }) +} + +/// Never touches the network +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn cached_browser_package_updates( + cluster_id: i64, + ctx: &ContentCtx, +) -> ContentResult> { + Ok(update_dao::list_for_cluster(&ctx.db, cluster_id) + .await? + .into_iter() + .map(BrowserPackageUpdate::from_row) + .collect()) +} + +/// The row stays and the package still reads as out of date +/// only the modal stops asking until a newer version than the declined one +/// appears +#[tracing::instrument(level = "debug", skip(ctx))] +pub async fn skip_browser_package_update( + cluster_id: i64, + hash: &str, + ctx: &ContentCtx, +) -> ContentResult<()> { + update_dao::set_skipped(&ctx.db, cluster_id, hash, true).await?; + Ok(()) +} + +/// Download first unlink second so a failed download leaves the cluster as it was +#[tracing::instrument(level = "debug", skip(update, child, ctx), fields(cluster_id = update.cluster_id, project_id = %update.project_id))] +pub async fn apply_browser_package_update( + update: &BrowserPackageUpdate, + child: Option<&GroupedProgressChild>, + ctx: &ContentCtx, +) -> ContentResult { + // Never touch anything a bundle owns however this was called + if bundle_dao::get_bundle_tracked(&ctx.db, update.cluster_id, &update.hash) + .await? + .is_some() + { + return Err(ContentError::InvalidData { + reason: format!( + "{} is provided by a bundle and is not updated by the browser flow", + update.display_name + ), + }); + } + + let provider = ctx.providers.get(update.provider)?; + let project = provider.get_project(&update.project_id, ctx).await?; + let version = provider + .get_version(&update.project_id, &update.latest_version_id, ctx) + .await?; + + // Compatibility is not re-checked + // a provider disagreeing at install time would strand the user on a build + // they cannot move off + let installed = PackageStore::install_to_cluster( + update.provider, + &project, + &version, + update.cluster_id, + true, + false, + child, + ctx, + ) + .await?; + + let enabled_before = + artifact_dao::get_cluster_artifact(&ctx.db, update.cluster_id, &update.hash) + .await? + .map(|link| link.enabled != 0); + + // Everything else of this project goes not just the recorded hash which can + // be stale if the user installed a version by hand since the check ran + unlink_other_versions( + update.cluster_id, + update.provider, + &update.project_id, + &installed.hash, + ctx, + ) + .await?; + + // Provider handed back the already-linked file + // leave the link untouched + if installed.hash == update.hash { + update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?; + return Ok(installed.hash); + } + + // A package switched off stays off + // the fresh link starts enabled so this is a toggle rather than a set + if enabled_before == Some(false) { + PackageStore::set_artifact_enabled(update.cluster_id, &installed.hash, ctx).await?; + } + + update_dao::delete(&ctx.db, update.cluster_id, &update.hash).await?; + + Ok(installed.hash) +} + +/// Bundle-owned copies are stepped over +/// unlinking one here would have the next bundle sync put it straight back +#[tracing::instrument(level = "debug", skip(ctx))] +async fn unlink_other_versions( + cluster_id: i64, + provider: ProviderId, + project_id: &str, + keep_hash: &str, + ctx: &ContentCtx, +) -> ContentResult<()> { + let others = artifact_dao::list_cluster_artifacts_for_project( + &ctx.db, + cluster_id, + provider as i64, + project_id, + keep_hash, + ) + .await?; + + for link in others { + if bundle_dao::get_bundle_tracked(&ctx.db, cluster_id, &link.hash) + .await? + .is_some() + { + tracing::debug!( + hash = %link.hash, + "leaving a bundle-owned version of the project in place" + ); + continue; + } + + tracing::info!( + cluster_id, + project_id, + hash = %link.hash, + "dropping a superseded version of the project" + ); + unlink_superseded(cluster_id, &link.hash, ctx).await?; + } + + Ok(()) +} + +/// Not `bundles::remove_artifact_from_cluster` +/// `packages` must not depend on `bundles` and its override reconciliation +/// does not apply here +#[tracing::instrument(level = "debug", skip(ctx))] +async fn unlink_superseded(cluster_id: i64, hash: &str, ctx: &ContentCtx) -> ContentResult<()> { + let cluster = PackageStore::get_cluster(cluster_id, ctx).await?; + let content_type = artifact_dao::get_artifact_by_hash(&ctx.db, hash) + .await? + .and_then(|artifact| { + oneclient_common::domain::ContentType::from_repr(artifact.content_type as u8) + }); + let link = artifact_dao::get_cluster_artifact(&ctx.db, cluster_id, hash).await?; + + artifact_dao::unlink_cluster_artifact(&ctx.db, cluster_id, hash).await?; + + if let (Some(content_type), Some(link)) = (content_type, link) { + try_unlink_materialized(&cluster, content_type, &link.cluster_file_name).await; + } + + if let Err(err) = evict_if_unused(hash, ctx).await { + tracing::warn!(hash, error = %err, "failed to evict the superseded artifact"); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use oneclient_common::domain::ContentType; + + fn linked( + hash: &str, + provider: Option, + project_id: Option<&str>, + version_id: Option<&str>, + ) -> LinkedArtifactInfo { + LinkedArtifactInfo { + hash: hash.into(), + cluster_file_name: format!("{hash}.jar"), + enabled: true, + content_type: ContentType::Mod, + file_name: format!("{hash}.jar"), + project_id: project_id.map(Into::into), + version_id: version_id.map(Into::into), + display_name: None, + display_version: None, + provider, + published_at: None, + } + } + + #[test] + fn bundle_provided_packages_are_never_candidates() { + let linked = vec![linked( + "a", + Some(ProviderId::Modrinth), + Some("sodium"), + Some("v1"), + )]; + let bundles: HashSet = ["a".to_string()].into_iter().collect(); + + assert!( + browser_installed(&linked, &bundles).is_empty(), + "a bundle-tracked artifact must stay out of the browser update flow" + ); + } + + #[test] + fn browser_installed_remote_packages_are_candidates() { + let linked = vec![linked( + "a", + Some(ProviderId::Modrinth), + Some("sodium"), + Some("v1"), + )]; + + let found = browser_installed(&linked, &HashSet::new()); + assert_eq!(found.len(), 1); + assert_eq!(found[0].project_id, "sodium"); + assert_eq!(found[0].version_id, "v1"); + } + + #[test] + fn local_imports_are_not_candidates() { + let linked = vec![ + linked("a", Some(ProviderId::Local), Some("x"), Some("v1")), + linked("b", None, None, None), + ]; + + assert!( + browser_installed(&linked, &HashSet::new()).is_empty(), + "a file with no provider release has no version to compare" + ); + } + + #[test] + fn a_remote_package_with_no_recorded_version_is_not_a_candidate() { + let linked = vec![linked( + "a", + Some(ProviderId::Modrinth), + Some("sodium"), + None, + )]; + + assert!( + browser_installed(&linked, &HashSet::new()).is_empty(), + "without an installed version id there is nothing to compare against" + ); + } + + #[test] + fn pending_hides_versions_the_user_declined() { + let make = |hash: &str, skipped: bool| BrowserPackageUpdate { + cluster_id: 1, + hash: hash.into(), + provider: ProviderId::Modrinth, + project_id: "sodium".into(), + installed_version_id: "v1".into(), + installed_version_name: "1.0".into(), + latest_version_id: "v2".into(), + latest_version_name: "2.0".into(), + display_name: "Sodium".into(), + skipped, + }; + + let check = BrowserUpdateCheck { + cluster_id: 1, + updates: vec![make("a", true), make("b", false)], + unchecked: Vec::new(), + }; + + let pending = check.pending(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].hash, "b"); + } +} diff --git a/packages/oneclient_content/tests/file_identity.rs b/packages/oneclient_content/tests/file_identity.rs new file mode 100644 index 00000000..0e9bfb08 --- /dev/null +++ b/packages/oneclient_content/tests/file_identity.rs @@ -0,0 +1,39 @@ +use oneclient_content::packages::{FileIdentity, curseforge_fingerprint}; + +#[test] +fn curseforge_fingerprint_ignores_padding_whitespace() { + let a = curseforge_fingerprint(b"hello"); + let b = curseforge_fingerprint(b"\t\n\r hello \n\t\r"); + assert_eq!(a, b); +} + +#[test] +fn curseforge_fingerprint_empty_is_stable() { + assert_eq!(curseforge_fingerprint(&[]), curseforge_fingerprint(&[])); +} + +#[test] +fn curseforge_fingerprint_differs_for_content() { + let a = curseforge_fingerprint(b"hello"); + let b = curseforge_fingerprint(b"world"); + assert_ne!(a, b); +} + +#[test] +fn file_identity_from_bytes_sets_sha1_and_cf_fingerprint() { + let id = FileIdentity::from_bytes(b"test jar content"); + assert_eq!(id.sha1.len(), 40); + assert!(id.cf_fingerprint.is_some()); +} + +#[test] +fn file_identity_from_sha1_has_no_cf_fingerprint() { + let id = FileIdentity::from_sha1("a".repeat(40)); + assert!(id.cf_fingerprint.is_none()); +} + +#[test] +fn file_identity_with_fingerprint_roundtrip() { + let id = FileIdentity::from_sha1("abc").with_curseforge_fingerprint(12345); + assert_eq!(id.cf_fingerprint, Some(12345)); +} diff --git a/packages/oneclient_core/.gitignore b/packages/oneclient_core/.gitignore new file mode 100644 index 00000000..c41cc9e3 --- /dev/null +++ b/packages/oneclient_core/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/packages/oneclient_core/Cargo.toml b/packages/oneclient_core/Cargo.toml new file mode 100644 index 00000000..aea60f7f --- /dev/null +++ b/packages/oneclient_core/Cargo.toml @@ -0,0 +1,95 @@ +[package] +authors.workspace = true +description = "OneClient launcher core" +documentation.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +name = "oneclient_core" +readme.workspace = true +repository.workspace = true +version.workspace = true + +[lib] +path = "src/lib.rs" + +[features] +default = [] + +[dev-dependencies] +async-tungstenite = {version = "=0.32.1", features = ["tokio-runtime"]} +polyio = {workspace = true, features = ["testing"]} + +[dependencies] +# core (extra) +discord-rich-presence.workspace = true +interfrost.workspace = true +oneclient_auth.workspace = true +oneclient_cluster.workspace = true +oneclient_common.workspace = true +oneclient_content.workspace = true +oneclient_db.workspace = true +oneclient_discord.workspace = true +oneclient_events.workspace = true +oneclient_java.workspace = true +oneclient_mc.workspace = true +oneclient_net.workspace = true +oneclient_polyplus.workspace = true +polyio.workspace = true + +# logging +tracing-futures.workspace = true +tracing-subscriber.workspace = true +tracing.workspace = true + +sentry-tracing.workspace = true +sentry.workspace = true + +# database +sqlx.workspace = true + +# async +async-compression.workspace = true +async-stream.workspace = true +async-tempfile.workspace = true +async-trait.workspace = true +async_zip.workspace = true +futures-lite.workspace = true +futures-util = {workspace = true, features = ["std", "sink"]} +reqwest-websocket.workspace = true +reqwest.workspace = true +tokio-stream.workspace = true +tokio-util.workspace = true +tokio.workspace = true + +# util (codegen/macro) +regex.workspace = true +serde_repr.workspace = true +strum.workspace = true +thiserror.workspace = true + +# crypto +digest.workspace = true +md-5.workspace = true +sha1.workspace = true +sha2.workspace = true + +# util +base64.workspace = true +bytes.workspace = true +chrono.workspace = true +directories.workspace = true +dunce.workspace = true +htmd.workspace = true +image.workspace = true +indicatif.workspace = true +parking_lot.workspace = true +rand.workspace = true +sanitize-filename.workspace = true +serde.workspace = true +serde_json.workspace = true +sysinfo.workspace = true +trash.workspace = true +url.workspace = true +uuid = { workspace = true, features = ["v3", "serde"] } +md5 = "0.8.1" diff --git a/packages/oneclient_core/examples/auth_msa.rs b/packages/oneclient_core/examples/auth_msa.rs new file mode 100644 index 00000000..c2093e4f --- /dev/null +++ b/packages/oneclient_core/examples/auth_msa.rs @@ -0,0 +1,52 @@ +use std::process::Command; + +use oneclient_core::{LauncherResult, dev, logger}; + +#[tokio::main] +async fn main() -> LauncherResult<()> { + logger::init_debug()?; + let state = dev::initialize().await?; + + let session = state.auth.begin_microsoft_login().await?; + + println!("Sign in with EITHER method:\n"); + println!(" Browser: opening {}", session.browser.auth_url); + try_open_browser(&session.browser.auth_url); + println!( + " Device code: go to {} and enter {}\n", + session.device.verification_uri, session.device.user_code + ); + + println!("Waiting for you to sign in..."); + let account = state.auth.finish_microsoft_login(session).await?; + + println!( + "Signed in as {} ({})\n token expires: {}", + account.username, account.id, account.expires + ); + println!( + "Account saved to {}", + oneclient_common::paths::auth_file()?.display() + ); + + Ok(()) +} + +fn try_open_browser(url: &str) { + let result = if cfg!(target_os = "windows") { + Command::new("cmd").args(["/C", "start", "", url]).spawn() + } else if cfg!(target_os = "macos") { + Command::new("open").arg(url).spawn() + } else { + Command::new("xdg-open").arg(url).spawn() + }; + + match result { + Ok(_) => println!("Opened verification page in your default browser.\n"), + Err(err) => { + println!( + "Could not open browser automatically ({err}). Open the URL above manually.\n" + ); + } + } +} diff --git a/packages/oneclient_core/examples/bundles_sync.rs b/packages/oneclient_core/examples/bundles_sync.rs new file mode 100644 index 00000000..bf9e3b58 --- /dev/null +++ b/packages/oneclient_core/examples/bundles_sync.rs @@ -0,0 +1,32 @@ +use oneclient_common::domain::GameLoader; +use oneclient_core::dev; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + + let state = dev::ephemeral_state().await?; + + state.bundles.sync(&state.services.content()).await?; + + let bundles = state + .bundles + .list_for(&state.services.content(), "1.21.11", GameLoader::Fabric) + .await?; + + println!("Visible Fabric bundles for 1.21.11: {}", bundles.len()); + for bundle in bundles { + println!( + "- {} ({}) at {}", + bundle.name, + bundle.version_id, + bundle.path.display() + ); + } + + let all = oneclient_db::dao::bundle::list_all(&state.services.db).await?; + let hidden = all.iter().filter(|row| row.hidden != 0).count(); + println!("Total catalog rows: {} (hidden: {hidden})", all.len()); + + Ok(()) +} diff --git a/packages/oneclient_core/examples/cluster_manage.rs b/packages/oneclient_core/examples/cluster_manage.rs new file mode 100644 index 00000000..ba287d81 --- /dev/null +++ b/packages/oneclient_core/examples/cluster_manage.rs @@ -0,0 +1,47 @@ +use oneclient_cluster::profiles::list_named_profiles; +use oneclient_common::domain::GameLoader; +use oneclient_core::clusters::CreateClusterOptions; +use oneclient_core::dev; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + + let state = dev::ephemeral_state().await?; + let global = state.settings.read().global_game_settings.clone(); + + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Example 1.21.1 Fabric", "1.21.1", GameLoader::Fabric) + .mem_max(3072), + ) + .await?; + + println!( + "Created cluster #{} at folder '{}'", + cluster.id, cluster.folder_name + ); + println!(" setting profile: {:?}", cluster.setting_profile_name); + println!(" created at: {:?}", cluster.created_at); + + let profile = state.clusters.resolve_settings(&global, &cluster).await?; + println!( + " resolved mem_max: {:?}, force_fullscreen: {:?}", + profile.mem_max, profile.force_fullscreen + ); + + for c in state.clusters.list().await? { + println!(" - {} ({}, {})", c.name, c.mc_version, c.mc_loader); + } + + for p in list_named_profiles(&state.services.db).await? { + println!("Profile {} (mem_max: {:?})", p.name, p.mem_max); + } + + state.clusters.delete(cluster.id, true).await?; + println!("Deleted cluster #{}", cluster.id); + + Ok(()) +} diff --git a/packages/oneclient_core/examples/detect_v1.rs b/packages/oneclient_core/examples/detect_v1.rs new file mode 100644 index 00000000..c6eee761 --- /dev/null +++ b/packages/oneclient_core/examples/detect_v1.rs @@ -0,0 +1,27 @@ +#[tokio::main] +async fn main() { + match oneclient_core::detect_migration().await { + Ok(Some(detection)) => { + println!( + "source: {} ({})", + detection.source.display_name(), + detection.source.id() + ); + println!("root: {}", detection.root.display()); + println!("instances: {}", detection.instances.len()); + for i in &detection.instances { + println!( + " [{}] {} ({} {:?}) game_dir={} categories={:?}", + i.instance_id, + i.folder_name, + i.mc_version, + i.mc_loader, + i.has_game_dir, + i.categories, + ); + } + } + Ok(None) => println!("no migratable launcher detected"), + Err(err) => eprintln!("detection error: {err}"), + } +} diff --git a/packages/oneclient_core/examples/download_game.rs b/packages/oneclient_core/examples/download_game.rs new file mode 100644 index 00000000..aceeee8b --- /dev/null +++ b/packages/oneclient_core/examples/download_game.rs @@ -0,0 +1,48 @@ +#![recursion_limit = "256"] +use oneclient_common::domain::GameLoader; +use oneclient_core::LauncherResult; +use oneclient_core::clusters::CreateClusterOptions; +use oneclient_core::dev; + +#[tokio::main] +async fn main() -> LauncherResult<()> { + dev::initialize().await?; + let state = dev::ephemeral_state().await?; + + let args: Vec = std::env::args().skip(1).collect(); + let mc_version = args.first().map(String::as_str).unwrap_or("26.1"); + let loader = args + .get(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(GameLoader::Vanilla); + let loader_version = args.get(2).map(String::as_str); + + let global = state.settings.read().global_game_settings.clone(); + + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions { + name: format!("download-{mc_version}"), + mc_version: mc_version.to_string(), + mc_loader: loader, + mc_loader_version: loader_version.map(str::to_string), + mem_max: None, + }, + ) + .await?; + + println!( + "Preparing cluster {} ({} {:?})...", + cluster.name, cluster.mc_version, cluster.mc_loader + ); + + let ready = oneclient_core::clusters::prepare_cluster_locked( + &state, cluster.id, false, true, true, None, + ) + .await?; + println!("Cluster ready at {}", ready.dir()?.display()); + + Ok(()) +} diff --git a/packages/oneclient_core/examples/launch_game.rs b/packages/oneclient_core/examples/launch_game.rs new file mode 100644 index 00000000..2c435b24 --- /dev/null +++ b/packages/oneclient_core/examples/launch_game.rs @@ -0,0 +1,48 @@ +#![recursion_limit = "256"] + +use oneclient_common::domain::GameLoader; +use oneclient_core::LauncherResult; +use oneclient_core::clusters::CreateClusterOptions; +use oneclient_core::dev; + +#[tokio::main] +async fn main() -> LauncherResult<()> { + dev::initialize().await?; + let state = dev::ephemeral_state().await?; + + let args: Vec = std::env::args().skip(1).collect(); + let mc_version = args.first().map(String::as_str).unwrap_or("1.21.4"); + let loader = args + .get(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(GameLoader::Vanilla); + + let account = state + .auth + .add_offline_account("Example".to_string()) + .await?; + println!( + "Using offline account {} ({})", + account.username, account.id + ); + + let global = state.settings.read().global_game_settings.clone(); + + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new(format!("launch-{mc_version}"), mc_version, loader), + ) + .await?; + + println!("Launching {} ({mc_version} {loader:?})...", cluster.name); + let game = oneclient_core::launch_cluster(&state, cluster.id, &account, true).await?; + println!( + "Launched cluster #{} (pid {:?}). Waiting 10s before exit...", + game.cluster_id, game.pid + ); + + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + Ok(()) +} diff --git a/packages/oneclient_core/examples/notifications.rs b/packages/oneclient_core/examples/notifications.rs new file mode 100644 index 00000000..432cbaf9 --- /dev/null +++ b/packages/oneclient_core/examples/notifications.rs @@ -0,0 +1,30 @@ +use oneclient_events::{EventBus, GroupedProgressSession}; +use tokio::sync::mpsc; +use uuid::Uuid; + +#[tokio::main] +async fn main() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let events = EventBus::new(tx); + + events.notify("Welcome").body("Launcher started").send(); + events + .notify("Heads up") + .body("Example error notification") + .error() + .send(); + + let download = Uuid::new_v4(); + for current in (0..=100).step_by(25) { + events.progress(download, "Downloading assets", current, 100); + } + + let session = GroupedProgressSession::start(&events, "Preparing cluster".to_string()); + session.finish(); + + drop(events); + + while let Some(notification) = rx.recv().await { + println!("{notification:?}"); + } +} diff --git a/packages/oneclient_core/examples/package_project.rs b/packages/oneclient_core/examples/package_project.rs new file mode 100644 index 00000000..857e4e34 --- /dev/null +++ b/packages/oneclient_core/examples/package_project.rs @@ -0,0 +1,59 @@ +use std::env; + +use oneclient_common::domain::ProviderId; +use oneclient_core::{LauncherResult, dev, logger}; + +#[tokio::main] +async fn main() -> LauncherResult<()> { + logger::init_debug()?; + + let mut args = env::args().skip(1); + let provider_name = args.next().unwrap_or_else(|| "modrinth".into()); + let project_ref = args.next().unwrap_or_else(|| "sodium".into()); + let mc_version = args.next(); + + let provider_id = parse_provider(&provider_name)?; + let env = dev::ephemeral_services().await?.content(); + let provider = env.providers.get(provider_id)?; + + let project = provider.get_project(&project_ref, &env).await?; + + println!( + "{:?} project: {} ({})", + project.provider, project.name, project.id + ); + println!(" {}\n", project.summary); + + let versions = provider + .list_versions(&project.id, mc_version.as_deref(), None, 0, 5, &env) + .await?; + + println!("Versions (up to {}):", versions.items.len()); + for v in &versions.items { + println!(" {} - {} ({})", v.name, v.version_number, v.version_id); + } + + if let Some(version) = versions.items.first() { + let detail = provider + .get_version(&project.id, &version.version_id, &env) + .await?; + if let Some(file) = detail.primary_file() { + println!( + "\nPrimary file: {} sha1={} cf_fp={:?}", + file.file_name, file.sha1, file.fingerprint + ); + } + } + + Ok(()) +} + +fn parse_provider(name: &str) -> LauncherResult { + match name.to_lowercase().as_str() { + "modrinth" | "mr" => Ok(ProviderId::Modrinth), + "curseforge" | "cf" => Ok(ProviderId::CurseForge), + other => Err(oneclient_core::LauncherError::InvalidSettingsProfile { + reason: format!("unknown provider {other:?}; use modrinth or curseforge"), + }), + } +} diff --git a/packages/oneclient_core/examples/package_search.rs b/packages/oneclient_core/examples/package_search.rs new file mode 100644 index 00000000..ba89859c --- /dev/null +++ b/packages/oneclient_core/examples/package_search.rs @@ -0,0 +1,56 @@ +use std::env; + +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_content::packages::types::SearchFilters; +use oneclient_core::{LauncherResult, dev, logger}; + +#[tokio::main] +async fn main() -> LauncherResult<()> { + logger::init_debug()?; + + let mut args = env::args().skip(1); + let provider_name = args.next().unwrap_or_else(|| "modrinth".into()); + let query = args.next().unwrap_or_else(|| "sodium".into()); + + let provider_id = parse_provider(&provider_name)?; + let env = dev::ephemeral_services().await?.content(); + let provider = env.providers.get(provider_id)?; + + let page = provider + .search( + &SearchFilters { + query: Some(query.clone()), + limit: Some(8), + content_type: Some(ContentType::Mod), + ..Default::default() + }, + &env, + ) + .await?; + + println!( + "Search {:?} for {query:?} - {} hit(s) (showing {}):\n", + provider_id, + page.total, + page.items.len() + ); + + for hit in &page.items { + println!( + " {} ({}) - {} downloads\n {}", + hit.name, hit.id, hit.downloads, hit.summary + ); + } + + Ok(()) +} + +fn parse_provider(name: &str) -> LauncherResult { + match name.to_lowercase().as_str() { + "modrinth" | "mr" => Ok(ProviderId::Modrinth), + "curseforge" | "cf" => Ok(ProviderId::CurseForge), + other => Err(oneclient_core::LauncherError::InvalidSettingsProfile { + reason: format!("unknown provider {other:?}; use modrinth or curseforge"), + }), + } +} diff --git a/packages/oneclient_core/src/auth/mod.rs b/packages/oneclient_core/src/auth/mod.rs new file mode 100644 index 00000000..e30678a6 --- /dev/null +++ b/packages/oneclient_core/src/auth/mod.rs @@ -0,0 +1,13 @@ +pub mod microsoft; +pub mod offline; + +pub use offline::{OfflineAccount, generate_offline_uuid}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AccountType { + Microsoft { refresh_token_key: String }, + Offline, +} diff --git a/packages/oneclient_core/src/auth/offline.rs b/packages/oneclient_core/src/auth/offline.rs new file mode 100644 index 00000000..5c3c507f --- /dev/null +++ b/packages/oneclient_core/src/auth/offline.rs @@ -0,0 +1,52 @@ +use serde::{Deserialize, Serialize}; +use uuid::{Builder, Uuid, Variant, Version}; +use md5::{Md5, Digest}; +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OfflineAccount { + pub username: String, + pub uuid: Uuid, + pub access_token: String, + pub user_type: String, +} + +impl OfflineAccount { + pub fn new(username: &str) -> Result { + let trimmed = username.trim(); + if trimmed.is_empty() { return Err(OfflineAuthError::EmptyUsername); } + if trimmed.len() > 16 { return Err(OfflineAuthError::UsernameTooLong); } + if !trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(OfflineAuthError::InvalidCharacters); + } + + let uuid = generate_offline_uuid(trimmed); + Ok(Self { + username: trimmed.to_string(), + uuid, + access_token: "offline".to_string(), + user_type: "offline".to_string(), + }) + } +} + +pub fn generate_offline_uuid(username: &str) -> Uuid { + let mut hasher = Md5::new(); + hasher.update(format!("OfflinePlayer:{}", username).as_bytes()); + let result = hasher.finalize(); + + let mut builder = Builder::from_slice(&result).expect("MD5 is 16 bytes"); + builder.set_version(Version::Md5); + builder.set_variant(Variant::RFC4122); + builder.into_uuid() +} + +#[derive(Debug, Error)] +pub enum OfflineAuthError { + #[error("Username cannot be empty")] + EmptyUsername, + #[error("Username cannot exceed 16 characters")] + UsernameTooLong, + #[error("Invalid characters in username")] + InvalidCharacters, +} diff --git a/packages/oneclient_core/src/changelog.rs b/packages/oneclient_core/src/changelog.rs new file mode 100644 index 00000000..9d55a9de --- /dev/null +++ b/packages/oneclient_core/src/changelog.rs @@ -0,0 +1,62 @@ +use crate::{LauncherError, LauncherResult}; +use oneclient_common::paths; +use oneclient_net::RequestClient; +use oneclient_net::{EtagPolicy, fetch_cached}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChangelogGroup { + pub version: String, + pub changes: Vec, +} + +pub fn parse_changelog(data: &str) -> Vec { + let mut groups = Vec::new(); + + for line in data.lines() { + if let Some(version) = line.strip_prefix("# ") { + groups.push(ChangelogGroup { + version: version.trim().to_string(), + changes: Vec::new(), + }); + } else if let Some(change) = line.strip_prefix("- ") { + if let Some(group) = groups.last_mut() { + group.changes.push(change.trim().to_string()); + } + } else if line.trim() == "###" { + break; + } + } + + groups +} + +#[tracing::instrument(level = "debug", skip(net))] +pub async fn fetch_changelog(net: &RequestClient) -> LauncherResult { + let url = format!("{}/oneclient/CHANGE_LOG.md", net.config().meta_url_base); + let cache_path = paths::caches_dir()?.join("CHANGE_LOG.md"); + + let fetched = fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow) + .await? + .ok_or_else(|| LauncherError::InvalidSettingsProfile { + reason: "changelog is unavailable and not cached".to_string(), + })?; + + Ok(fetched.text()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_changelog_groups_versions_and_bullets() { + let data = "# 2.0.0\n\n- Faster UI\n- Bug fixes\n# 1.9.0\n- Older change\n###\nignored\n"; + let groups = parse_changelog(data); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].version, "2.0.0"); + assert_eq!(groups[0].changes, vec!["Faster UI", "Bug fixes"]); + assert_eq!(groups[1].version, "1.9.0"); + assert_eq!(groups[1].changes, vec!["Older change"]); + } +} diff --git a/packages/oneclient_core/src/clusters/migrate.rs b/packages/oneclient_core/src/clusters/migrate.rs new file mode 100644 index 00000000..fd9a07f3 --- /dev/null +++ b/packages/oneclient_core/src/clusters/migrate.rs @@ -0,0 +1,272 @@ +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use oneclient_db::dao::{applied_migration as migration_dao, cluster as cluster_dao}; +use oneclient_db::models::ClusterRow; + +use crate::LauncherResult; +use crate::state::LauncherState; +use crate::versions::RemoteMigration; +use oneclient_common::domain::GameLoader; + +#[tracing::instrument(skip(state))] +pub async fn apply_remote_migrations(state: &LauncherState) -> LauncherResult { + let rules = state.versions.migrations().await; + if rules.is_empty() { + return Ok(0); + } + + let mut migrated = 0; + + for rule in rules { + match apply_rule(state, &rule).await { + Ok(true) => migrated += 1, + Ok(false) => {} + Err(err) => { + tracing::warn!( + migration_id = %rule.id, + error = %err, + "cluster migration failed; leaving install untouched" + ); + } + } + } + + if migrated > 0 { + tracing::info!(migrated, "applied cluster migrations"); + } + + Ok(migrated) +} + +async fn apply_rule(state: &LauncherState, rule: &RemoteMigration) -> LauncherResult { + let db = &state.services.db; + + if migration_dao::is_applied(db, &rule.id).await? { + return Ok(false); + } + + let Ok(loader) = GameLoader::from_str(&rule.from.loader) else { + tracing::warn!( + migration_id = %rule.id, + loader = %rule.from.loader, + "unknown loader in migration rule; retiring rule" + ); + migration_dao::mark_applied(db, &rule.id).await?; + return Ok(false); + }; + + if rule.from.mc_version == rule.to.mc_version { + tracing::warn!(migration_id = %rule.id, "migration is a no-op; retiring rule"); + migration_dao::mark_applied(db, &rule.id).await?; + return Ok(false); + } + + let Some(source) = + cluster_dao::find_by_version_loader(db, &rule.from.mc_version, loader as i64).await? + else { + migration_dao::mark_applied(db, &rule.id).await?; + return Ok(false); + }; + + if cluster_dao::find_by_version_loader(db, &rule.to.mc_version, loader as i64) + .await? + .is_some() + { + tracing::warn!( + migration_id = %rule.id, + from = %rule.from.mc_version, + to = %rule.to.mc_version, + "target cluster already exists; skipping migration" + ); + return Ok(false); + } + + if crate::game::is_running(state, source.id) { + tracing::info!( + migration_id = %rule.id, + cluster_id = source.id, + "cluster is running; deferring migration" + ); + return Ok(false); + } + + migrate_cluster(state, rule, &source).await?; + + migration_dao::mark_applied(db, &rule.id).await?; + tracing::info!( + migration_id = %rule.id, + cluster_id = source.id, + from = %rule.from.mc_version, + to = %rule.to.mc_version, + "migrated cluster" + ); + + Ok(true) +} + +async fn migrate_cluster( + state: &LauncherState, + rule: &RemoteMigration, + source: &ClusterRow, +) -> LauncherResult<()> { + let from = &rule.from.mc_version; + let to = &rule.to.mc_version; + + let clusters_dir = oneclient_common::paths::clusters_dir()?; + let old_dir = clusters_dir.join(&source.folder_name); + + let new_folder = resolve_new_folder(&clusters_dir, &source.folder_name, from, to).await?; + let new_name = retarget_version_prefix(&source.name, from, to); + + let mut renamed: Option<(PathBuf, PathBuf)> = None; + if let Some(folder) = &new_folder { + let new_dir = clusters_dir.join(folder); + if polyio::try_exists(&old_dir).await? { + polyio::rename(&old_dir, &new_dir).await?; + renamed = Some((old_dir.clone(), new_dir)); + } else { + tracing::warn!( + folder = %source.folder_name, + "cluster directory is missing on disk; migrating the row only" + ); + } + } + + let folder_for_db = folder_for_db( + &source.folder_name, + new_folder.as_deref(), + renamed.is_some(), + ); + + match cluster_dao::migrate_version( + &state.services.db, + source.id, + to, + new_name.as_deref(), + folder_for_db, + ) + .await + { + Ok(_) => Ok(()), + Err(err) => { + if let Some((old_dir, new_dir)) = renamed + && let Err(rollback) = polyio::rename(&new_dir, &old_dir).await + { + tracing::error!( + migration_id = %rule.id, + cluster_id = source.id, + from = ?new_dir, + to = ?old_dir, + error = %rollback, + "failed to roll back cluster directory rename after database error" + ); + } + Err(err.into()) + } + } +} + +fn folder_for_db<'a>(current: &'a str, new_folder: Option<&'a str>, renamed: bool) -> &'a str { + match (renamed, new_folder) { + (true, Some(folder)) => folder, + _ => current, + } +} + +async fn resolve_new_folder( + clusters_dir: &Path, + folder_name: &str, + from: &str, + to: &str, +) -> LauncherResult> { + let Some(candidate) = retarget_version_prefix(folder_name, from, to) else { + tracing::debug!( + folder = %folder_name, + "folder name is not in generated form; leaving it in place" + ); + return Ok(None); + }; + + if polyio::try_exists(&clusters_dir.join(&candidate)).await? { + tracing::warn!( + folder = %folder_name, + candidate = %candidate, + "target folder already exists; leaving directory in place" + ); + return Ok(None); + } + + Ok(Some(candidate)) +} + +fn retarget_version_prefix(value: &str, from: &str, to: &str) -> Option { + let rest = value.strip_prefix(from)?; + if !rest.starts_with(' ') { + return None; + } + Some(format!("{to}{rest}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retargets_generated_folder() { + assert_eq!( + retarget_version_prefix("26.1 fabric", "26.1", "26.1.2").as_deref(), + Some("26.1.2 fabric") + ); + } + + #[test] + fn retargets_deduplicated_folder() { + assert_eq!( + retarget_version_prefix("26.1 fabric (1)", "26.1", "26.1.2").as_deref(), + Some("26.1.2 fabric (1)") + ); + } + + #[test] + fn does_not_match_adjacent_minor() { + assert_eq!( + retarget_version_prefix("26.10 fabric", "26.1", "26.1.2"), + None + ); + } + + #[test] + fn leaves_custom_names_alone() { + assert_eq!( + retarget_version_prefix("my cool pack", "26.1", "26.1.2"), + None + ); + assert_eq!( + retarget_version_prefix("26.1fabric", "26.1", "26.1.2"), + None + ); + } + + #[test] + fn exact_version_with_no_suffix_is_not_retargeted() { + assert_eq!(retarget_version_prefix("26.1", "26.1", "26.1.2"), None); + } + + #[test] + fn row_claims_new_folder_only_when_the_directory_moved() { + assert_eq!( + folder_for_db("26.1 fabric", Some("26.1.2 fabric"), true), + "26.1.2 fabric" + ); + } + + #[test] + fn row_keeps_its_folder_when_the_rename_was_skipped() { + assert_eq!( + folder_for_db("26.1 fabric", Some("26.1.2 fabric"), false), + "26.1 fabric" + ); + assert_eq!(folder_for_db("my cool pack", None, false), "my cool pack"); + } +} diff --git a/packages/oneclient_core/src/clusters/mod.rs b/packages/oneclient_core/src/clusters/mod.rs new file mode 100644 index 00000000..8c484704 --- /dev/null +++ b/packages/oneclient_core/src/clusters/mod.rs @@ -0,0 +1,14 @@ +mod migrate; +pub(crate) mod prepare; +mod provision; +mod unlink_legacy; + +pub use migrate::apply_remote_migrations; +pub use prepare::{estimate_cluster_download, prepare_cluster, prepare_cluster_locked}; +pub use provision::{ensure_from_bundles, ensure_from_versions}; +pub use unlink_legacy::{SweepReport, unlink_legacy_cluster_content}; + +pub use oneclient_cluster::{ + Cluster, ClusterError, ClusterLinkTarget, ClusterManager, ClusterStage, ClusterUpdate, + CreateClusterOptions, +}; diff --git a/packages/oneclient_core/src/clusters/prepare.rs b/packages/oneclient_core/src/clusters/prepare.rs new file mode 100644 index 00000000..52235603 --- /dev/null +++ b/packages/oneclient_core/src/clusters/prepare.rs @@ -0,0 +1,389 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use interfrost::api::minecraft::{DownloadType, VersionInfo}; +use interfrost::api::modded::SidedDataEntry; +use tokio::process::Command; + +use crate::game::{ + self, download_minecraft, download_version_info, get_loader_version, resolve_minecraft_version, +}; +use crate::state::{LauncherServices, LauncherState}; +use crate::{GameError, LauncherResult}; +use oneclient_cluster::Cluster; +use oneclient_cluster::ClusterError; +use oneclient_cluster::ClusterStage; +use oneclient_common::paths; +use oneclient_events::GroupedProgressSession; +use oneclient_java::JavaRuntime; +use oneclient_mc::MetadataStore; + +#[tracing::instrument(skip(state, shared_progress))] +pub async fn prepare_cluster_locked( + state: &Arc, + cluster_id: i64, + force: bool, + search_for_java: bool, + auto_install_java: bool, + shared_progress: Option<&GroupedProgressSession>, +) -> LauncherResult { + let mut metadata = state.metadata.lock().await; + prepare_cluster( + state, + &mut metadata, + cluster_id, + force, + search_for_java, + auto_install_java, + shared_progress, + ) + .await +} + +pub async fn prepare_cluster( + state: &Arc, + metadata: &mut MetadataStore, + cluster_id: i64, + force: bool, + search_for_java: bool, + auto_install_java: bool, + shared_progress: Option<&GroupedProgressSession>, +) -> LauncherResult { + let cluster = state.clusters.get(cluster_id).await?; + let continuing = cluster.stage == ClusterStage::Downloading; + + tracing::info!( + cluster_id, + mc_version = %cluster.mc_version, + force, + continuing, + "preparing cluster" + ); + + if !continuing { + state + .clusters + .set_stage(cluster_id, ClusterStage::Downloading) + .await?; + } + + let owned = shared_progress.is_none().then(|| { + GroupedProgressSession::start( + &state.services.events, + format!("Downloading game - {}", cluster.mc_version), + ) + }); + let progress = shared_progress.or(owned.as_ref()).expect("session present"); + + let result = install_cluster( + state, + metadata, + &cluster, + progress, + force, + search_for_java, + auto_install_java, + ) + .await; + + if let Some(owned) = owned { + owned.finish(); + } + + if let Err(err) = result { + if err.is_cancelled() { + tracing::info!( + cluster_id, + sentry = false, + error = %err, + "cluster preparation cancelled by the user" + ); + } else { + tracing::error!(cluster_id, error = %err, "cluster preparation failed"); + } + if !continuing { + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::NotReady) + .await; + } + return Err(err); + } + + let cluster = state + .clusters + .set_stage(cluster_id, ClusterStage::Ready) + .await?; + tracing::debug!(cluster_id, "cluster stage set to Ready"); + Ok(cluster) +} + +const JRE_ESTIMATE_BYTES: u64 = 45_000_000; + +/// Bytes the game install still needs +/// Falls back to the manifest's full size +/// when the asset index isn't cached which is also when nothing is on disk +async fn game_download_bytes(services: &LauncherServices, info: &VersionInfo) -> u64 { + let Some(assets_index) = cached_assets_index(info).await else { + let client = info + .downloads + .get(&DownloadType::Client) + .map_or(0, |d| d.size as u64); + let libraries: u64 = info + .libraries + .iter() + .filter_map(|lib| lib.downloads.as_ref()) + .filter_map(|dl| dl.artifact.as_ref()) + .map(|artifact| artifact.size as u64) + .sum(); + return client + info.asset_index.total_size as u64 + libraries; + }; + + let _ = services; + // Runs before a Java runtime is resolved so rules are checked against the + // host architecture only natives-related edge cases shift + match game::plan_downloads(info, assets_index, std::env::consts::ARCH, false, false).await { + Ok(plan) => plan.total_bytes(), + Err(err) => { + tracing::warn!("download plan failed, estimating full size: {err}"); + info.asset_index.total_size as u64 + } + } +} + +async fn cached_assets_index( + info: &VersionInfo, +) -> Option { + let path = paths::assets_index_dir() + .ok()? + .join(format!("{}.json", info.asset_index.id)); + polyio::read_json(&path).await.ok() +} + +#[tracing::instrument(level = "debug", skip(state, bundles))] +pub async fn estimate_cluster_download( + state: &Arc, + cluster_id: i64, + bundles: &oneclient_content::bundles::BundlesManager, +) -> LauncherResult { + let cluster = state.clusters.get(cluster_id).await?; + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); + + let info = { + let mut metadata = state.metadata.lock().await; + let (version, _index, _updated) = + resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version) + .await + .map_err(|_| ClusterError::InvalidVersion(cluster.mc_version.clone()))?; + let loader_version = get_loader_version( + &mut metadata, + &state.services.mc(), + &mc_version, + cluster.mc_loader, + cluster.mc_loader_version.as_deref(), + ) + .await?; + download_version_info( + &state.services.mc(), + None, + &version, + loader_version.as_ref(), + false, + ) + .await? + }; + + let mut total = game_download_bytes(&state.services, &info).await; + + if let Some(java) = &info.java_version { + let installed = state.java.list_runtimes().await.unwrap_or_default(); + if !installed.iter().any(|rt| rt.major == java.major_version) { + total += JRE_ESTIMATE_BYTES; + } + } + + total += oneclient_content::bundles::enabled_bundle_bytes( + cluster_id, + bundles, + &state.services.content(), + ) + .await + .unwrap_or(0); + + Ok(total) +} + +#[tracing::instrument(skip(state, metadata, cluster, progress), fields(cluster_id = cluster.id))] +async fn install_cluster( + state: &Arc, + metadata: &mut MetadataStore, + cluster: &Cluster, + progress: &GroupedProgressSession, + force: bool, + search_for_java: bool, + auto_install_java: bool, +) -> LauncherResult<()> { + let global = state.settings.read().global_game_settings.clone(); + let profile = state.clusters.resolve_settings(&global, cluster).await?; + + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); + + let (version, _version_index, minecraft_updated) = + resolve_minecraft_version(metadata, &state.services.mc(), &mc_version) + .await + .map_err(|_| ClusterError::InvalidVersion(cluster.mc_version.clone()))?; + + let loader_version = get_loader_version( + metadata, + &state.services.mc(), + &mc_version, + cluster.mc_loader, + cluster.mc_loader_version.as_deref(), + ) + .await?; + + let mut version_info = download_version_info( + &state.services.mc(), + Some(progress), + &version, + loader_version.as_ref(), + force, + ) + .await?; + + let java_major = version_info + .java_version + .as_ref() + .map(|v| v.major_version) + .ok_or(ClusterError::MissingJavaVersion)?; + + let java = if let Some(runtime) = state + .java + .runtime_for_profile(profile.java_path.as_deref()) + .await? + { + runtime + } else { + state + .java + .prepare( + java_major, + search_for_java, + auto_install_java, + Some(progress), + ) + .await? + }; + + download_minecraft( + &state.services.mc(), + progress, + &version_info, + &java.os_arch, + minecraft_updated, + force, + ) + .await?; + + run_forge_processors(cluster, &mut version_info, &java).await?; + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(cluster, version_info, java), fields(cluster_id = cluster.id))] +async fn run_forge_processors( + cluster: &Cluster, + version_info: &mut VersionInfo, + java: &JavaRuntime, +) -> LauncherResult<()> { + let Some(processors) = &version_info.processors else { + return Ok(()); + }; + + let client = paths::versions_dir()? + .join(&version_info.id) + .join(format!("{}.jar", version_info.id)); + let libraries = paths::libraries_dir()?; + let cluster_dir = cluster.game_dir()?; + + let Some(data) = &mut version_info.data else { + return Ok(()); + }; + + macro_rules! data_entry { + ($dest:expr; $($name:literal: client => $client:expr, server => $server:expr;)+) => { + $(HashMap::insert( + $dest, + String::from($name), + SidedDataEntry { + client: String::from($client), + server: String::from($server), + }, + );)+ + }; + } + + data_entry! { + data; + "SIDE": + client => "client", + server => ""; + "MINECRAFT_JAR": + client => client.to_string_lossy(), + server => ""; + "MINECRAFT_VERSION": + client => cluster.mc_version.clone(), + server => ""; + "ROOT": + client => cluster_dir.to_string_lossy(), + server => ""; + "LIBRARY_DIR": + client => libraries.to_string_lossy(), + server => ""; + } + + let total = processors.len(); + for (index, processor) in processors.iter().enumerate() { + if let Some(sides) = &processor.sides + && !sides.contains(&String::from("client")) + { + continue; + } + + let mut cp = processor.classpath.clone(); + cp.push(processor.jar.clone()); + + let processor_jar = game::get_library(&libraries, &processor.jar, false)?; + let main = game::main_class(&processor_jar) + .await? + .ok_or_else(|| GameError::ProcessorMainClass(processor.jar.clone()))?; + + let output = Command::new(&java.absolute_path) + .arg("-cp") + .arg(game::get_classpath_library(&libraries, &cp)?) + .arg(&main) + .args(game::processor_arguments( + &libraries, + &processor.args, + data, + )?) + .output() + .await?; + + if !output.status.success() { + return Err(GameError::ProcessorFailed( + String::from_utf8_lossy(&output.stderr).into_owned(), + ) + .into()); + } + + tracing::debug!( + "ran forge processor {}/{} for {}", + index + 1, + total, + version_info.id + ); + } + + Ok(()) +} diff --git a/packages/oneclient_core/src/clusters/provision.rs b/packages/oneclient_core/src/clusters/provision.rs new file mode 100644 index 00000000..a4fde317 --- /dev/null +++ b/packages/oneclient_core/src/clusters/provision.rs @@ -0,0 +1,140 @@ +use std::str::FromStr; + +use oneclient_db::dao::{bundle as bundle_dao, cluster as cluster_dao}; + +use crate::LauncherResult; +use crate::state::LauncherState; +use oneclient_common::domain::GameLoader; +use oneclient_common::version::format_mc_version; + +use oneclient_cluster::Cluster; +use oneclient_cluster::CreateClusterOptions; + +#[tracing::instrument(skip(state))] +pub async fn ensure_from_bundles(state: &LauncherState) -> LauncherResult> { + let groups = bundle_dao::list_distinct_version_loaders(&state.services.db).await?; + let mut created = Vec::new(); + + for group in groups { + let Some(loader) = GameLoader::from_repr(group.mc_loader as u8) else { + tracing::warn!( + mc_version = %group.mc_version, + mc_loader = group.mc_loader, + "skipping bundle group with unknown loader" + ); + continue; + }; + + if cluster_dao::find_by_version_loader( + &state.services.db, + &group.mc_version, + group.mc_loader, + ) + .await? + .is_some() + { + continue; + } + + let mc_version = group.mc_version.clone(); + let name = format!("{mc_version} {loader}"); + let global = state.settings.read().global_game_settings.clone(); + match state + .clusters + .create_provisioned( + &global, + CreateClusterOptions::new(name, mc_version.clone(), loader), + ) + .await + { + Ok(Some(cluster)) => { + tracing::info!( + cluster_id = cluster.id, + mc_version = %cluster.mc_version, + loader = %cluster.mc_loader, + "created cluster from bundle catalog" + ); + created.push(cluster); + } + Ok(None) => {} + Err(err) => { + tracing::warn!( + mc_version = %mc_version, + loader = %loader, + error = %err, + "failed to create cluster for bundle group" + ); + } + } + } + + Ok(created) +} + +#[tracing::instrument(skip(state))] +pub async fn ensure_from_versions(state: &LauncherState) -> LauncherResult> { + let metadata = state + .versions + .metadata(&state.services.requester.config().meta_url_base) + .await; + let mut created = Vec::new(); + + for entry in metadata { + let Some(minor) = entry.minor_version else { + continue; + }; + let Some(loader_str) = entry.loader.as_deref() else { + continue; + }; + let Ok(loader) = GameLoader::from_str(loader_str) else { + tracing::warn!( + major = entry.major_version, + minor, + loader = loader_str, + "skipping versions entry with unknown loader" + ); + continue; + }; + + let mc_version = format_mc_version(entry.major_version, minor, entry.patch_version); + + if cluster_dao::find_by_version_loader(&state.services.db, &mc_version, loader as i64) + .await? + .is_some() + { + continue; + } + + let name = format!("{mc_version} {loader}"); + let global = state.settings.read().global_game_settings.clone(); + match state + .clusters + .create_provisioned( + &global, + CreateClusterOptions::new(name, mc_version.clone(), loader), + ) + .await + { + Ok(Some(cluster)) => { + tracing::info!( + cluster_id = cluster.id, + mc_version = %cluster.mc_version, + loader = %cluster.mc_loader, + "created cluster from versions manifest" + ); + created.push(cluster); + } + Ok(None) => {} + Err(err) => { + tracing::warn!( + mc_version = %mc_version, + loader = %loader, + error = %err, + "failed to create cluster for versions entry" + ); + } + } + } + + Ok(created) +} diff --git a/packages/oneclient_core/src/clusters/unlink_legacy.rs b/packages/oneclient_core/src/clusters/unlink_legacy.rs new file mode 100644 index 00000000..655516c4 --- /dev/null +++ b/packages/oneclient_core/src/clusters/unlink_legacy.rs @@ -0,0 +1,182 @@ +use std::path::Path; + +use oneclient_db::dao::artifact as artifact_dao; + +use crate::LauncherResult; +use crate::state::LauncherState; +use oneclient_common::domain::ContentType; +use oneclient_content::packages::store::artifact_absolute_path; +use oneclient_content::packages::store::manifest::{self, ManifestEntry, MaterializedManifest}; + +const SWEPT_TYPES: [ContentType; 4] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, +]; + +#[derive(Debug, Default, Clone, Copy)] +pub struct SweepReport { + pub removed: usize, + pub adopted: usize, + pub skipped: usize, +} + +/// User-triggered only the "hash matches a cached artifact so it is ours" rule +/// is true during the transition and false once cluster folders hold user content +#[tracing::instrument(skip(state))] +pub async fn unlink_legacy_cluster_content(state: &LauncherState) -> LauncherResult { + let mut report = SweepReport::default(); + + for cluster in state.clusters.list().await? { + let dedicated = cluster.uses_dedicated_dir(); + let Ok(cluster_root) = cluster.dir() else { + continue; + }; + + if dedicated { + adopt_dedicated(state, &cluster, &cluster_root, &mut report).await; + } else { + sweep_shared(state, &cluster_root, &mut report).await; + } + } + + if report.removed > 0 || report.adopted > 0 { + tracing::info!( + removed = report.removed, + adopted = report.adopted, + skipped = report.skipped, + "cleaned up legacy cluster content links" + ); + } + + Ok(report) +} + +async fn sweep_shared(state: &LauncherState, cluster_root: &Path, report: &mut SweepReport) { + for content_type in SWEPT_TYPES { + let dir = cluster_root.join(content_type.folder_name()); + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + // A symlink here is unambiguously ours nothing else writes links here + if file_type.is_symlink() { + polyio::remove_file(&path).await.ok(); + report.removed += 1; + continue; + } + + if !file_type.is_file() { + continue; + } + + match cached_artifact_for(state, &path).await { + Some(CacheMatch::Elsewhere) => { + if polyio::remove_file(&path).await.is_ok() { + report.removed += 1; + } + } + // Cache row points at this very file (older recovery pass stored it + // in place) deleting it would destroy the only copy + Some(CacheMatch::IsTheArtifact) => { + tracing::debug!( + path = %path.display(), + "leaving cluster file in place; it is the artifact's only copy" + ); + report.skipped += 1; + } + None => report.skipped += 1, + } + } + } +} + +/// Writes a manifest so later launches can tell adopted content from user files +async fn adopt_dedicated( + state: &LauncherState, + cluster: &crate::clusters::Cluster, + cluster_root: &Path, + report: &mut SweepReport, +) { + if manifest::load(cluster_root).await.is_some() { + return; + } + + let linked = match oneclient_content::packages::PackageStore::list_linked_artifacts( + cluster.id, + &state.services.content(), + ) + .await + { + Ok(linked) => linked, + Err(err) => { + tracing::warn!(cluster_id = cluster.id, error = %err, "cannot adopt dedicated content"); + return; + } + }; + + let mut entries = Vec::new(); + for link in linked { + if !link.enabled || !SWEPT_TYPES.contains(&link.content_type) { + continue; + } + + let relative = + manifest::entry_path(link.content_type.folder_name(), &link.cluster_file_name); + if polyio::symlink_metadata(cluster_root.join(&relative)) + .await + .is_err() + { + continue; + } + + entries.push(ManifestEntry { + path: relative, + hash: link.hash, + }); + } + + report.adopted += entries.len(); + manifest::save( + cluster_root, + &MaterializedManifest::new(cluster.id, entries), + ) + .await; +} + +enum CacheMatch { + /// Cache holds this content at its own path the cluster copy is a link + Elsewhere, + /// The artifact row points at this exact file + IsTheArtifact, +} + +async fn cached_artifact_for(state: &LauncherState, path: &Path) -> Option { + let hash = polyio::sha1_file(path).await.ok()?; + let row = + artifact_dao::get_artifact_by_hash(&state.services.db, &polyio::normalize_hash(&hash)) + .await + .ok() + .flatten()?; + + let canonical = artifact_absolute_path(&row.path).ok()?; + if same_file(&canonical, path) { + Some(CacheMatch::IsTheArtifact) + } else { + Some(CacheMatch::Elsewhere) + } +} + +fn same_file(a: &Path, b: &Path) -> bool { + match (polyio::canonicalize(a), polyio::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} diff --git a/packages/oneclient_core/src/dev.rs b/packages/oneclient_core/src/dev.rs new file mode 100644 index 00000000..be7d8d26 --- /dev/null +++ b/packages/oneclient_core/src/dev.rs @@ -0,0 +1,324 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use tokio::sync::mpsc; +use uuid::Uuid; + +use oneclient_content::packages::provider::PackageProviderRegistry; +use oneclient_events::{ + Event, EventBus, GameEvent, GroupedProgressEvent, Level, Notification, ProgressEvent, +}; +use oneclient_net::RequestClient; + +use crate::{LauncherResult, LauncherServices, LauncherState}; + +struct GroupedSessionUi { + title: String, + parent: ProgressBar, + children: HashMap, + child_bytes: HashMap, +} + +fn spawn_notification_handler(mut rx: oneclient_events::EventReceiver) { + tokio::spawn(async move { + let mp = MultiProgress::new(); + let mut progress_bars: HashMap = HashMap::new(); + let mut grouped_sessions: HashMap = HashMap::new(); + + let flat_style = ProgressStyle::with_template( + "{spinner:.green} {msg:<20} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})", + ) + .unwrap() + .progress_chars("#>-"); + + let parent_style = ProgressStyle::with_template( + "{spinner:.green} {msg:<30} [{elapsed_precise}] [{wide_bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})", + ) + .unwrap() + .progress_chars("#>-"); + + let child_style = ProgressStyle::with_template( + " {spinner:.cyan} {msg:<38} [{bar:32.cyan/blue}] {bytes}/{total_bytes}", + ) + .unwrap() + .progress_chars("#>-"); + + while let Some(event) = rx.recv().await { + match event { + Event::Notification(Notification::Message(message)) => { + let (title, body) = (message.title, message.body); + mp.suspend(|| match message.level { + Level::Info => tracing::info!(%title, %body), + Level::Error => tracing::error!(%title, %body), + }); + } + Event::Progress(ProgressEvent::Update { + id, + label, + current, + total, + }) => { + if current >= total { + if let Some(pb) = progress_bars.remove(&id) { + pb.finish_with_message(format!("{label} Done!")); + } + } else { + let pb = progress_bars.entry(id).or_insert_with(|| { + let pb = mp.add(ProgressBar::new(total)); + pb.set_style(flat_style.clone()); + pb.set_message(label.clone()); + pb + }); + + pb.set_length(total); + pb.set_position(current); + } + } + Event::Progress(ProgressEvent::Complete { id, title, body }) => { + if let Some(pb) = progress_bars.remove(&id) { + pb.finish_with_message(format!("{title}: {body}")); + } else { + mp.suspend(|| tracing::info!(%title, %body)); + } + } + Event::Progress(ProgressEvent::Grouped(event)) => match event { + GroupedProgressEvent::Start { session_id, title } => { + let parent = mp.add(ProgressBar::new(1)); + parent.set_style(parent_style.clone()); + parent.set_message(title.clone()); + + grouped_sessions.insert( + session_id, + GroupedSessionUi { + title, + parent, + children: HashMap::new(), + child_bytes: HashMap::new(), + }, + ); + } + GroupedProgressEvent::Expect { .. } => {} + GroupedProgressEvent::AddChild { + session_id, + child_id, + label, + total, + .. + } => { + let Some(session) = grouped_sessions.get_mut(&session_id) else { + continue; + }; + + let child = ProgressBar::new(total); + child.set_style(child_style.clone()); + child.set_message(label.clone()); + mp.add(child.clone()); + + session.children.insert(child_id, child); + session.child_bytes.insert(child_id, (0, total)); + refresh_grouped_parent(session); + } + GroupedProgressEvent::UpdateChild { + session_id, + child_id, + current, + total, + } => { + let Some(session) = grouped_sessions.get_mut(&session_id) else { + continue; + }; + + if let Some(child) = session.children.get(&child_id) { + child.set_length(total); + child.set_position(current); + } + session.child_bytes.insert(child_id, (current, total)); + refresh_grouped_parent(session); + } + GroupedProgressEvent::SetChildPhase { + session_id, + child_id, + phase, + } => { + if let Some(session) = grouped_sessions.get(&session_id) + && let Some(child) = session.children.get(&child_id) + { + child.set_prefix(phase.label()); + } + } + GroupedProgressEvent::FinishChild { + session_id, + child_id, + } => { + let Some(session) = grouped_sessions.get_mut(&session_id) else { + continue; + }; + + if let Some(child) = session.children.remove(&child_id) { + child.finish_and_clear(); + } + session.child_bytes.remove(&child_id); + refresh_grouped_parent(session); + } + GroupedProgressEvent::End { session_id } => { + if let Some(session) = grouped_sessions.remove(&session_id) { + session + .parent + .finish_with_message(format!("{} - complete", session.title)); + } + } + }, + Event::Signal(_) => {} + Event::Game(GameEvent::Stage { cluster_id, stage }) => { + mp.suspend(|| tracing::info!(cluster_id, ?stage, "game stage")); + } + Event::Game(GameEvent::Log { line, .. }) => { + mp.suspend(|| tracing::info!("[game] {line}")); + } + Event::Game(GameEvent::Failed { + cluster_id, + message, + }) => { + mp.suspend(|| tracing::error!(cluster_id, "launch failed: {message}")); + } + // No TTY dialog here answer `None` so the waiting task fails + // cleanly instead of hanging on a prompt nobody can answer + Event::Notification(Notification::Prompt(request)) => { + let choices: Vec<&str> = request.choices.iter().map(|c| c.id).collect(); + mp.suspend(|| { + tracing::warn!( + "{}: {} | choices: {choices:?}", + request.title, + request.body + ); + tracing::warn!("prompts are not interactive in dev; dismissing"); + }); + let _ = request.reply.send(None); + } + } + } + }); +} + +fn refresh_grouped_parent(session: &mut GroupedSessionUi) { + let (current, total) = session + .child_bytes + .values() + .fold((0u64, 0u64), |(cur, tot), (c, t)| (cur + c, tot + t)); + + let total = total.max(1); + session.parent.set_length(total); + session.parent.set_position(current.min(total)); +} + +pub async fn initialize() -> LauncherResult> { + let (tx, rx) = mpsc::unbounded_channel(); + + spawn_notification_handler(rx); + + LauncherState::new(EventBus::new(tx)).await +} + +async fn ephemeral_root() -> LauncherResult { + let path = std::env::current_dir() + .map_err(crate::LauncherError::StdIoError)? + .join("target") + .join(format!("ephemeral-{}", Uuid::new_v4())); + + polyio::create_dir_all(&path).await?; + + Ok(path) +} + +pub async fn ephemeral_state() -> LauncherResult> { + let root = ephemeral_root().await?; + oneclient_common::paths::set_launcher_dir(root.clone()); + + let (tx, rx) = mpsc::unbounded_channel(); + spawn_notification_handler(rx); + + let db = oneclient_db::connect(root.join("example.db")).await?; + let settings = crate::settings::store::load_settings(None).await; + + let services = LauncherServices { + events: EventBus::new(tx), + requester: RequestClient::new(oneclient_net::NetConfig::default())?, + db, + packages: PackageProviderRegistry::new(), + }; + let auth = Arc::new(oneclient_auth::AuthService::with_store( + Default::default(), + services.requester.clone(), + services.events.clone(), + )); + + let clusters = crate::clusters::ClusterManager::new(services.db.clone()); + + let java = oneclient_java::JavaService::new( + Arc::new(crate::java_store::SqlJavaStore::new(services.db.clone())), + services.requester.clone(), + services.events.clone(), + ); + + Ok(Arc::new(LauncherState { + services, + auth, + java, + clusters, + settings: parking_lot::RwLock::new(settings), + metadata: tokio::sync::Mutex::new(oneclient_mc::MetadataStore::new()), + bundles: Arc::new(oneclient_content::bundles::BundlesManager::new()), + versions: Arc::new(crate::versions::VersionsManager::new()), + images: crate::images::ImageCacheStore::new(), + games: crate::game::GameProcessManager::new(), + discord: oneclient_discord::DiscordRpc::spawn(false), + })) +} + +pub async fn ephemeral_services() -> LauncherResult { + let root = ephemeral_root().await?; + oneclient_common::paths::set_launcher_dir(root.clone()); + let db = oneclient_db::connect(root.join("example.db")).await?; + let (tx, rx) = mpsc::unbounded_channel(); + spawn_notification_handler(rx); + + Ok(LauncherServices { + events: EventBus::new(tx), + requester: RequestClient::new(oneclient_net::NetConfig::default())?, + db, + packages: PackageProviderRegistry::new(), + }) +} + +pub async fn seed_bundle_archive( + state: &LauncherState, + manifest: oneclient_content::bundles::BundleManifest, +) -> LauncherResult<()> { + let disk_path = format!("bundles/{}.mrpack", manifest.name); + let loader = manifest.loader as i64; + + oneclient_db::dao::bundle::upsert_bundle( + &state.services.db, + oneclient_db::models::NewBundle { + remote_path: &disk_path, + mc_version: &manifest.mc_version, + mc_loader: loader, + file_name: &format!("{}.mrpack", manifest.name), + name: Some(&manifest.name), + version_id: Some(&manifest.version_id), + category: Some(&manifest.category), + loader_version: Some(&manifest.loader_version), + disk_path: &disk_path, + hidden: false, + etag: None, + synced_at: None, + }, + ) + .await?; + + let path = oneclient_common::paths::launcher_dir()?.join(&disk_path); + state.bundles.cache_archive_manifest(path, manifest).await; + + Ok(()) +} diff --git a/packages/oneclient_core/src/error.rs b/packages/oneclient_core/src/error.rs new file mode 100644 index 00000000..32b61259 --- /dev/null +++ b/packages/oneclient_core/src/error.rs @@ -0,0 +1,272 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum LauncherError { + #[error("Unable to parse JSON: {0}")] + JsonError(#[from] serde_json::Error), + + #[error("Database execution failed: {0}")] + SqlError(#[from] sqlx::Error), + + #[error(transparent)] + IoError(#[from] polyio::IOError), + + #[error(transparent)] + StdIoError(#[from] std::io::Error), + + #[error(transparent)] + RequestError(#[from] oneclient_net::RequestError), + + #[error(transparent)] + PathsError(#[from] oneclient_common::PathsError), + + #[error(transparent)] + DbError(#[from] oneclient_db::DbError), + + #[error("launcher core is not initialized")] + NotInitialized, + + #[error("launcher core is already initialized")] + AlreadyInitialized, + + #[error("invalid settings profile: {reason}")] + InvalidSettingsProfile { reason: String }, + + #[error(transparent)] + JavaError(#[from] oneclient_java::JavaError), + + #[error(transparent)] + UrlError(#[from] url::ParseError), + + #[error(transparent)] + EventError(#[from] oneclient_events::EventError), + + #[error(transparent)] + PackageError(#[from] oneclient_content::packages::PackageError), + + #[error(transparent)] + AuthError(#[from] oneclient_auth::AuthError), + + #[error(transparent)] + ClusterError(#[from] crate::clusters::ClusterError), + + #[error(transparent)] + McError(#[from] oneclient_mc::McError), + + #[error(transparent)] + ContentError(#[from] oneclient_content::ContentError), + + #[error(transparent)] + BundleError(#[from] oneclient_content::bundles::BundleError), + + #[error(transparent)] + GameError(#[from] crate::game::GameError), + + #[error(transparent)] + LogsError(#[from] oneclient_cluster::logs::LogsError), + + #[error(transparent)] + ScreenshotsError(#[from] oneclient_cluster::screenshots::ScreenshotsError), + + #[error("minecraft: {0}")] + Minecraft(String), +} + +impl LauncherError { + #[must_use] + pub fn auth_guidance(&self) -> Option { + match self { + LauncherError::AuthError(oneclient_auth::AuthError::Minecraft(err)) => { + oneclient_auth::diagnose_auth_error(err) + } + _ => None, + } + } + + /// Not something to report the dialog is already gone by the time it lands + #[must_use] + pub fn is_login_cancelled(&self) -> bool { + matches!( + self, + LauncherError::AuthError(oneclient_auth::AuthError::LoginCancelled) + ) + } + + /// A dialog the user backed out of rather than anything that went wrong + #[must_use] + pub fn is_cancelled(&self) -> bool { + self.is_login_cancelled() + || matches!( + self, + LauncherError::JavaError(oneclient_java::JavaError::Cancelled) + ) + } + + /// Whether the install is missing pieces and so is worth repairing + #[must_use] + pub fn indicates_missing_files(&self) -> bool { + let mut source: Option<&(dyn std::error::Error + 'static)> = Some(self); + + // Walk the chain the `io::Error` carrying the kind is usually wrapped + // two or three layers deep by the time this surfaces + while let Some(err) = source { + if let Some(io) = err.downcast_ref::() + && io.kind() == std::io::ErrorKind::NotFound + { + return true; + } + source = err.source(); + } + + false + } +} + +pub trait SentryExclusion { + fn is_sentry_excluded(&self) -> bool { + false + } +} + +impl SentryExclusion for LauncherError { + fn is_sentry_excluded(&self) -> bool { + match self { + LauncherError::StdIoError(e) => e.is_sentry_excluded(), + LauncherError::IoError(e) => e.is_sentry_excluded(), + LauncherError::RequestError(e) => e.is_transient(), + LauncherError::JavaError(e) => e.is_transient(), + _ => false, + } + } +} + +impl SentryExclusion for std::io::Error { + fn is_sentry_excluded(&self) -> bool { + use std::io::ErrorKind; + + // `ErrorKind::StorageFull` is still unstable so match raw OS codes + // Platform-gated 112 is the unrelated EHOSTDOWN on Linux + if let Some(code) = self.raw_os_error() { + #[cfg(unix)] + if code == 28 { + // ENOSPC + return true; + } + #[cfg(windows)] + if code == 112 || code == 39 { + // ERROR_DISK_FULL / ERROR_HANDLE_DISK_FULL + return true; + } + let _ = code; + } + + matches!( + self.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) + } +} + +impl SentryExclusion for reqwest::Error { + fn is_sentry_excluded(&self) -> bool { + // Connectivity problems are the user's network not a launcher bug + self.is_timeout() || self.is_connect() + } +} + +impl SentryExclusion for polyio::IOError { + fn is_sentry_excluded(&self) -> bool { + match self { + polyio::IOError::IOError(source) | polyio::IOError::PathIOError { source, .. } => { + source.is_sentry_excluded() + } + _ => false, + } + } +} + +pub type LauncherResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + /// Raw OS error rather than a synthesized `ErrorKind` same kind but only + /// this one renders the `(os error 2)` text users report + fn not_found(path: &str) -> LauncherError { + LauncherError::IoError(polyio::IOError::PathIOError { + source: std::io::Error::from_raw_os_error(2), + path: path.to_string(), + }) + } + + #[test] + fn a_missing_asset_directory_asks_for_a_repair() { + let path = "/Users/someone/Library/Application Support/org.Polyfrost.OneClient-dev/metadata/assets"; + let err = not_found(path); + + assert!(err.indicates_missing_files(), "{err}"); + assert!(err.to_string().contains(path), "{err}"); + } + + #[test] + fn a_not_found_nested_several_layers_deep_is_still_found() { + let err = LauncherError::McError(oneclient_mc::McError::Io(polyio::IOError::PathIOError { + source: std::io::Error::from(std::io::ErrorKind::NotFound), + path: "metadata/libraries/foo.jar".to_string(), + })); + + assert!(err.indicates_missing_files(), "{err}"); + } + + #[test] + fn dismissing_a_dialog_is_a_cancellation_not_a_failure() { + assert!(LauncherError::JavaError(oneclient_java::JavaError::Cancelled).is_cancelled()); + assert!(LauncherError::AuthError(oneclient_auth::AuthError::LoginCancelled).is_cancelled()); + } + + /// The whole point of the split anything that is not a dismissal keeps + /// reporting at `error` + #[test] + fn real_java_failures_are_not_cancellations() { + for err in [ + oneclient_java::JavaError::MissingJava, + oneclient_java::JavaError::PackageNotFound { major: 21 }, + oneclient_java::JavaError::InvalidJavaPath { + path: "/opt/not-java".to_string(), + }, + oneclient_java::JavaError::VersionMismatch { + expected: 21, + found: 8, + }, + ] { + let err = LauncherError::JavaError(err); + assert!(!err.is_cancelled(), "{err}"); + } + + assert!(!LauncherError::NotInitialized.is_cancelled()); + assert!(!not_found("metadata/assets").is_cancelled()); + } + + #[test] + fn failures_a_repair_could_not_fix_are_left_alone() { + for kind in [ + std::io::ErrorKind::PermissionDenied, + std::io::ErrorKind::ConnectionRefused, + std::io::ErrorKind::TimedOut, + ] { + let err = LauncherError::IoError(polyio::IOError::PathIOError { + source: std::io::Error::from(kind), + path: "metadata/assets".to_string(), + }); + assert!(!err.indicates_missing_files(), "{kind:?} should not repair"); + } + + assert!(!LauncherError::NotInitialized.indicates_missing_files()); + assert!(!LauncherError::Minecraft("bad manifest".into()).indicates_missing_files()); + } +} diff --git a/packages/oneclient_core/src/game/analytics.rs b/packages/oneclient_core/src/game/analytics.rs new file mode 100644 index 00000000..a62bcb00 --- /dev/null +++ b/packages/oneclient_core/src/game/analytics.rs @@ -0,0 +1,348 @@ +use std::collections::HashMap; +use std::net::IpAddr; + +use chrono::{DateTime, Datelike, Local, Timelike}; +use oneclient_db::dao::game_session as session_dao; +use oneclient_db::models::{GameSessionServerRow, SessionSpan}; + +use crate::error::LauncherResult; + +const NIGHT_HOURS: [usize; 8] = [22, 23, 0, 1, 2, 3, 4, 5]; +const NIGHT_OWL_SHARE: f64 = 0.35; +const GAMER_SECS_PER_DAY: f64 = 5.0 * 3600.0; + +#[derive(Debug, Clone, PartialEq)] +pub struct DayPlaytime { + pub date: String, + pub secs: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Persona { + NightOwl, + Gamer, +} + +impl Persona { + pub fn title(self) -> &'static str { + match self { + Persona::NightOwl => "Night Owl", + Persona::Gamer => "Gamer", + } + } + + pub fn description(self) -> &'static str { + match self { + Persona::NightOwl => "Most of your playtime happens after dark.", + Persona::Gamer => "You average over 5 hours on the days you play.", + } + } +} + +pub const WEEKDAY_LABELS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +#[derive(Debug, Clone, PartialEq)] +pub struct PlaytimeStats { + pub total_secs: i64, + pub session_count: usize, + pub per_weekday: [i64; 7], + pub per_hour: [i64; 24], + pub daily: Vec, + pub session_secs: Vec, + pub active_days: usize, + pub avg_secs_per_active_day: f64, + pub peak_hour: Option, + pub peak_weekday: Option, + pub night_share: f64, + pub personas: Vec, +} + +impl PlaytimeStats { + pub fn from_spans(spans: &[SessionSpan]) -> Self { + let mut per_weekday = [0i64; 7]; + let mut per_hour = [0i64; 24]; + let mut total_secs = 0i64; + let mut night_secs = 0i64; + let mut session_count = 0usize; + let mut daily: Vec = Vec::new(); + let mut session_secs: Vec = Vec::new(); + + for span in spans { + let Some(ended) = span.ended_at.as_deref() else { + continue; + }; + let (Ok(start), Ok(end)) = ( + DateTime::parse_from_rfc3339(&span.started_at), + DateTime::parse_from_rfc3339(ended), + ) else { + continue; + }; + + let secs = (end - start).num_seconds(); + if secs <= 0 { + continue; + } + + let local = start.with_timezone(&Local); + let hour = local.hour() as usize; + let weekday = local.weekday().num_days_from_monday() as usize; + + total_secs += secs; + session_count += 1; + session_secs.push(secs); + per_hour[hour] += secs; + per_weekday[weekday] += secs; + if NIGHT_HOURS.contains(&hour) { + night_secs += secs; + } + + let date = local.format("%Y-%m-%d").to_string(); + match daily.last_mut() { + Some(last) if last.date == date => last.secs += secs, + _ => daily.push(DayPlaytime { date, secs }), + } + } + + let active_days = daily.len(); + let avg_secs_per_active_day = if active_days > 0 { + total_secs as f64 / active_days as f64 + } else { + 0.0 + }; + let night_share = if total_secs > 0 { + night_secs as f64 / total_secs as f64 + } else { + 0.0 + }; + + let peak_hour = argmax(&per_hour); + let peak_weekday = argmax(&per_weekday); + + let mut personas = Vec::new(); + if total_secs > 0 && night_share >= NIGHT_OWL_SHARE { + personas.push(Persona::NightOwl); + } + if avg_secs_per_active_day > GAMER_SECS_PER_DAY { + personas.push(Persona::Gamer); + } + + Self { + total_secs, + session_count, + per_weekday, + per_hour, + daily, + session_secs, + active_days, + avg_secs_per_active_day, + peak_hour, + peak_weekday, + night_share, + personas, + } + } +} + +fn argmax(buckets: &[i64]) -> Option { + buckets + .iter() + .enumerate() + .filter(|&(_, &v)| v > 0) + .max_by_key(|&(_, &v)| v) + .map(|(i, _)| i) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerStat { + pub address: String, + pub port: Option, + pub joins: i64, + pub total_secs: i64, + pub last_played: Option, + pub is_ip: bool, +} + +fn address_is_ip(address: &str) -> bool { + address.parse::().is_ok() +} + +pub fn aggregate_servers(rows: &[GameSessionServerRow]) -> Vec { + struct Acc { + port: Option, + joins: i64, + total_secs: i64, + last_played: Option, + } + + let mut map: HashMap = HashMap::new(); + for row in rows { + let acc = map.entry(row.address.clone()).or_insert(Acc { + port: row.port, + joins: 0, + total_secs: 0, + last_played: None, + }); + acc.joins += 1; + + if let Some(ended) = row.disconnected_at.as_deref() + && let (Ok(start), Ok(end)) = ( + DateTime::parse_from_rfc3339(&row.joined_at), + DateTime::parse_from_rfc3339(ended), + ) + { + let secs = (end - start).num_seconds(); + if secs > 0 { + acc.total_secs += secs; + } + } + + if acc + .last_played + .as_deref() + .is_none_or(|prev| row.joined_at.as_str() > prev) + { + acc.last_played = Some(row.joined_at.clone()); + acc.port = row.port; + } + } + + let mut servers: Vec = map + .into_iter() + .map(|(address, acc)| ServerStat { + is_ip: address_is_ip(&address), + address, + port: acc.port, + joins: acc.joins, + total_secs: acc.total_secs, + last_played: acc.last_played, + }) + .collect(); + + servers.sort_by(|a, b| { + b.total_secs + .cmp(&a.total_secs) + .then(b.joins.cmp(&a.joins)) + .then(a.address.cmp(&b.address)) + }); + servers +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Analytics { + pub playtime: PlaytimeStats, + pub servers: Vec, +} + +#[tracing::instrument(level = "debug", skip(db))] +pub async fn global_analytics(db: &oneclient_db::DbPool) -> LauncherResult { + let spans = session_dao::all_session_spans(db).await?; + let servers = session_dao::all_session_servers(db).await?; + Ok(Analytics { + playtime: PlaytimeStats::from_spans(&spans), + servers: aggregate_servers(&servers), + }) +} + +#[tracing::instrument(level = "debug", skip(db))] +pub async fn cluster_analytics( + db: &oneclient_db::DbPool, + cluster_id: i64, +) -> LauncherResult { + let spans = session_dao::session_spans_for_cluster(db, cluster_id).await?; + let servers = session_dao::session_servers_for_cluster(db, cluster_id).await?; + Ok(Analytics { + playtime: PlaytimeStats::from_spans(&spans), + servers: aggregate_servers(&servers), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn span(start: &str, end: &str) -> SessionSpan { + SessionSpan { + started_at: start.to_string(), + ended_at: Some(end.to_string()), + } + } + + #[test] + fn empty_has_no_personas() { + let stats = PlaytimeStats::from_spans(&[]); + assert_eq!(stats.total_secs, 0); + assert!(stats.personas.is_empty()); + assert_eq!(stats.peak_hour, None); + } + + #[test] + fn skips_open_and_negative_spans() { + let spans = vec![ + SessionSpan { + started_at: "2026-01-01T10:00:00+00:00".into(), + ended_at: None, + }, + span("2026-01-01T10:00:00+00:00", "2026-01-01T09:00:00+00:00"), + ]; + let stats = PlaytimeStats::from_spans(&spans); + assert_eq!(stats.session_count, 0); + assert_eq!(stats.total_secs, 0); + } + + fn server(address: &str, joined: &str, disconnected: Option<&str>) -> GameSessionServerRow { + GameSessionServerRow { + session_started_at: "s".into(), + address: address.into(), + port: Some(25565), + joined_at: joined.into(), + disconnected_at: disconnected.map(str::to_string), + } + } + + #[test] + fn ip_addresses_are_flagged_domains_are_not() { + assert!(address_is_ip("192.168.1.10")); + assert!(address_is_ip("::1")); + assert!(!address_is_ip("mc.hypixel.net")); + assert!(!address_is_ip("play.example.com")); + } + + #[test] + fn aggregate_servers_sums_time_and_counts_joins() { + let rows = vec![ + server( + "mc.hypixel.net", + "2026-01-01T10:00:00+00:00", + Some("2026-01-01T11:00:00+00:00"), + ), + server("mc.hypixel.net", "2026-01-02T10:00:00+00:00", None), + server( + "192.168.1.10", + "2026-01-01T12:00:00+00:00", + Some("2026-01-01T12:30:00+00:00"), + ), + ]; + + let servers = aggregate_servers(&rows); + assert_eq!(servers.len(), 2); + assert_eq!(servers[0].address, "mc.hypixel.net"); + assert_eq!(servers[0].joins, 2); + assert_eq!(servers[0].total_secs, 3600); + assert!(!servers[0].is_ip); + assert_eq!(servers[1].address, "192.168.1.10"); + assert_eq!(servers[1].joins, 1); + assert_eq!(servers[1].total_secs, 1800); + assert!(servers[1].is_ip); + } + + #[test] + fn gamer_needs_five_hour_average() { + let spans = vec![span( + "2026-01-01T12:00:00+00:00", + "2026-01-01T18:00:00+00:00", + )]; + let stats = PlaytimeStats::from_spans(&spans); + assert_eq!(stats.active_days, 1); + assert!(stats.personas.contains(&Persona::Gamer)); + } +} diff --git a/packages/oneclient_core/src/game/diagnosis.rs b/packages/oneclient_core/src/game/diagnosis.rs new file mode 100644 index 00000000..ddbf42cb --- /dev/null +++ b/packages/oneclient_core/src/game/diagnosis.rs @@ -0,0 +1,204 @@ +//! Minecraft's exit code says only that it died the log says why and some +//! reasons are ones the launcher can fix + +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CrashDiagnosis { + /// A jar the JVM could not read on the classpath this is nearly always a + /// damaged library or mod which verification repairs + CorruptArchive { + /// Many of these messages carry no path so this is a bonus rather than + /// something to depend on + file: Option, + }, +} + +impl CrashDiagnosis { + #[must_use] + pub fn body(&self) -> String { + match self { + Self::CorruptArchive { file: Some(file) } => format!( + "The game could not read {file} — the file is damaged. \ + Verifying will re-download anything that does not match." + ), + Self::CorruptArchive { file: None } => "The game could not read one of its \ + library or mod files — it is damaged. Verifying will re-download \ + anything that does not match." + .to_string(), + } + } +} + +/// Different sources printing the same underlying damage they share a remedy +/// so they share a diagnosis +const CORRUPT_ARCHIVE_MARKERS: [&str; 4] = [ + "java.util.zip.ZipException", + "java.util.zip.ZipError", + "Invalid or corrupt jarfile", + "zip END header not found", +]; + +/// Runs against every line the game prints so it stays substring scans rather +/// than anything that parses the line +#[must_use] +pub fn diagnose(line: &str) -> Option { + if !CORRUPT_ARCHIVE_MARKERS + .iter() + .any(|marker| line.contains(marker)) + { + return None; + } + + Some(CrashDiagnosis::CorruptArchive { file: jar_in(line) }) +} + +/// Opportunistic some JVMs omit the path entirely and a missing name still +/// leads to the same repair +fn jar_in(line: &str) -> Option { + let token = line + .split(|c: char| c.is_whitespace() || c == '(' || c == ')') + .find(|token| token.trim_end_matches(['.', ',', ':']).ends_with(".jar"))?; + + let token = token.trim_end_matches(['.', ',', ':']); + + // The full path is noise in a notification the file name is what the user + // recognises + let name = token + .rsplit(['/', '\\']) + .next() + .unwrap_or(token) + .to_string(); + + (!name.is_empty()).then_some(name) +} + +/// Keeps the first recognised cause not the last a corrupt jar cascades into +/// unrelated failures so the earliest line is closest to the root cause +#[derive(Clone, Default)] +pub(crate) struct CrashWatch { + found: Arc>>, +} + +impl CrashWatch { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn observe(&self, line: &str) { + // Cheap rejection first this runs on every line the game prints + if self.found.lock().is_ok_and(|found| found.is_some()) { + return; + } + + if let Some(diagnosis) = diagnose(line) + && let Ok(mut found) = self.found.lock() + && found.is_none() + { + tracing::warn!( + ?diagnosis, + "recognised a repairable crash cause in the game log" + ); + *found = Some(diagnosis); + } + } + + pub(crate) fn take(&self) -> Option { + self.found.lock().ok().and_then(|mut found| found.take()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_zip_exception_is_recognised() { + let diagnosis = diagnose("java.util.zip.ZipException: error in opening zip file"); + assert_eq!( + diagnosis, + Some(CrashDiagnosis::CorruptArchive { file: None }) + ); + } + + #[test] + fn a_nested_zip_exception_is_recognised() { + let line = "Caused by: java.util.zip.ZipException: zip END header not found"; + assert!(diagnose(line).is_some()); + } + + #[test] + fn the_jar_is_named_when_the_message_carries_one() { + let line = "java.util.zip.ZipException: error in opening zip file: \ + /Users/someone/metadata/libraries/net/fabricmc/fabric-loader-0.15.jar"; + + assert_eq!( + diagnose(line), + Some(CrashDiagnosis::CorruptArchive { + file: Some("fabric-loader-0.15.jar".to_string()), + }) + ); + } + + #[test] + fn a_windows_path_is_reduced_to_its_file_name() { + let line = + r"Error: Invalid or corrupt jarfile C:\Users\someone\metadata\libraries\asm-9.7.jar"; + + assert_eq!( + diagnose(line), + Some(CrashDiagnosis::CorruptArchive { + file: Some("asm-9.7.jar".to_string()), + }) + ); + } + + #[test] + fn ordinary_game_output_is_not_a_crash() { + // A false positive would nag the user into re-downloading for nothing + for line in [ + "[Render thread/INFO]: Setting user: Dev", + "[main/INFO]: Loading 42 mods", + "Loaded jar file sodium-0.5.jar", + "[Worker-Main-1/WARN]: Unable to play unknown soundEvent", + "", + ] { + assert_eq!(diagnose(line), None, "{line}"); + } + } + + #[test] + fn the_watch_keeps_the_first_cause_not_the_last() { + let watch = CrashWatch::new(); + + watch.observe("[main/INFO]: Loading mods"); + watch.observe("java.util.zip.ZipException: error in opening zip file: first.jar"); + watch.observe("java.util.zip.ZipException: error in opening zip file: second.jar"); + + assert_eq!( + watch.take(), + Some(CrashDiagnosis::CorruptArchive { + file: Some("first.jar".to_string()), + }) + ); + } + + #[test] + fn a_clean_session_diagnoses_nothing() { + let watch = CrashWatch::new(); + watch.observe("[Render thread/INFO]: Stopping!"); + assert_eq!(watch.take(), None); + } + + #[test] + fn taking_a_diagnosis_consumes_it() { + let watch = CrashWatch::new(); + watch.observe("java.util.zip.ZipException: error in opening zip file"); + + assert!(watch.take().is_some()); + assert!( + watch.take().is_none(), + "a diagnosis must not be reported twice" + ); + } +} diff --git a/packages/oneclient_core/src/game/error.rs b/packages/oneclient_core/src/game/error.rs new file mode 100644 index 00000000..2a2234ad --- /dev/null +++ b/packages/oneclient_core/src/game/error.rs @@ -0,0 +1,43 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum GameError { + #[error("no client download exists for version {0}")] + NoClientDownload(String), + + #[error("failed to resolve library artifact path for {0}")] + LibraryPath(String), + + #[error("download hash mismatch for {path}: expected {expected}, got {actual}")] + HashMismatch { + path: String, + expected: String, + actual: String, + }, + + #[error("cluster is missing a required Java version")] + MissingJavaVersion, + + #[error("forge processor failed: {0}")] + ProcessorFailed(String), + + #[error("processor main class not found for {0}")] + ProcessorMainClass(String), + + #[error("invalid game version {0}")] + InvalidVersion(String), + + #[error("cluster {0} is already running")] + AlreadyRunning(i64), + + #[error("another cluster ({0}) is already running in the same directory")] + DirectoryInUse(i64), + + #[error( + "{0} is already running in the shared game directory; close it before launching another cluster" + )] + SharedDirectoryBusy(String), + + #[error("failed to spawn the game process: {0}")] + Spawn(String), +} diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs new file mode 100644 index 00000000..b5817dff --- /dev/null +++ b/packages/oneclient_core/src/game/launch.rs @@ -0,0 +1,680 @@ +use std::path::Path; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use interfrost::api::minecraft::ArgumentType; +use tokio::process::Command; + +use crate::ClusterStage; +use crate::LauncherResult; +use crate::clusters::Cluster; +use crate::game::GameError; +use crate::game::session::SessionRecorder; +use crate::game::tail::spawn_log_tail; +use crate::settings::GameSettingsProfile; +use crate::state::LauncherState; +use oneclient_auth::MinecraftAccount; +use oneclient_common::paths; +use oneclient_discord::Presence; +use oneclient_events::{GroupedProgressSession, LaunchStage}; +use oneclient_mc::{ + self as arguments, download_minecraft, download_version_info, game_files_missing, + get_loader_version, resolve_minecraft_version, +}; + +pub fn is_running(state: &LauncherState, cluster_id: i64) -> bool { + state.games.is_running(cluster_id) +} + +#[derive(Debug, Clone)] +pub struct LaunchedGame { + pub cluster_id: i64, + pub pid: Option, +} + +#[tracing::instrument(skip(state, account))] +pub async fn launch_cluster( + state: &Arc, + cluster_id: i64, + account: &MinecraftAccount, + search_for_java: bool, +) -> LauncherResult { + tracing::info!(cluster_id, search_for_java, "launching cluster"); + + let parallel = state.settings.read().allow_parallel_running_clusters; + if !parallel && state.games.is_running(cluster_id) { + tracing::warn!(cluster_id, "cluster already running; refusing launch"); + return Err(GameError::AlreadyRunning(cluster_id).into()); + } + + let events = state.services.events.clone(); + let stage = |s: LaunchStage| { + state.games.set_stage(cluster_id, s); + events.game_stage(cluster_id, s); + }; + + stage(LaunchStage::Checking); + + let existing = state.clusters.get(cluster_id).await?; + + let game_dir = existing.game_dir()?; + let dedicated = existing.uses_dedicated_dir(); + + // Non-dedicated clusters share one directory so a second game there would + // materialize over the running one's + // `dir_in_use_by` waves a cluster past + // its own session which parallel launches make reachable + if !dedicated && let Some(other) = state.games.dir_in_use(&game_dir) { + tracing::warn!(cluster_id, other, "shared game dir busy; refusing launch"); + let name = running_cluster_name(state, other).await; + return Err(GameError::SharedDirectoryBusy(name).into()); + } + + if let Some(other) = state.games.dir_in_use_by(&game_dir, cluster_id) { + return Err(GameError::DirectoryInUse(other).into()); + } + + let progress = GroupedProgressSession::start( + &state.services.events, + format!("Launching {}", existing.name), + ); + + let cluster = if existing.stage == ClusterStage::Ready { + existing + } else { + stage(LaunchStage::Downloading); + match crate::clusters::prepare_cluster_locked( + state, + cluster_id, + false, + search_for_java, + false, + Some(&progress), + ) + .await + { + Ok(cluster) => cluster, + Err(err) => { + progress.finish(); + stage(LaunchStage::Exited); + return Err(err); + } + } + }; + if let Err(err) = oneclient_content::bundles::install_cluster_bundles( + cluster_id, + state.bundles.as_ref(), + Some(&progress), + &state.services.content(), + ) + .await + { + tracing::warn!(cluster_id, error = %err, "failed to install bundle content"); + } + + let global = state.settings.read().global_game_settings.clone(); + let profile = state.clusters.resolve_settings(&global, &cluster).await?; + + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); + + let (version, updated, loader_version, version_info) = { + let mut metadata = state.metadata.lock().await; + + let (version, _index, updated) = + resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version) + .await + .map_err(|_| GameError::InvalidVersion(cluster.mc_version.clone()))?; + + let loader_version = get_loader_version( + &mut metadata, + &state.services.mc(), + &mc_version, + cluster.mc_loader, + cluster.mc_loader_version.as_deref(), + ) + .await?; + + let version_info = download_version_info( + &state.services.mc(), + Some(&progress), + &version, + loader_version.as_ref(), + false, + ) + .await; + + (version, updated, loader_version, version_info) + }; + + let version_info = match version_info { + Ok(info) => info, + Err(err) => { + progress.finish(); + stage(LaunchStage::Exited); + return Err(err.into()); + } + }; + + let version_name = loader_version.as_ref().map_or_else( + || version.id.clone(), + |lv| format!("{}-{}", version.id, lv.id), + ); + + tracing::info!( + cluster_id, + mc_version = %version.id, + loader = %cluster.mc_loader, + loader_version = loader_version.as_ref().map(|lv| lv.id.as_str()).unwrap_or("none"), + version_name = %version_name, + libraries = version_info.libraries.len(), + main_class = %version_info.main_class, + "resolved launch metadata" + ); + + let java = if let Some(runtime) = state + .java + .runtime_for_profile(profile.java_path.as_deref()) + .await? + { + runtime + } else { + let major = version_info + .java_version + .as_ref() + .map(|v| v.major_version) + .ok_or(GameError::MissingJavaVersion)?; + + state + .java + .prepare(major, search_for_java, false, None) + .await? + }; + + match game_files_missing(&version_info, &java.os_arch, updated) { + Ok(true) => { + tracing::info!(cluster_id, "missing game files; repairing"); + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::Repairing) + .await; + stage(LaunchStage::Downloading); + if let Err(err) = download_minecraft( + &state.services.mc(), + &progress, + &version_info, + &java.os_arch, + updated, + false, + ) + .await + { + progress.finish(); + stage(LaunchStage::Exited); + return Err(err.into()); + } + let _ = state + .clusters + .set_stage(cluster_id, ClusterStage::Ready) + .await; + } + Ok(false) => {} + Err(err) => tracing::warn!(cluster_id, error = %err, "repair check failed"), + } + + progress.finish(); + + stage(LaunchStage::Launching); + + let cwd = game_dir; + polyio::create_dir_all(&cwd).await.ok(); + + if let Err(err) = crate::game::write_allowed_symlinks(&cwd).await { + tracing::warn!(cluster_id, error = %err, "failed to write allowed_symlinks.txt"); + } + + // The one moment nothing holds the content open so this is where a package + // removed or disabled mid-session actually leaves the folder + if let Err(err) = crate::game::materialize_content(&state.services, &cluster, &cwd).await { + tracing::warn!(cluster_id, error = %err, "failed to materialize cluster content"); + } + + if !dedicated { + // Redirects the shared dir's `logs`/`crash-reports` into this cluster's + // folder so output is attributable unlinked on exit + crate::game::link_cluster_logs(&cluster, &cwd).await; + } + + let client_jar = paths::versions_dir()? + .join(&version_name) + .join(format!("{version_name}.jar")); + let natives = paths::natives_dir()?.join(&version_name); + let libraries = paths::libraries_dir()?; + let assets = paths::assets_dir()?; + + let arg_map = version_info.arguments.clone().unwrap_or_default(); + + let classpaths = arguments::classpaths( + &libraries, + &version_info.libraries, + &client_jar, + &java.os_arch, + updated, + )?; + + let jvm_args = arguments::java_arguments( + updated, + arg_map.get(&ArgumentType::Jvm).map(Vec::as_slice), + &natives, + &libraries, + &classpaths, + &version_name, + profile.mem_max.unwrap_or(2048), + profile.launch_args.clone().unwrap_or_default(), + &java.os_arch, + java.major, + )?; + + let mut mc_args = arguments::minecraft_arguments( + updated, + arg_map.get(&ArgumentType::Game).map(Vec::as_slice), + version_info.minecraft_arguments.as_deref(), + &account.access_token, + &account.username, + account.id, + &version.id, + &version_info.asset_index.id, + &cwd, + &assets, + version.type_, + profile.resolution.unwrap_or_default(), + &java.os_arch, + )?; + arguments::append_profile_game_arguments(&mut mc_args, profile.force_fullscreen, None); + + run_hook(profile.hook_pre.as_deref(), &cwd).await; + + tracing::info!( + cluster_id, + java = %java.absolute_path, + jvm_args = jvm_args.len(), + mc_args = mc_args.len(), + cwd = %cwd.display(), + "spawning minecraft process" + ); + tracing::debug!(cluster_id, ?jvm_args, main_class = %version_info.main_class, "jvm arguments"); + + let mut command = base_command(&profile, &java.absolute_path); + apply_env(&mut command, &profile); + command + .args(jvm_args) + .arg(&version_info.main_class) + .args(mc_args) + .current_dir(&cwd); + + let log_path = oneclient_cluster::logs::cluster_output_log(&cluster)?; + if let Some(parent) = log_path.parent() { + polyio::create_dir_all(parent).await.ok(); + } + + // A file not a pipe a pipe would die with the launcher and take the game's + // next stdout write down with it + // The cloned handle shares the file offset + // so stdout and stderr interleave instead of overwriting + let handles = match tokio::fs::File::create(&log_path).await { + Ok(out) => match out.try_clone().await { + Ok(err) => Ok((out.into_std().await, err.into_std().await)), + Err(err) => Err(err), + }, + Err(err) => Err(err), + }; + + match handles { + Ok((out, err)) => { + command.stdout(Stdio::from(out)).stderr(Stdio::from(err)); + } + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to open game log; discarding output"); + command.stdout(Stdio::null()).stderr(Stdio::null()); + } + } + command.stdin(Stdio::null()); + detach(&mut command); + + let mut child = command + .spawn() + .map_err(|err| GameError::Spawn(err.to_string()))?; + let pid = child.id(); + + stage(LaunchStage::Running); + state.games.set_pid(cluster_id, pid); + state.games.set_dir(cluster_id, cwd.clone()); + state.discord.set_presence(Presence::Playing { + cluster: cluster.name.clone(), + mc_version: cluster.mc_version.clone(), + }); + + let recorder = + SessionRecorder::start(state, cluster_id, profile.mem_max.unwrap_or(2048), &java).await; + + // Pinned to the session row so that if the launcher exits first the next + // start can tell whether the game is still playing + if let (Some(recorder), Some(pid)) = (recorder.as_ref(), pid) { + recorder + .record_process(pid, crate::game::process_start_time(pid)) + .await; + } + + let started_at = recorder + .as_ref() + .and_then(SessionRecorder::started_at) + .unwrap_or_else(Utc::now); + let crash_watch = crate::game::diagnosis::CrashWatch::new(); + let tail = spawn_log_tail( + cluster_id, + log_path, + events.clone(), + recorder.clone(), + crash_watch.clone(), + ); + + let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); + state.games.register_kill(cluster_id, kill_tx); + + let state = Arc::clone(state); + let post_hook = profile.hook_post.clone(); + tokio::spawn(async move { + let cluster = cluster; + let status = tokio::select! { + status = child.wait() => status, + _ = kill_rx => { + let _ = child.start_kill(); + child.wait().await + } + }; + + tail.stop().await; + + let outcome = match status { + Ok(status) => Exit::Observed { + code: status.code().map(i64::from), + success: status.success(), + display: status.to_string(), + }, + Err(err) => Exit::Failed(err.to_string()), + }; + + finalize_session( + &state, + &cluster, + &cwd, + dedicated, + post_hook.as_deref(), + recorder, + SessionEnd { + started_at, + ended_at: Utc::now(), + outcome, + owns_slot: true, + diagnosis: crash_watch.take(), + }, + ) + .await; + }); + + Ok(LaunchedGame { cluster_id, pid }) +} + +async fn running_cluster_name(state: &Arc, cluster_id: i64) -> String { + state + .clusters + .get(cluster_id) + .await + .map_or_else(|_| format!("Cluster {cluster_id}"), |cluster| cluster.name) +} + +/// Cuts the game loose from the launcher's process group/console so signals +/// aimed at the launcher (Ctrl-C console close) don't reach the game +fn detach(command: &mut Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.as_std_mut().process_group(0); + } + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const DETACHED_PROCESS: u32 = 0x0000_0008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + command + .as_std_mut() + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + } +} + +pub(crate) enum Exit { + Observed { + code: Option, + success: bool, + display: String, + }, + Failed(String), + /// Game exited while the launcher was closed time recovered from the log + /// exit code unrecoverable + Inferred, +} + +pub(crate) struct SessionEnd { + pub started_at: DateTime, + pub ended_at: DateTime, + pub outcome: Exit, + /// False for a stale session recovered from the database whose cluster has a + /// live game book its playtime but clearing the slot would report the live + /// game as exited + pub owns_slot: bool, + /// `None` for a clean exit an unrecognised crash or a session recovered + /// after the fact with no log watched + pub diagnosis: Option, +} + +/// Shared by the live exit path and by recovery of sessions that outlived the +/// launcher +pub(crate) async fn finalize_session( + state: &Arc, + cluster: &Cluster, + cwd: &Path, + dedicated: bool, + post_hook: Option<&str>, + recorder: Option, + end: SessionEnd, +) { + let cluster_id = cluster.id; + let played = (end.ended_at - end.started_at).to_std().unwrap_or_default(); + + if end.owns_slot { + state.games.remove(cluster_id); + state + .services + .events + .game_stage(cluster_id, LaunchStage::Exited); + + if state.games.running_ids().is_empty() { + state.discord.set_presence(Presence::Idle); + } + } + + if played > Duration::from_secs(1) { + let _ = state.clusters.add_playtime(cluster_id, played).await; + } + + if let Some(recorder) = recorder { + let code = match &end.outcome { + Exit::Observed { code, .. } => *code, + Exit::Failed(_) | Exit::Inferred => None, + }; + recorder.finish_at(&end.ended_at.to_rfc3339(), code).await; + } + + run_hook(post_hook, cwd).await; + + if dedicated { + // The folder stays materialized so it remains a real Minecraft directory + // for external tools adopting drop-ins now keeps the UI right on close + crate::game::import_manual_content(&state.services, cluster, cwd).await; + } else { + if let Err(err) = crate::game::dematerialize_content(&state.services, cluster, cwd).await { + tracing::warn!(cluster_id, error = %err, "failed to clear shared content on exit"); + } + crate::game::unlink_cluster_logs(cwd).await; + } + + let name = &cluster.name; + let crashed = !matches!(end.outcome, Exit::Observed { success: true, .. }); + + match end.outcome { + Exit::Observed { success: true, .. } => state + .services + .events + .notify("Game closed") + .body(format!("{name} exited")) + .send(), + Exit::Observed { display, .. } => state + .services + .events + .notify("Game crashed") + .body(format!("{name} exited with {display}")) + .error() + .send(), + Exit::Failed(err) => state + .services + .events + .notify("Game error") + .body(format!("{name}: {err}")) + .error() + .send(), + // Nothing was watching so there is no crash to report + Exit::Inferred => {} + } + + // Only when the game actually died a `ZipException` it recovered from is not + // worth interrupting a finished session over + if crashed && let Some(diagnosis) = end.diagnosis { + offer_repair(state, cluster_id, &diagnosis).await; + } +} + +/// Asks rather than acting verification can re-download much of the game too +/// much to start unprompted +/// The offer returns on the next crash +#[tracing::instrument(skip(state, diagnosis), level = "debug")] +pub async fn offer_repair( + state: &Arc, + cluster_id: i64, + diagnosis: &crate::game::diagnosis::CrashDiagnosis, +) { + enum Answer { + Verify, + } + + let answer = state + .services + .events + .ask( + oneclient_events::Prompt::new("A damaged file crashed the game", diagnosis.body()) + .option( + oneclient_events::Choice::primary("verify", "Verify and repair"), + Answer::Verify, + ) + .dismiss("Not now"), + ) + .await; + + match answer { + Ok(Some(_)) => {} + // Dismissed or nobody there to ask neither is consent to re-download + Ok(None) => { + tracing::info!(cluster_id, "user declined the post-crash repair"); + return; + } + Err(err) => { + tracing::warn!(cluster_id, "could not offer a post-crash repair: {err}"); + return; + } + } + + match crate::verify::verify_cluster_files(state, cluster_id).await { + Ok(report) => { + state + .services + .events + .notify("Repair complete") + .body(report.summary()) + .send(); + } + Err(err) => { + tracing::error!(cluster_id, "post-crash repair failed: {err:#}"); + state + .services + .events + .notify("Repair failed") + .body(err.to_string()) + .error() + .send(); + } + } +} + +fn base_command(profile: &GameSettingsProfile, java_path: &str) -> Command { + if let Some(wrapper) = profile + .hook_wrapper + .as_deref() + .map(str::trim) + .filter(|hook| !hook.is_empty()) + { + let mut split = wrapper.split_whitespace(); + let mut command = Command::new(split.next().unwrap_or("sh")); + command.args(split); + command.arg(java_path); + command + } else { + Command::new(java_path) + } +} + +fn apply_env(command: &mut Command, profile: &GameSettingsProfile) { + command.env_remove("_JAVA_OPTIONS"); + if let Some(env) = &profile.launch_env { + for pair in env.split_whitespace() { + if let Some((key, value)) = pair.split_once('=') { + command.env(key, value); + } + } + } +} + +#[tracing::instrument(skip(cwd), fields(hook), level = "debug")] +async fn run_hook(hook: Option<&str>, cwd: &Path) { + let Some(hook) = hook.map(str::trim).filter(|h| !h.is_empty()) else { + return; + }; + + #[cfg(windows)] + let mut command = { + let mut c = Command::new("cmd"); + c.args(["/C", hook]); + c + }; + #[cfg(not(windows))] + let mut command = { + let mut c = Command::new("sh"); + c.args(["-c", hook]); + c + }; + + command.current_dir(cwd); + if let Err(err) = command.status().await { + tracing::warn!("hook '{hook}' failed: {err}"); + } +} diff --git a/packages/oneclient_core/src/game/log_replay.rs b/packages/oneclient_core/src/game/log_replay.rs new file mode 100644 index 00000000..2eb86497 --- /dev/null +++ b/packages/oneclient_core/src/game/log_replay.rs @@ -0,0 +1,288 @@ +//! Minecraft logs a bare local wall-clock time (`[12:29:00]`) no date no +//! offset Absolute timestamps must be rebuilt by anchoring at the session +//! start and walking forward + +use chrono::{DateTime, Local, LocalResult, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; + +use super::session::{ServerJoin, parse_server_join}; + +/// A backwards jump larger than this means the clock wrapped past midnight +/// rather than lines merely arriving out of order between threads +const ROLLOVER_SLACK_SECS: i64 = 12 * 60 * 60; + +/// Absent any of these the span is closed by the next join or the session end +/// so a missing marker costs accuracy never correctness +const LEAVE_MARKERS: &[&str] = &[ + "Stopping!", + "Stopping worker threads", + "Connection lost", + "Lost connection to server", + "Client disconnected with reason", + "Disconnected from server", +]; + +/// Clean-shutdown marker its timestamp is the truest exit time available +const STOP_MARKER: &str = "Stopping!"; + +pub(crate) fn parse_log_time(line: &str) -> Option { + let stamp = line.strip_prefix('[')?.split(']').next()?.trim(); + + // `%.f` also matches an empty fraction covering both `[12:29:00]` and + // `[12:29:00.123]` + if let Ok(time) = NaiveTime::parse_from_str(stamp, "%H:%M:%S%.f") { + return Some(time); + } + + // Some pack-shipped log4j configs prepend the date + for fmt in ["%Y-%m-%d %H:%M:%S%.f", "%d.%m.%Y %H:%M:%S%.f"] { + if let Ok(dt) = NaiveDateTime::parse_from_str(stamp, fmt) { + return Some(dt.time()); + } + } + + None +} + +pub(crate) fn is_leave_marker(line: &str) -> bool { + LEAVE_MARKERS.iter().any(|marker| line.contains(marker)) +} + +fn local_to_utc(date: NaiveDate, time: NaiveTime) -> DateTime { + let naive = NaiveDateTime::new(date, time); + match Local.from_local_datetime(&naive) { + LocalResult::Single(dt) => dt.with_timezone(&Utc), + // DST end the time happens twice the first fits a forward-walking log + LocalResult::Ambiguous(first, _) => first.with_timezone(&Utc), + // DST start this time never existed so step over the gap rather than + // reading it as UTC which would be off by the zone's whole offset + LocalResult::None => Local + .from_local_datetime(&(naive + chrono::Duration::hours(1))) + .earliest() + .map(|dt| dt.with_timezone(&Utc)) + .unwrap_or_else(|| Utc.from_utc_datetime(&naive)), + } +} + +/// Turns bare times into absolute instants advancing the date on wrap +pub(crate) struct LogClock { + date: NaiveDate, + /// High-water mark not the previous line threads interleave so a line + /// may legitimately sit behind the one before it + peak: NaiveTime, +} + +impl LogClock { + pub(crate) fn new(anchor: DateTime) -> Self { + let local = anchor.with_timezone(&Local).naive_local(); + Self { + date: local.date(), + peak: local.time(), + } + } + + pub(crate) fn absolute(&mut self, time: NaiveTime) -> DateTime { + let behind = self.peak.signed_duration_since(time).num_seconds(); + if behind > ROLLOVER_SLACK_SECS { + self.date = self.date.succ_opt().unwrap_or(self.date); + self.peak = time; + } else if time > self.peak { + self.peak = time; + } + local_to_utc(self.date, time) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ServerSpan { + pub host: String, + pub port: Option, + pub joined_at: DateTime, + pub disconnected_at: Option>, +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct LogReplay { + /// Timestamp of the last line carrying one a lower bound on the exit + pub last_activity: Option>, + pub stopped_at: Option>, + pub servers: Vec, +} + +pub(crate) fn replay(content: &str, started_at: DateTime) -> LogReplay { + let mut clock = LogClock::new(started_at); + let mut out = LogReplay::default(); + // Timestamps appear only on the first line of a multi-line entry so carry + // the last one forward + let mut now = started_at; + + for line in content.lines() { + if let Some(time) = parse_log_time(line) { + now = clock.absolute(time); + out.last_activity = Some(now); + } + + if let Some(ServerJoin { host, port }) = parse_server_join(line) { + close_open_span(&mut out, now); + out.servers.push(ServerSpan { + host, + port, + joined_at: now, + disconnected_at: None, + }); + continue; + } + + if is_leave_marker(line) { + close_open_span(&mut out, now); + } + + if line.contains(STOP_MARKER) { + out.stopped_at = Some(now); + } + } + + out +} + +fn close_open_span(replay: &mut LogReplay, at: DateTime) { + if let Some(open) = replay + .servers + .iter_mut() + .rev() + .find(|span| span.disconnected_at.is_none()) + { + open.disconnected_at = Some(at.max(open.joined_at)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + + fn anchor() -> DateTime { + Local + .with_ymd_and_hms(2026, 7, 16, 12, 0, 0) + .unwrap() + .with_timezone(&Utc) + } + + fn local_at(h: u32, m: u32, s: u32) -> DateTime { + Local + .with_ymd_and_hms(2026, 7, 16, h, m, s) + .unwrap() + .with_timezone(&Utc) + } + + #[test] + fn parses_plain_and_fractional_times() { + assert_eq!( + parse_log_time("[12:29:00] [Client thread/INFO]: Setting user: Lynith"), + NaiveTime::from_hms_opt(12, 29, 0) + ); + assert_eq!( + parse_log_time("[12:29:00.123] [Render thread/INFO]: hi"), + NaiveTime::from_hms_milli_opt(12, 29, 0, 123) + ); + } + + #[test] + fn parses_dated_stamp() { + assert_eq!( + parse_log_time("[2026-07-16 12:29:00] [Render thread/INFO]: hi"), + NaiveTime::from_hms_opt(12, 29, 0) + ); + } + + #[test] + fn ignores_untimestamped_lines() { + assert!(parse_log_time("\tat net.minecraft.Foo.bar(Foo.java:12)").is_none()); + assert!(parse_log_time("[Render thread/INFO]: no time here").is_none()); + } + + #[test] + fn clock_keeps_same_day_while_moving_forward() { + let mut clock = LogClock::new(anchor()); + let at = clock.absolute(NaiveTime::from_hms_opt(12, 30, 0).unwrap()); + assert_eq!(at, local_at(12, 30, 0)); + } + + #[test] + fn clock_rolls_over_midnight() { + let mut clock = LogClock::new(anchor()); + clock.absolute(NaiveTime::from_hms_opt(23, 59, 0).unwrap()); + let at = clock.absolute(NaiveTime::from_hms_opt(0, 1, 0).unwrap()); + assert_eq!( + at, + local_at(12, 0, 0) + Duration::hours(12) + Duration::minutes(1) + ); + } + + #[test] + fn clock_tolerates_out_of_order_threads() { + let mut clock = LogClock::new(anchor()); + clock.absolute(NaiveTime::from_hms_opt(12, 29, 8).unwrap()); + // A second thread's line lands a second late this must not add a day + let at = clock.absolute(NaiveTime::from_hms_opt(12, 29, 7).unwrap()); + assert_eq!(at, local_at(12, 29, 7)); + } + + #[test] + fn replays_server_span_closed_by_quit() { + let log = "\ +[12:29:00] [Client thread/INFO]: Setting user: Lynith +[12:30:00] [Render thread/INFO]: Connecting to mc.hypixel.net, 25565 +[12:45:00] [Client thread/INFO]: Stopping! +"; + let out = replay(log, anchor()); + assert_eq!(out.servers.len(), 1); + let span = &out.servers[0]; + assert_eq!(span.host, "mc.hypixel.net"); + assert_eq!(span.port, Some(25565)); + assert_eq!(span.joined_at, local_at(12, 30, 0)); + assert_eq!(span.disconnected_at, Some(local_at(12, 45, 0))); + assert_eq!(out.stopped_at, Some(local_at(12, 45, 0))); + assert_eq!(out.last_activity, Some(local_at(12, 45, 0))); + } + + #[test] + fn consecutive_joins_close_previous_span() { + let log = "\ +[12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565 +[12:40:00] [Render thread/INFO]: Connecting to b.example.com, 25566 +"; + let out = replay(log, anchor()); + assert_eq!(out.servers.len(), 2); + assert_eq!(out.servers[0].disconnected_at, Some(local_at(12, 40, 0))); + assert_eq!(out.servers[1].disconnected_at, None); + } + + #[test] + fn span_left_open_when_log_just_stops() { + let log = "[12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565\n"; + let out = replay(log, anchor()); + assert_eq!(out.servers[0].disconnected_at, None); + assert_eq!(out.stopped_at, None); + assert_eq!(out.last_activity, Some(local_at(12, 30, 0))); + } + + #[test] + fn crash_without_stop_marker_reports_last_activity() { + let log = "\ +[12:30:00] [Render thread/INFO]: Connecting to a.example.com, 25565 +[12:31:00] [Render thread/ERROR]: Encountered an unexpected exception +\tat net.minecraft.Foo.bar(Foo.java:12) +"; + let out = replay(log, anchor()); + assert_eq!(out.stopped_at, None); + assert_eq!(out.last_activity, Some(local_at(12, 31, 0))); + } + + #[test] + fn empty_log_yields_nothing() { + let out = replay("", anchor()); + assert!(out.servers.is_empty()); + assert!(out.last_activity.is_none()); + assert!(out.stopped_at.is_none()); + } +} diff --git a/packages/oneclient_core/src/game/mod.rs b/packages/oneclient_core/src/game/mod.rs new file mode 100644 index 00000000..77b3917b --- /dev/null +++ b/packages/oneclient_core/src/game/mod.rs @@ -0,0 +1,38 @@ +mod analytics; +mod error; +mod launch; +mod log_replay; +mod process; +mod reattach; +mod session; +mod shared_dir; +mod tail; + +pub use analytics::{ + Analytics, DayPlaytime, Persona, PlaytimeStats, ServerStat, WEEKDAY_LABELS, aggregate_servers, + cluster_analytics, global_analytics, +}; +pub use oneclient_mc::{ + append_profile_game_arguments, classpaths, download_to_path, fetch_bytes_verified, + get_classpath_library, get_library, java_arguments, main_class, minecraft_arguments, + processor_arguments, +}; +pub mod diagnosis; + +pub use diagnosis::{CrashDiagnosis, diagnose}; +pub use error::GameError; +pub use launch::{LaunchedGame, is_running, launch_cluster, offer_repair}; +pub use oneclient_mc::{ + DownloadPlan, confirm_incomplete_install, download_minecraft, download_version_info, + game_files_missing, get_game_versions, get_loader_version, get_loader_versions, + get_loaders_for_version, is_version_updated, libraries_missing, plan_downloads, + resolve_minecraft_version, validate_rules, verify_game_files, +}; +pub use process::{ + GameProcess, GameProcessManager, is_process_alive, kill_process, process_start_time, +}; +pub use reattach::recover_sessions; +pub use shared_dir::{ + dematerialize_content, import_manual_content, link_cluster_logs, materialize_content, + unlink_cluster_logs, write_allowed_symlinks, +}; diff --git a/packages/oneclient_core/src/game/process.rs b/packages/oneclient_core/src/game/process.rs new file mode 100644 index 00000000..fdd8c45e --- /dev/null +++ b/packages/oneclient_core/src/game/process.rs @@ -0,0 +1,172 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::Instant; + +use sysinfo::{Pid, ProcessesToUpdate, Signal, System}; +use tokio::sync::oneshot; + +use oneclient_events::LaunchStage; + +fn probe(pid: u32) -> Option<(System, Pid)> { + let pid = Pid::from_u32(pid); + let mut sys = System::new(); + sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true); + sys.process(pid)?; + Some((sys, pid)) +} + +/// Unix seconds Pids get recycled so this is what pins process identity +pub fn process_start_time(pid: u32) -> Option { + let (sys, pid) = probe(pid)?; + Some(sys.process(pid)?.start_time()) +} + +/// A matching pid whose start time differs is a recycled pid belonging to +/// someone else never ours +pub fn is_process_alive(pid: u32, started_at: Option) -> bool { + let Some(actual) = process_start_time(pid) else { + return false; + }; + match started_at { + Some(expected) => actual == expected, + // Pre-migration sessions have no recorded start time assuming running + // is the safer error as the exit time is recovered on a later start + None => true, + } +} + +/// For games re-adopted after a launcher restart where no `Child` handle +/// survives to kill through +pub fn kill_process(pid: u32) -> bool { + let Some((sys, pid)) = probe(pid) else { + return false; + }; + let Some(process) = sys.process(pid) else { + return false; + }; + // Prefer a graceful terminate so the game can save before shutting down + process + .kill_with(Signal::Term) + .unwrap_or_else(|| process.kill()) +} + +#[derive(Debug, Clone)] +pub struct GameProcess { + pub pid: Option, + pub stage: LaunchStage, + pub started: Instant, +} + +#[derive(Default)] +pub struct GameProcessManager { + inner: Mutex>, + kills: Mutex>>, + dirs: Mutex>, +} + +impl GameProcessManager { + pub fn new() -> Self { + Self::default() + } + + pub fn register_kill(&self, cluster_id: i64, tx: oneshot::Sender<()>) { + self.kills.lock().unwrap().insert(cluster_id, tx); + } + + #[tracing::instrument(skip(self), level = "debug")] + pub fn kill(&self, cluster_id: i64) -> bool { + match self.kills.lock().unwrap().remove(&cluster_id) { + Some(tx) => { + tracing::debug!(cluster_id, "signalling kill to running game"); + tx.send(()).is_ok() + } + None => false, + } + } + + pub fn set_stage(&self, cluster_id: i64, stage: LaunchStage) { + let mut map = self.inner.lock().unwrap(); + if stage == LaunchStage::Exited { + map.remove(&cluster_id); + return; + } + map.entry(cluster_id) + .and_modify(|p| p.stage = stage) + .or_insert_with(|| GameProcess { + pid: None, + stage, + started: Instant::now(), + }); + } + + pub fn set_pid(&self, cluster_id: i64, pid: Option) { + if let Some(p) = self.inner.lock().unwrap().get_mut(&cluster_id) { + p.pid = pid; + } + } + + pub fn remove(&self, cluster_id: i64) { + self.inner.lock().unwrap().remove(&cluster_id); + self.kills.lock().unwrap().remove(&cluster_id); + self.dirs.lock().unwrap().remove(&cluster_id); + } + + pub fn set_dir(&self, cluster_id: i64, dir: PathBuf) { + self.dirs.lock().unwrap().insert(cluster_id, dir); + } + + /// Like [`Self::dir_in_use_by`] but counts a cluster's own session too the + /// shared game directory holds one game at a time whoever it belongs to + pub fn dir_in_use(&self, dir: &Path) -> Option { + self.dirs + .lock() + .unwrap() + .iter() + .find(|(_, d)| d.as_path() == dir) + .map(|(id, _)| *id) + } + + pub fn dir_in_use_by(&self, dir: &Path, exclude: i64) -> Option { + self.dirs + .lock() + .unwrap() + .iter() + .find(|(id, d)| **id != exclude && d.as_path() == dir) + .map(|(id, _)| *id) + } + + pub fn pid(&self, cluster_id: i64) -> Option { + self.inner + .lock() + .unwrap() + .get(&cluster_id) + .and_then(|p| p.pid) + } + + pub fn is_running(&self, cluster_id: i64) -> bool { + self.inner + .lock() + .unwrap() + .get(&cluster_id) + .is_some_and(|p| p.stage == LaunchStage::Running) + } + + pub fn is_active(&self, cluster_id: i64) -> bool { + self.inner.lock().unwrap().contains_key(&cluster_id) + } + + pub fn stage(&self, cluster_id: i64) -> Option { + self.inner.lock().unwrap().get(&cluster_id).map(|p| p.stage) + } + + pub fn running_ids(&self) -> Vec { + self.inner + .lock() + .unwrap() + .iter() + .filter(|(_, p)| p.stage == LaunchStage::Running) + .map(|(id, _)| *id) + .collect() + } +} diff --git a/packages/oneclient_core/src/game/reattach.rs b/packages/oneclient_core/src/game/reattach.rs new file mode 100644 index 00000000..92e97289 --- /dev/null +++ b/packages/oneclient_core/src/game/reattach.rs @@ -0,0 +1,410 @@ +//! Games are spawned detached so sessions can outlive the launcher they are +//! settled on the next start (rows with no `ended_at`) + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use oneclient_db::dao::game_session as session_dao; +use oneclient_db::models::UnfinishedSession; + +use crate::clusters::Cluster; +use crate::game::launch::{Exit, SessionEnd, finalize_session}; +use crate::game::log_replay::{self, ServerSpan}; +use crate::game::process::{is_process_alive, kill_process}; +use crate::game::session::SessionRecorder; +use crate::game::tail::spawn_log_tail; +use crate::state::LauncherState; +use oneclient_discord::Presence; +use oneclient_events::LaunchStage; + +/// Only a fallback for a process we don't own so a slow poll is plenty +const LIVENESS_POLL: Duration = Duration::from_secs(3); + +/// Call once at startup before anything can launch a game +#[tracing::instrument(skip(state), level = "debug")] +pub async fn recover_sessions(state: &Arc) { + let sessions = match session_dao::unfinished_sessions(&state.services.db).await { + Ok(sessions) => sessions, + Err(err) => { + tracing::warn!(error = %err, "failed to list unfinished game sessions"); + return; + } + }; + + if sessions.is_empty() { + return; + } + + tracing::info!( + count = sessions.len(), + "recovering unfinished game sessions" + ); + + let mut live = Vec::new(); + let mut dead = Vec::new(); + for session in sessions { + match classify(state, session).await { + Some((cluster, session, started_at, Some(pid))) => { + live.push((cluster, session, started_at, pid)); + } + Some((cluster, session, started_at, None)) => { + dead.push((cluster, session, started_at)); + } + None => {} + } + } + + // Live games first reconciling an exited session tears down a shared game + // dir and that check reads the live registrations + for (cluster, session, started_at, pid) in live { + readopt(state, cluster, session, started_at, pid).await; + } + + for (cluster, session, started_at) in dead { + reconcile(state, cluster, session, started_at).await; + } +} + +/// `None` means the session was closed out here and needs no further work +async fn classify( + state: &Arc, + session: UnfinishedSession, +) -> Option<(Cluster, UnfinishedSession, DateTime, Option)> { + let cluster_id = session.cluster_id; + + if state.games.is_active(cluster_id) { + return None; + } + + let started_at = match DateTime::parse_from_rfc3339(&session.started_at) { + Ok(at) => at.with_timezone(&Utc), + Err(err) => { + tracing::warn!(cluster_id, session = %session.started_at, error = %err, "unparseable session start; closing it"); + close_untraceable(state, &session).await; + return None; + } + }; + + // No cluster means no log and no playtime worth attributing close the row + // so it stops being reconsidered forever + let Ok(cluster) = state.clusters.get(cluster_id).await else { + tracing::warn!( + cluster_id, + "cluster missing for unfinished session; closing it" + ); + close_untraceable(state, &session).await; + return None; + }; + + let alive = session + .pid + .and_then(|pid| u32::try_from(pid).ok()) + .filter(|pid| is_process_alive(*pid, session.pid_started_at.map(|t| t as u64))); + + Some((cluster, session, started_at, alive)) +} + +/// Ends at the start time which books no playtime rather than inventing some +async fn close_untraceable(state: &Arc, session: &UnfinishedSession) { + if let Err(err) = session_dao::finish_session_at( + &state.services.db, + &session.started_at, + &session.started_at, + None, + ) + .await + { + tracing::warn!(error = %err, "failed to close untraceable session"); + } +} + +#[tracing::instrument(skip(state, cluster, session), fields(cluster_id = cluster.id, pid), level = "debug")] +async fn readopt( + state: &Arc, + cluster: Cluster, + session: UnfinishedSession, + started_at: DateTime, + pid: u32, +) { + let cluster_id = cluster.id; + let Ok(cwd) = cluster.game_dir() else { + return; + }; + + tracing::info!(cluster_id, pid, "re-adopting running game"); + + let open_server = open_server_of(state, &session.started_at).await; + let recorder = SessionRecorder::resume( + state.services.db.clone(), + session.started_at.clone(), + open_server, + ); + + state.games.set_stage(cluster_id, LaunchStage::Running); + state.games.set_pid(cluster_id, Some(pid)); + state.games.set_dir(cluster_id, cwd.clone()); + state + .services + .events + .game_stage(cluster_id, LaunchStage::Running); + state.discord.set_presence(Presence::Playing { + cluster: cluster.name.clone(), + mc_version: cluster.mc_version.clone(), + }); + + let Ok(log_path) = oneclient_cluster::logs::cluster_output_log(&cluster) else { + return; + }; + // A re-adopted game still writes its log so crashes stay readable even + // though the exit code went with the launcher that spawned it + let crash_watch = crate::game::diagnosis::CrashWatch::new(); + let tail = spawn_log_tail( + cluster_id, + log_path, + state.services.events.clone(), + Some(recorder.clone()), + crash_watch.clone(), + ); + + let (kill_tx, kill_rx) = tokio::sync::oneshot::channel::<()>(); + state.games.register_kill(cluster_id, kill_tx); + + // Resolved up front so the watcher task doesn't reach back into the state + let global = state.settings.read().global_game_settings.clone(); + let post_hook = state + .clusters + .resolve_settings(&global, &cluster) + .await + .ok() + .and_then(|profile| profile.hook_post); + let dedicated = cluster.uses_dedicated_dir(); + let pid_started_at = session.pid_started_at.map(|t| t as u64); + let state = Arc::clone(state); + + tokio::spawn(async move { + tokio::select! { + () = wait_for_exit(pid, pid_started_at) => {} + _ = kill_rx => { + kill_process(pid); + wait_for_exit(pid, pid_started_at).await; + } + } + + tail.stop().await; + + finalize_session( + &state, + &cluster, + &cwd, + dedicated, + post_hook.as_deref(), + Some(recorder), + SessionEnd { + started_at, + ended_at: Utc::now(), + outcome: Exit::Inferred, + // With parallel clusters a newer game may already own this + // cluster's slot only tear down if it is still ours + owns_slot: state.games.pid(cluster_id) == Some(pid), + diagnosis: crash_watch.take(), + }, + ) + .await; + }); +} + +async fn wait_for_exit(pid: u32, pid_started_at: Option) { + while is_process_alive(pid, pid_started_at) { + tokio::time::sleep(LIVENESS_POLL).await; + } +} + +#[tracing::instrument(skip(state, cluster, session), fields(cluster_id = cluster.id), level = "debug")] +async fn reconcile( + state: &Arc, + cluster: Cluster, + session: UnfinishedSession, + started_at: DateTime, +) { + let cluster_id = cluster.id; + let log = read_session_log(&cluster).await; + + let replay = log + .as_ref() + .map(|log| log_replay::replay(&log.content, started_at)) + .unwrap_or_default(); + + let now = Utc::now().max(started_at); + + // Prefer the log's own timestamps over mtime mtime only helps when nothing + // in the log carries a time + let ended_at = replay + .stopped_at + .or(replay.last_activity) + .or_else(|| log.as_ref().and_then(|log| log.modified)) + .unwrap_or(started_at) + .clamp(started_at, now); + + tracing::info!( + cluster_id, + session = %session.started_at, + %ended_at, + clean_exit = replay.stopped_at.is_some(), + servers = replay.servers.len(), + "recovered session that outlived the launcher" + ); + + let open_server = apply_spans(state, &session.started_at, &replay.servers).await; + let recorder = SessionRecorder::resume( + state.services.db.clone(), + session.started_at.clone(), + open_server, + ); + + let Ok(cwd) = cluster.game_dir() else { + recorder.finish_at(&ended_at.to_rfc3339(), None).await; + return; + }; + + // A cluster re-adopted moments ago may share this directory clearing it + // mid-session would pull that game apart underneath it + let shared_dir_busy = state.games.dir_in_use_by(&cwd, cluster_id).is_some(); + if shared_dir_busy { + tracing::debug!( + cluster_id, + "shared game dir still in use; skipping exit cleanup" + ); + } + + finalize_session( + state, + &cluster, + &cwd, + cluster.uses_dedicated_dir() || shared_dir_busy, + // Post hook skipped on purpose the exit already happened possibly days + // ago so firing it during startup would surprise the user + None, + Some(recorder), + SessionEnd { + started_at, + ended_at, + outcome: Exit::Inferred, + // The cluster may have a newer session playing right now so only + // claim the slot if nothing else holds it + owns_slot: !state.games.is_active(cluster_id), + // Nothing watched this log while it ran so no crash was recognised + diagnosis: None, + }, + ) + .await; +} + +/// Returns the `joined_at` of a span still left open for the caller to close +async fn apply_spans( + state: &Arc, + session_id: &str, + spans: &[ServerSpan], +) -> Option { + let db = &state.services.db; + let existing = session_dao::list_session_servers(db, session_id) + .await + .unwrap_or_default(); + + // Fewer replayed spans than recorded means the log was rotated or truncated + // so the live rows are the better record keep them + if spans.len() < existing.len() { + tracing::debug!( + session = %session_id, + replayed = spans.len(), + existing = existing.len(), + "log replay is thinner than recorded spans; keeping recorded ones" + ); + return existing + .into_iter() + .find(|row| row.disconnected_at.is_none()) + .map(|row| row.joined_at); + } + + if let Err(err) = session_dao::delete_session_servers(db, session_id).await { + tracing::warn!(session = %session_id, error = %err, "failed to clear server spans for replay"); + return None; + } + + let mut open = None; + for span in spans { + let joined_at = span.joined_at.to_rfc3339(); + if let Err(err) = session_dao::insert_server_join_at( + db, + session_id, + &span.host, + span.port.map(i64::from), + &joined_at, + ) + .await + { + tracing::warn!(session = %session_id, error = %err, "failed to replay server join"); + continue; + } + + match span.disconnected_at { + Some(at) => { + if let Err(err) = + session_dao::finish_server_at(db, &joined_at, &at.to_rfc3339()).await + { + tracing::warn!(session = %session_id, error = %err, "failed to replay server leave"); + } + } + None => open = Some(joined_at), + } + } + + open +} + +async fn open_server_of(state: &Arc, session_id: &str) -> Option { + session_dao::list_session_servers(&state.services.db, session_id) + .await + .unwrap_or_default() + .into_iter() + .find(|row| row.disconnected_at.is_none()) + .map(|row| row.joined_at) +} + +struct SessionLog { + content: String, + modified: Option>, +} + +/// `cluster-output.log` is preferred it is truncated at every launch so it +/// belongs to one session whereas `latest.log` may already have rotated +async fn read_session_log(cluster: &Cluster) -> Option { + let mut candidates: Vec = Vec::new(); + if let Ok(path) = oneclient_cluster::logs::cluster_output_log(cluster) { + candidates.push(path); + } + if let Ok(dir) = cluster.dir() { + candidates.push(dir.join("logs").join("latest.log")); + } + + for path in candidates { + match read_log(&path).await { + Some(log) if !log.content.trim().is_empty() => return Some(log), + _ => continue, + } + } + + None +} + +async fn read_log(path: &Path) -> Option { + let content = polyio::read_to_string(path).await.ok()?; + let modified = polyio::stat(path) + .await + .ok() + .and_then(|meta| meta.modified().ok()) + .map(DateTime::::from); + + Some(SessionLog { content, modified }) +} diff --git a/packages/oneclient_core/src/game/session.rs b/packages/oneclient_core/src/game/session.rs new file mode 100644 index 00000000..795552ac --- /dev/null +++ b/packages/oneclient_core/src/game/session.rs @@ -0,0 +1,244 @@ +use std::sync::Arc; + +use oneclient_db::DbPool; +use oneclient_db::dao::game_session as session_dao; +use oneclient_db::models::NewGameSession; +use tokio::sync::Mutex; + +use crate::state::LauncherState; +use oneclient_content::packages::{ContentType, PackageStore}; +use oneclient_java::JavaRuntime; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ServerJoin { + pub host: String, + pub port: Option, +} + +/// The session row's `started_at` which is its primary key +pub(crate) type SessionId = String; + +pub(crate) fn parse_server_join(line: &str) -> Option { + const MARKER: &str = "Connecting to "; + let idx = line.find(MARKER)?; + let rest = line[idx + MARKER.len()..].trim(); + if rest.is_empty() { + return None; + } + + let (host, port) = match rest.split_once(", ") { + Some((host, port)) => { + let port = port + .split_whitespace() + .next() + .and_then(|p| p.parse::().ok()); + (host.trim(), port) + } + None => (rest, None), + }; + + let host = host.trim(); + if host.is_empty() || host.chars().any(char::is_whitespace) { + return None; + } + + Some(ServerJoin { + host: host.to_string(), + port, + }) +} + +#[derive(Clone)] +pub(crate) struct SessionRecorder { + session_started_at: String, + db: DbPool, + open_server: Arc>>, +} + +impl SessionRecorder { + #[tracing::instrument( + skip(state, java), + fields(cluster_id, ram_allocated_mb), + level = "debug" + )] + pub(crate) async fn start( + state: &Arc, + cluster_id: i64, + ram_allocated_mb: u32, + java: &JavaRuntime, + ) -> Option { + let mods_enabled = count_enabled_mods(state, cluster_id).await; + + let java_vendor = java.vendor.to_string(); + let started_at = chrono::Utc::now().to_rfc3339(); + let new = NewGameSession { + cluster_id, + started_at: &started_at, + ram_allocated_mb: i64::from(ram_allocated_mb), + mods_enabled: mods_enabled as i64, + java_vendor: Some(java_vendor.as_str()), + java_version: Some(java.version.as_str()), + }; + + let session = match session_dao::insert_session(&state.services.db, &new).await { + Ok(row) => row, + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to record game session"); + return None; + } + }; + + Some(Self { + session_started_at: session.started_at, + db: state.services.db.clone(), + open_server: Arc::new(Mutex::new(None)), + }) + } + + /// Re-attaches to a row left open by a previous launcher run so a game that + /// outlived the launcher keeps recording into the same session + pub(crate) fn resume( + db: DbPool, + session_started_at: SessionId, + open_server: Option, + ) -> Self { + Self { + session_started_at, + db, + open_server: Arc::new(Mutex::new(open_server)), + } + } + + /// Playtime is measured against this so it agrees with the session span + /// analytics reads back out of the row + pub(crate) fn started_at(&self) -> Option> { + chrono::DateTime::parse_from_rfc3339(&self.session_started_at) + .ok() + .map(|at| at.with_timezone(&chrono::Utc)) + } + + /// Without this a launcher restart cannot tell a live game from one that + /// exited unobserved + #[tracing::instrument(skip(self), level = "debug")] + pub(crate) async fn record_process(&self, pid: u32, pid_started_at: Option) { + if let Err(err) = session_dao::set_session_process( + &self.db, + &self.session_started_at, + Some(i64::from(pid)), + pid_started_at.map(|t| t as i64), + ) + .await + { + tracing::warn!(pid, error = %err, "failed to record session process"); + } + } + + pub(crate) async fn observe(&self, line: &str) { + if let Some(join) = parse_server_join(line) { + self.open(join).await; + } else if super::log_replay::is_leave_marker(line) { + self.close_open().await; + } + } + + async fn open(&self, join: ServerJoin) { + let mut open = self.open_server.lock().await; + if let Some(prev) = open.take() + && let Err(err) = session_dao::finish_server(&self.db, &prev).await + { + tracing::warn!(error = %err, "failed to close previous server row"); + } + + let port = join.port.map(i64::from); + match session_dao::insert_server_join(&self.db, &self.session_started_at, &join.host, port) + .await + { + Ok(joined_at) => *open = Some(joined_at), + Err(err) => tracing::warn!(error = %err, "failed to record server join"), + } + } + + async fn close_open(&self) { + if let Some(prev) = self.open_server.lock().await.take() + && let Err(err) = session_dao::finish_server(&self.db, &prev).await + { + tracing::warn!(error = %err, "failed to close server row"); + } + } + + /// The time is explicit because an exit is not always observed as it happens + /// one recovered from a log ended in the past + #[tracing::instrument(skip(self), fields(exit_code), level = "debug")] + pub(crate) async fn finish_at(self, ended_at: &str, exit_code: Option) { + if let Some(open) = self.open_server.lock().await.take() + && let Err(err) = session_dao::finish_server_at(&self.db, &open, ended_at).await + { + tracing::warn!(error = %err, "failed to close open server on exit"); + } + + if let Err(err) = + session_dao::finish_session_at(&self.db, &self.session_started_at, ended_at, exit_code) + .await + { + tracing::warn!(session = %self.session_started_at, error = %err, "failed to finish game session"); + } + } +} + +#[tracing::instrument(skip(state), fields(cluster_id), level = "debug")] +async fn count_enabled_mods(state: &Arc, cluster_id: i64) -> usize { + match PackageStore::list_linked_artifacts(cluster_id, &state.services.content()).await { + Ok(linked) => linked + .into_iter() + .filter(|a| a.enabled && a.content_type == ContentType::Mod) + .count(), + Err(err) => { + tracing::warn!(cluster_id, error = %err, "failed to count mods for session"); + 0 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_host_and_port() { + let join = parse_server_join( + "[12:00:00] [Render thread/INFO]: Connecting to mc.hypixel.net, 25565", + ) + .expect("should parse"); + assert_eq!(join.host, "mc.hypixel.net"); + assert_eq!(join.port, Some(25565)); + } + + #[test] + fn parses_ip_and_port() { + let join = parse_server_join("[Render thread/INFO]: Connecting to 192.168.1.10, 25577") + .expect("should parse"); + assert_eq!(join.host, "192.168.1.10"); + assert_eq!(join.port, Some(25577)); + } + + #[test] + fn parses_host_without_port() { + let join = parse_server_join("Connecting to play.example.com").expect("should parse"); + assert_eq!(join.host, "play.example.com"); + assert_eq!(join.port, None); + } + + #[test] + fn ignores_unrelated_lines() { + assert!(parse_server_join("[Render thread/INFO]: Loading world").is_none()); + assert!(parse_server_join("Connecting to ").is_none()); + } + + #[test] + fn rejects_addresses_with_spaces() { + assert!( + parse_server_join("[INFO]: Connecting to voice chat server: 'vc.example.com:24454'") + .is_none() + ); + } +} diff --git a/packages/oneclient_core/src/game/shared_dir.rs b/packages/oneclient_core/src/game/shared_dir.rs new file mode 100644 index 00000000..edda2874 --- /dev/null +++ b/packages/oneclient_core/src/game/shared_dir.rs @@ -0,0 +1,941 @@ +use std::collections::HashSet; +use std::fs::FileType; +use std::path::Path; + +use oneclient_db::dao::artifact as artifact_dao; + +use crate::LauncherResult; +use crate::clusters::Cluster; +use crate::state::LauncherServices; +use oneclient_common::domain::ContentType; +use oneclient_content::packages::PackageStore; +use oneclient_content::packages::store::manifest::{self, ManifestEntry, MaterializedManifest}; +use oneclient_content::packages::store::{artifact_absolute_path, link_or_copy, remove_entry}; + +const REDIRECTED_DIRS: [&str; 2] = ["logs", "crash-reports"]; + +const SWAP_TYPES: [ContentType; 3] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, +]; + +const FABRIC_DEP_OVERRIDES: &str = "config/fabric_loader_dependencies.json"; + +struct Desired { + content_type: ContentType, + file_name: String, + hash: String, + /// Where it lives in the artifact cache + src: std::path::PathBuf, +} + +impl Desired { + fn relative_path(&self) -> String { + manifest::entry_path(self.content_type.folder_name(), &self.file_name) + } +} + +/// Safe to run over a directory left by a crashed session another cluster or +/// a launcher version predating the manifest +#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id, game_dir = %game_dir.display()), level = "debug")] +pub async fn materialize_content( + services: &LauncherServices, + cluster: &Cluster, + game_dir: &Path, +) -> LauncherResult<()> { + let dedicated = cluster.uses_dedicated_dir(); + polyio::create_dir_all(game_dir).await.ok(); + + // In the shared directory this often belongs to another cluster so every + // use of it checks the id + let previous = manifest::load(game_dir).await; + + import_manual_content(services, cluster, game_dir).await; + + // Before the folder is built not after handing the game several enabled + // versions of one mod is a classloader conflict + if let Err(err) = + oneclient_content::bundles::reconcile_duplicate_activity(cluster.id, &services.content()) + .await + { + // Not worth blocking a launch the duplicates were already there + tracing::warn!(cluster_id = cluster.id, %err, "failed to resolve duplicate package versions"); + } + + let desired = desired_content(services, cluster).await?; + let desired_paths: HashSet = desired.iter().map(Desired::relative_path).collect(); + + // While the game is still closed this is what lands a package removed + // mid-session and clears another cluster's content from the shared dir + prune_previous(game_dir, previous.as_ref(), &desired_paths).await; + + if !dedicated { + let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) + .await + .unwrap_or_default(); + + for content_type in SWAP_TYPES { + let dir = game_dir.join(content_type.folder_name()); + let stash = cluster.dir()?.join(content_type.folder_name()); + polyio::create_dir_all(&dir).await.ok(); + + // Whatever is still here belongs to whoever played (or crashed) + // last so take it into this cluster rather than deleting it + let ours = ours_in_folder(content_type, &linked, previous.as_ref()); + stash_content_files(&dir, &stash, &ours).await; + ensure_note(&dir, content_type).await; + restore_stashed(&stash, &dir, content_type, &ours).await; + } + } + + let entries = link_desired(game_dir, &desired).await; + manifest::save(game_dir, &MaterializedManifest::new(cluster.id, entries)).await; + + sync_fabric_dep_overrides(cluster, game_dir).await?; + + Ok(()) +} + +/// Only for shared directories a dedicated cluster's content stays put between +/// sessions and [`materialize_content`] reconciles it at the next launch +#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")] +pub async fn dematerialize_content( + services: &LauncherServices, + cluster: &Cluster, + game_dir: &Path, +) -> LauncherResult<()> { + // Runs first so anything dropped in during the session is a tracked artifact + // by now and gets dropped rather than stashed as a loose file + import_manual_content(services, cluster, game_dir).await; + + let current = manifest::load(game_dir).await; + let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()) + .await + .unwrap_or_default(); + + for content_type in SWAP_TYPES { + let dir = game_dir.join(content_type.folder_name()); + let stash = cluster.dir()?.join(content_type.folder_name()); + polyio::create_dir_all(&dir).await.ok(); + + let ours = ours_in_folder(content_type, &linked, current.as_ref()); + stash_content_files(&dir, &stash, &ours).await; + ensure_note(&dir, content_type).await; + } + + manifest::clear(game_dir).await; + Ok(()) +} + +async fn desired_content( + services: &LauncherServices, + cluster: &Cluster, +) -> LauncherResult> { + let linked = PackageStore::list_linked_artifacts(cluster.id, &services.content()).await?; + let mut desired = Vec::with_capacity(linked.len()); + + for link in linked { + if !link.enabled || !SWAP_TYPES.contains(&link.content_type) { + continue; + } + + let Some(artifact) = artifact_dao::get_artifact_by_hash(&services.db, &link.hash).await? + else { + continue; + }; + + let src = artifact_absolute_path(&artifact.path)?; + if !polyio::try_exists(&src).await.unwrap_or(false) { + tracing::warn!(hash = %link.hash, "cached artifact missing; skipping"); + continue; + } + + desired.push(Desired { + content_type: link.content_type, + file_name: link.cluster_file_name, + hash: link.hash, + src, + }); + } + + Ok(desired) +} + +/// Only files that made it are recorded so a failed link is never later +/// mistaken for ours and deleted out from under the user +async fn link_desired(game_dir: &Path, desired: &[Desired]) -> Vec { + let mut entries = Vec::with_capacity(desired.len()); + + for item in desired { + let dest = game_dir + .join(item.content_type.folder_name()) + .join(&item.file_name); + + match link_or_copy(&item.src, &dest).await { + Ok(()) => entries.push(ManifestEntry { + path: item.relative_path(), + hash: item.hash.clone(), + }), + Err(err) => tracing::warn!( + file = %item.file_name, + error = %err, + "failed to materialize content into the game directory" + ), + } + } + + entries +} + +/// Entries are keyed by path so a package whose file name is unchanged is left +/// in place rather than being deleted and relinked on every launch +async fn prune_previous( + game_dir: &Path, + previous: Option<&MaterializedManifest>, + keep: &HashSet, +) { + let Some(previous) = previous else { + return; + }; + + for entry in &previous.entries { + if keep.contains(&entry.path) { + continue; + } + + let path = game_dir.join(&entry.path); + if let Err(err) = remove_entry(&path).await { + tracing::warn!( + file = %entry.path, + error = %err, + "failed to clear stale materialized content" + ); + } + } +} + +/// Names in one folder that are the launcher's not the user's what we +/// materialized last plus what the database tracks (covering files just +/// adopted by [`import_manual_content`]) +fn ours_in_folder( + content_type: ContentType, + linked: &[oneclient_content::packages::LinkedArtifactInfo], + manifest: Option<&MaterializedManifest>, +) -> HashSet { + let mut names: HashSet = linked + .iter() + .filter(|link| link.content_type == content_type) + .map(|link| link.cluster_file_name.clone()) + .collect(); + + let folder = content_type.folder_name(); + if let Some(manifest) = manifest { + names.extend( + manifest + .entries + .iter() + .filter_map(|entry| entry.path.strip_prefix(&format!("{folder}/"))) + .map(str::to_owned), + ); + } + + names +} + +#[tracing::instrument(skip(services, cluster), fields(cluster_id = cluster.id), level = "debug")] +pub async fn import_manual_content( + services: &LauncherServices, + cluster: &Cluster, + game_dir: &Path, +) { + let linked = match PackageStore::list_linked_artifacts(cluster.id, &services.content()).await { + Ok(linked) => linked, + Err(err) => { + tracing::warn!(error = %err, "failed to list links; skipping manual-content import"); + return; + } + }; + + let manifest = manifest::load(game_dir).await; + + for content_type in SWAP_TYPES { + let dir = game_dir.join(content_type.folder_name()); + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + let known: HashSet<&str> = linked + .iter() + .filter(|link| link.content_type == content_type) + .map(|link| link.cluster_file_name.as_str()) + .collect(); + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + if !file_type.is_file() { + continue; + } + + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + if name.starts_with('.') || !has_content_extension(content_type, name) { + continue; + } + if known.contains(name) { + continue; + } + + // Ours from a previous launch `prune_previous` handles it + // Taking it as a user drop-in would reinstall a just-deleted package + let relative = manifest::entry_path(content_type.folder_name(), name); + if manifest.as_ref().is_some_and(|m| m.contains(&relative)) { + continue; + } + + // No manifest a directory from a launcher version predating it + // If the cache holds this exact file the launcher put it here + if manifest.is_none() && is_cached_artifact(services, &path).await { + tracing::debug!(file = name, "discarding stale launcher content in game dir"); + if let Err(err) = polyio::remove_file(&path).await { + tracing::warn!(file = name, error = %err, "failed to discard stale content"); + } + continue; + } + + match PackageStore::import_local_file( + &path, + content_type, + cluster.id, + &services.content(), + ) + .await + { + Ok(_) => { + tracing::debug!(file = name, "registered manually-added content") + } + Err(err) => tracing::warn!( + file = name, + error = %err, + "failed to register manually-added content" + ), + } + } + } +} + +/// Already in the artifact cache i.e. the launcher put it in the game dir +/// rather than the user dropping it there +async fn is_cached_artifact(services: &LauncherServices, path: &Path) -> bool { + let Ok(hash) = polyio::sha1_file(path).await else { + return false; + }; + + artifact_dao::get_artifact_by_hash(&services.db, &polyio::normalize_hash(&hash)) + .await + .ok() + .flatten() + .is_some() +} + +fn has_content_extension(content_type: ContentType, name: &str) -> bool { + let lower = name.to_lowercase(); + match content_type { + ContentType::Mod => lower.ends_with(".jar"), + ContentType::ResourcePack | ContentType::Shader => lower.ends_with(".zip"), + _ => false, + } +} + +const ALLOWED_SYMLINKS_NAME: &str = "allowed_symlinks.txt"; + +#[tracing::instrument(level = "debug")] +pub async fn write_allowed_symlinks(game_dir: &Path) -> LauncherResult<()> { + let root = oneclient_common::paths::launcher_dir()?; + let base = polyio::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let sep = std::path::MAIN_SEPARATOR; + + let body = format!("[prefix]{}{}", base.to_string_lossy(), sep); + + polyio::write(game_dir.join(ALLOWED_SYMLINKS_NAME), body).await?; + Ok(()) +} + +const EMPTY_NOTE_NAME: &str = "WHY_NOTHING_HERE.txt"; + +/// Launcher-owned content is dropped (the cache has it) everything else +/// (sidecars unzipped packs stray configs) is *moved* into the cluster folder +/// so it stays attached to that cluster and [`restore_stashed`] links it back +async fn stash_content_files(dir: &Path, stash: &Path, ours: &HashSet) { + let Ok(mut entries) = polyio::read_dir(dir).await else { + return; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { + continue; + }; + if name == EMPTY_NOTE_NAME || name.starts_with('.') { + continue; + } + + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + // Ours and the cache has it covered + // On Windows `symlink_file` hard-links so ours is not always a symlink + if file_type.is_symlink() || ours.contains(&name) { + remove_dir_or_file(&path, file_type).await; + continue; + } + + let dest = stash.join(&name); + if let Err(err) = move_entry(&path, &dest).await { + tracing::warn!( + file = %name, + error = %err, + "failed to stash content into cluster; leaving it in place" + ); + } + } +} + +/// Links stashed leftovers back so the game writes straight through into the +/// cluster folder even if we never get to run on exit +async fn restore_stashed( + stash: &Path, + dir: &Path, + content_type: ContentType, + ours: &HashSet, +) { + let Ok(mut entries) = polyio::read_dir(stash).await else { + return; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else { + continue; + }; + if name == EMPTY_NOTE_NAME || name.starts_with('.') || ours.contains(&name) { + continue; + } + + let Ok(file_type) = entry.file_type().await else { + continue; + }; + // A link into the artifact cache managed content is materialized from + // the database not from here + if file_type.is_symlink() { + continue; + } + + // A bare jar in the stash is a leftover from the old scheme often with + // its package already removed restoring it would put a deleted mod back + // every launch + // Left on disk for the Storage settings page to clear + if file_type.is_file() && has_content_extension(content_type, &name) { + tracing::debug!( + file = %name, + "ignoring stray content in cluster folder; use the Storage settings page to clear it" + ); + continue; + } + + let dest = dir.join(&name); + let result = if file_type.is_dir() { + polyio::symlink_dir(&path, &dest).await.map_err(Into::into) + } else { + link_or_copy(&path, &dest).await + }; + + if let Err(err) = result { + tracing::warn!( + file = %name, + error = %err, + "failed to restore stashed content into game dir" + ); + } + } +} + +async fn remove_dir_or_file(path: &Path, file_type: FileType) { + if file_type.is_dir() { + polyio::remove_dir_all(path).await.ok(); + } else if polyio::remove_file(path).await.is_err() { + // A Windows junction has to go through `remove_dir` + polyio::remove_symlink_dir(path).await.ok(); + } +} + +async fn move_entry(src: &Path, dest: &Path) -> LauncherResult<()> { + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await.ok(); + } + + // A stash from an earlier session is older than what the game just wrote + // so it loses + if let Ok(meta) = polyio::symlink_metadata(dest).await { + remove_dir_or_file(dest, meta.file_type()).await; + } + + if polyio::rename(src, dest).await.is_ok() { + return Ok(()); + } + + // `rename` cannot cross devices fall back to a copy for plain files + polyio::copy(src, dest).await?; + polyio::remove_file(src).await.ok(); + Ok(()) +} + +async fn ensure_note(dir: &Path, content_type: ContentType) { + let note = dir.join(EMPTY_NOTE_NAME); + + if polyio::try_exists(¬e).await.unwrap_or(false) { + return; + } + + polyio::write(¬e, { + let noun = content_type.folder_name(); + + format!( + "It's empty here, but nothing is broken!\n\ + \n\ + OneClient keeps your {noun} safe somewhere else (specifically in the global launcher cache)\ + and only puts them here while you play. When you close the game, it tidies them away again.\n\ + \n\ + Want to add {noun}? The best way is to do it right inside OneClient. Or you can drop \ + files in this folder, and OneClient will pick them up the next time you play.\n" + ) + }) + .await + .ok(); +} + +#[tracing::instrument(skip(cluster), fields(cluster_id = cluster.id), level = "debug")] +pub async fn link_cluster_logs(cluster: &Cluster, game_dir: &Path) { + let cluster_dir = match cluster.dir() { + Ok(dir) => dir, + Err(err) => { + tracing::warn!(error = %err, "cannot resolve cluster dir; skipping log redirect"); + return; + } + }; + + for name in REDIRECTED_DIRS { + let target = cluster_dir.join(name); + let shared = game_dir.join(name); + + if let Err(err) = redirect_dir(&shared, &target).await { + tracing::warn!( + dir = name, + error = %err, + "failed to redirect shared game dir into cluster; logs may pool in shared dir" + ); + } + } +} + +async fn redirect_dir(shared: &Path, target: &Path) -> LauncherResult<()> { + polyio::create_dir_all(target).await.ok(); + + match polyio::symlink_metadata(shared).await { + Ok(meta) if meta.file_type().is_symlink() => { + polyio::remove_symlink_dir(shared).await?; + } + + // A real directory likely the user's so salvage its contents into the + // cluster folder instead of deleting it + Ok(meta) if meta.is_dir() => { + move_dir_contents(shared, target).await; + polyio::remove_dir_all(shared).await.ok(); + } + + Ok(_) => { + polyio::remove_file(shared).await.ok(); + } + + Err(_) => {} + } + + if let Some(parent) = shared.parent() { + polyio::create_dir_all(parent).await.ok(); + } + polyio::symlink_dir(target, shared).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug")] +pub async fn unlink_cluster_logs(game_dir: &Path) { + for name in REDIRECTED_DIRS { + let shared = game_dir.join(name); + + match polyio::symlink_metadata(&shared).await { + Ok(meta) if meta.file_type().is_symlink() => { + if let Err(err) = polyio::remove_symlink_dir(&shared).await { + tracing::warn!(dir = name, error = %err, "failed to unlink shared log dir"); + } + } + Ok(_) => { + tracing::warn!( + dir = name, + "shared log dir is not our link; leaving as-is (next launch will salvage it)" + ); + } + Err(_) => {} + } + } +} + +async fn move_dir_contents(from: &Path, to: &Path) { + let Ok(mut entries) = polyio::read_dir(from).await else { + return; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let src = entry.path(); + let Some(name) = src.file_name() else { + continue; + }; + + let dest = to.join(name); + + if polyio::rename(&src, &dest).await.is_err() { + tracing::warn!(file = %src.display(), "failed to salvage leaked log file"); + } + } +} + +async fn sync_fabric_dep_overrides(cluster: &Cluster, game_dir: &Path) -> LauncherResult<()> { + let src = cluster.dir()?.join(FABRIC_DEP_OVERRIDES); + let dest = game_dir.join(FABRIC_DEP_OVERRIDES); + + if src == dest { + return Ok(()); + } + + if polyio::try_exists(&src).await.unwrap_or(false) { + if let Some(parent) = dest.parent() { + polyio::create_dir_all(parent).await.ok(); + } + polyio::copy(&src, &dest).await?; + } else if polyio::try_exists(&dest).await.unwrap_or(false) { + polyio::remove_file(&dest).await.ok(); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + + use super::*; + + fn names(names: &[&str]) -> HashSet { + names.iter().map(|n| (*n).to_string()).collect() + } + + fn manifest_of(cluster_id: i64, paths: &[&str]) -> MaterializedManifest { + MaterializedManifest::new( + cluster_id, + paths + .iter() + .map(|p| ManifestEntry { + path: (*p).to_string(), + hash: "hash".into(), + }) + .collect(), + ) + } + + /// A shaderpack's settings sidecar has to survive a launch/exit cycle and + /// end up in the cluster rather than the shared dir + #[tokio::test] + async fn shader_settings_survive_a_session() { + let root = polyio::testing::ScratchDir::new("shader_settings"); + let shared = root.join("shared").join("shaderpacks"); + let stash = root.join("cluster").join("shaderpacks"); + polyio::create_dir_all(&shared).await.unwrap(); + + let ours = names(&["bsl.zip"]); + + polyio::write(shared.join("bsl.zip"), b"pack".as_slice()) + .await + .unwrap(); + polyio::write(shared.join("bsl.zip.txt"), b"BLOOM=off".as_slice()) + .await + .unwrap(); + + stash_content_files(&shared, &stash, &ours).await; + assert!(!shared.join("bsl.zip").exists(), "managed pack left behind"); + assert!(!shared.join("bsl.zip.txt").exists(), "sidecar left behind"); + assert_eq!( + polyio::read_to_string(stash.join("bsl.zip.txt")) + .await + .unwrap(), + "BLOOM=off" + ); + + restore_stashed(&stash, &shared, ContentType::Shader, &ours).await; + assert_eq!( + polyio::read_to_string(shared.join("bsl.zip.txt")) + .await + .unwrap(), + "BLOOM=off" + ); + + polyio::write(stash.join("bsl.zip.txt"), b"BLOOM=on".as_slice()) + .await + .unwrap(); + assert_eq!( + polyio::read_to_string(shared.join("bsl.zip.txt")) + .await + .unwrap(), + "BLOOM=on" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn unpacked_dirs_move_into_the_cluster() { + let root = polyio::testing::ScratchDir::new("unpacked"); + let shared = root.join("shared").join("shaderpacks"); + let stash = root.join("cluster").join("shaderpacks"); + polyio::create_dir_all(shared.join("Loose/shaders")) + .await + .unwrap(); + polyio::write( + shared.join("Loose/shaders/final.fsh"), + b"void main".as_slice(), + ) + .await + .unwrap(); + + stash_content_files(&shared, &stash, &HashSet::new()).await; + assert!(!shared.join("Loose").exists()); + assert!(stash.join("Loose/shaders/final.fsh").exists()); + + restore_stashed(&stash, &shared, ContentType::Shader, &HashSet::new()).await; + assert!(shared.join("Loose/shaders/final.fsh").exists()); + + // Only the link goes never the stashed original + stash_content_files(&shared, &stash, &HashSet::new()).await; + assert!(!shared.join("Loose").exists()); + assert!(stash.join("Loose/shaders/final.fsh").exists()); + + std::fs::remove_dir_all(root.path()).ok(); + } + + #[tokio::test] + async fn note_is_never_stashed() { + let root = polyio::testing::ScratchDir::new("note"); + let shared = root.join("shared").join("mods"); + let stash = root.join("cluster").join("mods"); + polyio::create_dir_all(&shared).await.unwrap(); + polyio::write(shared.join(EMPTY_NOTE_NAME), b"hi".as_slice()) + .await + .unwrap(); + + stash_content_files(&shared, &stash, &HashSet::new()).await; + assert!(shared.join(EMPTY_NOTE_NAME).exists()); + assert!(!stash.join(EMPTY_NOTE_NAME).exists()); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// The reported bug a package removed while the game held it open stays in + /// the folder so the next launch must clear it + /// An ordinary file is used + /// because that is what a managed file looks like on Windows + #[tokio::test] + async fn removed_package_is_pruned_at_the_next_launch() { + let root = polyio::testing::ScratchDir::new("prune_removed"); + let game_dir = root.path(); + polyio::create_dir_all(game_dir.join("mods")).await.unwrap(); + + let jar = game_dir.join("mods").join("removed.jar"); + polyio::write(&jar, b"jar".as_slice()).await.unwrap(); + + let previous = manifest_of(1, &["mods/removed.jar", "mods/kept.jar"]); + let keep: HashSet = ["mods/kept.jar".to_string()].into_iter().collect(); + + prune_previous(game_dir, Some(&previous), &keep).await; + + assert!( + polyio::symlink_metadata(&jar).await.is_err(), + "a removed package must not survive into the next session" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// Whatever a previous cluster materialized in the shared dir is ours to + /// clear not the user's to keep + #[tokio::test] + async fn another_clusters_content_is_cleared_not_stashed() { + let root = polyio::testing::ScratchDir::new("cross_cluster"); + let game_dir = root.path(); + polyio::create_dir_all(game_dir.join("mods")).await.unwrap(); + + let jar = game_dir.join("mods").join("theirs.jar"); + polyio::write(&jar, b"jar".as_slice()).await.unwrap(); + + prune_previous( + game_dir, + Some(&manifest_of(1, &["mods/theirs.jar"])), + &HashSet::new(), + ) + .await; + + assert!(polyio::symlink_metadata(&jar).await.is_err()); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// A hand-dropped file is in no manifest so it is stashed into the cluster + /// rather than deleted + #[tokio::test] + async fn user_files_are_never_pruned() { + let root = polyio::testing::ScratchDir::new("user_file"); + let game_dir = root.path(); + polyio::create_dir_all(game_dir.join("mods")).await.unwrap(); + + let mine = game_dir.join("mods").join("handmade.jar"); + polyio::write(&mine, b"jar".as_slice()).await.unwrap(); + + prune_previous( + game_dir, + Some(&manifest_of(1, &["mods/ours.jar"])), + &HashSet::new(), + ) + .await; + + assert!( + mine.exists(), + "a file we never materialized is not ours to delete" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// A jar left in a cluster folder by an older launcher whose package has + /// since been removed must not be linked back into the game + #[tokio::test] + async fn stray_content_in_the_stash_is_not_restored() { + let root = polyio::testing::ScratchDir::new("stray_content"); + let shared = root.join("shared").join("mods"); + let stash = root.join("cluster").join("mods"); + polyio::create_dir_all(&shared).await.unwrap(); + polyio::create_dir_all(&stash).await.unwrap(); + + // A leftover from the old scheme an ordinary file (which is what a + // Windows hard link looks like) with no database row to explain it + polyio::write(stash.join("removed.jar"), b"jar".as_slice()) + .await + .unwrap(); + polyio::write(stash.join("options.txt"), b"k=v".as_slice()) + .await + .unwrap(); + + restore_stashed(&stash, &shared, ContentType::Mod, &HashSet::new()).await; + + assert!( + !shared.join("removed.jar").exists(), + "a removed package must not be restored into the game" + ); + assert!( + stash.join("removed.jar").exists(), + "and it must not be silently deleted either" + ); + assert!( + shared.join("options.txt").exists(), + "sidecars still restore" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + /// Pinned because the halves are easy to swap `stash` reads the game dir + /// and writes the cluster `restore` the reverse and both take two + /// same-typed `&Path`s a swap would quietly delete a user's files + #[tokio::test] + async fn a_session_never_removes_anything_from_the_cluster_folder() { + let root = polyio::testing::ScratchDir::new("stash_is_sacred"); + let shared = root.join("shared").join("mods"); + let stash = root.join("cluster").join("mods"); + polyio::create_dir_all(&shared).await.unwrap(); + polyio::create_dir_all(&stash).await.unwrap(); + + polyio::write(stash.join("options.txt"), b"k=v".as_slice()) + .await + .unwrap(); + polyio::write(stash.join("leftover.jar"), b"jar".as_slice()) + .await + .unwrap(); + polyio::create_dir_all(stash.join("unpacked")) + .await + .unwrap(); + polyio::write(stash.join("unpacked").join("inner.txt"), b"x".as_slice()) + .await + .unwrap(); + + polyio::write(shared.join("managed.jar"), b"jar".as_slice()) + .await + .unwrap(); + + let ours = names(&["managed.jar"]); + + let before = dir_entries(&stash).await; + + stash_content_files(&shared, &stash, &ours).await; + restore_stashed(&stash, &shared, ContentType::Mod, &ours).await; + stash_content_files(&shared, &stash, &ours).await; + restore_stashed(&stash, &shared, ContentType::Mod, &ours).await; + + let after = dir_entries(&stash).await; + + assert_eq!( + before, after, + "a session must not add to or remove from the cluster folder" + ); + assert!( + stash.join("unpacked").join("inner.txt").exists(), + "the contents of a stashed directory survive too" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } + + async fn dir_entries(dir: &Path) -> Vec { + let mut names = Vec::new(); + let Ok(mut entries) = polyio::read_dir(dir).await else { + return names; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + names.push(entry.file_name().to_string_lossy().into_owned()); + } + names.sort(); + names + } + + #[test] + fn ownership_spans_the_manifest_and_the_database() { + let manifest = manifest_of(1, &["mods/from_manifest.jar", "shaderpacks/bsl.zip"]); + let linked = Vec::new(); + + let ours = ours_in_folder(ContentType::Mod, &linked, Some(&manifest)); + + assert!(ours.contains("from_manifest.jar")); + // Scoped to the folder a shaderpack entry must not make a mod of the + // same name look managed + assert!(!ours.contains("bsl.zip")); + } +} diff --git a/packages/oneclient_core/src/game/tail.rs b/packages/oneclient_core/src/game/tail.rs new file mode 100644 index 00000000..e63493fb --- /dev/null +++ b/packages/oneclient_core/src/game/tail.rs @@ -0,0 +1,303 @@ +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader}; + +use crate::game::diagnosis::CrashWatch; +use crate::game::session::SessionRecorder; +use oneclient_events::EventBus; + +const POLL_INTERVAL: Duration = Duration::from_millis(200); + +pub(crate) struct LogTail { + stop: Arc, + handle: tokio::task::JoinHandle<()>, +} + +impl LogTail { + /// Stops only after one last pass the lines explaining why the game + /// exited are written moments before it does + pub(crate) async fn stop(self) { + self.stop.store(true, Ordering::Relaxed); + let _ = self.handle.await; + } +} + +pub(crate) fn spawn_log_tail( + cluster_id: i64, + path: PathBuf, + events: EventBus, + recorder: Option, + crash_watch: CrashWatch, +) -> LogTail { + let stop = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&stop); + + let handle = tokio::spawn(async move { + if let Err(err) = tail( + cluster_id, + &path, + &events, + recorder.as_ref(), + &crash_watch, + &flag, + ) + .await + { + tracing::warn!(cluster_id, path = %path.display(), error = %err, "game log tail stopped"); + } + }); + + LogTail { stop, handle } +} + +async fn tail( + cluster_id: i64, + path: &PathBuf, + events: &EventBus, + recorder: Option<&SessionRecorder>, + crash_watch: &CrashWatch, + stop: &AtomicBool, +) -> std::io::Result<()> { + // The game may not have created the file yet on a re-adopt so wait for it + // rather than giving up + let file = loop { + match tokio::fs::File::open(path).await { + Ok(file) => break file, + Err(_) if !stop.load(Ordering::Relaxed) => { + tokio::time::sleep(POLL_INTERVAL).await; + } + Err(err) => return Err(err), + } + }; + + let mut reader = BufReader::new(file); + let mut line = String::new(); + let mut offset = 0u64; + + loop { + line.clear(); + let read = reader.read_line(&mut line).await?; + + if read == 0 { + // The read above returned EOF on a drained file so the final pass + // has already run by the time the flag is seen here + if stop.load(Ordering::Relaxed) { + return Ok(()); + } + // A relaunch truncates the log if the file shrank our offset is + // past the end and we would never read again + let len = polyio::stat(path).await.map(|m| m.len()).unwrap_or(offset); + if len < offset { + reader.seek(std::io::SeekFrom::Start(0)).await?; + offset = 0; + } + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } + + offset += read as u64; + + // A partial line means the game is mid-write wait for the newline + // rather than emitting half a line and re-emitting the rest later + if !line.ends_with('\n') { + // Unless the game is gone a process killed mid-write leaves a + // newline that never arrives hanging the caller of `stop()` + if stop.load(Ordering::Relaxed) { + emit(cluster_id, &line, events, recorder, crash_watch).await; + return Ok(()); + } + reader + .seek(std::io::SeekFrom::Start(offset - read as u64)) + .await?; + offset -= read as u64; + tokio::time::sleep(POLL_INTERVAL).await; + continue; + } + + emit(cluster_id, &line, events, recorder, crash_watch).await; + } +} + +async fn emit( + cluster_id: i64, + line: &str, + events: &EventBus, + recorder: Option<&SessionRecorder>, + crash_watch: &CrashWatch, +) { + // Blank lines are kept so the console reads the way the log does + let text = line.trim_end_matches(['\n', '\r']).to_string(); + if let Some(recorder) = recorder { + recorder.observe(&text).await; + } + // Read on the way past rather than re-opening the log afterwards a crash + // can take the log with it + crash_watch.observe(&text); + events.game_log(cluster_id, text); +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::AsyncWriteExt; + use tokio::sync::mpsc; + + struct Harness { + dir: async_tempfile::TempDir, + rx: oneclient_events::EventReceiver, + events: EventBus, + } + + impl Harness { + async fn new() -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + let dir = polyio::tempdir().await.expect("temp dir"); + Self { + dir, + rx, + events: EventBus::new(tx), + } + } + + fn path(&self) -> PathBuf { + self.dir.dir_path().join("cluster-output.log") + } + + fn drain(&mut self) -> Vec { + let mut out = Vec::new(); + while let Ok(oneclient_events::Event::Game(oneclient_events::GameEvent::Log { + line, + .. + })) = self.rx.try_recv() + { + out.push(line); + } + out + } + } + + async fn settle() { + tokio::time::sleep(POLL_INTERVAL * 4).await; + } + + #[tokio::test] + async fn follows_lines_appended_after_start() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] first\n").await.unwrap(); + + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + + let mut file = tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await + .unwrap(); + file.write_all(b"[12:00:01] second\n").await.unwrap(); + file.flush().await.unwrap(); + settle().await; + tail.stop().await; + + assert_eq!(h.drain(), vec!["[12:00:00] first", "[12:00:01] second"]); + } + + #[tokio::test] + async fn waits_for_a_file_that_does_not_exist_yet() { + let mut h = Harness::new().await; + let path = h.path(); + + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + polyio::write(&path, "[12:00:00] late\n").await.unwrap(); + settle().await; + tail.stop().await; + + assert_eq!(h.drain(), vec!["[12:00:00] late"]); + } + + #[tokio::test] + async fn holds_back_a_partial_line_until_its_newline_lands() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] half").await.unwrap(); + + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + // A line mid-write must not be emitted or it would arrive twice + assert!(h.drain().is_empty()); + + let mut file = tokio::fs::OpenOptions::new() + .append(true) + .open(&path) + .await + .unwrap(); + file.write_all(b" and half\n").await.unwrap(); + file.flush().await.unwrap(); + settle().await; + tail.stop().await; + + assert_eq!(h.drain(), vec!["[12:00:00] half and half"]); + } + + #[tokio::test] + async fn recovers_when_the_log_is_truncated() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] old session line\n") + .await + .unwrap(); + + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + assert_eq!(h.drain(), vec!["[12:00:00] old session line"]); + + // A relaunch truncates the log the tail would otherwise sit forever at + // an offset past the new end + polyio::write(&path, "[13:00:00] new\n").await.unwrap(); + settle().await; + tail.stop().await; + + assert_eq!(h.drain(), vec!["[13:00:00] new"]); + } + + #[tokio::test] + async fn stops_even_when_the_last_line_has_no_newline() { + let mut h = Harness::new().await; + let path = h.path(); + // A process killed mid-write leaves a newline that never arrives + polyio::write(&path, "[12:00:00] cut off mid-writ") + .await + .unwrap(); + + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + settle().await; + + // Waiting for that newline would hang the exit path forever + let stopped = tokio::time::timeout(Duration::from_secs(5), tail.stop()).await; + assert!( + stopped.is_ok(), + "tail.stop() hung on an unterminated final line" + ); + assert_eq!(h.drain(), vec!["[12:00:00] cut off mid-writ"]); + } + + #[tokio::test] + async fn emits_the_final_lines_written_just_before_exit() { + let mut h = Harness::new().await; + let path = h.path(); + polyio::write(&path, "[12:00:00] Stopping!\n") + .await + .unwrap(); + + // Stopping immediately as the exit path does must still flush the + // reason the game exited + let tail = spawn_log_tail(7, path.clone(), h.events.clone(), None, CrashWatch::new()); + tail.stop().await; + + assert_eq!(h.drain(), vec!["[12:00:00] Stopping!"]); + } +} diff --git a/packages/oneclient_core/src/images/mod.rs b/packages/oneclient_core/src/images/mod.rs new file mode 100644 index 00000000..2fa595b1 --- /dev/null +++ b/packages/oneclient_core/src/images/mod.rs @@ -0,0 +1,130 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use bytes::Bytes; +use reqwest::Method; +use tokio::sync::Mutex; + +use crate::LauncherResult; +use oneclient_common::paths; +use oneclient_net::RequestClient; +use oneclient_net::RequestError; +use polyio::sha1_bytes; + +pub const DEFAULT_IMAGE_EDGE: u32 = 1600; + +#[derive(Debug, Default)] +pub struct ImageCacheStore { + memory: Mutex>, +} + +impl ImageCacheStore { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[tracing::instrument(level = "debug", skip(self, net))] + pub async fn get( + &self, + net: &RequestClient, + url: &str, + max_edge: u32, + ) -> LauncherResult { + let mem_key = Self::mem_key(url, max_edge); + if let Some(hit) = self.memory.lock().await.get(&mem_key).cloned() { + return Ok(hit); + } + + let path = Self::disk_path(url, max_edge)?; + + if let Ok(bytes) = polyio::read(&path).await { + let bytes = Bytes::from(bytes); + self.memory.lock().await.insert(mem_key, bytes.clone()); + return Ok(bytes); + } + + let raw = download(net, url).await?; + let bytes = downscale_if_oversized(raw, max_edge).await; + + if let Some(parent) = path.parent() { + polyio::create_dir_all(parent).await?; + } + if let Err(err) = polyio::write(&path, &bytes).await { + tracing::warn!("failed to persist cached image for {url}: {err}"); + } + + self.memory.lock().await.insert(mem_key, bytes.clone()); + + Ok(bytes) + } + + fn mem_key(url: &str, max_edge: u32) -> String { + format!("{max_edge}|{url}") + } + + fn disk_path(url: &str, max_edge: u32) -> LauncherResult { + let mut name = sha1_bytes(url.as_bytes()); + name.push('_'); + name.push_str(&max_edge.to_string()); + if let Some(ext) = extension_from_url(url) { + name.push('.'); + name.push_str(&ext); + } + Ok(paths::images_cache_dir()?.join(name)) + } +} + +static DECODE_LIMIT: std::sync::LazyLock = + std::sync::LazyLock::new(|| tokio::sync::Semaphore::new(2)); + +async fn downscale_if_oversized(bytes: Bytes, max_edge: u32) -> Bytes { + let _permit = DECODE_LIMIT.acquire().await; + let candidate = bytes.clone(); + match tokio::task::spawn_blocking(move || downscale(&candidate, max_edge)).await { + Ok(Some(smaller)) => smaller, + _ => bytes, + } +} + +fn downscale(bytes: &[u8], max_edge: u32) -> Option { + let img = image::load_from_memory(bytes).ok()?; + if img.width().max(img.height()) <= max_edge { + return None; + } + + let resized = img.resize(max_edge, max_edge, image::imageops::FilterType::Lanczos3); + + let mut out = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut out); + if resized.color().has_alpha() { + resized + .write_to(&mut cursor, image::ImageFormat::Png) + .ok()?; + } else { + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut cursor, 85) + .encode_image(&resized) + .ok()?; + } + + Some(Bytes::from(out)) +} + +#[tracing::instrument(level = "debug", skip(net))] +async fn download(net: &RequestClient, url: &str) -> LauncherResult { + let parsed = url.parse().map_err(RequestError::from)?; + let request = reqwest::Request::new(Method::GET, parsed); + let res = net.send(request).await?; + let bytes = res.bytes().await.map_err(RequestError::from)?; + Ok(bytes) +} + +fn extension_from_url(url: &str) -> Option { + let path = url.split(['?', '#']).next().unwrap_or(url); + let name = path.rsplit('/').next().unwrap_or(path); + let ext = name.rsplit_once('.')?.1; + if ext.is_empty() || ext.len() > 5 || !ext.chars().all(|c| c.is_ascii_alphanumeric()) { + return None; + } + Some(ext.to_ascii_lowercase()) +} diff --git a/packages/oneclient_core/src/java_store.rs b/packages/oneclient_core/src/java_store.rs new file mode 100644 index 00000000..31f2f0c5 --- /dev/null +++ b/packages/oneclient_core/src/java_store.rs @@ -0,0 +1,79 @@ +//! The adapter lives here rather than in `oneclient_java` so that crate builds +//! without sqlx and can test against `MemoryJavaStore` + +use std::str::FromStr; + +use oneclient_db::{DbPool, dao, models::JavaVersionRow}; +use oneclient_java::{JavaRuntime, JavaStore, JavaVendor, StoreError, StoreResult}; + +pub struct SqlJavaStore { + pool: DbPool, +} + +impl SqlJavaStore { + #[must_use] + pub fn new(pool: DbPool) -> Self { + Self { pool } + } +} + +fn row_to_runtime(row: JavaVersionRow) -> JavaRuntime { + JavaRuntime { + absolute_path: row.absolute_path, + major: row.major as u32, + version: row.version, + vendor: JavaVendor::from_str(&row.vendor).unwrap_or(JavaVendor::Other(row.vendor)), + os_arch: row.os_arch, + is_jdk: row.is_jdk, + probe_version: row.probe_version.try_into().unwrap_or(0), + } +} + +#[async_trait::async_trait] +impl JavaStore for SqlJavaStore { + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult { + let row = dao::java::insert( + &self.pool, + &runtime.absolute_path, + runtime.major, + &runtime.version, + &runtime.vendor.to_string(), + &runtime.os_arch, + runtime.is_jdk, + runtime.probe_version, + ) + .await + .map_err(StoreError::new)?; + + Ok(row_to_runtime(row)) + } + + async fn get_by_path(&self, absolute_path: &str) -> StoreResult> { + Ok(dao::java::get_by_path(&self.pool, absolute_path) + .await + .map_err(StoreError::new)? + .map(row_to_runtime)) + } + + async fn latest_by_major(&self, major: u32) -> StoreResult> { + Ok(dao::java::get_latest_by_major(&self.pool, major) + .await + .map_err(StoreError::new)? + .map(row_to_runtime)) + } + + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()> { + dao::java::delete_by_path(&self.pool, absolute_path) + .await + .map_err(StoreError::new) + } + + async fn list(&self) -> StoreResult> { + Ok(dao::java::list_all(&self.pool) + .await + .map_err(StoreError::new)? + .into_iter() + .map(row_to_runtime) + .collect()) + } +} diff --git a/packages/oneclient_core/src/lib.rs b/packages/oneclient_core/src/lib.rs new file mode 100644 index 00000000..51a948d5 --- /dev/null +++ b/packages/oneclient_core/src/lib.rs @@ -0,0 +1,68 @@ +#![recursion_limit = "256"] + +#[cfg(debug_assertions)] +pub mod dev; + +pub mod changelog; +pub mod clusters; +mod error; +pub mod game; +pub mod images; +mod java_store; +pub mod logger; +pub mod migration; +pub mod recovery; +pub mod reporting; +pub mod settings; +pub mod simulate; +mod state; +pub mod storage; +pub mod tos; +pub mod verify; +pub mod versions; + +pub use changelog::{ChangelogGroup, fetch_changelog, parse_changelog}; +pub use clusters::{ + Cluster, ClusterError, ClusterManager, ClusterStage, ClusterUpdate, CreateClusterOptions, + ensure_from_bundles, ensure_from_versions, estimate_cluster_download, +}; +pub use error::{LauncherError, LauncherResult, SentryExclusion}; +pub use game::{GameError, LaunchedGame, get_loader_versions, launch_cluster}; +pub use images::ImageCacheStore; +pub use migration::{ + ImportTarget, MigrationDetection, MigrationSource, SourceInstance, detect as detect_migration, + import_game_dir as import_migration_game_dir, +}; +pub use oneclient_cluster::logs::{ + LogFileInfo, LogKind, LogLevel, LogLine, LogsError, MclogsUploadResponse, ReadOptions, + delete_log_at, list_cluster_logs, read_log_at, upload_log_at, +}; +pub use oneclient_cluster::screenshots::{ + ScreenshotInfo, ScreenshotsError, delete_screenshot, list_cluster_screenshots, load_screenshot, +}; +pub use oneclient_content::bundles::{ + ApplyBundleUpdatesResult, Bundle, BundleArchive, BundleError, BundleFile, BundleFileKind, + BundleManifest, BundleUpdateCheckResult, BundleWithUpdateStatus, BundlesManager, + FileUpdateStatus, apply_bundle_updates, check_bundle_updates, effective_enabled, + get_bundles_with_update_status, install_bundle, install_cluster_bundles, + install_package_from_bundle, is_bundle_syncing, list_cluster_bundle_overrides, + remove_artifact_from_cluster, set_bundle_package_enabled, set_bundle_package_opt_in, + set_bundle_package_override, set_bundle_package_overrides, toggle_artifact_enabled, +}; +pub use oneclient_content::packages::LinkedArtifactInfo; +pub use oneclient_content::packages::updates::{ + BrowserPackageUpdate, BrowserUpdateCheck, apply_browser_package_update, + cached_browser_package_updates, check_browser_package_updates, refresh_browser_package_updates, + skip_browser_package_update, +}; +pub use oneclient_discord::{DiscordRpc, Presence}; +pub use oneclient_mc::{McError as MetadataError, MetadataStore}; +pub use settings::ProfileUpdate; +pub use state::LauncherServices; +pub use state::LauncherState; +pub use state::run_startup_tasks; +pub use tos::{TermsDocument, fetch_terms}; +pub use verify::{ClusterVerifyReport, verify_cluster_files}; +pub use versions::{ + RemoteMigration, VersionMetadata, VersionsManager, VersionsManifest, resolve_migration_chain, +}; diff --git a/packages/oneclient_core/src/logger.rs b/packages/oneclient_core/src/logger.rs new file mode 100644 index 00000000..7f47f774 --- /dev/null +++ b/packages/oneclient_core/src/logger.rs @@ -0,0 +1,219 @@ +use std::io; +use std::sync::OnceLock; + +use parking_lot::Mutex; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::registry::Registry; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, reload}; + +use crate::LauncherResult; + +pub mod console; + +/// The launcher's own crates given an explicit directive rather than falling +/// back to the base level +const APP_TARGETS: &[&str] = &[ + "oneclient_app", + "oneclient_auth", + "oneclient_cluster", + "oneclient_common", + "oneclient_content", + "oneclient_core", + "oneclient_db", + "oneclient_discord", + "oneclient_events", + "oneclient_java", + "oneclient_mc", + "oneclient_net", + "oneclient_polyplus", + "polyio", +]; + +/// Pinned regardless of the base level because the debug filter raises the base +/// to `info` and that would otherwise let this lot flood the log +const NOISY_TARGETS: &[&str] = &[ + "calloop", + "freya_core", + "freya_winit", + "h2", + "hickory_proto", + "hickory_resolver", + "html5ever", + "hyper", + "hyper_util", + "mio", + "quinn", + "reqwest", + "rustls", + "smithay_client_toolkit", + // Only `sqlx::query` `sqlx` proper reports pool and migration problems we want + "sqlx::query", + "tokio_util", + "tower", + "want", + "winit", + "zbus", +]; + +/// Whether `target` belongs to one of [`APP_TARGETS`] rather than a dependency +fn is_app_target(target: &str) -> bool { + APP_TARGETS.iter().any(|app| { + target + .strip_prefix(app) + .is_some_and(|rest| rest.is_empty() || rest.starts_with("::")) + }) +} + +fn sentry_event_filter(metadata: &tracing::Metadata<'_>) -> sentry_tracing::EventFilter { + dependency_filter(*metadata.level(), metadata.target()) + .unwrap_or_else(|| sentry_tracing::default_event_filter(metadata)) +} + +/// `None` when the target is one of ours, meaning the default filter decides +fn dependency_filter(level: tracing::Level, target: &str) -> Option { + if is_app_target(target) { + return None; + } + + Some(match level { + // Kept as context for a report of ours, but never a report of its own + tracing::Level::ERROR => sentry_tracing::EventFilter::Breadcrumb, + _ => sentry_tracing::EventFilter::Ignore, + }) +} + +pub fn default_directives() -> String { + directives("info", "warn") +} + +/// Raises the base level too so an unlisted dependency's `info` still reaches +/// the log +pub fn debug_directives() -> String { + directives("debug", "info") +} + +fn directives(app_level: &str, base_level: &str) -> String { + let mut out = String::from(base_level); + for target in NOISY_TARGETS { + out.push_str(&format!(",{target}=warn")); + } + for target in APP_TARGETS { + out.push_str(&format!(",{target}={app_level}")); + } + out +} + +/// The filter layer sits directly on the [`Registry`] so that is the subscriber +/// type the reload handle is parameterised over +type FilterHandle = reload::Handle; + +static FILTER: OnceLock = OnceLock::new(); +static ACTIVE_DIRECTIVES: Mutex = Mutex::new(String::new()); + +#[derive(Debug, thiserror::Error)] +pub enum FilterError { + #[error("invalid log filter directives: {0}")] + Invalid(String), + + #[error("the logger has not been initialized")] + NotInitialized, +} + +pub fn active_directives() -> String { + ACTIVE_DIRECTIVES.lock().clone() +} + +/// Swaps the active filter without restarting +/// Unparseable directives leave the previous filter in place rather than +/// blanking the log +pub fn set_filter(directives: &str) -> Result<(), FilterError> { + let filter = + EnvFilter::try_new(directives).map_err(|err| FilterError::Invalid(err.to_string()))?; + + FILTER + .get() + .ok_or(FilterError::NotInitialized)? + .reload(filter) + .map_err(|err| FilterError::Invalid(err.to_string()))?; + + *ACTIVE_DIRECTIVES.lock() = directives.to_string(); + tracing::info!(%directives, "log filter reloaded"); + + Ok(()) +} + +pub fn init_debug() -> LauncherResult<()> { + init_filtered(debug_directives) +} + +pub fn init() -> LauncherResult<()> { + init_filtered(default_directives) +} + +pub fn init_filtered(filter: impl FnOnce() -> String) -> LauncherResult<()> { + // `RUST_LOG` wins outright rather than merging appending ours would quietly + // override the targets the user asked for + let directives = std::env::var(EnvFilter::DEFAULT_ENV) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(filter); + + let env_filter = EnvFilter::try_new(&directives).unwrap_or_else(|_| { + eprintln!( + "ignoring unparseable {} filter: {directives}", + EnvFilter::DEFAULT_ENV + ); + EnvFilter::new(default_directives()) + }); + + let (filter, handle) = reload::Layer::new(env_filter); + let _ = FILTER.set(handle); + *ACTIVE_DIRECTIVES.lock() = directives; + + let stdout_layer = tracing_subscriber::fmt::layer().with_writer(io::stdout); + + let sentry_layer = sentry_tracing::layer().event_filter(sentry_event_filter); + + #[cfg(debug_assertions)] + { + tracing_subscriber::registry() + .with(filter) + .with(stdout_layer) + .with(console::layer()) + .with(sentry_layer) + .init(); + } + + #[cfg(not(debug_assertions))] + { + let logs_dir = oneclient_common::paths::logs_dir()?; + std::fs::create_dir_all(&logs_dir)?; + + let log_path = logs_dir.join(format!( + "{}.log", + chrono::Local::now().to_rfc3339().replace(':', "-") + )); + + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path)?; + + let file_layer = tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_writer(file); + + tracing_subscriber::registry() + .with(filter) + .with(stdout_layer) + .with(file_layer) + .with(console::layer()) + .with(sentry_layer) + .init(); + + tracing::info!(path = %log_path.display(), "writing logs to file"); + } + + Ok(()) +} diff --git a/packages/oneclient_core/src/logger/console.rs b/packages/oneclient_core/src/logger/console.rs new file mode 100644 index 00000000..1435f737 --- /dev/null +++ b/packages/oneclient_core/src/logger/console.rs @@ -0,0 +1,121 @@ +//! Capture is gated on the broadcast channel's receiver count rather than a flag +//! we maintain so a dropped receiver can never leave capture switched on + +use std::sync::{Arc, OnceLock}; + +use tokio::sync::broadcast; +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::Context; + +/// A console falling further behind than this loses its oldest lines which +/// beats blocking the thread that emitted the log +const CAPACITY: usize = 4096; + +static CHANNEL: OnceLock> = OnceLock::new(); + +#[derive(Clone, Debug)] +pub struct ConsoleLine { + pub level: Level, + /// Pre-formatted as `[HH:MM:SS.mmm] [target/LEVEL]: message` the shape the + /// log viewer already parses for Minecraft logs + pub text: Arc, +} + +/// Capture runs for exactly as long as this is alive +pub struct ConsoleSubscription(broadcast::Receiver); + +impl ConsoleSubscription { + pub async fn recv(&mut self) -> Result { + self.0.recv().await + } +} + +/// Nothing is buffered before this call so the console opens empty +pub fn subscribe() -> ConsoleSubscription { + ConsoleSubscription(channel().subscribe()) +} + +pub fn is_capturing() -> bool { + CHANNEL.get().is_some_and(|tx| tx.receiver_count() > 0) +} + +pub(super) fn layer() -> impl Layer { + ConsoleLayer +} + +fn channel() -> &'static broadcast::Sender { + CHANNEL.get_or_init(|| broadcast::channel(CAPACITY).0) +} + +struct ConsoleLayer; + +impl Layer for ConsoleLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + // Not `Layer::enabled` that is ANDed into the whole subscriber's + // decision so returning false there would silence stdout and the file too + if !is_capturing() { + return; + } + + let meta = event.metadata(); + let mut visitor = LineVisitor::default(); + event.record(&mut visitor); + + let prefix = format!( + "[{}] [{}/{}]: ", + chrono::Local::now().format("%H:%M:%S%.3f"), + meta.target(), + meta.level().as_str(), + ); + + let level = *meta.level(); + let tx = channel(); + + // Embedded newlines (a panic backtrace say) are sent line by line so the + // viewer can select them only the first carries the prefix + let mut body = visitor.message; + body.push_str(&visitor.fields); + + let mut rest = body.lines(); + let first = rest.next().unwrap_or_default(); + let _ = tx.send(ConsoleLine { + level, + text: format!("{prefix}{first}").into(), + }); + + for line in rest { + let _ = tx.send(ConsoleLine { + level, + text: line.into(), + }); + } + } +} + +/// Renders non-message fields as `key=value` matching what `fmt` writes to stdout +#[derive(Default)] +struct LineVisitor { + message: String, + fields: String, +} + +impl Visit for LineVisitor { + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message.push_str(value); + } else { + self.fields.push_str(&format!(" {}={value}", field.name())); + } + } + + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.message.push_str(&format!("{value:?}")); + } else { + self.fields + .push_str(&format!(" {}={value:?}", field.name())); + } + } +} diff --git a/packages/oneclient_core/src/migration/mod.rs b/packages/oneclient_core/src/migration/mod.rs new file mode 100644 index 00000000..72f3673b --- /dev/null +++ b/packages/oneclient_core/src/migration/mod.rs @@ -0,0 +1,144 @@ +pub mod oneclient_v1; +pub mod vanilla; + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::LauncherResult; +use oneclient_common::domain::GameLoader; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum MigrationSource { + OneClientV1, + Vanilla, +} + +impl MigrationSource { + pub const ALL: &'static [MigrationSource] = + &[MigrationSource::OneClientV1, MigrationSource::Vanilla]; + + pub fn id(self) -> &'static str { + match self { + MigrationSource::OneClientV1 => "oneclient_v1", + MigrationSource::Vanilla => "vanilla", + } + } + + pub fn display_name(self) -> &'static str { + match self { + MigrationSource::OneClientV1 => "OneClient", + MigrationSource::Vanilla => "Minecraft", + } + } + + pub fn from_id(id: &str) -> Option { + Self::ALL.iter().copied().find(|s| s.id() == id) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceInstance { + /// Source-local identifier (v1 the cluster row id) + /// Purely informational + pub instance_id: i64, + pub folder_name: String, + pub mc_version: String, + /// Set for "migrated" clusters (e.g. 26.1 fabric -> 26.1.2 fabric) + pub target_mc_version: Option, + pub mc_loader: GameLoader, + /// Empty when the source has no category concept + pub categories: Vec, + pub has_game_dir: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MigrationDetection { + pub source: MigrationSource, + pub root: PathBuf, + pub instances: Vec, +} + +impl SourceInstance { + #[must_use] + pub fn import_version(&self) -> &str { + self.target_mc_version + .as_deref() + .unwrap_or(&self.mc_version) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ImportTarget { + /// Copy into the shared `/.minecraft` + Shared, + /// Copy into the new cluster's own folder and mark it dedicated so + /// `Cluster::game_dir()` resolves there instead of the shared dir + Dedicated { new_cluster_id: i64 }, +} + +#[tracing::instrument] +pub async fn detect() -> LauncherResult> { + for source in MigrationSource::ALL.iter().copied() { + let detection = match source { + MigrationSource::OneClientV1 => oneclient_v1::detect().await?, + MigrationSource::Vanilla => vanilla::detect().await?, + }; + + if let Some(detection) = detection + && !detection.instances.is_empty() + { + return Ok(Some(detection)); + } + } + Ok(None) +} + +#[tracing::instrument(skip(state))] +pub async fn import_game_dir( + state: &std::sync::Arc, + source: MigrationSource, + folder_name: &str, + target: ImportTarget, +) -> LauncherResult<()> { + match source { + MigrationSource::OneClientV1 => { + oneclient_v1::import_game_dir(state, folder_name, target).await + } + MigrationSource::Vanilla => vanilla::import_game_dir(state, target).await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vanilla_is_the_last_resort() { + assert_eq!(MigrationSource::ALL.last(), Some(&MigrationSource::Vanilla)); + } + + #[test] + fn import_version_prefers_resolved_target() { + let mut instance = SourceInstance { + instance_id: 1, + folder_name: "26.1 fabric".to_string(), + mc_version: "26.1".to_string(), + target_mc_version: None, + mc_loader: GameLoader::Fabric, + categories: Vec::new(), + has_game_dir: true, + }; + assert_eq!(instance.import_version(), "26.1"); + + instance.target_mc_version = Some("26.1.2".to_string()); + assert_eq!(instance.import_version(), "26.1.2"); + } + + #[test] + fn ids_round_trip() { + for source in MigrationSource::ALL.iter().copied() { + assert_eq!(MigrationSource::from_id(source.id()), Some(source)); + } + } +} diff --git a/packages/oneclient_core/src/migration/oneclient_v1.rs b/packages/oneclient_core/src/migration/oneclient_v1.rs new file mode 100644 index 00000000..4da642d6 --- /dev/null +++ b/packages/oneclient_core/src/migration/oneclient_v1.rs @@ -0,0 +1,195 @@ +use std::path::{Path, PathBuf}; + +use sqlx::Row; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + +use crate::LauncherResult; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; + +use super::{ImportTarget, MigrationDetection, MigrationSource, SourceInstance}; + +const IMPORT_EXCLUDE_TOP: &[&str] = &["mods", "logs"]; + +pub fn old_root() -> Option { + let base = directories::BaseDirs::new().map(|d| d.data_dir().join("OneClient")); + + let root = match std::env::var("ONECLIENT_V1_DIR") { + Ok(dir) => PathBuf::from(dir), + Err(_) => base?, + }; + + let exists = root.is_dir(); + tracing::debug!(root = %root.display(), exists, "v1 migration: resolved old root"); + exists.then_some(root) +} + +pub fn category_from_bundle_name(name: &str) -> Option { + let start = name.find('[')?; + let end = name[start + 1..].find(']')? + start + 1; + let inner = name[start + 1..end].trim(); + (!inner.is_empty()).then(|| inner.to_string()) +} + +#[tracing::instrument] +pub async fn detect() -> LauncherResult> { + let Some(root) = old_root() else { + return Ok(None); + }; + + let db_path = root.join("user_data.db"); + if !db_path.exists() { + tracing::debug!(db = %db_path.display(), "v1 migration: no user_data.db, skipping"); + return Ok(None); + } + + match detect_inner(&root, &db_path).await { + Ok(detection) => { + tracing::info!( + instances = detection.instances.len(), + "v1 migration: detected old install" + ); + Ok(Some(detection)) + } + Err(err) => { + tracing::warn!(error = %err, "failed to read old launcher database; skipping v1 migration"); + Ok(None) + } + } +} + +#[tracing::instrument(level = "debug")] +async fn detect_inner(root: &Path, db_path: &Path) -> LauncherResult { + let opts = SqliteConnectOptions::new() + .filename(db_path) + .read_only(true) + .immutable(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(opts) + .await?; + + let cluster_rows = sqlx::query("SELECT id, folder_name, mc_version, mc_loader FROM clusters") + .fetch_all(&pool) + .await?; + + let mut instances = Vec::with_capacity(cluster_rows.len()); + for row in cluster_rows { + let cluster_id: i64 = row.try_get("id")?; + let folder_name: String = row.try_get("folder_name")?; + let mc_version: String = row.try_get("mc_version")?; + let mc_loader_raw: i64 = row.try_get("mc_loader")?; + + let Some(mc_loader) = GameLoader::from_repr(mc_loader_raw as u8) else { + tracing::warn!( + cluster_id, + mc_loader_raw, + "old cluster has unknown loader; skipping" + ); + continue; + }; + + let categories = fetch_categories(&pool, cluster_id).await?; + + let has_game_dir = polyio::dir_has_content(&root.join("clusters").join(&folder_name)).await; + + instances.push(SourceInstance { + instance_id: cluster_id, + folder_name, + mc_version, + target_mc_version: None, + mc_loader, + categories, + has_game_dir, + }); + } + + pool.close().await; + + Ok(MigrationDetection { + source: MigrationSource::OneClientV1, + root: root.to_path_buf(), + instances, + }) +} + +#[tracing::instrument(level = "debug", skip(pool))] +async fn fetch_categories(pool: &sqlx::SqlitePool, cluster_id: i64) -> LauncherResult> { + let rows = sqlx::query( + "SELECT DISTINCT bundle_name FROM cluster_packages \ + WHERE cluster_id = ? AND bundle_name IS NOT NULL AND bundle_name <> ''", + ) + .bind(cluster_id) + .fetch_all(pool) + .await?; + + let mut categories: Vec = Vec::new(); + for row in rows { + let bundle_name: String = row.try_get("bundle_name")?; + let Some(category) = category_from_bundle_name(&bundle_name) else { + continue; + }; + + if !categories.iter().any(|c| c.eq_ignore_ascii_case(&category)) { + categories.push(category); + } + } + + Ok(categories) +} + +#[tracing::instrument(skip(state, target))] +pub async fn import_game_dir( + state: &std::sync::Arc, + folder_name: &str, + target: ImportTarget, +) -> LauncherResult<()> { + let Some(root) = old_root() else { + return Ok(()); + }; + let src = root.join("clusters").join(folder_name); + if !src.is_dir() { + tracing::warn!(folder_name, "old cluster folder missing; nothing to import"); + return Ok(()); + } + + let dest = match &target { + ImportTarget::Shared => paths::shared_minecraft_dir()?, + ImportTarget::Dedicated { new_cluster_id } => { + let cluster = state.clusters.get(*new_cluster_id).await?; + let dir = cluster.dir()?; + polyio::create_dir_all(&dir).await?; + // Mark dedicated so game_dir() resolves to this cluster's own dir + polyio::write(cluster.dedicated_marker()?, Vec::new()).await?; + dir + } + }; + + polyio::create_dir_all(&dest).await?; + polyio::copy_dir(&src, &dest, IMPORT_EXCLUDE_TOP).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn category_extraction() { + assert_eq!( + category_from_bundle_name("OneClient 1.21.1 Fabric [HUD]"), + Some("HUD".to_string()) + ); + assert_eq!( + category_from_bundle_name("OneClient 1.21.11 Fabric [QoL]"), + Some("QoL".to_string()) + ); + assert_eq!(category_from_bundle_name("no brackets here"), None); + assert_eq!(category_from_bundle_name("empty []"), None); + assert_eq!( + category_from_bundle_name("trims [ Performance ]"), + Some("Performance".to_string()) + ); + } +} diff --git a/packages/oneclient_core/src/migration/vanilla.rs b/packages/oneclient_core/src/migration/vanilla.rs new file mode 100644 index 00000000..fcb8fc72 --- /dev/null +++ b/packages/oneclient_core/src/migration/vanilla.rs @@ -0,0 +1,243 @@ +use std::path::PathBuf; + +use crate::LauncherResult; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; + +use super::{ImportTarget, MigrationDetection, MigrationSource, SourceInstance}; + +const ROOT_INSTANCE_NAME: &str = ".minecraft"; + +const IMPORT_EXCLUDE_TOP: &[&str] = &[ + "mods", + "resourcepacks", + "shaderpacks", + "datapacks", + "versions", + "libraries", + "assets", + "bin", + "jre", + "logs", + "crash-reports", + "launcher_accounts.json", + "launcher_accounts_microsoft_store.json", + "launcher_msa_credentials.bin", + "launcher_profiles.json", + "launcher_settings.json", + "usercache.json", + "webcache", + "webcache2", + ".mojang", + "cheatbreaker_accounts.json", + "feather", + "feather-mods", + "feather-versions", + "optionsLC.txt", + "servers.dat_old", + "cache", + "downloads", + "jfr", + "skyblock-repo-cache", + "meowdding-repo-cache", +]; + +pub fn old_root() -> Option { + let base = directories::BaseDirs::new().map(|d| { + // Windows `%APPDATA%\.minecraft` (data_dir is Roaming) + #[cfg(target_os = "windows")] + { + d.data_dir().join(".minecraft") + } + // macOS `~/Library/Application Support/minecraft` no leading dot + #[cfg(target_os = "macos")] + { + d.data_dir().join("minecraft") + } + // Linux `~/.minecraft` not data_dir() (`~/.local/share`) + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + d.home_dir().join(".minecraft") + } + }); + + let root = match std::env::var("VANILLA_MC_DIR") { + Ok(dir) => PathBuf::from(dir), + Err(_) => base?, + }; + + let exists = root.is_dir(); + tracing::debug!(root = %root.display(), exists, "vanilla migration: resolved old root"); + exists.then_some(root) +} + +#[tracing::instrument] +pub async fn detect() -> LauncherResult> { + let Some(root) = old_root() else { + return Ok(None); + }; + + if !polyio::dir_has_content(&root).await { + tracing::debug!(root = %root.display(), "vanilla migration: empty install, skipping"); + return Ok(None); + } + + tracing::info!(root = %root.display(), "vanilla migration: detected install"); + + Ok(Some(MigrationDetection { + source: MigrationSource::Vanilla, + instances: vec![SourceInstance { + instance_id: 0, + folder_name: ROOT_INSTANCE_NAME.to_string(), + mc_version: String::new(), + target_mc_version: None, + mc_loader: GameLoader::Vanilla, + categories: Vec::new(), + has_game_dir: true, + }], + root, + })) +} + +#[tracing::instrument(skip(state, target))] +pub async fn import_game_dir( + state: &std::sync::Arc, + target: ImportTarget, +) -> LauncherResult<()> { + let Some(src) = old_root() else { + return Ok(()); + }; + + let dest = match &target { + ImportTarget::Shared => paths::shared_minecraft_dir()?, + ImportTarget::Dedicated { new_cluster_id } => { + let cluster = state.clusters.get(*new_cluster_id).await?; + let dir = cluster.dir()?; + polyio::create_dir_all(&dir).await?; + polyio::write(cluster.dedicated_marker()?, Vec::new()).await?; + dir + } + }; + + polyio::create_dir_all(&dest).await?; + polyio::copy_dir(&src, &dest, IMPORT_EXCLUDE_TOP).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn excluded(name: &str) -> bool { + IMPORT_EXCLUDE_TOP + .iter() + .any(|e| e.eq_ignore_ascii_case(name)) + } + + #[test] + fn excludes_package_and_launcher_managed_dirs() { + for name in [ + "mods", + "resourcepacks", + "shaderpacks", + "datapacks", + "versions", + "libraries", + "assets", + "logs", + ] { + assert!(excluded(name), "{name} should be excluded"); + } + } + + #[test] + fn excludes_mojang_credentials() { + assert!(excluded("launcher_accounts.json")); + assert!(excluded("launcher_msa_credentials.bin")); + } + + #[test] + fn excludes_third_party_launcher_data() { + for name in ["cheatbreaker_accounts.json", "feather-mods", "jre"] { + assert!(excluded(name), "{name} should be excluded"); + } + } + + #[test] + fn keeps_user_data() { + for name in [ + "saves", + "config", + "options.txt", + "screenshots", + "servers.dat", + ] { + assert!(!excluded(name), "{name} must not be excluded"); + } + } + + #[tokio::test] + async fn import_copies_user_data_and_skips_the_rest() { + let src = polyio::tempdir().await.expect("src dir"); + let dst = polyio::tempdir().await.expect("dst dir"); + let (src, dst) = (src.dir_path(), dst.dir_path()); + + for file in [ + "options.txt", + "servers.dat", + "launcher_accounts.json", + "launcher_msa_credentials.bin", + ] { + polyio::write(src.join(file), b"x".to_vec()).await.unwrap(); + } + for (dir, file) in [ + ("saves", "world/level.dat"), + ("config", "sodium.json"), + ("screenshots", "shot.png"), + ("mods", "sodium.jar"), + ("resourcepacks", "faithful.zip"), + ("shaderpacks", "bsl.zip"), + ("versions", "1.21.1/1.21.1.jar"), + ("libraries", "com/foo/foo.jar"), + ("assets", "objects/ab/abcdef"), + ("logs", "latest.log"), + ] { + let path = src.join(dir).join(file); + polyio::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + polyio::write(path, b"x".to_vec()).await.unwrap(); + } + + polyio::copy_dir(src, dst, IMPORT_EXCLUDE_TOP) + .await + .unwrap(); + + for kept in [ + "options.txt", + "servers.dat", + "saves/world/level.dat", + "config/sodium.json", + "screenshots/shot.png", + ] { + assert!(dst.join(kept).exists(), "{kept} should have been copied"); + } + for skipped in [ + "mods", + "resourcepacks", + "shaderpacks", + "versions", + "libraries", + "assets", + "logs", + "launcher_accounts.json", + "launcher_msa_credentials.bin", + ] { + assert!( + !dst.join(skipped).exists(), + "{skipped} should not have been copied" + ); + } + } +} diff --git a/packages/oneclient_core/src/recovery.rs b/packages/oneclient_core/src/recovery.rs new file mode 100644 index 00000000..c6b9cff5 --- /dev/null +++ b/packages/oneclient_core/src/recovery.rs @@ -0,0 +1,426 @@ +use std::path::{Path, PathBuf}; + +use oneclient_db::DbPool; +use oneclient_db::dao::{ + artifact as artifact_dao, cluster as cluster_dao, cluster_bundle as cluster_bundle_dao, +}; +use oneclient_db::models::NewCluster; +use strum::IntoEnumIterator; + +use crate::LauncherResult; +use crate::clusters::ClusterStage; +use crate::state::LauncherState; +use oneclient_cluster::profiles::create_profile_from_global; +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_common::paths; +use oneclient_common::version::parse_mc_version; +use oneclient_content::packages::PackageStore; +use polyio::sha1_file; + +const FILE_CONTENT_TYPES: [ContentType; 4] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, +]; + +#[derive(Debug, Default, Clone, Copy)] +pub struct RecoveryReport { + pub adopted_clusters: usize, + pub indexed_artifacts: usize, + pub relinked_files: usize, +} + +impl RecoveryReport { + pub fn did_recover(&self) -> bool { + self.adopted_clusters > 0 + } +} + +#[tracing::instrument(skip(state))] +pub async fn reconstruct_from_disk(state: &LauncherState) -> LauncherResult { + let mut report = RecoveryReport::default(); + + // Held across the whole scan+adopt so a concurrent cluster create (folder + // written before its DB row commits) isn't misclassified as an orphan + let _guard = state.clusters.provisioning_guard().await; + + let clusters_dir = paths::clusters_dir()?; + let orphans = orphan_cluster_folders(&state.services.db, &clusters_dir).await?; + if orphans.is_empty() { + return Ok(report); + } + + tracing::info!( + orphan_folders = orphans.len(), + "user_data.db appears reset; reconstructing from disk" + ); + + if let Err(err) = state.java.rescan().await { + tracing::warn!("java rescan during recovery failed: {err:#}"); + } + + report.indexed_artifacts = rebuild_artifact_cache(&state.services.db).await?; + + for folder in orphans { + match adopt_cluster(state, &folder, &mut report).await { + Ok(()) => {} + Err(err) => { + tracing::warn!(folder = %folder, "failed to adopt cluster folder: {err:#}"); + } + } + } + + tracing::info!( + adopted = report.adopted_clusters, + artifacts = report.indexed_artifacts, + files = report.relinked_files, + "database reconstruction complete" + ); + + Ok(report) +} + +#[tracing::instrument(level = "debug", skip(pool))] +async fn orphan_cluster_folders(pool: &DbPool, clusters_dir: &Path) -> LauncherResult> { + let mut orphans = Vec::new(); + for name in list_dir_names(clusters_dir).await? { + if cluster_dao::get_by_folder_name(pool, &name) + .await? + .is_none() + { + orphans.push(name); + } + } + Ok(orphans) +} + +#[tracing::instrument(level = "debug", skip(pool))] +async fn rebuild_artifact_cache(pool: &DbPool) -> LauncherResult { + let cache_root = paths::packages_cache_dir()?; + if !cache_root.exists() { + return Ok(0); + } + + let launcher_dir = paths::launcher_dir()?; + let mut indexed = 0; + + for content_name in list_dir_names(&cache_root).await? { + let Some(content_type) = ContentType::from_folder_name(&content_name) else { + continue; + }; + let content_dir = cache_root.join(&content_name); + + for provider_name in list_dir_names(&content_dir).await? { + let Some(provider) = provider_from_dir_name(&provider_name) else { + continue; + }; + let provider_dir = content_dir.join(&provider_name); + + for project_id in list_dir_names(&provider_dir).await? { + let project_dir = provider_dir.join(&project_id); + + for version_id in list_dir_names(&project_dir).await? { + let version_dir = project_dir.join(&version_id); + + for file in list_files(&version_dir).await? { + if let Err(err) = index_cache_file( + pool, + launcher_dir, + content_type, + provider, + &project_id, + &version_id, + &file, + ) + .await + { + tracing::warn!(path = %file.display(), "failed to index cached file: {err:#}"); + continue; + } + indexed += 1; + } + } + } + } + } + + Ok(indexed) +} + +#[allow(clippy::too_many_arguments)] +#[tracing::instrument(level = "debug", skip(pool, launcher_dir, file))] +async fn index_cache_file( + pool: &DbPool, + launcher_dir: &Path, + content_type: ContentType, + provider: ProviderId, + project_id: &str, + version_id: &str, + file: &Path, +) -> LauncherResult<()> { + let hash = sha1_file(file).await?; + let stored_path = relative_launcher_path(launcher_dir, file); + let file_name = file + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let size = polyio::stat(file).await.ok().map(|m| m.len() as i64); + + artifact_dao::insert_artifact( + pool, + &hash, + content_type as i64, + &stored_path, + &file_name, + size, + ) + .await?; + + if matches!(provider, ProviderId::Modrinth | ProviderId::CurseForge) { + artifact_dao::upsert_provider_release( + pool, + provider as i64, + project_id, + version_id, + &hash, + &file_name, + version_id, + None, + "[]", + "[]", + ) + .await?; + } + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(state, report))] +async fn adopt_cluster( + state: &LauncherState, + folder_name: &str, + report: &mut RecoveryReport, +) -> LauncherResult<()> { + let (name, mc_version, loader) = parse_folder_identity(folder_name); + + let global = state.settings.read().global_game_settings.clone(); + let profile = + create_profile_from_global(&state.services.db, &global, &name, None, None).await?; + + let row = cluster_dao::insert( + &state.services.db, + &NewCluster { + name: &name, + folder_name, + mc_version: &mc_version, + mc_loader: loader as i64, + mc_loader_version: None, + setting_profile_name: Some(&profile.name), + stage: ClusterStage::NotReady as i64, + }, + ) + .await?; + + report.adopted_clusters += 1; + tracing::info!( + cluster_id = row.id, + folder = folder_name, + mc_version = %mc_version, + loader = %loader, + "adopted cluster folder from disk" + ); + + relink_cluster_files(state, row.id, folder_name, report).await?; + Ok(()) +} + +/// Files are taken into the artifact cache not indexed in place the cache is +/// the only place content is materialized from so in-place rows break on tidy-up +#[tracing::instrument(level = "debug", skip(state, report))] +async fn relink_cluster_files( + state: &LauncherState, + cluster_id: i64, + folder_name: &str, + report: &mut RecoveryReport, +) -> LauncherResult<()> { + let pool = &state.services.db; + let content = state.services.content(); + let cluster_root = paths::cluster_dir(folder_name)?; + + for content_type in FILE_CONTENT_TYPES { + let dir = cluster_root.join(content_type.folder_name()); + let files = match list_files(&dir).await { + Ok(files) => files, + Err(_) => continue, + }; + + for file in files { + let file_name = file + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let enabled = !file_name.ends_with(".disabled"); + let base_name = file_name.trim_end_matches(".disabled").to_string(); + + let row = match PackageStore::import_local_file( + &file, + content_type, + cluster_id, + &content, + ) + .await + { + Ok(row) => row, + Err(err) => { + tracing::warn!(path = %file.display(), "failed to adopt cluster file: {err:#}"); + continue; + } + }; + + // `import_local_file` links under the artifact's own name which is + // the `.disabled` spelling for a file the user had switched off + if !enabled { + artifact_dao::update_cluster_artifact(pool, cluster_id, &row.hash, &base_name, 0) + .await?; + } + + report.relinked_files += 1; + } + } + + Ok(()) +} + +#[tracing::instrument(skip(state))] +pub async fn restore_bundle_tracking(state: &LauncherState) -> LauncherResult<()> { + let clusters = state.clusters.list().await?; + + for cluster in clusters { + let archives = match state + .bundles + .archives_for( + &state.services.content(), + &cluster.mc_version, + cluster.mc_loader, + ) + .await + { + Ok(archives) => archives, + Err(err) => { + tracing::warn!( + cluster_id = cluster.id, + "failed to load bundle archives during recovery: {err:#}" + ); + continue; + } + }; + + for archive in archives { + let bundle_name = &archive.manifest.name; + for file in &archive.manifest.files { + use oneclient_content::bundles::BundleFileKind; + let BundleFileKind::Managed { + provider, + project_id, + version_id, + .. + } = &file.kind + else { + continue; + }; + + let Some(release) = artifact_dao::get_provider_release( + &state.services.db, + *provider as i64, + project_id, + version_id, + ) + .await? + else { + continue; + }; + + if artifact_dao::is_cluster_linked(&state.services.db, cluster.id, &release.hash) + .await? + { + cluster_bundle_dao::track_bundle_artifact( + &state.services.db, + cluster.id, + &release.hash, + bundle_name, + version_id, + project_id, + ) + .await?; + } + } + } + } + + Ok(()) +} + +fn parse_folder_identity(folder_name: &str) -> (String, String, GameLoader) { + let tokens: Vec<&str> = folder_name.split_whitespace().collect(); + + for take in [2usize, 1] { + if tokens.len() > take { + let split = tokens.len() - take; + let loader_str = tokens[split..].join(" "); + if let Ok(loader) = loader_str.parse::() { + let version = tokens[..split].join(" "); + if parse_mc_version(&version).is_some() { + return (folder_name.to_string(), version, loader); + } + } + } + } + + let mc_version = parse_mc_version(folder_name) + .map(|_| folder_name.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + (folder_name.to_string(), mc_version, GameLoader::Vanilla) +} + +fn provider_from_dir_name(name: &str) -> Option { + ProviderId::iter().find(|p| p.dir_name() == name) +} + +fn relative_launcher_path(launcher_dir: &Path, abs: &Path) -> String { + abs.strip_prefix(launcher_dir) + .unwrap_or(abs) + .to_string_lossy() + .replace('\\', "/") +} + +async fn list_dir_names(dir: &Path) -> LauncherResult> { + let mut names = Vec::new(); + let Ok(mut entries) = polyio::read_dir(dir).await else { + return Ok(names); + }; + while let Some(entry) = entries.next_entry().await? { + if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) { + names.push(entry.file_name().to_string_lossy().into_owned()); + } + } + Ok(names) +} + +async fn list_files(dir: &Path) -> LauncherResult> { + let mut files = Vec::new(); + let Ok(mut entries) = polyio::read_dir(dir).await else { + return Ok(files); + }; + while let Some(entry) = entries.next_entry().await? { + if entry + .file_type() + .await + .map(|t| t.is_file()) + .unwrap_or(false) + { + files.push(entry.path()); + } + } + Ok(files) +} diff --git a/packages/oneclient_core/src/reporting.rs b/packages/oneclient_core/src/reporting.rs new file mode 100644 index 00000000..fbca1d5e --- /dev/null +++ b/packages/oneclient_core/src/reporting.rs @@ -0,0 +1,89 @@ +use std::panic; +use std::sync::Arc; +use std::time::Duration; + +use sentry::protocol::{Breadcrumb, Context, Event}; +use sentry::{ClientInitGuard, ClientOptions}; + +use oneclient_common::constants::SENTRY_DSN; + +const PANIC_FLUSH_TIMEOUT: Duration = Duration::from_secs(2); +const TRACING_FIELDS_CONTEXT: &str = "Rust Tracing Fields"; + +const ENVIRONMENT: &str = if cfg!(debug_assertions) { + "development" +} else { + "production" +}; + +/// `before_send` hook drops events whose log call set `sentry = false` +/// +/// ```ignore +/// tracing::error!(sentry = false, "handled MSA failure, don't report as a crash"); +/// ``` +fn drop_opted_out_events(event: Event<'static>) -> Option> { + let opted_out = matches!( + event.contexts.get(TRACING_FIELDS_CONTEXT), + Some(Context::Other(fields)) + if fields.get("sentry").and_then(serde_json::Value::as_bool) == Some(false) + ); + + if opted_out { None } else { Some(event) } +} + +fn drop_opted_out_breadcrumbs(breadcrumb: Breadcrumb) -> Option { + let opted_out = breadcrumb + .data + .get("sentry") + .and_then(serde_json::Value::as_bool) + == Some(false); + + if opted_out { None } else { Some(breadcrumb) } +} + +pub fn init(enabled: bool) -> Option { + if !enabled { + tracing::debug!("crash reporting disabled by settings"); + return None; + } + + if cfg!(debug_assertions) && option_env!("ONECLIENT_SENTRY_DSN").is_none() { + tracing::debug!("crash reporting skipped: debug build without an explicit DSN"); + return None; + } + + let guard = sentry::init(( + SENTRY_DSN, + ClientOptions { + release: Some(format!("oneclient@{}", env!("CARGO_PKG_VERSION")).into()), + environment: Some(ENVIRONMENT.into()), + attach_stacktrace: true, + send_default_pii: false, + before_send: Some(Arc::new(drop_opted_out_events)), + before_breadcrumb: Some(Arc::new(drop_opted_out_breadcrumbs)), + ..Default::default() + }, + )); + + if !guard.is_enabled() { + tracing::warn!("sentry client failed to start; crash reports will not be sent"); + return None; + } + + install_panic_flush(); + + tracing::info!(environment = ENVIRONMENT, "crash reporting enabled"); + Some(guard) +} + +fn install_panic_flush() { + let capture = panic::take_hook(); + + panic::set_hook(Box::new(move |info| { + capture(info); + + if let Some(client) = sentry::Hub::current().client() { + client.flush(Some(PANIC_FLUSH_TIMEOUT)); + } + })); +} diff --git a/packages/oneclient_core/src/settings/launcher.rs b/packages/oneclient_core/src/settings/launcher.rs new file mode 100644 index 00000000..12ff3ed1 --- /dev/null +++ b/packages/oneclient_core/src/settings/launcher.rs @@ -0,0 +1,85 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use oneclient_cluster::GameSettingsProfile; + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum ViewLayout { + #[default] + Grid, + List, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)] +#[serde(default)] +pub struct ViewState { + pub layout: ViewLayout, + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(default)] +pub struct LauncherSettings { + pub settings_version: u32, + pub log_debug: bool, + pub auto_update: bool, + pub crash_reporting: bool, + pub enable_gamemode: bool, + pub discord_enabled: bool, + pub max_concurrent_requests: usize, + pub global_game_settings: GameSettingsProfile, + pub allow_parallel_running_clusters: bool, + pub dynamic_background_enabled: bool, + pub animations_enabled: bool, + pub view_states: BTreeMap, + pub seen_onboarding: bool, + pub accepted_tos_version: u32, + pub accepted_privacy_version: u32, + pub seen_versions: Vec, + pub seen_changelog_version: Option, + pub modrinth_api_key: Option, + pub curseforge_api_key: Option, + pub custom_api_endpoint: Option, + pub custom_meta_url_base: Option, +} + +impl LauncherSettings { + pub fn view_state(&self, key: &str) -> ViewState { + self.view_states.get(key).cloned().unwrap_or_default() + } + + pub fn set_view_state(&mut self, key: impl Into, state: ViewState) { + self.view_states.insert(key.into(), state); + } +} + +impl Default for LauncherSettings { + fn default() -> Self { + Self { + settings_version: 1, + log_debug: false, + auto_update: true, + crash_reporting: true, + discord_enabled: true, + enable_gamemode: false, + max_concurrent_requests: 25, + global_game_settings: GameSettingsProfile::default_global_profile(), + allow_parallel_running_clusters: false, + dynamic_background_enabled: true, + animations_enabled: true, + view_states: BTreeMap::new(), + seen_onboarding: false, + accepted_tos_version: 0, + accepted_privacy_version: 0, + seen_versions: Vec::new(), + seen_changelog_version: None, + modrinth_api_key: None, + curseforge_api_key: None, + custom_api_endpoint: None, + custom_meta_url_base: None, + } + } +} diff --git a/packages/oneclient_core/src/settings/mod.rs b/packages/oneclient_core/src/settings/mod.rs new file mode 100644 index 00000000..f98bdf7f --- /dev/null +++ b/packages/oneclient_core/src/settings/mod.rs @@ -0,0 +1,21 @@ +mod launcher; + +pub use launcher::{LauncherSettings, ViewLayout, ViewState}; +pub use oneclient_cluster::{ + GameSettingsProfile, PackageUpdateMode, ProfileUpdate, SettingsOsExtra, +}; +pub use oneclient_common::Resolution; + +pub mod store; + +/// Pushed down by the composition layer on every settings change so the HTTP +/// client never reaches back into global settings (and thus into its own owner) +#[must_use] +pub fn net_config(settings: &LauncherSettings) -> oneclient_net::NetConfig { + oneclient_net::NetConfig::default().with_overrides( + settings.curseforge_api_key.as_deref(), + settings.modrinth_api_key.as_deref(), + settings.custom_api_endpoint.as_deref(), + settings.custom_meta_url_base.as_deref(), + ) +} diff --git a/packages/oneclient_core/src/settings/store.rs b/packages/oneclient_core/src/settings/store.rs new file mode 100644 index 00000000..9f472187 --- /dev/null +++ b/packages/oneclient_core/src/settings/store.rs @@ -0,0 +1,75 @@ +use parking_lot::RwLock; + +use crate::{LauncherError, LauncherResult}; +use oneclient_common::paths; +use oneclient_events::EventBus; + +use super::launcher::LauncherSettings; +use oneclient_cluster::GameSettingsProfile; + +#[tracing::instrument(level = "debug", skip(notify))] +pub async fn load_settings(notify: Option<&EventBus>) -> LauncherSettings { + match async { + let path = paths::settings_file()?; + let exists = polyio::try_exists(&path).await?; + + Ok::(if !exists { + LauncherSettings::default() + } else { + let data = polyio::read(&path).await?; + serde_json::from_slice(&data)? + }) + } + .await + { + Ok(settings) => settings, + Err(err) => { + tracing::warn!("failed to read settings file: {err}"); + + if let Some(notify) = notify { + notify + .notify("Settings") + .body("Failed to load settings") + .error() + .send(); + } + + LauncherSettings::default() + } + } +} + +/// Prefer [`save_settings_and_apply`] this leaves the HTTP client on its old +/// endpoints/keys so those changes only take effect on the next launch +#[tracing::instrument(level = "debug", skip(settings))] +pub async fn save_settings(settings: &LauncherSettings) -> LauncherResult<()> { + let path = paths::settings_file()?; + + let data = serde_json::to_string_pretty(settings)?; + polyio::write_atomic(path, data).await?; + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(services, settings))] +pub async fn save_settings_and_apply( + services: &crate::LauncherServices, + settings: &LauncherSettings, +) -> LauncherResult<()> { + save_settings(settings).await?; + services.requester.set_config(super::net_config(settings)); + Ok(()) +} + +#[tracing::instrument(level = "debug", skip_all)] +pub async fn save_global_profile( + settings: &RwLock, + global: GameSettingsProfile, +) -> LauncherResult<()> { + { + let mut lock = settings.write(); + lock.global_game_settings = global; + } + + let snapshot = settings.read().clone(); + save_settings(&snapshot).await +} diff --git a/packages/oneclient_core/src/simulate.rs b/packages/oneclient_core/src/simulate.rs new file mode 100644 index 00000000..c25c6750 --- /dev/null +++ b/packages/oneclient_core/src/simulate.rs @@ -0,0 +1,365 @@ +//! Deliberately damaging an installation so repair paths can be exercised +//! Each mode breaks files differently because the launcher catches different +//! damage at different layers + +use std::path::{Path, PathBuf}; + +use oneclient_common::paths; +use oneclient_content::packages::PackageStore; +use oneclient_content::packages::store::artifact_absolute_path; +use oneclient_db::dao::artifact as artifact_dao; + +use crate::LauncherResult; +use crate::state::LauncherState; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct SimulationReport { + pub affected: usize, + /// Capped sample not the full list 50 damaged assets must not become a + /// 50-line toast + pub samples: Vec, +} + +impl SimulationReport { + #[must_use] + pub fn summary(&self, verb: &str) -> String { + if self.affected == 0 { + return "Nothing to affect — is the game installed?".to_string(); + } + + let mut text = format!("{verb} {} file(s)", self.affected); + if let Some(first) = self.samples.first() { + text.push_str(&format!(": {first}")); + if self.affected > 1 { + text.push_str(&format!(" and {} more", self.affected - 1)); + } + } + text + } + + fn push(&mut self, path: &Path) { + self.affected += 1; + if self.samples.len() < 3 { + self.samples.push( + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), + ); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Damage { + /// Same length wrong bytes passes a size check fails a hash check + Corrupt, + /// Half length as an interrupted write would leave it + Truncate, + Delete, +} + +impl Damage { + #[must_use] + pub fn verb(self) -> &'static str { + match self { + Self::Corrupt => "Corrupted", + Self::Truncate => "Truncated", + Self::Delete => "Deleted", + } + } + + fn apply(self, path: &Path) -> std::io::Result<()> { + match self { + Self::Delete => std::fs::remove_file(path), + Self::Truncate => { + let len = std::fs::metadata(path)?.len(); + let file = std::fs::OpenOptions::new().write(true).open(path)?; + file.set_len(len / 2) + } + Self::Corrupt => { + let mut bytes = std::fs::read(path)?; + if bytes.is_empty() { + return Ok(()); + } + // Mid-file so the damage lands past any header a reader + // validates first keeping the file superficially plausible + let start = bytes.len() / 2; + let end = (start + 8).min(bytes.len()); + for byte in &mut bytes[start..end] { + *byte = !*byte; + } + std::fs::write(path, &bytes) + } + } + } +} + +/// Spread across shards rather than taken from one so the verify pass has to +/// walk the whole tree +#[tracing::instrument(level = "debug")] +pub async fn damage_assets(count: usize, damage: Damage) -> LauncherResult { + let dir = paths::assets_object_dir()?; + + tokio::task::spawn_blocking(move || { + let mut report = SimulationReport::default(); + let Ok(shards) = std::fs::read_dir(&dir) else { + return report; + }; + + // One file per shard before a second from any so a small count never + // all lands in `00/` + let mut shard_dirs: Vec = shards + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .collect(); + shard_dirs.sort(); + + let mut round = 0; + while report.affected < count { + let mut progressed = false; + + for shard in &shard_dirs { + if report.affected >= count { + break; + } + + let Ok(entries) = std::fs::read_dir(shard) else { + continue; + }; + let Some(path) = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .nth(round) + else { + continue; + }; + + progressed = true; + match damage.apply(&path) { + Ok(()) => report.push(&path), + Err(err) => { + tracing::warn!(path = %path.display(), "could not damage: {err}"); + } + } + } + + // Every shard exhausted asking for more than exists is not an error + if !progressed { + break; + } + round += 1; + } + + tracing::warn!( + affected = report.affected, + ?damage, + "simulated asset damage" + ); + report + }) + .await + .map_err(std::io::Error::other) + .map_err(Into::into) +} + +/// Libraries are on the classpath so damage stops the game outright +#[tracing::instrument(level = "debug")] +pub async fn damage_libraries(count: usize, damage: Damage) -> LauncherResult { + let dir = paths::libraries_dir()?; + + tokio::task::spawn_blocking(move || { + let mut report = SimulationReport::default(); + let mut stack = vec![dir]; + + while let Some(current) = stack.pop() { + if report.affected >= count { + break; + } + let Ok(entries) = std::fs::read_dir(¤t) else { + continue; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.extension().is_none_or(|ext| ext != "jar") { + continue; + } + if report.affected >= count { + break; + } + match damage.apply(&path) { + Ok(()) => report.push(&path), + Err(err) => tracing::warn!(path = %path.display(), "could not damage: {err}"), + } + } + } + + tracing::warn!( + affected = report.affected, + ?damage, + "simulated library damage" + ); + report + }) + .await + .map_err(std::io::Error::other) + .map_err(Into::into) +} + +/// Reproduces the reported failure a cluster marked `Ready` skips preparation +/// on later launches so nothing notices the missing tree until a read fails +#[tracing::instrument(level = "debug")] +pub async fn delete_assets_tree() -> LauncherResult { + let dir = paths::assets_dir()?; + + let existed = polyio::try_exists(&dir).await.unwrap_or(false); + if existed { + tokio::fs::remove_dir_all(&dir) + .await + .map_err(std::io::Error::other)?; + } + + tracing::warn!(path = %dir.display(), existed, "simulated a missing assets tree"); + + Ok(SimulationReport { + affected: usize::from(existed), + samples: vec![dir.display().to_string()], + }) +} + +/// Content is cached content-addressed so a mod that no longer hashes to its +/// key must be refetched from the provider a different path from game files +#[tracing::instrument(level = "debug", skip(state))] +pub async fn damage_cluster_content( + state: &std::sync::Arc, + cluster_id: i64, + count: usize, + damage: Damage, +) -> LauncherResult { + let content = state.services.content(); + let linked = PackageStore::list_linked_artifacts(cluster_id, &content).await?; + + let mut paths = Vec::new(); + for link in linked { + let Some(artifact) = + artifact_dao::get_artifact_by_hash(&state.services.db, &link.hash).await? + else { + continue; + }; + if let Ok(path) = artifact_absolute_path(&artifact.path) { + paths.push(path); + } + if paths.len() >= count { + break; + } + } + + tokio::task::spawn_blocking(move || { + let mut report = SimulationReport::default(); + for path in paths { + if !path.is_file() { + continue; + } + match damage.apply(&path) { + Ok(()) => report.push(&path), + Err(err) => tracing::warn!(path = %path.display(), "could not damage: {err}"), + } + } + + tracing::warn!( + affected = report.affected, + ?damage, + "simulated content damage" + ); + report + }) + .await + .map_err(std::io::Error::other) + .map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("oneclient-simulate-{tag}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn corrupting_keeps_the_length_and_changes_the_bytes() { + // Must survive a size check so only the hashing pass can catch it + let dir = scratch("corrupt"); + let file = dir.join("object.bin"); + let original: Vec = (0..=255u8).collect(); + std::fs::write(&file, &original).unwrap(); + + Damage::Corrupt.apply(&file).unwrap(); + + let after = std::fs::read(&file).unwrap(); + assert_eq!(after.len(), original.len(), "length must not change"); + assert_ne!(after, original, "bytes must change"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn corrupting_an_empty_file_is_a_no_op_rather_than_a_panic() { + let dir = scratch("corrupt-empty"); + let file = dir.join("empty.bin"); + std::fs::write(&file, []).unwrap(); + + Damage::Corrupt.apply(&file).unwrap(); + + assert!(std::fs::read(&file).unwrap().is_empty()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn truncating_halves_the_file() { + let dir = scratch("truncate"); + let file = dir.join("object.bin"); + std::fs::write(&file, vec![7u8; 100]).unwrap(); + + Damage::Truncate.apply(&file).unwrap(); + + assert_eq!(std::fs::metadata(&file).unwrap().len(), 50); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_report_over_nothing_says_so() { + let report = SimulationReport::default(); + assert_eq!( + report.summary("Corrupted"), + "Nothing to affect — is the game installed?" + ); + } + + #[test] + fn a_report_names_a_sample_and_counts_the_rest() { + let mut report = SimulationReport::default(); + report.push(Path::new("/tmp/assets/objects/00/aaaa")); + report.push(Path::new("/tmp/assets/objects/01/bbbb")); + report.push(Path::new("/tmp/assets/objects/02/cccc")); + report.push(Path::new("/tmp/assets/objects/03/dddd")); + + assert_eq!(report.affected, 4); + assert_eq!(report.samples.len(), 3); + assert_eq!( + report.summary("Corrupted"), + "Corrupted 4 file(s): aaaa and 3 more" + ); + } +} diff --git a/packages/oneclient_core/src/state.rs b/packages/oneclient_core/src/state.rs new file mode 100644 index 00000000..3e55c495 --- /dev/null +++ b/packages/oneclient_core/src/state.rs @@ -0,0 +1,166 @@ +use std::sync::Arc; + +use oneclient_db::DbPool; +use tokio::sync::Mutex; + +use crate::LauncherResult; +use crate::clusters::ClusterManager; +use crate::images::ImageCacheStore; +use crate::settings::{LauncherSettings, store}; +use crate::versions::VersionsManager; +use oneclient_auth::AuthService; +use oneclient_common::paths; +use oneclient_content::bundles::BundlesManager; +use oneclient_content::packages::provider::PackageProviderRegistry; +use oneclient_discord::DiscordRpc; +use oneclient_events::EventBus; +use oneclient_java::JavaService; +use oneclient_mc::MetadataStore; +use oneclient_net::RequestClient; + +#[derive(Clone)] +pub struct LauncherServices { + pub events: EventBus, + pub requester: RequestClient, + pub db: DbPool, + pub packages: PackageProviderRegistry, +} + +pub struct LauncherState { + pub services: LauncherServices, + pub settings: parking_lot::RwLock, + pub auth: Arc, + pub java: JavaService, + pub clusters: ClusterManager, + pub metadata: Mutex, + pub bundles: Arc, + pub versions: Arc, + pub images: ImageCacheStore, + pub games: crate::game::GameProcessManager, + pub discord: DiscordRpc, +} + +impl LauncherServices { + #[must_use] + pub fn content(&self) -> oneclient_content::ContentCtx { + oneclient_content::ContentCtx::new( + self.db.clone(), + self.requester.clone(), + self.events.clone(), + self.packages.clone(), + ) + } + + /// Both fields are refcounted handles so building this per call is cheap + #[must_use] + pub fn mc(&self) -> oneclient_mc::McCtx { + oneclient_mc::McCtx::new(self.requester.clone(), self.events.clone()) + } +} + +impl LauncherState { + /// No side effects beyond opening the database and reading settings the + /// background startup work is [`run_startup_tasks`] which the caller runs + #[tracing::instrument(skip(events))] + pub async fn new(events: EventBus) -> LauncherResult> { + let services = LauncherServices { + events, + db: oneclient_db::connect(paths::database_file()?).await?, + // Defaults because loading settings needs the event bus already inside + // `services` Real config is pushed in below before any request + requester: RequestClient::new(oneclient_net::NetConfig::default())?, + packages: PackageProviderRegistry::new(), + }; + + let settings = store::load_settings(Some(&services.events)).await; + services + .requester + .set_config(crate::settings::net_config(&settings)); + let auth = + Arc::new(AuthService::load(services.requester.clone(), services.events.clone()).await?); + let java = JavaService::new( + std::sync::Arc::new(crate::java_store::SqlJavaStore::new(services.db.clone())), + services.requester.clone(), + services.events.clone(), + ); + let discord = DiscordRpc::spawn(settings.discord_enabled); + + let clusters = ClusterManager::new(services.db.clone()); + + let state = Arc::new(Self { + services, + settings: parking_lot::RwLock::new(settings), + auth, + java, + clusters, + metadata: Mutex::new(MetadataStore::new()), + bundles: Arc::new(BundlesManager::new()), + versions: Arc::new(VersionsManager::from_cache().await), + images: ImageCacheStore::new(), + games: crate::game::GameProcessManager::new(), + discord, + }); + + Ok(state) + } +} + +pub fn run_startup_tasks(state: &Arc) { + let background = Arc::clone(state); + tokio::spawn(async move { + let recovery = match crate::recovery::reconstruct_from_disk(&background).await { + Ok(report) => report, + Err(err) => { + tracing::error!("disk recovery failed: {err:#}"); + crate::recovery::RecoveryReport::default() + } + }; + + crate::game::recover_sessions(&background).await; + + let content = background.services.content(); + let (versions_res, bundles_res) = tokio::join!( + background.versions.sync(&background.services), + background.bundles.sync(&content), + ); + if let Err(err) = versions_res { + tracing::error!("versions manifest sync failed: {err:#}"); + } + if let Err(err) = bundles_res { + tracing::error!("bundle catalog sync failed: {err:#}"); + } + + if recovery.did_recover() + && let Err(err) = crate::recovery::restore_bundle_tracking(&background).await + { + tracing::warn!("bundle tracking restore failed: {err:#}"); + } + + if let Err(err) = crate::clusters::apply_remote_migrations(&background).await { + tracing::error!("cluster migrations failed: {err:#}"); + } + + if let Err(err) = crate::clusters::ensure_from_versions(&background).await { + tracing::error!("versions cluster provisioning failed: {err:#}"); + } else { + background + .services + .events + .signal(oneclient_events::Signal::ClustersChanged); + } + + // Must run last judging an artifact unused before recovery bundle + // tracking and provisioning have restored their rows would evict + // content still in use Only the row-driven half runs unattended + if let Err(err) = + oneclient_content::packages::store::collect_unused_artifacts(&content).await + { + tracing::warn!("package cache cleanup failed: {err:#}"); + } + + background + .services + .events + .signal(oneclient_events::Signal::SyncComplete); + }); +} diff --git a/packages/oneclient_core/src/storage.rs b/packages/oneclient_core/src/storage.rs new file mode 100644 index 00000000..070cec00 --- /dev/null +++ b/packages/oneclient_core/src/storage.rs @@ -0,0 +1,380 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::LauncherResult; +use crate::state::LauncherState; +use oneclient_common::domain::ContentType; +use oneclient_common::paths; +use oneclient_content::packages::store::{find_unreferenced_files, remove_unreferenced_files}; + +const LEGACY_TYPES: [ContentType; 4] = [ + ContentType::Mod, + ContentType::ResourcePack, + ContentType::Shader, + ContentType::DataPack, +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageEntry { + pub label: String, + pub bytes: u64, + pub path: PathBuf, + pub files: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ReclaimableEntry { + pub bytes: u64, + pub files: usize, +} + +impl ReclaimableEntry { + #[must_use] + pub fn is_empty(&self) -> bool { + self.files == 0 + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StorageReport { + pub total_bytes: u64, + pub categories: Vec, + pub clusters: Vec, + pub unreferenced_cache: ReclaimableEntry, + pub legacy_cluster_content: ReclaimableEntry, +} + +#[tracing::instrument(skip(state))] +pub async fn storage_report(state: &LauncherState) -> LauncherResult { + #[cfg(debug_assertions)] + if let Some(report) = fixture_report() { + return Ok(report); + } + + let launcher = paths::launcher_dir()?; + + let mut categories = vec![ + entry("Packages", paths::packages_cache_dir()?).await, + entry("Minecraft versions", paths::versions_dir()?).await, + entry("Libraries", paths::libraries_dir()?).await, + entry("Assets", paths::assets_dir()?).await, + entry("Natives", paths::natives_dir()?).await, + entry("Java runtimes", paths::java_dir()?).await, + entry("Shared game directory", paths::shared_minecraft_dir()?).await, + entry("Logs", paths::logs_dir()?).await, + entry("Image cache", paths::images_cache_dir()?).await, + entry("Bundles", paths::bundles_dir()?).await, + ]; + categories.sort_by_key(|entry| std::cmp::Reverse(entry.bytes)); + + let mut clusters = Vec::new(); + for cluster in state.clusters.list().await? { + let Ok(dir) = cluster.dir() else { + continue; + }; + clusters.push(entry(&cluster.name, dir).await); + } + clusters.sort_by_key(|entry| std::cmp::Reverse(entry.bytes)); + + let unreferenced = find_unreferenced_files(&state.services.content()).await?; + let unreferenced_cache = ReclaimableEntry { + bytes: unreferenced.iter().map(|(_, size)| size).sum(), + files: unreferenced.len(), + }; + + Ok(StorageReport { + total_bytes: dir_size(launcher).await, + categories, + clusters, + unreferenced_cache, + legacy_cluster_content: legacy_cluster_content(state).await?, + }) +} + +/// Content is materialized from the cache now so anything in a cluster's own +/// folder is an inert leftover from an older launcher space not correctness +async fn legacy_cluster_content(state: &LauncherState) -> LauncherResult { + let mut found = ReclaimableEntry::default(); + + for cluster in state.clusters.list().await? { + // A dedicated cluster's folder *is* its game directory so content there belongs + if cluster.uses_dedicated_dir() { + continue; + } + let Ok(root) = cluster.dir() else { + continue; + }; + + for content_type in LEGACY_TYPES { + let dir = root.join(content_type.folder_name()); + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + + if file_type.is_symlink() { + found.files += 1; + continue; + } + + if file_type.is_file() && is_content_file(content_type, name) { + found.files += 1; + found.bytes += polyio::stat(&path).await.map(|m| m.len()).unwrap_or(0); + } + } + } + } + + Ok(found) +} + +fn is_content_file(content_type: ContentType, name: &str) -> bool { + let lower = name.to_lowercase(); + let lower = lower.trim_end_matches(".disabled"); + + match content_type { + ContentType::Mod => lower.ends_with(".jar"), + ContentType::ResourcePack | ContentType::Shader | ContentType::DataPack => { + lower.ends_with(".zip") + } + _ => false, + } +} + +async fn entry(label: &str, path: PathBuf) -> StorageEntry { + StorageEntry { + label: label.to_string(), + bytes: dir_size(&path).await, + path, + files: None, + } +} + +/// Links are not followed so a materialized game directory does not appear to +/// double the size of the package cache +pub async fn dir_size(root: impl AsRef) -> u64 { + let mut total = 0; + let mut stack = vec![root.as_ref().to_path_buf()]; + + while let Some(dir) = stack.pop() { + let Ok(mut entries) = polyio::read_dir(&dir).await else { + continue; + }; + + while let Ok(Some(entry)) = entries.next_entry().await { + let Ok(file_type) = entry.file_type().await else { + continue; + }; + + if file_type.is_symlink() { + continue; + } + + if file_type.is_dir() { + stack.push(entry.path()); + } else if let Ok(meta) = entry.metadata().await { + total += meta.len(); + } + } + } + + total +} + +/// Cleanup refuses to run while fixture numbers are shown the report bears no +/// relation to disk so the only thing it could delete is the user's real data +fn showing_fixture() -> bool { + #[cfg(debug_assertions)] + { + std::env::var("ONECLIENT_FAKE_STORAGE").is_ok() + } + + #[cfg(not(debug_assertions))] + false +} + +pub async fn clean_unreferenced_cache(state: &LauncherState) -> LauncherResult { + if showing_fixture() { + tracing::info!("fixture storage report is active; skipping cache cleanup"); + return Ok(0); + } + + let report = remove_unreferenced_files(&state.services.content()).await?; + Ok(report.bytes_freed) +} + +pub async fn clean_legacy_cluster_content(state: &LauncherState) -> LauncherResult { + if showing_fixture() { + tracing::info!("fixture storage report is active; skipping leftover cleanup"); + return Ok(0); + } + + let report = crate::clusters::unlink_legacy_cluster_content(state).await?; + Ok(report.removed) +} + +/// Set `ONECLIENT_FAKE_STORAGE` to `empty` `clean` or `full` to return a fixture +/// instead of scanning disk +/// Read on every refresh so no rebuild is needed +#[cfg(debug_assertions)] +fn fixture_report() -> Option { + let scenario = std::env::var("ONECLIENT_FAKE_STORAGE").ok()?; + + let fake = |label: &str, bytes: u64| StorageEntry { + label: label.to_string(), + bytes, + path: PathBuf::from("/tmp/oneclient-fake").join(label.to_lowercase().replace(' ', "-")), + files: None, + }; + + let report = match scenario.trim().to_lowercase().as_str() { + "empty" => StorageReport { + total_bytes: 0, + categories: Vec::new(), + clusters: Vec::new(), + unreferenced_cache: ReclaimableEntry::default(), + legacy_cluster_content: ReclaimableEntry::default(), + }, + "clean" => StorageReport { + total_bytes: 1_284_000_000, + categories: vec![ + fake("Minecraft versions", 512_000_000), + fake("Packages", 368_000_000), + fake("Libraries", 240_000_000), + fake("Assets", 96_000_000), + fake("Java runtimes", 61_000_000), + fake("Shared game directory", 5_400_000), + fake("Logs", 1_200_000), + fake("Image cache", 400_000), + ], + clusters: vec![ + fake("26.1.2 Fabric", 42_000_000), + fake("26.2 Fabric", 18_500_000), + fake("1.21.11 Fabric", 9_100_000), + ], + unreferenced_cache: ReclaimableEntry::default(), + legacy_cluster_content: ReclaimableEntry::default(), + }, + "full" => StorageReport { + total_bytes: 4_930_000_000, + categories: vec![ + fake("Packages", 2_100_000_000), + fake("Minecraft versions", 1_400_000_000), + fake("Libraries", 890_000_000), + fake("Assets", 412_000_000), + fake("Java runtimes", 118_000_000), + fake("Natives", 6_800_000), + fake("Shared game directory", 2_100_000), + fake("Logs", 940_000), + fake("Bundles", 120_000), + ], + clusters: vec![ + fake("26.1.2 Fabric", 310_000_000), + fake("26.2 Fabric", 148_000_000), + fake("1.21.11 Fabric", 96_000_000), + fake("1.21.10 Fabric", 12_000_000), + fake("1.21.1 Fabric", 0), + ], + unreferenced_cache: ReclaimableEntry { + bytes: 743_000_000, + files: 214, + }, + legacy_cluster_content: ReclaimableEntry { + bytes: 261_000_000, + files: 439, + }, + }, + other => { + tracing::warn!( + scenario = other, + "unknown ONECLIENT_FAKE_STORAGE value; expected empty, clean or full" + ); + return None; + } + }; + + tracing::info!(scenario = %scenario, "serving fixture storage report"); + Some(report) +} + +#[must_use] +pub fn format_bytes(bytes: u64) -> String { + const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"]; + + if bytes < 1000 { + return format!("{bytes} B"); + } + + let mut value = bytes as f64 / 1000.0; + let mut unit = 0; + + while value >= 1000.0 && unit < UNITS.len() - 1 { + value /= 1000.0; + unit += 1; + } + + if value >= 100.0 { + format!("{value:.0} {}", UNITS[unit]) + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bytes_read_the_way_a_person_would_say_them() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(999), "999 B"); + assert_eq!(format_bytes(1_500), "1.5 KB"); + assert_eq!(format_bytes(628_000_000), "628 MB"); + assert_eq!(format_bytes(2_400_000_000), "2.4 GB"); + } + + #[test] + fn disabled_content_still_counts_as_content() { + assert!(is_content_file(ContentType::Mod, "sodium.jar")); + assert!(is_content_file(ContentType::Mod, "sodium.jar.disabled")); + assert!(is_content_file(ContentType::Shader, "BSL.zip")); + assert!(!is_content_file(ContentType::Shader, "BSL.zip.txt")); + assert!(!is_content_file(ContentType::Mod, "options.txt")); + } + + #[tokio::test] + async fn size_ignores_what_links_point_at() { + let root = polyio::testing::ScratchDir::new("dir_size"); + let dir = root.path(); + polyio::create_dir_all(dir.join("nested")).await.unwrap(); + + polyio::write(dir.join("a.bin"), vec![0u8; 1000]) + .await + .unwrap(); + polyio::write(dir.join("nested").join("b.bin"), vec![0u8; 500]) + .await + .unwrap(); + + assert_eq!(dir_size(dir).await, 1500); + + polyio::symlink_file(dir.join("a.bin"), dir.join("link.bin")) + .await + .unwrap(); + assert_eq!( + dir_size(dir).await, + 1500, + "a link is not another copy of its target" + ); + + std::fs::remove_dir_all(root.path()).ok(); + } +} diff --git a/packages/oneclient_core/src/tos.rs b/packages/oneclient_core/src/tos.rs new file mode 100644 index 00000000..f58e6ba7 --- /dev/null +++ b/packages/oneclient_core/src/tos.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; + +use crate::{LauncherError, LauncherResult}; +use oneclient_common::constants; +use oneclient_common::paths; +use oneclient_net::RequestClient; +use oneclient_net::{EtagPolicy, fetch_cached}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TermsDocument { + pub version: u32, + #[serde(default)] + pub privacy_version: Option, + #[serde(default)] + pub updated_at: Option, + pub terms: String, + #[serde(default)] + pub privacy: Option, + #[serde(default)] + pub terms_url: Option, + #[serde(default)] + pub privacy_url: Option, +} + +impl TermsDocument { + pub fn terms_url(&self) -> &str { + self.terms_url + .as_deref() + .filter(|url| !url.trim().is_empty()) + .unwrap_or(constants::TOS_URL) + } + + pub fn privacy_url(&self) -> &str { + self.privacy_url + .as_deref() + .filter(|url| !url.trim().is_empty()) + .unwrap_or(constants::PRIVACY_URL) + } + + pub fn privacy_version(&self) -> u32 { + self.privacy_version.unwrap_or(self.version) + } + + pub fn privacy_body(&self) -> Option<&str> { + self.privacy + .as_deref() + .filter(|body| !body.trim().is_empty()) + } +} + +#[tracing::instrument(level = "debug", skip(net))] +pub async fn fetch_terms(net: &RequestClient) -> LauncherResult { + let url = format!("{}/oneclient/tos.json", net.config().meta_url_base); + let cache_path = paths::caches_dir()?.join("TERMS.json"); + + let fetched = fetch_cached(net, &url, &cache_path, EtagPolicy::CommitNow) + .await? + .ok_or_else(|| LauncherError::InvalidSettingsProfile { + reason: "terms are unavailable and not cached".to_string(), + })?; + + Ok(fetched.json()?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_minimal_document() { + let raw = br###"{"version":2,"terms":"## Terms\n\nBe nice."}"###; + let document: TermsDocument = serde_json::from_slice(raw).unwrap(); + + assert_eq!(document.version, 2); + assert_eq!(document.terms, "## Terms\n\nBe nice."); + assert_eq!(document.terms_url(), constants::TOS_URL); + assert_eq!(document.privacy_url(), constants::PRIVACY_URL); + } + + #[test] + fn privacy_version_falls_back_to_terms_version() { + let raw = br#"{"version":4,"terms":"x"}"#; + let document: TermsDocument = serde_json::from_slice(raw).unwrap(); + + assert_eq!(document.privacy_version(), 4); + assert_eq!(document.privacy_body(), None); + } + + #[test] + fn privacy_version_is_independent_when_published() { + let raw = br###"{"version":4,"privacy_version":2,"terms":"x","privacy":"## Privacy"}"###; + let document: TermsDocument = serde_json::from_slice(raw).unwrap(); + + assert_eq!(document.version, 4); + assert_eq!(document.privacy_version(), 2); + assert_eq!(document.privacy_body(), Some("## Privacy")); + } + + #[test] + fn blank_privacy_body_is_treated_as_absent() { + let raw = br#"{"version":1,"terms":"x","privacy":" "}"#; + let document: TermsDocument = serde_json::from_slice(raw).unwrap(); + + assert_eq!(document.privacy_body(), None); + } + + #[test] + fn document_urls_override_constants() { + let raw = br#"{"version":1,"terms":"x","terms_url":"https://example.com/tos","privacy_url":" "}"#; + let document: TermsDocument = serde_json::from_slice(raw).unwrap(); + + assert_eq!(document.terms_url(), "https://example.com/tos"); + assert_eq!(document.privacy_url(), constants::PRIVACY_URL); + } +} diff --git a/packages/oneclient_core/src/verify.rs b/packages/oneclient_core/src/verify.rs new file mode 100644 index 00000000..e542ef3b --- /dev/null +++ b/packages/oneclient_core/src/verify.rs @@ -0,0 +1,403 @@ +use std::sync::Arc; + +use oneclient_cluster::ClusterError; +use oneclient_common::paths; +use oneclient_content::packages::PackageStore; +use oneclient_content::packages::store::artifact_absolute_path; +use oneclient_db::dao::artifact as artifact_dao; +use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase}; +use polyio::{normalize_hash, sha1_file}; + +use crate::LauncherResult; +use crate::clusters::prepare::prepare_cluster_locked; +use crate::game::{ + download_version_info, get_loader_version, resolve_minecraft_version, verify_game_files, +}; +use crate::state::LauncherState; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ClusterVerifyReport { + pub checked: usize, + /// Mismatched files also deleted from disk + pub corrupt: usize, + pub missing: usize, + pub repaired: usize, + /// Corrupt with no source to re-fetch from named so the user can replace them + pub unrepairable: Vec, + /// Game files re-downloaded uncounted because the assets index was gone too + pub reinstalled_game_files: bool, +} + +impl ClusterVerifyReport { + #[must_use] + pub fn is_clean(&self) -> bool { + self.corrupt == 0 + && self.missing == 0 + && self.unrepairable.is_empty() + && !self.reinstalled_game_files + } + + #[must_use] + pub fn summary(&self) -> String { + if self.is_clean() { + return format!("All {} files verified.", self.checked); + } + + if self.reinstalled_game_files && self.corrupt == 0 && self.missing == 0 { + return "Game files were missing and have been re-downloaded.".to_string(); + } + + let mut parts = Vec::new(); + if self.corrupt > 0 { + parts.push(format!("{} corrupt", self.corrupt)); + } + if self.missing > 0 { + parts.push(format!("{} missing", self.missing)); + } + if self.repaired > 0 { + parts.push(format!("{} repaired", self.repaired)); + } + if !self.unrepairable.is_empty() { + parts.push(format!("{} could not be replaced", self.unrepairable.len())); + } + + format!("Checked {} files: {}.", self.checked, parts.join(", ")) + } +} + +/// Deletes bad files and re-prepares rather than overwriting in place to reuse +/// the one well-tested download path instead of maintaining a second +#[tracing::instrument(skip(state), level = "debug")] +pub async fn verify_cluster_files( + state: &Arc, + cluster_id: i64, +) -> LauncherResult { + let cluster = state.clusters.get(cluster_id).await?; + + let progress = GroupedProgressSession::start( + &state.services.events, + format!("Verifying files - {}", cluster.mc_version), + ); + + let result = run_verify(state, cluster_id, &progress).await; + progress.finish(); + + match &result { + Ok(report) => tracing::info!( + cluster_id, + checked = report.checked, + corrupt = report.corrupt, + missing = report.missing, + repaired = report.repaired, + "cluster verification finished" + ), + Err(err) => tracing::error!(cluster_id, "cluster verification failed: {err}"), + } + + result +} + +async fn run_verify( + state: &Arc, + cluster_id: i64, + progress: &GroupedProgressSession, +) -> LauncherResult { + let cluster = state.clusters.get(cluster_id).await?; + let mc_version = oneclient_common::version::normalize_mc_version_input(&cluster.mc_version); + + // Scoped so the metadata lock is released before the repair pass which takes it too + let (version_info, minecraft_updated) = { + let mut metadata = state.metadata.lock().await; + let (version, _index, minecraft_updated) = + resolve_minecraft_version(&mut metadata, &state.services.mc(), &mc_version) + .await + .map_err(|_| ClusterError::InvalidVersion(cluster.mc_version.clone()))?; + + let loader_version = get_loader_version( + &mut metadata, + &state.services.mc(), + &mc_version, + cluster.mc_loader, + cluster.mc_loader_version.as_deref(), + ) + .await?; + + let info = download_version_info( + &state.services.mc(), + Some(progress), + &version, + loader_version.as_ref(), + false, + ) + .await?; + + (info, minecraft_updated) + }; + + let mut report = ClusterVerifyReport::default(); + + // Absent or corrupt index means nothing to verify assets against so skip + // rather than fail prepare fetches it and the assets below + let assets_index = cached_assets_index(&version_info).await; + + // Library rules are per-architecture use the runtime this cluster launches + // with or a different library set than the launch path installs is verified + let java_arch = { + let global = state.settings.read().global_game_settings.clone(); + let profile = state.clusters.resolve_settings(&global, &cluster).await?; + state + .java + .runtime_for_profile(profile.java_path.as_deref()) + .await + .ok() + .flatten() + .map_or_else(|| std::env::consts::ARCH.to_string(), |rt| rt.os_arch) + }; + + // Counted apart from content which repairs itself as it goes one bucket + // would count the content ones twice + let mut game_broken = 0; + + // The index lives in the assets directory so its absence usually means that + // whole directory is gone hand the work to prepare + let reinstalled_game_files = assets_index.is_none(); + + if let Some(assets_index) = assets_index { + let game = verify_game_files( + progress, + &version_info, + assets_index, + &java_arch, + minecraft_updated, + ) + .await?; + + report.checked += game.checked; + report.corrupt += game.corrupt; + report.missing += game.missing; + game_broken = game.corrupt + game.missing; + } else { + tracing::warn!("assets index is missing; re-downloading the game files wholesale"); + } + + verify_cluster_content(state, cluster_id, progress, &mut report).await?; + + // Runs `prepare` directly not the launch path which skips clusters already + // marked `Ready` and so never heals one with deleted files + if game_broken > 0 || reinstalled_game_files { + prepare_cluster_locked(state, cluster_id, false, true, true, Some(progress)).await?; + report.repaired += game_broken; + } + + report.reinstalled_game_files = reinstalled_game_files; + + Ok(report) +} + +/// The content cache is content-addressed so a file that does not hash to its +/// own key is unusable nothing can find it again +async fn verify_cluster_content( + state: &Arc, + cluster_id: i64, + progress: &GroupedProgressSession, + report: &mut ClusterVerifyReport, +) -> LauncherResult<()> { + let content = state.services.content(); + let linked = PackageStore::list_linked_artifacts(cluster_id, &content).await?; + if linked.is_empty() { + return Ok(()); + } + + let child = progress.child( + "Verifying content", + linked.len() as u64, + TaskCategory::Packages, + ); + child.set_phase(TaskPhase::Verifying); + + for (index, link) in linked.iter().enumerate() { + child.set_progress(index as u64, Some(linked.len() as u64)); + + let Some(artifact) = + artifact_dao::get_artifact_by_hash(&state.services.db, &link.hash).await? + else { + continue; + }; + + let Ok(path) = artifact_absolute_path(&artifact.path) else { + continue; + }; + + if !path.is_file() { + report.missing += 1; + repair_content(state, link, report).await; + continue; + } + + let actual = match sha1_file(&path).await { + Ok(actual) => actual, + Err(err) => { + tracing::warn!(path = %path.display(), "could not hash content: {err}"); + report.corrupt += 1; + let _ = polyio::remove_file(&path).await; + repair_content(state, link, report).await; + continue; + } + }; + + report.checked += 1; + + if normalize_hash(&actual) == normalize_hash(&link.hash) { + continue; + } + + tracing::warn!( + file = %link.file_name, + expected = %link.hash, + %actual, + "corrupt content file; removing" + ); + report.corrupt += 1; + let _ = polyio::remove_file(&path).await; + repair_content(state, link, report).await; + } + + child.finish(); + Ok(()) +} + +/// Failures are recorded not propagated one delisted mod should not abandon +/// verification of everything else +async fn repair_content( + state: &Arc, + link: &oneclient_content::packages::LinkedArtifactInfo, + report: &mut ClusterVerifyReport, +) { + let content = state.services.content(); + + let (Some(provider_id), Some(project_id), Some(version_id)) = ( + link.provider, + link.project_id.as_deref(), + link.version_id.as_deref(), + ) else { + tracing::warn!(file = %link.file_name, "no provider to re-download from"); + report.unrepairable.push(link.file_name.clone()); + return; + }; + + let outcome = async { + let provider = content.providers.get(provider_id)?; + let version = provider + .get_version(project_id, version_id, &content) + .await?; + + // The cluster is pinned to one exact file by hash another file from the + // same version would silently change what is installed + let file = version + .files + .iter() + .find(|file| normalize_hash(&file.sha1) == normalize_hash(&link.hash)) + .ok_or_else(|| oneclient_content::ContentError::InvalidData { + reason: format!("{} is no longer served by {provider_id:?}", link.file_name), + })?; + + oneclient_content::packages::store::download_version_file( + provider_id, + project_id, + &version, + link.content_type, + file, + true, + None, + &content, + ) + .await + } + .await; + + match outcome { + Ok(_) => { + tracing::info!(file = %link.file_name, "re-downloaded corrupt content"); + report.repaired += 1; + } + Err(err) => { + tracing::warn!(file = %link.file_name, "could not re-download: {err}"); + report.unrepairable.push(link.file_name.clone()); + } + } +} + +async fn cached_assets_index( + info: &interfrost::api::minecraft::VersionInfo, +) -> Option { + let path = paths::assets_index_dir() + .ok()? + .join(format!("{}.json", info.asset_index.id)); + polyio::read_json(&path).await.ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_clean_sweep_says_so() { + let report = ClusterVerifyReport { + checked: 5123, + ..Default::default() + }; + + assert!(report.is_clean()); + assert_eq!(report.summary(), "All 5123 files verified."); + } + + #[test] + fn a_repaired_sweep_reports_what_it_did() { + let report = ClusterVerifyReport { + checked: 5123, + corrupt: 3, + missing: 1, + repaired: 4, + unrepairable: Vec::new(), + reinstalled_game_files: false, + }; + + assert!(!report.is_clean()); + assert_eq!( + report.summary(), + "Checked 5123 files: 3 corrupt, 1 missing, 4 repaired." + ); + } + + #[test] + fn a_wholesale_reinstall_is_not_reported_as_a_clean_sweep() { + let report = ClusterVerifyReport { + checked: 2, + reinstalled_game_files: true, + ..Default::default() + }; + + assert!(!report.is_clean()); + assert_eq!( + report.summary(), + "Game files were missing and have been re-downloaded." + ); + } + + #[test] + fn content_that_cannot_be_replaced_is_still_reported() { + let report = ClusterVerifyReport { + checked: 12, + corrupt: 1, + repaired: 0, + unrepairable: vec!["my-local-mod.jar".to_string()], + ..Default::default() + }; + + assert!(!report.is_clean()); + assert_eq!( + report.summary(), + "Checked 12 files: 1 corrupt, 1 could not be replaced." + ); + } +} diff --git a/packages/oneclient_core/src/versions/manager.rs b/packages/oneclient_core/src/versions/manager.rs new file mode 100644 index 00000000..d1ab08be --- /dev/null +++ b/packages/oneclient_core/src/versions/manager.rs @@ -0,0 +1,93 @@ +use tokio::sync::RwLock; + +use crate::LauncherResult; +use crate::state::LauncherServices; +use crate::versions::manifest::{RemoteMigration, VersionMetadata, VersionsManifest}; +use oneclient_common::paths; +use oneclient_net::{EtagPolicy, fetch_cached}; + +pub struct VersionsManager { + manifest: RwLock, +} + +impl VersionsManager { + #[must_use] + pub fn new() -> Self { + Self { + manifest: RwLock::new(VersionsManifest::default()), + } + } + + /// Seeds from the last synced copy on disk so the UI renders stale metadata + /// immediately instead of blocking on a request that usually 304s + #[must_use] + pub async fn from_cache() -> Self { + let manifest = Self::cached_manifest() + .await + .unwrap_or_else(VersionsManifest::default); + + Self { + manifest: RwLock::new(manifest), + } + } + + async fn cached_manifest() -> Option { + let path = paths::caches_dir().ok()?.join("versions.json"); + let bytes = polyio::read(&path).await.ok()?; + match serde_json::from_slice(&bytes) { + Ok(manifest) => Some(manifest), + Err(err) => { + tracing::warn!("cached versions manifest is unreadable: {err}"); + None + } + } + } + + #[tracing::instrument(level = "debug", skip(self, services))] + pub async fn sync(&self, services: &LauncherServices) -> LauncherResult { + let Some((manifest, changed)) = Self::fetch_manifest(services).await? else { + tracing::debug!("skipping versions sync; no remote or cached manifest available"); + return Ok(false); + }; + *self.manifest.write().await = manifest; + Ok(changed) + } + + pub async fn metadata(&self, meta_url_base: &str) -> Vec { + self.manifest.read().await.metadata(meta_url_base) + } + + pub async fn migrations(&self) -> Vec { + self.manifest.read().await.migrations.clone() + } + + #[tracing::instrument(level = "debug", skip(services))] + async fn fetch_manifest( + services: &LauncherServices, + ) -> LauncherResult> { + let manifest_path = paths::caches_dir()?.join("versions.json"); + let url = format!( + "{}/oneclient/versions/metadata.json", + services.requester.config().meta_url_base + ); + + let Some(fetched) = fetch_cached( + &services.requester, + &url, + &manifest_path, + EtagPolicy::CommitNow, + ) + .await? + else { + return Ok(None); + }; + + Ok(Some((fetched.json()?, fetched.changed))) + } +} + +impl Default for VersionsManager { + fn default() -> Self { + Self::new() + } +} diff --git a/packages/oneclient_core/src/versions/manifest.rs b/packages/oneclient_core/src/versions/manifest.rs new file mode 100644 index 00000000..33ed2006 --- /dev/null +++ b/packages/oneclient_core/src/versions/manifest.rs @@ -0,0 +1,347 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +use oneclient_common::domain::GameLoader; +use oneclient_common::version::{VersionKey, format_mc_version}; + +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +pub struct VersionsManifest { + #[serde(default)] + pub clusters: Vec, + #[serde(default)] + pub migrations: Vec, +} + +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +pub struct RemoteCluster { + pub major_version: u32, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub art: Option, + #[serde(default)] + pub long_description: Option, + #[serde(default)] + pub tags: Vec, + /// Default for every entry in this cluster an entry's own key wins + #[serde(default)] + pub predownload: Option, + #[serde(default)] + pub entries: Vec, +} + +#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] +pub struct RemoteEntry { + pub minor_version: u32, + #[serde(default)] + pub patch_version: Option, + #[serde(default)] + pub loader: Option, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub art: Option, + #[serde(default)] + pub long_description: Option, + #[serde(default)] + pub tags: Option>, + #[serde(default)] + pub predownload: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RemoteMigration { + pub id: String, + pub from: MigrationSource, + pub to: MigrationTarget, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MigrationSource { + pub mc_version: String, + pub loader: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct MigrationTarget { + pub mc_version: String, +} + +/// Returns the input unchanged when no rule matches +/// Multi-hop chains are +/// followed the loop is bounded by rule count so cyclic rules can't spin forever +#[must_use] +pub fn resolve_migration_chain( + mc_version: &str, + loader: GameLoader, + rules: &[RemoteMigration], +) -> String { + let mut current = mc_version.to_string(); + + for _ in 0..=rules.len() { + let Some(rule) = rules.iter().find(|rule| { + rule.from.mc_version == current + && GameLoader::from_str(&rule.from.loader).is_ok_and(|l| l == loader) + }) else { + break; + }; + + if rule.to.mc_version == current { + break; + } + + current = rule.to.mc_version.clone(); + } + + current +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct VersionMetadata { + pub major_version: u32, + pub minor_version: Option, + pub patch_version: Option, + pub loader: Option, + pub name: String, + pub art_url: Option, + pub long_description: Option, + pub tags: Vec, + /// Fetched up front during onboarding rather than on first launch + pub predownload: bool, +} + +impl VersionMetadata { + #[must_use] + pub fn key(&self) -> Option { + Some((self.minor_version?, self.patch_version)) + } + + /// `None` for the synthetic cluster-level row which has no minor version + #[must_use] + pub fn mc_version(&self) -> Option { + Some(format_mc_version( + self.major_version, + self.minor_version?, + self.patch_version, + )) + } +} + +fn art_url(path: &Option, meta_url_base: &str) -> Option { + path.as_ref().map(|p| format!("{meta_url_base}{p}")) +} + +impl VersionsManifest { + pub fn metadata(&self, meta_url_base: &str) -> Vec { + let mut out = Vec::new(); + + for cluster in &self.clusters { + let cluster_name = cluster + .name + .clone() + .unwrap_or_else(|| format!("1.{}", cluster.major_version)); + + out.push(VersionMetadata { + major_version: cluster.major_version, + minor_version: None, + patch_version: None, + loader: None, + name: cluster_name.clone(), + art_url: art_url(&cluster.art, meta_url_base), + long_description: cluster.long_description.clone(), + tags: cluster.tags.clone(), + predownload: cluster.predownload.unwrap_or(false), + }); + + for entry in &cluster.entries { + out.push(VersionMetadata { + major_version: cluster.major_version, + minor_version: Some(entry.minor_version), + patch_version: entry.patch_version, + loader: entry.loader.clone(), + name: entry.name.clone().unwrap_or_else(|| cluster_name.clone()), + art_url: art_url(&entry.art, meta_url_base) + .or_else(|| art_url(&cluster.art, meta_url_base)), + long_description: entry + .long_description + .clone() + .or_else(|| cluster.long_description.clone()), + tags: entry.tags.clone().unwrap_or_else(|| cluster.tags.clone()), + predownload: entry.predownload.or(cluster.predownload).unwrap_or(false), + }); + } + } + + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_without_migrations_key_parses() { + let manifest: VersionsManifest = + serde_json::from_str(r#"{"clusters": []}"#).expect("should parse"); + assert!(manifest.migrations.is_empty()); + } + + #[test] + fn entry_without_patch_key_parses() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[{"major_version":26,"entries":[{"minor_version":1}]}]}"#, + ) + .expect("should parse"); + assert_eq!(manifest.clusters[0].entries[0].patch_version, None); + } + + #[test] + fn patch_version_reaches_metadata() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[{"major_version":26,"entries":[ + {"minor_version":1,"patch_version":2,"loader":"fabric"} + ]}]}"#, + ) + .expect("should parse"); + + let metadata = manifest.metadata("https://example.test"); + let entry = metadata + .iter() + .find(|m| m.minor_version == Some(1)) + .expect("entry present"); + assert_eq!(entry.patch_version, Some(2)); + assert_eq!(entry.key(), Some((1, Some(2)))); + } + + #[test] + fn predownload_inherits_from_cluster_and_entry_overrides() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[ + {"major_version":26,"predownload":true,"entries":[ + {"minor_version":1}, + {"minor_version":2,"predownload":false} + ]}, + {"major_version":21,"entries":[ + {"minor_version":1}, + {"minor_version":11,"predownload":true} + ]} + ]}"#, + ) + .expect("should parse"); + + let metadata = manifest.metadata("https://example.test"); + let flag = |major: u32, minor: Option| { + metadata + .iter() + .find(|m| m.major_version == major && m.minor_version == minor) + .expect("row present") + .predownload + }; + + assert!(flag(26, None)); + assert!(flag(26, Some(1))); + assert!(!flag(26, Some(2))); + assert!(flag(21, Some(11))); + assert!(!flag(21, None)); + assert!(!flag(21, Some(1))); + } + + #[test] + fn mc_version_skips_the_cluster_row() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[{"major_version":26,"entries":[ + {"minor_version":1,"patch_version":2} + ]}]}"#, + ) + .expect("should parse"); + + let metadata = manifest.metadata("https://example.test"); + assert_eq!(metadata[0].mc_version(), None); + assert_eq!(metadata[1].mc_version().as_deref(), Some("26.1.2")); + } + + fn rule(id: &str, from: &str, loader: &str, to: &str) -> RemoteMigration { + RemoteMigration { + id: id.to_string(), + from: MigrationSource { + mc_version: from.to_string(), + loader: loader.to_string(), + }, + to: MigrationTarget { + mc_version: to.to_string(), + }, + } + } + + #[test] + fn chain_resolves_single_hop() { + let rules = [rule("a", "26.1", "fabric", "26.1.2")]; + assert_eq!( + resolve_migration_chain("26.1", GameLoader::Fabric, &rules), + "26.1.2" + ); + } + + #[test] + fn chain_follows_multiple_hops() { + let rules = [ + rule("a", "26.1", "fabric", "26.1.2"), + rule("b", "26.1.2", "fabric", "26.1.3"), + ]; + assert_eq!( + resolve_migration_chain("26.1", GameLoader::Fabric, &rules), + "26.1.3" + ); + } + + #[test] + fn chain_requires_matching_loader() { + let rules = [rule("a", "26.1", "forge", "26.1.2")]; + assert_eq!( + resolve_migration_chain("26.1", GameLoader::Fabric, &rules), + "26.1" + ); + } + + #[test] + fn chain_returns_input_when_no_rule_matches() { + let rules = [rule("a", "21.1", "fabric", "21.1.2")]; + assert_eq!( + resolve_migration_chain("26.1", GameLoader::Fabric, &rules), + "26.1" + ); + } + + #[test] + fn chain_terminates_on_self_and_cyclic_rules() { + let self_rule = [rule("a", "26.1", "fabric", "26.1")]; + assert_eq!( + resolve_migration_chain("26.1", GameLoader::Fabric, &self_rule), + "26.1" + ); + + let cycle = [ + rule("a", "26.1", "fabric", "26.2"), + rule("b", "26.2", "fabric", "26.1"), + ]; + let out = resolve_migration_chain("26.1", GameLoader::Fabric, &cycle); + assert!(out == "26.1" || out == "26.2"); + } + + #[test] + fn migrations_parse() { + let manifest: VersionsManifest = serde_json::from_str( + r#"{"clusters":[],"migrations":[ + {"id":"x","from":{"mc_version":"26.1","loader":"fabric"}, + "to":{"mc_version":"26.1.2"}} + ]}"#, + ) + .expect("should parse"); + + assert_eq!(manifest.migrations[0].id, "x"); + assert_eq!(manifest.migrations[0].from.mc_version, "26.1"); + assert_eq!(manifest.migrations[0].to.mc_version, "26.1.2"); + } +} diff --git a/packages/oneclient_core/src/versions/mod.rs b/packages/oneclient_core/src/versions/mod.rs new file mode 100644 index 00000000..e9ace6ba --- /dev/null +++ b/packages/oneclient_core/src/versions/mod.rs @@ -0,0 +1,8 @@ +mod manager; +mod manifest; + +pub use manager::VersionsManager; +pub use manifest::{ + MigrationSource, MigrationTarget, RemoteMigration, VersionMetadata, VersionsManifest, + resolve_migration_chain, +}; diff --git a/packages/oneclient_core/tests/bundle_updates.rs b/packages/oneclient_core/tests/bundle_updates.rs new file mode 100644 index 00000000..9bd51cc6 --- /dev/null +++ b/packages/oneclient_core/tests/bundle_updates.rs @@ -0,0 +1,396 @@ +use oneclient_common::domain::{ContentType, GameLoader, ProviderId}; +use oneclient_content::bundles::{ + BundleFile, BundleFileKind, BundleManifest, check_bundle_updates, +}; +use oneclient_core::LauncherState; +use oneclient_core::clusters::CreateClusterOptions; +use oneclient_db::dao::{artifact as artifact_dao, cluster_bundle as bundle_dao}; +use oneclient_db::models::OverrideType; + +const BUNDLE: &str = "Test Bundle"; +const MC_VERSION: &str = "1.21.1"; +const PROJECT_ID: &str = "sodium"; +const VERSION_ID: &str = "v1"; +const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +fn managed_file(enabled: bool) -> BundleFile { + BundleFile { + enabled, + hidden: false, + path: "mods/sodium.jar".to_string(), + size: 1, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: PROJECT_ID.to_string(), + version_id: VERSION_ID.to_string(), + sha1: HASH.to_string(), + }, + } +} + +/// Added by the catalog after the last sync untracked so it has no override +fn newly_shipped_file() -> BundleFile { + BundleFile { + enabled: true, + hidden: false, + path: "mods/newcomer.jar".to_string(), + size: 1, + kind: BundleFileKind::Managed { + provider: ProviderId::Modrinth, + project_id: "newcomer".to_string(), + version_id: "v1".to_string(), + sha1: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + }, + } +} + +fn manifest(files: Vec) -> BundleManifest { + BundleManifest { + name: BUNDLE.to_string(), + version_id: "1".to_string(), + category: "test".to_string(), + mc_version: MC_VERSION.to_string(), + loader: GameLoader::Fabric, + loader_version: "0.16.0".to_string(), + enabled: true, + files, + } +} + +async fn cluster_with_tracked_mod(state: &LauncherState) -> i64 { + let global = state.settings.read().global_game_settings.clone(); + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Bundle Cluster", MC_VERSION, GameLoader::Fabric), + ) + .await + .unwrap(); + + artifact_dao::insert_artifact( + &state.services.db, + HASH, + ContentType::Mod as i64, + "artifacts/sodium.jar", + "sodium.jar", + Some(1), + ) + .await + .unwrap(); + + artifact_dao::link_cluster_artifact(&state.services.db, cluster.id, HASH, "sodium.jar") + .await + .unwrap(); + + artifact_dao::upsert_provider_release( + &state.services.db, + ProviderId::Modrinth as i64, + PROJECT_ID, + VERSION_ID, + HASH, + "Sodium", + "1.0.0", + None, + MC_VERSION, + "fabric", + ) + .await + .unwrap(); + + bundle_dao::track_bundle_artifact( + &state.services.db, + cluster.id, + HASH, + BUNDLE, + VERSION_ID, + PROJECT_ID, + ) + .await + .unwrap(); + + cluster.id +} + +#[tokio::test] +async fn mod_still_in_manifest_is_not_removed() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive(&state, manifest(vec![managed_file(true)])) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert!( + check.removals_available.is_empty(), + "an unchanged tracked mod was flagged for removal: {:?}", + check.removals_available + ); + assert!(check.updates_available.is_empty()); +} + +#[tokio::test] +async fn mod_dropped_from_manifest_is_removed() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive(&state, manifest(vec![])) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert_eq!( + check.removals_available.len(), + 1, + "a mod the bundle no longer ships should be removed" + ); + assert_eq!(check.removals_available[0].package_id, PROJECT_ID); +} + +#[tokio::test] +async fn disabled_mod_dropped_from_manifest_is_still_removed() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive(&state, manifest(vec![])) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert_eq!( + check.removals_available.len(), + 1, + "a disabled mod the bundle no longer ships should still be removed" + ); +} + +#[tokio::test] +async fn user_disabled_mod_is_not_treated_as_a_removal() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive(&state, manifest(vec![managed_file(true)])) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert!( + check.removals_available.is_empty(), + "a user-disabled mod is still shipped by the bundle and must not be removed: {:?}", + check.removals_available + ); +} + +#[tokio::test] +async fn live_bundle_takes_on_new_catalog_files() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![managed_file(true), newly_shipped_file()]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert_eq!( + check.additions_available.len(), + 1, + "a bundle the user still has enabled should pick up newly shipped files" + ); + assert_eq!( + check.additions_available[0].new_file.kind.package_id(), + "newcomer" + ); +} + +#[tokio::test] +async fn emptied_bundle_does_not_take_on_new_catalog_files() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![managed_file(true), newly_shipped_file()]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + // Disabling everything is how you opt out + // The newcomer lacks an override only + // because it did not exist yet that absence must not read as consent + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert!( + check.additions_available.is_empty(), + "a bundle whose content the user disabled must not reinstate itself: {:?}", + check + .additions_available + .iter() + .map(|a| a.new_file.kind.package_id()) + .collect::>() + ); +} + +#[tokio::test] +async fn removed_bundle_content_does_not_take_on_new_catalog_files() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![managed_file(true), newly_shipped_file()]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + oneclient_content::bundles::remove_artifact_from_cluster( + cluster_id, + HASH, + true, + &state.services.content(), + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert!( + check.additions_available.is_empty(), + "a bundle the user emptied by removal must not reinstate itself: {:?}", + check + .additions_available + .iter() + .map(|a| a.new_file.kind.package_id()) + .collect::>() + ); +} + +#[tokio::test] +async fn opting_a_single_file_in_keeps_the_bundle_live() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + oneclient_core::dev::seed_bundle_archive( + &state, + manifest(vec![managed_file(true), newly_shipped_file()]), + ) + .await + .unwrap(); + let cluster_id = cluster_with_tracked_mod(&state).await; + + artifact_dao::update_cluster_artifact(&state.services.db, cluster_id, HASH, "sodium.jar", 0) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + PROJECT_ID, + OverrideType::Disabled, + ) + .await + .unwrap(); + bundle_dao::save_override( + &state.services.db, + cluster_id, + BUNDLE, + "newcomer", + OverrideType::Enabled, + ) + .await + .unwrap(); + + let check = check_bundle_updates( + cluster_id, + state.bundles.as_ref(), + &state.services.content(), + ) + .await + .unwrap(); + + assert_eq!( + check.additions_available.len(), + 1, + "an explicit opt-in is consent, even with the rest of the bundle disabled" + ); +} diff --git a/packages/oneclient_core/tests/clusters.rs b/packages/oneclient_core/tests/clusters.rs new file mode 100644 index 00000000..bb5bd7ff --- /dev/null +++ b/packages/oneclient_core/tests/clusters.rs @@ -0,0 +1,147 @@ +use std::time::Duration; + +use oneclient_cluster::profiles::create_settings_profile; +use oneclient_common::domain::GameLoader; +use oneclient_common::patch::Patch; +use oneclient_core::ProfileUpdate; +use oneclient_core::clusters::{ClusterStage, CreateClusterOptions}; + +#[tokio::test] +async fn cluster_lifecycle_with_settings_profile() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + let settings = state.settings.read().clone(); + + let global = state.settings.read().global_game_settings.clone(); + + // Deliberately not the global default of 4096 so the assertions distinguish + // "the cluster's own profile was used" from "fell back to global and matched" + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Test Cluster", "1.21.1", GameLoader::Fabric).mem_max(2048), + ) + .await + .unwrap(); + + assert_eq!(cluster.name, "Test Cluster"); + assert_eq!(cluster.mc_version, "1.21.1"); + assert_eq!(cluster.stage, ClusterStage::NotReady); + assert!(cluster.setting_profile_name.is_some()); + assert!(cluster.created_at.is_some()); + + let resolved = state + .clusters + .resolve_settings(&global, &cluster) + .await + .unwrap(); + assert_eq!(resolved.mem_max, Some(2048)); + + state + .clusters + .update_profile( + cluster.id, + ProfileUpdate { + mem_max: Patch::Set(6144), + ..Default::default() + }, + ) + .await + .unwrap(); + + let updated = state + .clusters + .resolve_settings(&global, &cluster) + .await + .unwrap(); + assert_eq!(updated.mem_max, Some(6144)); + + let shared = create_settings_profile( + &state.services.db, + &settings.global_game_settings, + "Shared Profile", + ) + .await + .unwrap(); + + state + .clusters + .update( + cluster.id, + oneclient_core::ClusterUpdate::default().setting_profile(shared.name.clone()), + ) + .await + .unwrap(); + + let reassigned = state.clusters.get(cluster.id).await.unwrap(); + assert_eq!( + reassigned.setting_profile_name.as_deref(), + Some("Shared Profile") + ); + + let after_play = state + .clusters + .add_playtime(cluster.id, Duration::from_secs(120)) + .await + .unwrap(); + assert_eq!(after_play.overall_played, Duration::from_secs(120)); + assert!(after_play.last_played.is_some()); + + state.clusters.delete(cluster.id, true).await.unwrap(); + assert!(state.clusters.get(cluster.id).await.is_err()); +} + +#[tokio::test] +async fn cleared_profile_fields_inherit_from_global() { + let state = oneclient_core::dev::ephemeral_state().await.unwrap(); + + { + let mut settings = state.settings.write(); + settings.global_game_settings.mem_max = Some(8192); + } + + // Read the baseline *after* the write above so the new cluster inherits it + let global = state.settings.read().global_game_settings.clone(); + let cluster = state + .clusters + .create( + &global, + CreateClusterOptions::new("Inherit Test", "1.21.1", GameLoader::Vanilla).mem_max(2048), + ) + .await + .unwrap(); + + let profile_name = cluster.setting_profile_name.clone().unwrap(); + + state + .clusters + .update_profile( + cluster.id, + ProfileUpdate { + mem_max: Patch::Clear, + force_fullscreen: Patch::Clear, + ..Default::default() + }, + ) + .await + .unwrap(); + + let global = state.settings.read().global_game_settings.clone(); + let stored = oneclient_cluster::profiles::get_profile_or_default( + &state.services.db, + &global, + Some(&profile_name), + ) + .await + .unwrap(); + assert_eq!(stored.mem_max, None); + assert_eq!(stored.force_fullscreen, None); + + let resolved = state + .clusters + .resolve_settings(&global, &cluster) + .await + .unwrap(); + assert_eq!(resolved.mem_max, Some(8192)); + assert_eq!(resolved.force_fullscreen, Some(false)); +} diff --git a/packages/oneclient_core/tests/download_verify.rs b/packages/oneclient_core/tests/download_verify.rs new file mode 100644 index 00000000..6d762017 --- /dev/null +++ b/packages/oneclient_core/tests/download_verify.rs @@ -0,0 +1,364 @@ +use oneclient_core::dev; +use oneclient_core::game::download_to_path; +use oneclient_events::{GroupedProgressSession, TaskCategory}; +use polyio::testing::ScratchDir as Scratch; +use polyio::{Sha1Stream, sha1_bytes, sha1_file}; + +/// Minecraft asset objects are content-addressed and immutable so this +/// URL/hash pair stays valid +const ASSET_SHA1: &str = "af96f55a90eaf11b327f1b5f8834a051027dc506"; +const ASSET_URL: &str = + "https://resources.download.minecraft.net/af/af96f55a90eaf11b327f1b5f8834a051027dc506"; +const ASSET_SIZE: u64 = 2063; + +#[test] +fn sha1_stream_matches_one_shot_hash() { + let data: Vec = (0..70_000u32).map(|i| (i % 251) as u8).collect(); + + let mut hasher = Sha1Stream::new(); + for chunk in data.chunks(4096) { + hasher.update(chunk); + } + + assert_eq!(hasher.finish(), sha1_bytes(&data)); +} + +#[test] +fn sha1_stream_of_empty_input_matches() { + assert_eq!(Sha1Stream::new().finish(), sha1_bytes(&[])); +} + +#[tokio::test] +async fn sha1_file_matches_sha1_bytes() { + let dir = Scratch::new("sha1-file"); + let path = dir.join("payload.bin"); + + // Larger than the hashing buffer so the multi-read path is covered too + let data: Vec = (0..600_000u32).map(|i| (i % 253) as u8).collect(); + std::fs::write(&path, &data).unwrap(); + + assert_eq!(sha1_file(&path).await.unwrap(), sha1_bytes(&data)); +} + +#[tokio::test] +async fn sha1_file_handles_empty_file() { + let dir = Scratch::new("sha1-empty"); + let path = dir.join("empty.bin"); + std::fs::write(&path, []).unwrap(); + + assert_eq!(sha1_file(&path).await.unwrap(), sha1_bytes(&[])); +} + +/// Serves `body` with a 200 declaring `content_length` however long `body` is +/// pass a larger one to imitate a mirror holding the wrong file +async fn serve(body: Vec, content_length: usize) -> String { + use tokio::io::AsyncWriteExt; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let port = listener.local_addr().expect("addr").port(); + + tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + let body = body.clone(); + + tokio::spawn(async move { + let mut request = [0u8; 1024]; + let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut request).await; + + let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {content_length}\r\n\r\n"); + let _ = stream.write_all(head.as_bytes()).await; + let _ = stream.write_all(&body).await; + let _ = stream.flush().await; + }); + } + }); + + format!("http://127.0.0.1:{port}/artifact.jar") +} + +/// Answers the first `failures` requests with `status` then serves `ok_body` +/// Returns the URL and a request counter +/// `usize::MAX` never recovers +async fn serve_flaky( + status: u16, + reason: &'static str, + failures: usize, + error_body: Vec, + ok_body: Vec, +) -> (String, std::sync::Arc) { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use tokio::io::AsyncWriteExt; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let port = listener.local_addr().expect("addr").port(); + let served = Arc::new(AtomicUsize::new(0)); + + tokio::spawn({ + let served = Arc::clone(&served); + async move { + while let Ok((mut stream, _)) = listener.accept().await { + let (error_body, ok_body) = (error_body.clone(), ok_body.clone()); + let served = Arc::clone(&served); + + tokio::spawn(async move { + // Draining the request first or the client sees a reset + let mut request = [0u8; 1024]; + let _ = tokio::io::AsyncReadExt::read(&mut stream, &mut request).await; + + let seen = served.fetch_add(1, Ordering::SeqCst); + let (status, reason, body) = if seen < failures { + (status, reason, error_body) + } else { + (200, "OK", ok_body) + }; + + let head = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + let _ = stream.write_all(head.as_bytes()).await; + let _ = stream.write_all(&body).await; + let _ = stream.flush().await; + }); + } + } + }); + + (format!("http://127.0.0.1:{port}/artifact.jar"), served) +} + +#[tokio::test] +async fn download_to_path_never_writes_an_error_response_to_disk() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-404"); + let dest = dir.join("artifact.jar"); + + // What a CDN answers for a missing object well-formed but XML not a jar + let (url, served) = serve_flaky( + 404, + "Not Found", + usize::MAX, + b"NoSuchKey".to_vec(), + Vec::new(), + ) + .await; + + let err = download_to_path( + &services.requester, + &services.events, + &progress, + "artifact", + TaskCategory::Libraries, + 0, + &url, + &dest, + None, + ) + .await + .expect_err("a 404 must not be saved as the file"); + + assert!(err.to_string().contains("404"), "{err}"); + assert!(err.to_string().contains("NoSuchKey"), "{err}"); + assert!(!dest.exists(), "an error body must never reach disk"); + // A 404 answers the same way every time retrying it only delays the error + assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn download_to_path_retries_a_server_error_and_recovers() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-500"); + let dest = dir.join("artifact.jar"); + + // Unlike a 404 a 500 can succeed on the next attempt + let (url, served) = serve_flaky( + 500, + "Internal Server Error", + 1, + b"boom".to_vec(), + vec![7u8; 128], + ) + .await; + + download_to_path( + &services.requester, + &services.events, + &progress, + "artifact", + TaskCategory::Libraries, + 128, + &url, + &dest, + None, + ) + .await + .expect("a retried server error should recover"); + + assert_eq!(std::fs::metadata(&dest).unwrap().len(), 128); + assert_eq!(served.load(std::sync::atomic::Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn download_to_path_rejects_a_body_shorter_than_the_manifest_size() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-short"); + let dest = dir.join("artifact.jar"); + + let url = serve(vec![7u8; 64], 64).await; + + let err = download_to_path( + &services.requester, + &services.events, + &progress, + "artifact", + TaskCategory::Libraries, + 4096, + &url, + &dest, + None, + ) + .await + .expect_err("a short body should fail without a hash to check"); + + assert!(err.to_string().contains("ended after 64 of 4096"), "{err}"); + assert!(!dest.exists(), "truncated download should be removed"); +} + +#[tokio::test] +async fn download_to_path_accepts_an_unhashed_body_of_the_promised_length() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-exact"); + let dest = dir.join("artifact.jar"); + + let url = serve(vec![7u8; 4096], 4096).await; + + download_to_path( + &services.requester, + &services.events, + &progress, + "artifact", + TaskCategory::Libraries, + 4096, + &url, + &dest, + None, + ) + .await + .expect("a body of the promised length should be kept"); + + assert_eq!(std::fs::metadata(&dest).unwrap().len(), 4096); +} + +#[tokio::test] +async fn download_to_path_keeps_an_unhashed_body_of_unknown_length() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-unknown"); + let dest = dir.join("artifact.jar"); + + let url = serve(vec![7u8; 128], 128).await; + + // Nothing declared a size so there is nothing to check against + download_to_path( + &services.requester, + &services.events, + &progress, + "artifact", + TaskCategory::Libraries, + 0, + &url, + &dest, + None, + ) + .await + .expect("an unmeasurable download should still be written"); + + assert_eq!(std::fs::metadata(&dest).unwrap().len(), 128); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn download_to_path_accepts_matching_hash() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-ok"); + let dest = dir.join("nested").join("icon.png"); + + download_to_path( + &services.requester, + &services.events, + &progress, + "icon", + TaskCategory::Assets, + ASSET_SIZE, + ASSET_URL, + &dest, + Some(ASSET_SHA1), + ) + .await + .expect("download should verify"); + + assert_eq!(std::fs::metadata(&dest).unwrap().len(), ASSET_SIZE); + assert_eq!(sha1_file(&dest).await.unwrap(), ASSET_SHA1); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn download_to_path_rejects_mismatched_hash_and_removes_file() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-bad"); + let dest = dir.join("icon.png"); + + let err = download_to_path( + &services.requester, + &services.events, + &progress, + "icon", + TaskCategory::Assets, + ASSET_SIZE, + ASSET_URL, + &dest, + Some("0000000000000000000000000000000000000000"), + ) + .await + .expect_err("mismatched hash should fail"); + + assert!(err.to_string().contains("0000000000000000"), "{err}"); + assert!(!dest.exists(), "corrupt download should be removed"); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn download_to_path_without_expected_hash_still_writes() { + let services = dev::ephemeral_services().await.unwrap(); + let progress = GroupedProgressSession::start(&services.events, "test"); + let dir = Scratch::new("download-nohash"); + let dest = dir.join("icon.png"); + + download_to_path( + &services.requester, + &services.events, + &progress, + "icon", + TaskCategory::Assets, + 0, + ASSET_URL, + &dest, + None, + ) + .await + .expect("download should succeed"); + + assert_eq!(sha1_file(&dest).await.unwrap(), ASSET_SHA1); +} diff --git a/packages/oneclient_core/tests/patch.rs b/packages/oneclient_core/tests/patch.rs new file mode 100644 index 00000000..e5ba07a6 --- /dev/null +++ b/packages/oneclient_core/tests/patch.rs @@ -0,0 +1,33 @@ +use oneclient_common::patch::Patch; + +#[test] +fn patch_apply_to_option() { + let mut slot = Some(42u32); + + Patch::Unchanged.apply_to_option(&mut slot); + assert_eq!(slot, Some(42)); + + Patch::Set(99).apply_to_option(&mut slot); + assert_eq!(slot, Some(99)); + + Patch::::Clear.apply_to_option(&mut slot); + assert_eq!(slot, None); +} + +#[test] +fn patch_command_empty_string_clears() { + let mut slot: Option = Some("wrapper".into()); + + Patch::Set(" ".to_owned()).apply_to_command_option(&mut slot); + assert_eq!(slot, Some("".to_owned())); +} + +#[test] +fn patch_into_db_patch() { + assert_eq!(Patch::::Unchanged.into_db_patch(), None); + assert_eq!(Patch::::Clear.into_db_patch(), Some(None)); + assert_eq!( + Patch::Set("x".to_string()).into_db_patch(), + Some(Some("x".to_string())) + ); +} diff --git a/packages/oneclient_core/tests/providers.rs b/packages/oneclient_core/tests/providers.rs new file mode 100644 index 00000000..b5d98484 --- /dev/null +++ b/packages/oneclient_core/tests/providers.rs @@ -0,0 +1,163 @@ +use oneclient_common::domain::{ContentType, ProviderId}; +use oneclient_content::packages::types::{PackageBody, SearchFilters}; +use oneclient_core::dev; + +#[test] +fn registry_get_unknown_provider_errors() { + let registry = oneclient_content::packages::PackageProviderRegistry::new(); + + let err = match registry.get(ProviderId::Local) { + Err(e) => e, + Ok(_) => panic!("expected ProviderId::Local to be unregistered"), + }; + + assert!(err.to_string().contains("not registered")); +} + +#[tokio::test] +async fn registry_providers_match_ids() { + let registry = oneclient_content::packages::PackageProviderRegistry::new(); + + for id in registry.remote_ids() { + assert_eq!(registry.get(id).unwrap().id(), id); + } +} + +#[tokio::test] +#[ignore = "requires network"] +async fn modrinth_search_returns_hits() { + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::Modrinth).unwrap(); + + let page = provider + .search( + &SearchFilters { + query: Some("sodium".into()), + limit: Some(5), + content_type: Some(ContentType::Mod), + ..Default::default() + }, + &env, + ) + .await + .unwrap(); + + assert!(!page.items.is_empty()); + assert!( + page.items + .iter() + .any(|p| p.name.to_lowercase().contains("sodium")) + ); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn modrinth_get_project_and_versions() { + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::Modrinth).unwrap(); + + let project = provider.get_project("AANobbMI", &env).await.unwrap(); + assert_eq!(project.slug, "sodium"); + assert_eq!(project.provider, ProviderId::Modrinth); + + let versions = provider + .list_versions(&project.id, Some("1.20.1"), None, 0, 5, &env) + .await + .unwrap(); + assert!(!versions.items.is_empty()); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn curseforge_search_returns_hits() { + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::CurseForge).unwrap(); + + let page = provider + .search( + &SearchFilters { + query: Some("sodium".into()), + limit: Some(5), + content_type: Some(ContentType::Mod), + ..Default::default() + }, + &env, + ) + .await + .unwrap(); + + assert!(!page.items.is_empty()); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn modrinth_lookup_version_by_sha1() { + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::Modrinth).unwrap(); + + let versions = provider + .list_versions("AANobbMI", Some("1.20.1"), None, 0, 1, &env) + .await + .unwrap(); + let version = provider + .get_version("AANobbMI", &versions.items[0].version_id, &env) + .await + .unwrap(); + let sha1 = version + .primary_file() + .expect("version has a primary file") + .sha1 + .clone(); + + let found = env.providers.lookup_version(&sha1, &env).await.unwrap(); + assert!(found.is_some()); + + let (provider_id, _) = found.unwrap(); + assert_eq!(provider_id, ProviderId::Modrinth); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn curseforge_get_project_fetches_markdown_body() { + let env = dev::ephemeral_services().await.unwrap().content(); + let provider = env.providers.get(ProviderId::CurseForge).unwrap(); + + // 238222 = Just Enough Items (JEI) + let project = provider + .get_project_with_body("238222", &env) + .await + .unwrap(); + assert_eq!(project.provider, ProviderId::CurseForge); + + let body = match &project.body { + PackageBody::Raw(body) => body, + PackageBody::Url(url) => panic!("expected a fetched body, got a link to {url}"), + }; + + assert!(!body.is_empty(), "body should not be empty"); + assert!( + !body.contains("

") && !body.contains(" assert!(!body.is_empty(), "body should not be empty"), + PackageBody::Url(url) => panic!("expected a fetched body, got a link to {url}"), + } +} diff --git a/packages/oneclient_db/.env b/packages/oneclient_db/.env new file mode 100644 index 00000000..721eb5ca --- /dev/null +++ b/packages/oneclient_db/.env @@ -0,0 +1,2 @@ +# Uncomment the line below to develop with sqlx type checking +# DATABASE_URL=sqlite:///tmp/OneClient/user_data.db \ No newline at end of file diff --git a/packages/oneclient_db/.env.template b/packages/oneclient_db/.env.template new file mode 100644 index 00000000..03f1330c --- /dev/null +++ b/packages/oneclient_db/.env.template @@ -0,0 +1,2 @@ +# Uncomment the line below to develop with sqlx type checking +#DATABASE_URL=sqlite:///tmp/OneClient/user_data.db \ No newline at end of file diff --git a/packages/oneclient_db/.gitignore b/packages/oneclient_db/.gitignore new file mode 100644 index 00000000..c41cc9e3 --- /dev/null +++ b/packages/oneclient_db/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/packages/oneclient_db/.sqlx/query-010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc.json b/packages/oneclient_db/.sqlx/query-010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc.json new file mode 100644 index 00000000..33514fa6 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc.json @@ -0,0 +1,86 @@ +{ + "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", + "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": 6 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "010f5dcda59ec32eaad1b9c3e2e9807f547a1376372c8b201653a36d6dd14cbc" +} diff --git a/packages/oneclient_db/.sqlx/query-07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab.json b/packages/oneclient_db/.sqlx/query-07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab.json new file mode 100644 index 00000000..8a0fed68 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT id FROM applied_migrations WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "applied_migrations", + "name": "id" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "07450861d48b3bff106b7c06e958665f6be44e7937deffb921ddb6a5450805ab" +} diff --git a/packages/oneclient_db/.sqlx/query-0c13ec99c2157d0f8fe60a159807e23cd24ddea07917d5f77b7c0d01cc617ccb.json b/packages/oneclient_db/.sqlx/query-0c13ec99c2157d0f8fe60a159807e23cd24ddea07917d5f77b7c0d01cc617ccb.json new file mode 100644 index 00000000..c2543978 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-0c13ec99c2157d0f8fe60a159807e23cd24ddea07917d5f77b7c0d01cc617ccb.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT started_at, ended_at\n\t\tFROM game_sessions\n\t\tWHERE ended_at IS NOT NULL\n\t\tORDER BY started_at ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "started_at", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "started_at" + } + } + }, + { + "name": "ended_at", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "ended_at" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + true + ] + }, + "hash": "0c13ec99c2157d0f8fe60a159807e23cd24ddea07917d5f77b7c0d01cc617ccb" +} diff --git a/packages/oneclient_db/.sqlx/query-0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba.json b/packages/oneclient_db/.sqlx/query-0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba.json new file mode 100644 index 00000000..5da3a9d7 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n FROM java_versions\n ORDER BY major DESC, version DESC\n ", + "describe": { + "columns": [ + { + "name": "absolute_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "absolute_path" + } + } + }, + { + "name": "major", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "major" + } + } + }, + { + "name": "version", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "version" + } + } + }, + { + "name": "vendor", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "vendor" + } + } + }, + { + "name": "os_arch", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "os_arch" + } + } + }, + { + "name": "is_jdk: bool", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "is_jdk" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "0d9fb5d2fa8a788770e946241ea2f62f4b794c0fdda3ccbde49384689911aeba" +} diff --git a/packages/oneclient_db/.sqlx/query-0f4cedf6055f4ba6a5e6ef78304c5f9df31fc6de0f6aa3dc09c4341fcb19c871.json b/packages/oneclient_db/.sqlx/query-0f4cedf6055f4ba6a5e6ef78304c5f9df31fc6de0f6aa3dc09c4341fcb19c871.json new file mode 100644 index 00000000..5adaaa3a --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-0f4cedf6055f4ba6a5e6ef78304c5f9df31fc6de0f6aa3dc09c4341fcb19c871.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO clusters (\n\t\t\tname, folder_name, mc_version, mc_loader, mc_loader_version,\n\t\t\tsetting_profile_name, stage, created_at\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?)\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 8 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "0f4cedf6055f4ba6a5e6ef78304c5f9df31fc6de0f6aa3dc09c4341fcb19c871" +} diff --git a/packages/oneclient_db/.sqlx/query-0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813.json b/packages/oneclient_db/.sqlx/query-0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813.json new file mode 100644 index 00000000..8adcc90a --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813.json @@ -0,0 +1,110 @@ +{ + "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 ", + "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" + } + } + }, + { + "name": "bundle_name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "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, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "package_id" + } + } + }, + { + "name": "installed_at", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "installed_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "0fa6a144689ecd95fc076d340645a3417d56b455c6bb2a9ef3e027a3e1915813" +} diff --git a/packages/oneclient_db/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json b/packages/oneclient_db/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json new file mode 100644 index 00000000..e2104f28 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE clusters\n\t\tSET mc_version = ?,\n\t\t name = ?,\n\t\t folder_name = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "1a83654d854901219ca4be57d793ddef96835a67aa8affe3453845a646e5ba61" +} diff --git a/packages/oneclient_db/.sqlx/query-1f0b5e466773838f6b800f9d5afe5b84ad8ece01b56b9e22d595271db2c5c202.json b/packages/oneclient_db/.sqlx/query-1f0b5e466773838f6b800f9d5afe5b84ad8ece01b56b9e22d595271db2c5c202.json new file mode 100644 index 00000000..7e904084 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-1f0b5e466773838f6b800f9d5afe5b84ad8ece01b56b9e22d595271db2c5c202.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM setting_profiles WHERE name = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "1f0b5e466773838f6b800f9d5afe5b84ad8ece01b56b9e22d595271db2c5c202" +} diff --git a/packages/oneclient_db/.sqlx/query-2005ac6f15d4c5fe36d5bfa87191e6be22264a2ca3e91155cb01f2debd39b319.json b/packages/oneclient_db/.sqlx/query-2005ac6f15d4c5fe36d5bfa87191e6be22264a2ca3e91155cb01f2debd39b319.json new file mode 100644 index 00000000..c822cc59 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-2005ac6f15d4c5fe36d5bfa87191e6be22264a2ca3e91155cb01f2debd39b319.json @@ -0,0 +1,146 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tcluster_id, hash, provider as \"provider!: i64\", project_id,\n\t\t\tinstalled_version_id, installed_version_name,\n\t\t\tlatest_version_id, latest_version_name,\n\t\t\tdisplay_name, checked_at, skipped\n\t\tFROM browser_package_updates\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "hash" + } + } + }, + { + "name": "provider!: i64", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "provider" + } + } + }, + { + "name": "project_id", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "project_id" + } + } + }, + { + "name": "installed_version_id", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "installed_version_id" + } + } + }, + { + "name": "installed_version_name", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "installed_version_name" + } + } + }, + { + "name": "latest_version_id", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "latest_version_id" + } + } + }, + { + "name": "latest_version_name", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "latest_version_name" + } + } + }, + { + "name": "display_name", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "display_name" + } + } + }, + { + "name": "checked_at", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "checked_at" + } + } + }, + { + "name": "skipped", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "skipped" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "2005ac6f15d4c5fe36d5bfa87191e6be22264a2ca3e91155cb01f2debd39b319" +} diff --git a/packages/oneclient_db/.sqlx/query-24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51.json b/packages/oneclient_db/.sqlx/query-24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51.json new file mode 100644 index 00000000..f01cd4bc --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO cluster_artifacts (cluster_id, hash, cluster_file_name, enabled)\n\t\tVALUES (?, ?, ?, 1)\n\t\tON CONFLICT(cluster_id, hash) DO UPDATE SET\n\t\t\tcluster_file_name = excluded.cluster_file_name\n\t\tRETURNING cluster_id, hash, cluster_file_name, enabled\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "hash" + } + } + }, + { + "name": "cluster_file_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_file_name" + } + } + }, + { + "name": "enabled", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "enabled" + } + } + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "24209a44c9a51e8a318b080aa0c79a00a6002a35b47ec3723e3aa6a327cb7f51" +} diff --git a/packages/oneclient_db/.sqlx/query-26faf03500344e939addc574799459bb3294907c8fdf0727c0ef503e4b7bdf2c.json b/packages/oneclient_db/.sqlx/query-26faf03500344e939addc574799459bb3294907c8fdf0727c0ef503e4b7bdf2c.json new file mode 100644 index 00000000..7a7d4881 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-26faf03500344e939addc574799459bb3294907c8fdf0727c0ef503e4b7bdf2c.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE cluster_artifacts\n SET\n bundle_name = ?,\n bundle_version_id = ?,\n package_id = ?,\n installed_at = ?\n WHERE cluster_id = ? AND hash = ?\n RETURNING\n cluster_id, hash, cluster_file_name, enabled,\n bundle_name, bundle_version_id, package_id, installed_at\n ", + "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" + } + } + }, + { + "name": "bundle_name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "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, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "package_id" + } + } + }, + { + "name": "installed_at", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "installed_at" + } + } + } + ], + "parameters": { + "Right": 6 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "26faf03500344e939addc574799459bb3294907c8fdf0727c0ef503e4b7bdf2c" +} diff --git a/packages/oneclient_db/.sqlx/query-271e5a3e03861f3699e3b32dc4a9bb0c0dcb880fcff67a096fa94b236fbe6107.json b/packages/oneclient_db/.sqlx/query-271e5a3e03861f3699e3b32dc4a9bb0c0dcb880fcff67a096fa94b236fbe6107.json new file mode 100644 index 00000000..1d517c25 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-271e5a3e03861f3699e3b32dc4a9bb0c0dcb880fcff67a096fa94b236fbe6107.json @@ -0,0 +1,146 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tcluster_id, hash, provider as \"provider!: i64\", project_id,\n\t\t\tinstalled_version_id, installed_version_name,\n\t\t\tlatest_version_id, latest_version_name,\n\t\t\tdisplay_name, checked_at, skipped\n\t\tFROM browser_package_updates\n\t\tWHERE cluster_id = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "hash" + } + } + }, + { + "name": "provider!: i64", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "provider" + } + } + }, + { + "name": "project_id", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "project_id" + } + } + }, + { + "name": "installed_version_id", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "installed_version_id" + } + } + }, + { + "name": "installed_version_name", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "installed_version_name" + } + } + }, + { + "name": "latest_version_id", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "latest_version_id" + } + } + }, + { + "name": "latest_version_name", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "latest_version_name" + } + } + }, + { + "name": "display_name", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "display_name" + } + } + }, + { + "name": "checked_at", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "checked_at" + } + } + }, + { + "name": "skipped", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "skipped" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "271e5a3e03861f3699e3b32dc4a9bb0c0dcb880fcff67a096fa94b236fbe6107" +} diff --git a/packages/oneclient_db/.sqlx/query-2b5b633bb658e5f3c733acc7977b3c7fd7f8d8b5f425e71989e98a2b27338b42.json b/packages/oneclient_db/.sqlx/query-2b5b633bb658e5f3c733acc7977b3c7fd7f8d8b5f425e71989e98a2b27338b42.json new file mode 100644 index 00000000..f914a967 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-2b5b633bb658e5f3c733acc7977b3c7fd7f8d8b5f425e71989e98a2b27338b42.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE clusters\n\t\tSET name = ?,\n\t\t setting_profile_name = ?,\n\t\t mc_loader_version = ?,\n\t\t linked_modpack_hash = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 5 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "2b5b633bb658e5f3c733acc7977b3c7fd7f8d8b5f425e71989e98a2b27338b42" +} diff --git a/packages/oneclient_db/.sqlx/query-33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b.json b/packages/oneclient_db/.sqlx/query-33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b.json new file mode 100644 index 00000000..caaad113 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b.json @@ -0,0 +1,86 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT absolute_path, major, version, vendor, os_arch, is_jdk as \"is_jdk: bool\"\n\t\tFROM java_versions\n\t\tWHERE major = ?\n\t\tORDER BY version DESC\n\t\tLIMIT 1\n\t\t", + "describe": { + "columns": [ + { + "name": "absolute_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "absolute_path" + } + } + }, + { + "name": "major", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "major" + } + } + }, + { + "name": "version", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "version" + } + } + }, + { + "name": "vendor", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "vendor" + } + } + }, + { + "name": "os_arch", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "java_versions", + "name": "os_arch" + } + } + }, + { + "name": "is_jdk: bool", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "java_versions", + "name": "is_jdk" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "33771b7003e64c8f19d15d663de773afa568d626c60a37bbc32cc932746ad49b" +} diff --git a/packages/oneclient_db/.sqlx/query-3ab373a6f0167df9b2fa3a6a2f2c6ee2a9a36d8ae0948b71369897c014acae77.json b/packages/oneclient_db/.sqlx/query-3ab373a6f0167df9b2fa3a6a2f2c6ee2a9a36d8ae0948b71369897c014acae77.json new file mode 100644 index 00000000..8de9a5e2 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-3ab373a6f0167df9b2fa3a6a2f2c6ee2a9a36d8ae0948b71369897c014acae77.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT session_started_at, address, port, joined_at, disconnected_at\n\t\tFROM game_session_servers\n\t\tORDER BY joined_at ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "session_started_at", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "session_started_at" + } + } + }, + { + "name": "address", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "address" + } + } + }, + { + "name": "port", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "port" + } + } + }, + { + "name": "joined_at", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "joined_at" + } + } + }, + { + "name": "disconnected_at", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "disconnected_at" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + true, + false, + true + ] + }, + "hash": "3ab373a6f0167df9b2fa3a6a2f2c6ee2a9a36d8ae0948b71369897c014acae77" +} diff --git a/packages/oneclient_db/.sqlx/query-3fb91d89cb135d2af105a0f40f7150d03ab2fa9b955ba223066edf210e3556a1.json b/packages/oneclient_db/.sqlx/query-3fb91d89cb135d2af105a0f40f7150d03ab2fa9b955ba223066edf210e3556a1.json new file mode 100644 index 00000000..b1ced123 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-3fb91d89cb135d2af105a0f40f7150d03ab2fa9b955ba223066edf210e3556a1.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE clusters\n\t\tSET overall_played = COALESCE(overall_played, 0) + ?,\n\t\t last_played = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "3fb91d89cb135d2af105a0f40f7150d03ab2fa9b955ba223066edf210e3556a1" +} diff --git a/packages/oneclient_db/.sqlx/query-42c26d63ca860840c0e8e75bf1a46218b16c849301fa3db250e24ec81d15d1c7.json b/packages/oneclient_db/.sqlx/query-42c26d63ca860840c0e8e75bf1a46218b16c849301fa3db250e24ec81d15d1c7.json new file mode 100644 index 00000000..b05cc0d4 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-42c26d63ca860840c0e8e75bf1a46218b16c849301fa3db250e24ec81d15d1c7.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT session_started_at, address, port, joined_at, disconnected_at\n\t\tFROM game_session_servers\n\t\tWHERE session_started_at = ?\n\t\tORDER BY joined_at ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "session_started_at", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "session_started_at" + } + } + }, + { + "name": "address", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "address" + } + } + }, + { + "name": "port", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "port" + } + } + }, + { + "name": "joined_at", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "joined_at" + } + } + }, + { + "name": "disconnected_at", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "disconnected_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + false, + true + ] + }, + "hash": "42c26d63ca860840c0e8e75bf1a46218b16c849301fa3db250e24ec81d15d1c7" +} diff --git a/packages/oneclient_db/.sqlx/query-43521070cb5de5b2fe5e25c88e683b933c2f6788e2d2482ca9fd7d71ee67381a.json b/packages/oneclient_db/.sqlx/query-43521070cb5de5b2fe5e25c88e683b933c2f6788e2d2482ca9fd7d71ee67381a.json new file mode 100644 index 00000000..6ddb354e --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-43521070cb5de5b2fe5e25c88e683b933c2f6788e2d2482ca9fd7d71ee67381a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM cluster_bundle_overrides\n WHERE cluster_id = ? AND package_id = ? AND override_type IN ('disabled', 'removed')\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "43521070cb5de5b2fe5e25c88e683b933c2f6788e2d2482ca9fd7d71ee67381a" +} diff --git a/packages/oneclient_db/.sqlx/query-439e74ae2697056b206fc346749b190781cf0aac8ae6b4e1a333608d29946919.json b/packages/oneclient_db/.sqlx/query-439e74ae2697056b206fc346749b190781cf0aac8ae6b4e1a333608d29946919.json new file mode 100644 index 00000000..f1d3ef92 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-439e74ae2697056b206fc346749b190781cf0aac8ae6b4e1a333608d29946919.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT hash, content_type, path, file_name, size_bytes\n\t\tFROM artifacts\n\t\tWHERE hash = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "hash", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "artifacts", + "name": "hash" + } + } + }, + { + "name": "content_type", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "artifacts", + "name": "content_type" + } + } + }, + { + "name": "path", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "artifacts", + "name": "path" + } + } + }, + { + "name": "file_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "artifacts", + "name": "file_name" + } + } + }, + { + "name": "size_bytes", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "artifacts", + "name": "size_bytes" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "439e74ae2697056b206fc346749b190781cf0aac8ae6b4e1a333608d29946919" +} diff --git a/packages/oneclient_db/.sqlx/query-44e40c724779e5e1cffbbbcf857223cac28f59431933ffefea04e59b62deab3a.json b/packages/oneclient_db/.sqlx/query-44e40c724779e5e1cffbbbcf857223cac28f59431933ffefea04e59b62deab3a.json new file mode 100644 index 00000000..f16b3da9 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-44e40c724779e5e1cffbbbcf857223cac28f59431933ffefea04e59b62deab3a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE game_sessions\n\t\tSET ended_at = ?, exit_code = ?\n\t\tWHERE started_at = ? AND ended_at IS NULL\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "44e40c724779e5e1cffbbbcf857223cac28f59431933ffefea04e59b62deab3a" +} diff --git a/packages/oneclient_db/.sqlx/query-4557535ab4f4e972a2b3111cc29af15fd91e22e080742f75a0a393fc45c59b22.json b/packages/oneclient_db/.sqlx/query-4557535ab4f4e972a2b3111cc29af15fd91e22e080742f75a0a393fc45c59b22.json new file mode 100644 index 00000000..c5405caf --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-4557535ab4f4e972a2b3111cc29af15fd91e22e080742f75a0a393fc45c59b22.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\tFROM clusters\n\t\tORDER BY last_played IS NULL, last_played DESC, name ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "4557535ab4f4e972a2b3111cc29af15fd91e22e080742f75a0a393fc45c59b22" +} diff --git a/packages/oneclient_db/.sqlx/query-472a614b1f1484e839f373fcfecf338462888518de3307bdab3db0b419a82be9.json b/packages/oneclient_db/.sqlx/query-472a614b1f1484e839f373fcfecf338462888518de3307bdab3db0b419a82be9.json new file mode 100644 index 00000000..cfc5f2d7 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-472a614b1f1484e839f373fcfecf338462888518de3307bdab3db0b419a82be9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE browser_package_updates\n\t\tSET skipped = ?\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "472a614b1f1484e839f373fcfecf338462888518de3307bdab3db0b419a82be9" +} diff --git a/packages/oneclient_db/.sqlx/query-4b7fa3441d3d18943086acd54232c492521a693f48d07f9230cda440297ac02d.json b/packages/oneclient_db/.sqlx/query-4b7fa3441d3d18943086acd54232c492521a693f48d07f9230cda440297ac02d.json new file mode 100644 index 00000000..7c99e1d0 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-4b7fa3441d3d18943086acd54232c492521a693f48d07f9230cda440297ac02d.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\tFROM clusters\n\t\tWHERE id = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "4b7fa3441d3d18943086acd54232c492521a693f48d07f9230cda440297ac02d" +} diff --git a/packages/oneclient_db/.sqlx/query-4ce8e2a5f335030cf0b851a92bd08f37b12f571608bbafc0a2e1a083ec73239f.json b/packages/oneclient_db/.sqlx/query-4ce8e2a5f335030cf0b851a92bd08f37b12f571608bbafc0a2e1a083ec73239f.json new file mode 100644 index 00000000..aa9723d9 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-4ce8e2a5f335030cf0b851a92bd08f37b12f571608bbafc0a2e1a083ec73239f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM artifacts WHERE hash = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "4ce8e2a5f335030cf0b851a92bd08f37b12f571608bbafc0a2e1a083ec73239f" +} diff --git a/packages/oneclient_db/.sqlx/query-4d14a10baba86d0d63f6b172873f4fffe319a5de18e34f5f190366a113a8831c.json b/packages/oneclient_db/.sqlx/query-4d14a10baba86d0d63f6b172873f4fffe319a5de18e34f5f190366a113a8831c.json new file mode 100644 index 00000000..559d63ef --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-4d14a10baba86d0d63f6b172873f4fffe319a5de18e34f5f190366a113a8831c.json @@ -0,0 +1,110 @@ +{ + "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 hash = ? AND bundle_name IS NOT NULL\n ", + "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" + } + } + }, + { + "name": "bundle_name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "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, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "package_id" + } + } + }, + { + "name": "installed_at", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "installed_at" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "4d14a10baba86d0d63f6b172873f4fffe319a5de18e34f5f190366a113a8831c" +} diff --git a/packages/oneclient_db/.sqlx/query-4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f.json b/packages/oneclient_db/.sqlx/query-4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f.json new file mode 100644 index 00000000..5eb036f5 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f.json @@ -0,0 +1,62 @@ +{ + "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", + "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": 4 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "4f209d73ce580a5e305b6ccdbe47ce139b331506d1c53ed0f4011596c810252f" +} diff --git a/packages/oneclient_db/.sqlx/query-5183d452802d8bbec7f2a52ceba7ffe26960fdc25855f6cc993d6ab5deb84c02.json b/packages/oneclient_db/.sqlx/query-5183d452802d8bbec7f2a52ceba7ffe26960fdc25855f6cc993d6ab5deb84c02.json new file mode 100644 index 00000000..ea400fff --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-5183d452802d8bbec7f2a52ceba7ffe26960fdc25855f6cc993d6ab5deb84c02.json @@ -0,0 +1,146 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO browser_package_updates (\n\t\t\tcluster_id, hash, provider, project_id,\n\t\t\tinstalled_version_id, installed_version_name,\n\t\t\tlatest_version_id, latest_version_name,\n\t\t\tdisplay_name, checked_at, skipped\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)\n\t\tON CONFLICT(cluster_id, hash) DO UPDATE SET\n\t\t\tprovider = excluded.provider,\n\t\t\tproject_id = excluded.project_id,\n\t\t\tinstalled_version_id = excluded.installed_version_id,\n\t\t\tinstalled_version_name = excluded.installed_version_name,\n\t\t\tlatest_version_name = excluded.latest_version_name,\n\t\t\tdisplay_name = excluded.display_name,\n\t\t\tchecked_at = excluded.checked_at,\n\t\t\tskipped = CASE\n\t\t\t\tWHEN browser_package_updates.latest_version_id = excluded.latest_version_id\n\t\t\t\tTHEN browser_package_updates.skipped\n\t\t\t\tELSE 0\n\t\t\tEND,\n\t\t\tlatest_version_id = excluded.latest_version_id\n\t\tRETURNING\n\t\t\tcluster_id, hash, provider as \"provider!: i64\", project_id,\n\t\t\tinstalled_version_id, installed_version_name,\n\t\t\tlatest_version_id, latest_version_name,\n\t\t\tdisplay_name, checked_at, skipped\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "hash" + } + } + }, + { + "name": "provider!: i64", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "provider" + } + } + }, + { + "name": "project_id", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "project_id" + } + } + }, + { + "name": "installed_version_id", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "installed_version_id" + } + } + }, + { + "name": "installed_version_name", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "installed_version_name" + } + } + }, + { + "name": "latest_version_id", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "latest_version_id" + } + } + }, + { + "name": "latest_version_name", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "latest_version_name" + } + } + }, + { + "name": "display_name", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "display_name" + } + } + }, + { + "name": "checked_at", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "checked_at" + } + } + }, + { + "name": "skipped", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "browser_package_updates", + "name": "skipped" + } + } + } + ], + "parameters": { + "Right": 10 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "5183d452802d8bbec7f2a52ceba7ffe26960fdc25855f6cc993d6ab5deb84c02" +} diff --git a/packages/oneclient_db/.sqlx/query-53373194f104bdefd553ff6669b53d367c52a142b7afdae2f1bf8c07444d9e30.json b/packages/oneclient_db/.sqlx/query-53373194f104bdefd553ff6669b53d367c52a142b7afdae2f1bf8c07444d9e30.json new file mode 100644 index 00000000..72420f7a --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-53373194f104bdefd553ff6669b53d367c52a142b7afdae2f1bf8c07444d9e30.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT s.session_started_at, s.address, s.port, s.joined_at, s.disconnected_at\n\t\tFROM game_session_servers s\n\t\tJOIN game_sessions g ON g.started_at = s.session_started_at\n\t\tWHERE g.cluster_id = ?\n\t\tORDER BY s.joined_at ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "session_started_at", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "session_started_at" + } + } + }, + { + "name": "address", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "address" + } + } + }, + { + "name": "port", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "port" + } + } + }, + { + "name": "joined_at", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "joined_at" + } + } + }, + { + "name": "disconnected_at", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "disconnected_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + false, + true + ] + }, + "hash": "53373194f104bdefd553ff6669b53d367c52a142b7afdae2f1bf8c07444d9e30" +} diff --git a/packages/oneclient_db/.sqlx/query-54da501aee46ee04b6f3c628737cece742d6ce62a230335e56e987b6b1d0996a.json b/packages/oneclient_db/.sqlx/query-54da501aee46ee04b6f3c628737cece742d6ce62a230335e56e987b6b1d0996a.json new file mode 100644 index 00000000..2dcb5d1b --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-54da501aee46ee04b6f3c628737cece742d6ce62a230335e56e987b6b1d0996a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM clusters WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "54da501aee46ee04b6f3c628737cece742d6ce62a230335e56e987b6b1d0996a" +} diff --git a/packages/oneclient_db/.sqlx/query-56a95ce1cf65b5628481aeb2c6bbd20f083308b702a51c3e490409c37d0e8549.json b/packages/oneclient_db/.sqlx/query-56a95ce1cf65b5628481aeb2c6bbd20f083308b702a51c3e490409c37d0e8549.json new file mode 100644 index 00000000..141791a5 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-56a95ce1cf65b5628481aeb2c6bbd20f083308b702a51c3e490409c37d0e8549.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO cluster_bundle_overrides (cluster_id, bundle_name, package_id, override_type)\n VALUES (?, ?, ?, ?)\n ON CONFLICT(cluster_id, bundle_name, package_id) DO UPDATE SET\n override_type = excluded.override_type\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "56a95ce1cf65b5628481aeb2c6bbd20f083308b702a51c3e490409c37d0e8549" +} diff --git a/packages/oneclient_db/.sqlx/query-5c51104ad11fd39178c9eb43f8c3a9b4f796753ab1fbb28efcbf71562b3fafa1.json b/packages/oneclient_db/.sqlx/query-5c51104ad11fd39178c9eb43f8c3a9b4f796753ab1fbb28efcbf71562b3fafa1.json new file mode 100644 index 00000000..e8b5a800 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-5c51104ad11fd39178c9eb43f8c3a9b4f796753ab1fbb28efcbf71562b3fafa1.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO bundles (\n remote_path, mc_version, mc_loader, file_name, name, version_id,\n category, loader_version, disk_path, hidden, etag, synced_at\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(remote_path) DO UPDATE SET\n mc_version = excluded.mc_version,\n mc_loader = excluded.mc_loader,\n file_name = excluded.file_name,\n name = COALESCE(excluded.name, bundles.name),\n version_id = COALESCE(excluded.version_id, bundles.version_id),\n category = COALESCE(excluded.category, bundles.category),\n loader_version = COALESCE(excluded.loader_version, bundles.loader_version),\n disk_path = excluded.disk_path,\n hidden = excluded.hidden,\n etag = COALESCE(excluded.etag, bundles.etag),\n synced_at = COALESCE(excluded.synced_at, bundles.synced_at)\n RETURNING\n remote_path, mc_version, mc_loader, file_name, name, version_id,\n category, loader_version, disk_path, hidden, etag, synced_at\n ", + "describe": { + "columns": [ + { + "name": "remote_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "remote_path" + } + } + }, + { + "name": "mc_version", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_loader" + } + } + }, + { + "name": "file_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "file_name" + } + } + }, + { + "name": "name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "name" + } + } + }, + { + "name": "version_id", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "version_id" + } + } + }, + { + "name": "category", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "category" + } + } + }, + { + "name": "loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "loader_version" + } + } + }, + { + "name": "disk_path", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "disk_path" + } + } + }, + { + "name": "hidden", + "ordinal": 9, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "hidden" + } + } + }, + { + "name": "etag", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "etag" + } + } + }, + { + "name": "synced_at", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "synced_at" + } + } + } + ], + "parameters": { + "Right": 12 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "5c51104ad11fd39178c9eb43f8c3a9b4f796753ab1fbb28efcbf71562b3fafa1" +} diff --git a/packages/oneclient_db/.sqlx/query-5d302dbecf00a8e32014e0cb6ffecab5db70f3f37058b87fc9896028c1e8f644.json b/packages/oneclient_db/.sqlx/query-5d302dbecf00a8e32014e0cb6ffecab5db70f3f37058b87fc9896028c1e8f644.json new file mode 100644 index 00000000..9f55b11a --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-5d302dbecf00a8e32014e0cb6ffecab5db70f3f37058b87fc9896028c1e8f644.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\tFROM clusters\n\t\tWHERE mc_version = ? AND mc_loader = ?\n\t\tLIMIT 1\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "5d302dbecf00a8e32014e0cb6ffecab5db70f3f37058b87fc9896028c1e8f644" +} diff --git a/packages/oneclient_db/.sqlx/query-628245048eb9791987e8d1ac5a97c8364fda672d92ba43b641edf8c028e25186.json b/packages/oneclient_db/.sqlx/query-628245048eb9791987e8d1ac5a97c8364fda672d92ba43b641edf8c028e25186.json new file mode 100644 index 00000000..1df54e88 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-628245048eb9791987e8d1ac5a97c8364fda672d92ba43b641edf8c028e25186.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n remote_path, mc_version, mc_loader, file_name, name, version_id,\n category, loader_version, disk_path, hidden, etag, synced_at\n FROM bundles\n WHERE mc_version = ? AND mc_loader = ? AND hidden = 0\n ORDER BY name, file_name\n ", + "describe": { + "columns": [ + { + "name": "remote_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "remote_path" + } + } + }, + { + "name": "mc_version", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_loader" + } + } + }, + { + "name": "file_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "file_name" + } + } + }, + { + "name": "name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "name" + } + } + }, + { + "name": "version_id", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "version_id" + } + } + }, + { + "name": "category", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "category" + } + } + }, + { + "name": "loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "loader_version" + } + } + }, + { + "name": "disk_path", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "disk_path" + } + } + }, + { + "name": "hidden", + "ordinal": 9, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "hidden" + } + } + }, + { + "name": "etag", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "etag" + } + } + }, + { + "name": "synced_at", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "synced_at" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "628245048eb9791987e8d1ac5a97c8364fda672d92ba43b641edf8c028e25186" +} diff --git a/packages/oneclient_db/.sqlx/query-6c4518b6e29605c7699e12aa80e8202b0de13f77c7dc9ef0c4a967fab8135e02.json b/packages/oneclient_db/.sqlx/query-6c4518b6e29605c7699e12aa80e8202b0de13f77c7dc9ef0c4a967fab8135e02.json new file mode 100644 index 00000000..dc4f2d75 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-6c4518b6e29605c7699e12aa80e8202b0de13f77c7dc9ef0c4a967fab8135e02.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE game_session_servers\n\t\tSET disconnected_at = ?\n\t\tWHERE joined_at = ? AND disconnected_at IS NULL\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "6c4518b6e29605c7699e12aa80e8202b0de13f77c7dc9ef0c4a967fab8135e02" +} diff --git a/packages/oneclient_db/.sqlx/query-70b38391613b4f5d538c18e08014661d57dc172286b91235ab034a827fcc061e.json b/packages/oneclient_db/.sqlx/query-70b38391613b4f5d538c18e08014661d57dc172286b91235ab034a827fcc061e.json new file mode 100644 index 00000000..976cb60e --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-70b38391613b4f5d538c18e08014661d57dc172286b91235ab034a827fcc061e.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE clusters\n\t\tSET stage = ?\n\t\tWHERE id = ?\n\t\tRETURNING\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "70b38391613b4f5d538c18e08014661d57dc172286b91235ab034a827fcc061e" +} diff --git a/packages/oneclient_db/.sqlx/query-71fbe674b85a1fd6dc07a062e96a35888bb3b6a5a4fda5df3c662ca16b1d801e.json b/packages/oneclient_db/.sqlx/query-71fbe674b85a1fd6dc07a062e96a35888bb3b6a5a4fda5df3c662ca16b1d801e.json new file mode 100644 index 00000000..cb59124e --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-71fbe674b85a1fd6dc07a062e96a35888bb3b6a5a4fda5df3c662ca16b1d801e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tUPDATE game_sessions\n\t\tSET pid = ?, pid_started_at = ?\n\t\tWHERE started_at = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "71fbe674b85a1fd6dc07a062e96a35888bb3b6a5a4fda5df3c662ca16b1d801e" +} diff --git a/packages/oneclient_db/.sqlx/query-7d5011427a55581c66ea9d8ade35b33ced5913589907d80f4b20159150e1d1f1.json b/packages/oneclient_db/.sqlx/query-7d5011427a55581c66ea9d8ade35b33ced5913589907d80f4b20159150e1d1f1.json new file mode 100644 index 00000000..1088f25e --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-7d5011427a55581c66ea9d8ade35b33ced5913589907d80f4b20159150e1d1f1.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tcluster_id, started_at, ended_at, exit_code,\n\t\t\tram_allocated_mb, mods_enabled, java_vendor, java_version\n\t\tFROM game_sessions\n\t\tWHERE cluster_id = ?\n\t\tORDER BY started_at DESC\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "cluster_id" + } + } + }, + { + "name": "started_at", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "started_at" + } + } + }, + { + "name": "ended_at", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "ended_at" + } + } + }, + { + "name": "exit_code", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "exit_code" + } + } + }, + { + "name": "ram_allocated_mb", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "ram_allocated_mb" + } + } + }, + { + "name": "mods_enabled", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "mods_enabled" + } + } + }, + { + "name": "java_vendor", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "java_vendor" + } + } + }, + { + "name": "java_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "java_version" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "7d5011427a55581c66ea9d8ade35b33ced5913589907d80f4b20159150e1d1f1" +} diff --git a/packages/oneclient_db/.sqlx/query-80e69ca0c87bb70e0fcf7bf6c0aefc092f51300c10ad8776815637fc4616d12b.json b/packages/oneclient_db/.sqlx/query-80e69ca0c87bb70e0fcf7bf6c0aefc092f51300c10ad8776815637fc4616d12b.json new file mode 100644 index 00000000..6870408b --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-80e69ca0c87bb70e0fcf7bf6c0aefc092f51300c10ad8776815637fc4616d12b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n UPDATE cluster_artifacts\n SET bundle_name = NULL, bundle_version_id = NULL, package_id = NULL, installed_at = NULL\n WHERE cluster_id = ? AND hash = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "80e69ca0c87bb70e0fcf7bf6c0aefc092f51300c10ad8776815637fc4616d12b" +} diff --git a/packages/oneclient_db/.sqlx/query-836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c.json b/packages/oneclient_db/.sqlx/query-836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c.json new file mode 100644 index 00000000..b445c9cb --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "836429d5d4054d1605d7de4cbcf3703ba78d72f39b3090734bb3e69c2b72d50c" +} diff --git a/packages/oneclient_db/.sqlx/query-8efa8fcf00c6aca1714253c35f3850f3bf1f02bebabd8239716c2b3f2853dfcd.json b/packages/oneclient_db/.sqlx/query-8efa8fcf00c6aca1714253c35f3850f3bf1f02bebabd8239716c2b3f2853dfcd.json new file mode 100644 index 00000000..c368c4c2 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-8efa8fcf00c6aca1714253c35f3850f3bf1f02bebabd8239716c2b3f2853dfcd.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO package_metadata (provider, project_id, name, summary, author, icon_url, updated_at)\n\t\tVALUES (?, ?, ?, ?, ?, ?, datetime('now'))\n\t\tON CONFLICT(provider, project_id) DO UPDATE SET\n\t\t\tname = excluded.name,\n\t\t\tsummary = excluded.summary,\n\t\t\tauthor = excluded.author,\n\t\t\ticon_url = excluded.icon_url,\n\t\t\tupdated_at = excluded.updated_at\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "8efa8fcf00c6aca1714253c35f3850f3bf1f02bebabd8239716c2b3f2853dfcd" +} diff --git a/packages/oneclient_db/.sqlx/query-968efdce37d30ac18897c64536707be05abf1a8a20b730075288e20d6166044f.json b/packages/oneclient_db/.sqlx/query-968efdce37d30ac18897c64536707be05abf1a8a20b730075288e20d6166044f.json new file mode 100644 index 00000000..82961e5f --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-968efdce37d30ac18897c64536707be05abf1a8a20b730075288e20d6166044f.json @@ -0,0 +1,122 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tprovider as \"provider!: i64\", project_id, version_id, hash,\n\t\t\tdisplay_name, display_version, published_at, mc_versions, mc_loaders\n\t\tFROM provider_releases\n\t\tWHERE provider = ? AND project_id = ? AND version_id = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "provider!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "provider_releases", + "name": "provider" + } + } + }, + { + "name": "project_id", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "project_id" + } + } + }, + { + "name": "version_id", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "version_id" + } + } + }, + { + "name": "hash", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "hash" + } + } + }, + { + "name": "display_name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "display_name" + } + } + }, + { + "name": "display_version", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "display_version" + } + } + }, + { + "name": "published_at", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "published_at" + } + } + }, + { + "name": "mc_versions", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "mc_versions" + } + } + }, + { + "name": "mc_loaders", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "mc_loaders" + } + } + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "968efdce37d30ac18897c64536707be05abf1a8a20b730075288e20d6166044f" +} diff --git a/packages/oneclient_db/.sqlx/query-97d17b2c905bdd08abf41039e31972d10b5ac1d6b0d4df02f42edbf250d81782.json b/packages/oneclient_db/.sqlx/query-97d17b2c905bdd08abf41039e31972d10b5ac1d6b0d4df02f42edbf250d81782.json new file mode 100644 index 00000000..ad2eb026 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-97d17b2c905bdd08abf41039e31972d10b5ac1d6b0d4df02f42edbf250d81782.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tid, name, folder_name, setting_profile_name, mc_version, mc_loader,\n\t\t\tstage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash\n\t\tFROM clusters\n\t\tWHERE folder_name = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "id" + } + } + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "name" + } + } + }, + { + "name": "folder_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "folder_name" + } + } + }, + { + "name": "setting_profile_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "setting_profile_name" + } + } + }, + { + "name": "mc_version", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader" + } + } + }, + { + "name": "stage", + "ordinal": 6, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "stage" + } + } + }, + { + "name": "mc_loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "mc_loader_version" + } + } + }, + { + "name": "created_at", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "created_at" + } + } + }, + { + "name": "last_played", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "last_played" + } + } + }, + { + "name": "overall_played", + "ordinal": 10, + "type_info": "Integer", + "origin": { + "Table": { + "table": "clusters", + "name": "overall_played" + } + } + }, + { + "name": "linked_modpack_hash", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "clusters", + "name": "linked_modpack_hash" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true + ] + }, + "hash": "97d17b2c905bdd08abf41039e31972d10b5ac1d6b0d4df02f42edbf250d81782" +} diff --git a/packages/oneclient_db/.sqlx/query-98500a40f7e1ca7df79dd0ca90eaaa4f0b079ccbd757e5db1a8229ea58d2e700.json b/packages/oneclient_db/.sqlx/query-98500a40f7e1ca7df79dd0ca90eaaa4f0b079ccbd757e5db1a8229ea58d2e700.json new file mode 100644 index 00000000..e08f44bc --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-98500a40f7e1ca7df79dd0ca90eaaa4f0b079ccbd757e5db1a8229ea58d2e700.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env,\n\t\t hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\tFROM setting_profiles\n\t\tWHERE name = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "name" + } + } + }, + { + "name": "java_path", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "java_path" + } + } + }, + { + "name": "resolution", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "resolution" + } + } + }, + { + "name": "force_fullscreen", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "force_fullscreen" + } + } + }, + { + "name": "mem_max", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "mem_max" + } + } + }, + { + "name": "launch_args", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_args" + } + } + }, + { + "name": "launch_env", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_env" + } + } + }, + { + "name": "hook_pre", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_pre" + } + } + }, + { + "name": "hook_wrapper", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_wrapper" + } + } + }, + { + "name": "hook_post", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_post" + } + } + }, + { + "name": "os_extra", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "os_extra" + } + } + }, + { + "name": "browser_update_mode", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "browser_update_mode" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "98500a40f7e1ca7df79dd0ca90eaaa4f0b079ccbd757e5db1a8229ea58d2e700" +} diff --git a/packages/oneclient_db/.sqlx/query-9882797c904aa209f9cd6504ddb938a45c3a1dde7fe82f61aa78012f372a3263.json b/packages/oneclient_db/.sqlx/query-9882797c904aa209f9cd6504ddb938a45c3a1dde7fe82f61aa78012f372a3263.json new file mode 100644 index 00000000..36c33164 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-9882797c904aa209f9cd6504ddb938a45c3a1dde7fe82f61aa78012f372a3263.json @@ -0,0 +1,33 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT s.address AS \"address!\", COUNT(*) AS \"joins!: i64\"\n\t\tFROM game_session_servers s\n\t\tJOIN game_sessions g ON g.started_at = s.session_started_at\n\t\tWHERE g.cluster_id = ?\n\t\tGROUP BY s.address\n\t\tORDER BY COUNT(*) DESC\n\t\t", + "describe": { + "columns": [ + { + "name": "address!", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_session_servers", + "name": "address" + } + } + }, + { + "name": "joins!: i64", + "ordinal": 1, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "9882797c904aa209f9cd6504ddb938a45c3a1dde7fe82f61aa78012f372a3263" +} diff --git a/packages/oneclient_db/.sqlx/query-a5b7826e41c36b5c781d9e6c012a32dce67133c35f44119a81996f3fc9145a4e.json b/packages/oneclient_db/.sqlx/query-a5b7826e41c36b5c781d9e6c012a32dce67133c35f44119a81996f3fc9145a4e.json new file mode 100644 index 00000000..5d43cd32 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-a5b7826e41c36b5c781d9e6c012a32dce67133c35f44119a81996f3fc9145a4e.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT started_at, ended_at\n\t\tFROM game_sessions\n\t\tWHERE cluster_id = ? AND ended_at IS NOT NULL\n\t\tORDER BY started_at ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "started_at", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "started_at" + } + } + }, + { + "name": "ended_at", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "ended_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "a5b7826e41c36b5c781d9e6c012a32dce67133c35f44119a81996f3fc9145a4e" +} diff --git a/packages/oneclient_db/.sqlx/query-a68c066d19000056ed56251834ecf6d83ed846b9b65132a46ccb14de5abe9391.json b/packages/oneclient_db/.sqlx/query-a68c066d19000056ed56251834ecf6d83ed846b9b65132a46ccb14de5abe9391.json new file mode 100644 index 00000000..b27df2e7 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-a68c066d19000056ed56251834ecf6d83ed846b9b65132a46ccb14de5abe9391.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n remote_path, mc_version, mc_loader, file_name, name, version_id,\n category, loader_version, disk_path, hidden, etag, synced_at\n FROM bundles\n WHERE remote_path = ?\n ", + "describe": { + "columns": [ + { + "name": "remote_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "remote_path" + } + } + }, + { + "name": "mc_version", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_loader" + } + } + }, + { + "name": "file_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "file_name" + } + } + }, + { + "name": "name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "name" + } + } + }, + { + "name": "version_id", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "version_id" + } + } + }, + { + "name": "category", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "category" + } + } + }, + { + "name": "loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "loader_version" + } + } + }, + { + "name": "disk_path", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "disk_path" + } + } + }, + { + "name": "hidden", + "ordinal": 9, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "hidden" + } + } + }, + { + "name": "etag", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "etag" + } + } + }, + { + "name": "synced_at", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "synced_at" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "a68c066d19000056ed56251834ecf6d83ed846b9b65132a46ccb14de5abe9391" +} diff --git a/packages/oneclient_db/.sqlx/query-ab6dcea84a9502d5f8396a207ae613a4c41eb729ca22249f15eb45e6c6030094.json b/packages/oneclient_db/.sqlx/query-ab6dcea84a9502d5f8396a207ae613a4c41eb729ca22249f15eb45e6c6030094.json new file mode 100644 index 00000000..e564fe71 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-ab6dcea84a9502d5f8396a207ae613a4c41eb729ca22249f15eb45e6c6030094.json @@ -0,0 +1,21 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT 1 FROM cluster_artifacts\n WHERE cluster_id = ? AND bundle_name IS NOT NULL AND package_id = ?\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "name": "1", + "ordinal": 0, + "type_info": "Integer", + "origin": "Expression" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "ab6dcea84a9502d5f8396a207ae613a4c41eb729ca22249f15eb45e6c6030094" +} diff --git a/packages/oneclient_db/.sqlx/query-ade5a33ae1812b878e1ad43f6e2758d1103037947f14aeff5794511f1c002d55.json b/packages/oneclient_db/.sqlx/query-ade5a33ae1812b878e1ad43f6e2758d1103037947f14aeff5794511f1c002d55.json new file mode 100644 index 00000000..117cee86 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-ade5a33ae1812b878e1ad43f6e2758d1103037947f14aeff5794511f1c002d55.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM java_versions WHERE absolute_path = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "ade5a33ae1812b878e1ad43f6e2758d1103037947f14aeff5794511f1c002d55" +} diff --git a/packages/oneclient_db/.sqlx/query-b115456078000751a19d915b122c489501629742786fd6e00d96650c9f2404b2.json b/packages/oneclient_db/.sqlx/query-b115456078000751a19d915b122c489501629742786fd6e00d96650c9f2404b2.json new file mode 100644 index 00000000..6ef33182 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-b115456078000751a19d915b122c489501629742786fd6e00d96650c9f2404b2.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO artifacts (hash, content_type, path, file_name, size_bytes)\n\t\tVALUES (?, ?, ?, ?, ?)\n\t\tON CONFLICT(hash) DO UPDATE SET\n\t\t\tcontent_type = excluded.content_type,\n\t\t\tpath = excluded.path,\n\t\t\tfile_name = excluded.file_name,\n\t\t\tsize_bytes = COALESCE(excluded.size_bytes, artifacts.size_bytes)\n\t\tRETURNING hash, content_type, path, file_name, size_bytes\n\t\t", + "describe": { + "columns": [ + { + "name": "hash", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "artifacts", + "name": "hash" + } + } + }, + { + "name": "content_type", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "artifacts", + "name": "content_type" + } + } + }, + { + "name": "path", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "artifacts", + "name": "path" + } + } + }, + { + "name": "file_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "artifacts", + "name": "file_name" + } + } + }, + { + "name": "size_bytes", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "artifacts", + "name": "size_bytes" + } + } + } + ], + "parameters": { + "Right": 5 + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "b115456078000751a19d915b122c489501629742786fd6e00d96650c9f2404b2" +} diff --git a/packages/oneclient_db/.sqlx/query-b42a04c7b073b6cceff8fbd48a7b55f9beddb045bc43eaef851da89b3e547933.json b/packages/oneclient_db/.sqlx/query-b42a04c7b073b6cceff8fbd48a7b55f9beddb045bc43eaef851da89b3e547933.json new file mode 100644 index 00000000..ebc1b8fd --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-b42a04c7b073b6cceff8fbd48a7b55f9beddb045bc43eaef851da89b3e547933.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tDELETE FROM game_session_servers\n\t\tWHERE session_started_at = ?\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "b42a04c7b073b6cceff8fbd48a7b55f9beddb045bc43eaef851da89b3e547933" +} diff --git a/packages/oneclient_db/.sqlx/query-b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af.json b/packages/oneclient_db/.sqlx/query-b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af.json new file mode 100644 index 00000000..c7ab1753 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT cluster_id, hash, cluster_file_name, enabled\n\t\tFROM cluster_artifacts\n\t\tWHERE cluster_id = ? AND hash = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "hash" + } + } + }, + { + "name": "cluster_file_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_file_name" + } + } + }, + { + "name": "enabled", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "enabled" + } + } + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "b5935e0e5d751a7d33e898f7cc1e0f985a66434365f0939f7572434af40e43af" +} diff --git a/packages/oneclient_db/.sqlx/query-bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca.json b/packages/oneclient_db/.sqlx/query-bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca.json new file mode 100644 index 00000000..b79fa719 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca.json @@ -0,0 +1,62 @@ +{ + "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", + "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": 4 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "bbbc24e8d4f13a39ebce8e8598f4adbc864909984313ea1c065ba226465b23ca" +} diff --git a/packages/oneclient_db/.sqlx/query-c3f6c0105273939636dd0a7c7895a90e1e0902bde48eea1ced6d89ea6dbb4555.json b/packages/oneclient_db/.sqlx/query-c3f6c0105273939636dd0a7c7895a90e1e0902bde48eea1ced6d89ea6dbb4555.json new file mode 100644 index 00000000..584a28cf --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-c3f6c0105273939636dd0a7c7895a90e1e0902bde48eea1ced6d89ea6dbb4555.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM browser_package_updates WHERE cluster_id = ? AND hash = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "c3f6c0105273939636dd0a7c7895a90e1e0902bde48eea1ced6d89ea6dbb4555" +} diff --git a/packages/oneclient_db/.sqlx/query-c49ab599ef0aa31813f2a84d86f79235e2470eeec5b10f1e768fcf54d37d57fe.json b/packages/oneclient_db/.sqlx/query-c49ab599ef0aa31813f2a84d86f79235e2470eeec5b10f1e768fcf54d37d57fe.json new file mode 100644 index 00000000..9d2eae4f --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-c49ab599ef0aa31813f2a84d86f79235e2470eeec5b10f1e768fcf54d37d57fe.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT cluster_id, started_at, pid, pid_started_at\n\t\tFROM game_sessions\n\t\tWHERE ended_at IS NULL\n\t\tORDER BY started_at ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "cluster_id" + } + } + }, + { + "name": "started_at", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "started_at" + } + } + }, + { + "name": "pid", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "pid" + } + } + }, + { + "name": "pid_started_at", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "pid_started_at" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + true, + true + ] + }, + "hash": "c49ab599ef0aa31813f2a84d86f79235e2470eeec5b10f1e768fcf54d37d57fe" +} diff --git a/packages/oneclient_db/.sqlx/query-c939cef4b51d506d5b73b4c2e0330ba45423c0116c911b9da16576290ed9f3c2.json b/packages/oneclient_db/.sqlx/query-c939cef4b51d506d5b73b4c2e0330ba45423c0116c911b9da16576290ed9f3c2.json new file mode 100644 index 00000000..72745260 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-c939cef4b51d506d5b73b4c2e0330ba45423c0116c911b9da16576290ed9f3c2.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO setting_profiles (\n\t\t\tname, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env,\n\t\t\thook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(name) DO UPDATE SET\n\t\t\tjava_path = excluded.java_path,\n\t\t\tresolution = excluded.resolution,\n\t\t\tforce_fullscreen = excluded.force_fullscreen,\n\t\t\tmem_max = excluded.mem_max,\n\t\t\tlaunch_args = excluded.launch_args,\n\t\t\tlaunch_env = excluded.launch_env,\n\t\t\thook_pre = excluded.hook_pre,\n\t\t\thook_wrapper = excluded.hook_wrapper,\n\t\t\thook_post = excluded.hook_post,\n\t\t\tos_extra = excluded.os_extra,\n\t\t\tbrowser_update_mode = excluded.browser_update_mode\n\t\tRETURNING name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env,\n hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\t", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "name" + } + } + }, + { + "name": "java_path", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "java_path" + } + } + }, + { + "name": "resolution", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "resolution" + } + } + }, + { + "name": "force_fullscreen", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "force_fullscreen" + } + } + }, + { + "name": "mem_max", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "mem_max" + } + } + }, + { + "name": "launch_args", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_args" + } + } + }, + { + "name": "launch_env", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_env" + } + } + }, + { + "name": "hook_pre", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_pre" + } + } + }, + { + "name": "hook_wrapper", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_wrapper" + } + } + }, + { + "name": "hook_post", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_post" + } + } + }, + { + "name": "os_extra", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "os_extra" + } + } + }, + { + "name": "browser_update_mode", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "browser_update_mode" + } + } + } + ], + "parameters": { + "Right": 12 + }, + "nullable": [ + false, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "c939cef4b51d506d5b73b4c2e0330ba45423c0116c911b9da16576290ed9f3c2" +} diff --git a/packages/oneclient_db/.sqlx/query-c975a1fddfa0442fc400fefd6c0a110da6fb1628894dcecf5ec1c8f9e648cd38.json b/packages/oneclient_db/.sqlx/query-c975a1fddfa0442fc400fefd6c0a110da6fb1628894dcecf5ec1c8f9e648cd38.json new file mode 100644 index 00000000..6b724ee8 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-c975a1fddfa0442fc400fefd6c0a110da6fb1628894dcecf5ec1c8f9e648cd38.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT id, cluster_id, bundle_name, package_id, override_type\n FROM cluster_bundle_overrides\n WHERE cluster_id = ?\n ", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_bundle_overrides", + "name": "id" + } + } + }, + { + "name": "cluster_id", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_bundle_overrides", + "name": "cluster_id" + } + } + }, + { + "name": "bundle_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_bundle_overrides", + "name": "bundle_name" + } + } + }, + { + "name": "package_id", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_bundle_overrides", + "name": "package_id" + } + } + }, + { + "name": "override_type", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_bundle_overrides", + "name": "override_type" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "c975a1fddfa0442fc400fefd6c0a110da6fb1628894dcecf5ec1c8f9e648cd38" +} diff --git a/packages/oneclient_db/.sqlx/query-caadff7aec8bda1adf38f7eb572a254b21d865175669c985322a7c773c8c202a.json b/packages/oneclient_db/.sqlx/query-caadff7aec8bda1adf38f7eb572a254b21d865175669c985322a7c773c8c202a.json new file mode 100644 index 00000000..5f455621 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-caadff7aec8bda1adf38f7eb572a254b21d865175669c985322a7c773c8c202a.json @@ -0,0 +1,110 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO game_sessions (\n\t\t\tcluster_id, started_at, ram_allocated_mb, mods_enabled, java_vendor, java_version\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?)\n\t\tRETURNING\n\t\t\tcluster_id, started_at, ended_at, exit_code,\n\t\t\tram_allocated_mb, mods_enabled, java_vendor, java_version\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "cluster_id" + } + } + }, + { + "name": "started_at", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "started_at" + } + } + }, + { + "name": "ended_at", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "ended_at" + } + } + }, + { + "name": "exit_code", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "exit_code" + } + } + }, + { + "name": "ram_allocated_mb", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "ram_allocated_mb" + } + } + }, + { + "name": "mods_enabled", + "ordinal": 5, + "type_info": "Integer", + "origin": { + "Table": { + "table": "game_sessions", + "name": "mods_enabled" + } + } + }, + { + "name": "java_vendor", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "java_vendor" + } + } + }, + { + "name": "java_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "game_sessions", + "name": "java_version" + } + } + } + ], + "parameters": { + "Right": 6 + }, + "nullable": [ + false, + false, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "caadff7aec8bda1adf38f7eb572a254b21d865175669c985322a7c773c8c202a" +} diff --git a/packages/oneclient_db/.sqlx/query-caf65db8daf7fd11d9bdaa74b5de8d96ea46be6570ea9c7bdb92cc7cd0274892.json b/packages/oneclient_db/.sqlx/query-caf65db8daf7fd11d9bdaa74b5de8d96ea46be6570ea9c7bdb92cc7cd0274892.json new file mode 100644 index 00000000..db3b7431 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-caf65db8daf7fd11d9bdaa74b5de8d96ea46be6570ea9c7bdb92cc7cd0274892.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM cluster_artifacts WHERE cluster_id = ? AND hash = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "caf65db8daf7fd11d9bdaa74b5de8d96ea46be6570ea9c7bdb92cc7cd0274892" +} diff --git a/packages/oneclient_db/.sqlx/query-d5cfe04a1efe86513c7f3a6bdd6352f1ca512d7ea2ef4ed48c2dd7c44eb11854.json b/packages/oneclient_db/.sqlx/query-d5cfe04a1efe86513c7f3a6bdd6352f1ca512d7ea2ef4ed48c2dd7c44eb11854.json new file mode 100644 index 00000000..d2eadb39 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-d5cfe04a1efe86513c7f3a6bdd6352f1ca512d7ea2ef4ed48c2dd7c44eb11854.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT DISTINCT mc_version, mc_loader\n FROM bundles\n WHERE hidden = 0\n ORDER BY mc_version, mc_loader\n ", + "describe": { + "columns": [ + { + "name": "mc_version", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 1, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_loader" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false + ] + }, + "hash": "d5cfe04a1efe86513c7f3a6bdd6352f1ca512d7ea2ef4ed48c2dd7c44eb11854" +} diff --git a/packages/oneclient_db/.sqlx/query-da7349c8fe12f92abb661dd986914caee3a8cb31d7bd5ba6faf7d081be94ce8c.json b/packages/oneclient_db/.sqlx/query-da7349c8fe12f92abb661dd986914caee3a8cb31d7bd5ba6faf7d081be94ce8c.json new file mode 100644 index 00000000..abc98452 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-da7349c8fe12f92abb661dd986914caee3a8cb31d7bd5ba6faf7d081be94ce8c.json @@ -0,0 +1,122 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO provider_releases (\n\t\t\tprovider, project_id, version_id, hash,\n\t\t\tdisplay_name, display_version, published_at, mc_versions, mc_loaders\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\n\t\tON CONFLICT(provider, project_id, version_id) DO UPDATE SET\n\t\t\thash = excluded.hash,\n\t\t\tdisplay_name = excluded.display_name,\n\t\t\tdisplay_version = excluded.display_version,\n\t\t\tpublished_at = excluded.published_at,\n\t\t\tmc_versions = excluded.mc_versions,\n\t\t\tmc_loaders = excluded.mc_loaders\n\t\tRETURNING\n\t\t\tprovider as \"provider!: i64\", project_id, version_id, hash,\n\t\t\tdisplay_name, display_version, published_at, mc_versions, mc_loaders\n\t\t", + "describe": { + "columns": [ + { + "name": "provider!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "provider_releases", + "name": "provider" + } + } + }, + { + "name": "project_id", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "project_id" + } + } + }, + { + "name": "version_id", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "version_id" + } + } + }, + { + "name": "hash", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "hash" + } + } + }, + { + "name": "display_name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "display_name" + } + } + }, + { + "name": "display_version", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "display_version" + } + } + }, + { + "name": "published_at", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "published_at" + } + } + }, + { + "name": "mc_versions", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "mc_versions" + } + } + }, + { + "name": "mc_loaders", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "mc_loaders" + } + } + } + ], + "parameters": { + "Right": 9 + }, + "nullable": [ + null, + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "da7349c8fe12f92abb661dd986914caee3a8cb31d7bd5ba6faf7d081be94ce8c" +} diff --git a/packages/oneclient_db/.sqlx/query-e36fff618611a8fb6d7fa6e7745af3f2c7b9b6cd5727816704793d3723195dee.json b/packages/oneclient_db/.sqlx/query-e36fff618611a8fb6d7fa6e7745af3f2c7b9b6cd5727816704793d3723195dee.json new file mode 100644 index 00000000..c354f764 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-e36fff618611a8fb6d7fa6e7745af3f2c7b9b6cd5727816704793d3723195dee.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tINSERT INTO game_session_servers (session_started_at, address, port, joined_at)\n\t\tVALUES (?, ?, ?, ?)\n\t\t", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "e36fff618611a8fb6d7fa6e7745af3f2c7b9b6cd5727816704793d3723195dee" +} diff --git a/packages/oneclient_db/.sqlx/query-e372eec993f6e82238fe95b1c74dbb377ee08e44c7e8c3b5eaa6f3b8b229fddf.json b/packages/oneclient_db/.sqlx/query-e372eec993f6e82238fe95b1c74dbb377ee08e44c7e8c3b5eaa6f3b8b229fddf.json new file mode 100644 index 00000000..0a86cb15 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-e372eec993f6e82238fe95b1c74dbb377ee08e44c7e8c3b5eaa6f3b8b229fddf.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT cluster_id, hash, cluster_file_name, enabled\n\t\tFROM cluster_artifacts\n\t\tWHERE cluster_id = ?\n\t\t", + "describe": { + "columns": [ + { + "name": "cluster_id", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_id" + } + } + }, + { + "name": "hash", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "hash" + } + } + }, + { + "name": "cluster_file_name", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "cluster_file_name" + } + } + }, + { + "name": "enabled", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "cluster_artifacts", + "name": "enabled" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "e372eec993f6e82238fe95b1c74dbb377ee08e44c7e8c3b5eaa6f3b8b229fddf" +} diff --git a/packages/oneclient_db/.sqlx/query-e9fceeb38ba1df37e7713046653944691cece457a493bd67c52f399e35bf1501.json b/packages/oneclient_db/.sqlx/query-e9fceeb38ba1df37e7713046653944691cece457a493bd67c52f399e35bf1501.json new file mode 100644 index 00000000..cd62c77c --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-e9fceeb38ba1df37e7713046653944691cece457a493bd67c52f399e35bf1501.json @@ -0,0 +1,122 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT\n\t\t\tprovider as \"provider!: i64\", project_id, version_id, hash,\n\t\t\tdisplay_name, display_version, published_at, mc_versions, mc_loaders\n\t\tFROM provider_releases\n\t\tWHERE hash = ?\n\t\tLIMIT 1\n\t\t", + "describe": { + "columns": [ + { + "name": "provider!: i64", + "ordinal": 0, + "type_info": "Integer", + "origin": { + "Table": { + "table": "provider_releases", + "name": "provider" + } + } + }, + { + "name": "project_id", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "project_id" + } + } + }, + { + "name": "version_id", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "version_id" + } + } + }, + { + "name": "hash", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "hash" + } + } + }, + { + "name": "display_name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "display_name" + } + } + }, + { + "name": "display_version", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "display_version" + } + } + }, + { + "name": "published_at", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "published_at" + } + } + }, + { + "name": "mc_versions", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "mc_versions" + } + } + }, + { + "name": "mc_loaders", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "provider_releases", + "name": "mc_loaders" + } + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "e9fceeb38ba1df37e7713046653944691cece457a493bd67c52f399e35bf1501" +} diff --git a/packages/oneclient_db/.sqlx/query-f050a2d799f838b8fffca63c8ca0c758fc8787e982a2587b0a6f3c91abdcb3ec.json b/packages/oneclient_db/.sqlx/query-f050a2d799f838b8fffca63c8ca0c758fc8787e982a2587b0a6f3c91abdcb3ec.json new file mode 100644 index 00000000..b3eb3cf9 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-f050a2d799f838b8fffca63c8ca0c758fc8787e982a2587b0a6f3c91abdcb3ec.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM browser_package_updates WHERE cluster_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "f050a2d799f838b8fffca63c8ca0c758fc8787e982a2587b0a6f3c91abdcb3ec" +} diff --git a/packages/oneclient_db/.sqlx/query-f2a54203a624bd086d3a42f82b7abe9cbe31fcfe919b841aa022a353e08cf1ed.json b/packages/oneclient_db/.sqlx/query-f2a54203a624bd086d3a42f82b7abe9cbe31fcfe919b841aa022a353e08cf1ed.json new file mode 100644 index 00000000..3743afe5 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-f2a54203a624bd086d3a42f82b7abe9cbe31fcfe919b841aa022a353e08cf1ed.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n DELETE FROM cluster_bundle_overrides\n WHERE cluster_id = ? AND bundle_name = ? AND package_id = ?\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "f2a54203a624bd086d3a42f82b7abe9cbe31fcfe919b841aa022a353e08cf1ed" +} diff --git a/packages/oneclient_db/.sqlx/query-f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2.json b/packages/oneclient_db/.sqlx/query-f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2.json new file mode 100644 index 00000000..27530fbf --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2.json @@ -0,0 +1,86 @@ +{ + "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 ", + "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": "f5992c9cec6a6d7015cda4c04b463bd5fd9c42ac38b434fa8a0c0fe4f7517cf2" +} diff --git a/packages/oneclient_db/.sqlx/query-f68b9ade92feb989828ca81582c5960bf2968c4b75705e1a0e6800e97c3f4628.json b/packages/oneclient_db/.sqlx/query-f68b9ade92feb989828ca81582c5960bf2968c4b75705e1a0e6800e97c3f4628.json new file mode 100644 index 00000000..c1c2145d --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-f68b9ade92feb989828ca81582c5960bf2968c4b75705e1a0e6800e97c3f4628.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n\t\tSELECT name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env,\n\t\t hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode\n\t\tFROM setting_profiles\n\t\tORDER BY name ASC\n\t\t", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "name" + } + } + }, + { + "name": "java_path", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "java_path" + } + } + }, + { + "name": "resolution", + "ordinal": 2, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "resolution" + } + } + }, + { + "name": "force_fullscreen", + "ordinal": 3, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "force_fullscreen" + } + } + }, + { + "name": "mem_max", + "ordinal": 4, + "type_info": "Integer", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "mem_max" + } + } + }, + { + "name": "launch_args", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_args" + } + } + }, + { + "name": "launch_env", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "launch_env" + } + } + }, + { + "name": "hook_pre", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_pre" + } + } + }, + { + "name": "hook_wrapper", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_wrapper" + } + } + }, + { + "name": "hook_post", + "ordinal": 9, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "hook_post" + } + } + }, + { + "name": "os_extra", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "os_extra" + } + } + }, + { + "name": "browser_update_mode", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "setting_profiles", + "name": "browser_update_mode" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "f68b9ade92feb989828ca81582c5960bf2968c4b75705e1a0e6800e97c3f4628" +} diff --git a/packages/oneclient_db/.sqlx/query-f92620faa2bae008556f8d049ade9d689e971fbe525a5a66c7ba72b69c808073.json b/packages/oneclient_db/.sqlx/query-f92620faa2bae008556f8d049ade9d689e971fbe525a5a66c7ba72b69c808073.json new file mode 100644 index 00000000..22f62585 --- /dev/null +++ b/packages/oneclient_db/.sqlx/query-f92620faa2bae008556f8d049ade9d689e971fbe525a5a66c7ba72b69c808073.json @@ -0,0 +1,158 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n remote_path, mc_version, mc_loader, file_name, name, version_id,\n category, loader_version, disk_path, hidden, etag, synced_at\n FROM bundles\n ORDER BY mc_version, mc_loader, name, file_name\n ", + "describe": { + "columns": [ + { + "name": "remote_path", + "ordinal": 0, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "remote_path" + } + } + }, + { + "name": "mc_version", + "ordinal": 1, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_version" + } + } + }, + { + "name": "mc_loader", + "ordinal": 2, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "mc_loader" + } + } + }, + { + "name": "file_name", + "ordinal": 3, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "file_name" + } + } + }, + { + "name": "name", + "ordinal": 4, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "name" + } + } + }, + { + "name": "version_id", + "ordinal": 5, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "version_id" + } + } + }, + { + "name": "category", + "ordinal": 6, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "category" + } + } + }, + { + "name": "loader_version", + "ordinal": 7, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "loader_version" + } + } + }, + { + "name": "disk_path", + "ordinal": 8, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "disk_path" + } + } + }, + { + "name": "hidden", + "ordinal": 9, + "type_info": "Integer", + "origin": { + "Table": { + "table": "bundles", + "name": "hidden" + } + } + }, + { + "name": "etag", + "ordinal": 10, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "etag" + } + } + }, + { + "name": "synced_at", + "ordinal": 11, + "type_info": "Text", + "origin": { + "Table": { + "table": "bundles", + "name": "synced_at" + } + } + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "f92620faa2bae008556f8d049ade9d689e971fbe525a5a66c7ba72b69c808073" +} diff --git a/packages/oneclient_db/Cargo.toml b/packages/oneclient_db/Cargo.toml new file mode 100644 index 00000000..73e03cb4 --- /dev/null +++ b/packages/oneclient_db/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "oneclient_db" +description = "OneClient SQLx database layer" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dev-dependencies] +tokio.workspace = true +async-tempfile.workspace = true + +[dependencies] +sqlx.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +chrono.workspace = true +tracing.workspace = true +dunce.workspace = true diff --git a/packages/oneclient_db/migrations/20260525104022_initial_migration.sql b/packages/oneclient_db/migrations/20260525104022_initial_migration.sql new file mode 100644 index 00000000..5c5cb2d1 --- /dev/null +++ b/packages/oneclient_db/migrations/20260525104022_initial_migration.sql @@ -0,0 +1,153 @@ +CREATE TABLE `java_versions` ( + `absolute_path` TEXT PRIMARY KEY NOT NULL, + `major` INT NOT NULL, + `version` TEXT NOT NULL, + `vendor` TEXT NOT NULL, + `os_arch` TEXT NOT NULL +); +CREATE INDEX `java_versions_major_idx` ON `java_versions` (`major`); + +CREATE TABLE `setting_profiles` ( + `name` TEXT PRIMARY KEY NOT NULL, + `java_path` TEXT, + `resolution` TEXT, + `force_fullscreen` INTEGER, + `mem_max` INTEGER, + `launch_args` TEXT, + `launch_env` TEXT, + `hook_pre` TEXT, + `hook_wrapper` TEXT, + `hook_post` TEXT, + `os_extra` TEXT, + FOREIGN KEY (`java_path`) REFERENCES `java_versions` (`absolute_path`) +); + +CREATE TABLE `clusters` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `name` TEXT NOT NULL, + `folder_name` TEXT NOT NULL, + `setting_profile_name` TEXT, + `mc_version` TEXT NOT NULL DEFAULT 'unknown', + `mc_loader` INTEGER NOT NULL DEFAULT 0, + `stage` INTEGER NOT NULL DEFAULT 0, + `mc_loader_version` TEXT, + `created_at` TEXT, + `last_played` TEXT, + `overall_played` INTEGER, + `linked_modpack_hash` TEXT, + FOREIGN KEY (`setting_profile_name`) REFERENCES `setting_profiles` (`name`) +); +CREATE INDEX `clusters_folder_name_idx` ON `clusters` (`folder_name`); + +-- Content-addressed artifact cache (mods, packs, worlds, etc. - modpacks included) +CREATE TABLE artifacts ( + hash TEXT PRIMARY KEY NOT NULL, + content_type INTEGER NOT NULL, + path TEXT NOT NULL, + file_name TEXT NOT NULL, + size_bytes INTEGER +); +CREATE INDEX artifacts_content_type_idx ON artifacts (content_type); + +-- Provider metadata for a specific project version (optional for local-only files) +CREATE TABLE provider_releases ( + provider INTEGER NOT NULL, + project_id TEXT NOT NULL, + version_id TEXT NOT NULL, + hash TEXT NOT NULL, + display_name TEXT NOT NULL, + display_version TEXT NOT NULL, + published_at TEXT, + mc_versions TEXT NOT NULL DEFAULT '[]', + mc_loaders TEXT NOT NULL DEFAULT '[]', + PRIMARY KEY (provider, project_id, version_id), + FOREIGN KEY (hash) REFERENCES artifacts (hash) ON DELETE CASCADE +); +CREATE INDEX provider_releases_hash_idx ON provider_releases (hash); + +-- Links installed artifacts into a cluster instance folder +CREATE TABLE cluster_artifacts ( + cluster_id INTEGER NOT NULL, + hash TEXT NOT NULL, + cluster_file_name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + bundle_name TEXT, + bundle_version_id TEXT, + package_id TEXT, + installed_at TEXT, + PRIMARY KEY (cluster_id, hash), + FOREIGN KEY (cluster_id) REFERENCES clusters (id) ON DELETE CASCADE, + FOREIGN KEY (hash) REFERENCES artifacts (hash) ON DELETE CASCADE +); +CREATE INDEX cluster_artifacts_cluster_id_idx ON cluster_artifacts (cluster_id); +CREATE INDEX cluster_artifacts_bundle_name_idx ON cluster_artifacts (cluster_id, bundle_name); + +CREATE TABLE bundles ( + remote_path TEXT PRIMARY KEY NOT NULL, + mc_version TEXT NOT NULL, + mc_loader INTEGER NOT NULL, + file_name TEXT NOT NULL, + name TEXT, + version_id TEXT, + category TEXT, + loader_version TEXT, + disk_path TEXT NOT NULL, + hidden INTEGER NOT NULL DEFAULT 0, + etag TEXT, + synced_at TEXT +); +CREATE INDEX bundles_mc_version_loader_idx ON bundles (mc_version, mc_loader); +CREATE INDEX bundles_hidden_idx ON bundles (hidden); + +CREATE TABLE cluster_bundle_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + cluster_id INTEGER NOT NULL, + bundle_name TEXT NOT NULL, + package_id TEXT NOT NULL, + override_type TEXT NOT NULL, + FOREIGN KEY (cluster_id) REFERENCES clusters (id) ON DELETE CASCADE, + UNIQUE (cluster_id, bundle_name, package_id) +); +CREATE INDEX cluster_bundle_overrides_cluster_id_idx ON cluster_bundle_overrides (cluster_id); + +-- Local cache of remote provider project metadata, keyed by package id. +-- Lets package lists render names/icons/descriptions offline and without +-- refetching from the provider on every session. Icon bytes themselves live in +-- the on-disk image cache (keyed by url); here we persist the url -> package id +-- mapping plus the display fields. +CREATE TABLE package_metadata ( + provider INTEGER NOT NULL, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + author TEXT NOT NULL DEFAULT '', + icon_url TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (provider, project_id) +); + +CREATE TABLE game_sessions ( + cluster_id INTEGER NOT NULL, + started_at TEXT NOT NULL, + ended_at TEXT, + exit_code INTEGER, + ram_allocated_mb INTEGER NOT NULL, + mods_enabled INTEGER NOT NULL DEFAULT 0, + java_vendor TEXT, + java_version TEXT, + PRIMARY KEY (started_at, ended_at), + UNIQUE (started_at), + FOREIGN KEY (cluster_id) REFERENCES clusters(id) ON DELETE CASCADE +); +CREATE INDEX game_sessions_cluster_id_idx ON game_sessions(cluster_id); + +CREATE TABLE game_session_servers ( + session_started_at TEXT NOT NULL, + address TEXT NOT NULL, + port INTEGER, + joined_at TEXT NOT NULL, + disconnected_at TEXT, + PRIMARY KEY (joined_at, disconnected_at), + FOREIGN KEY (session_started_at) REFERENCES game_sessions(started_at) ON DELETE CASCADE +); +CREATE INDEX game_session_servers_session_started_at_idx ON game_session_servers(session_started_at); \ No newline at end of file diff --git a/packages/oneclient_db/migrations/20260715120000_applied_migrations.sql b/packages/oneclient_db/migrations/20260715120000_applied_migrations.sql new file mode 100644 index 00000000..8fe785eb --- /dev/null +++ b/packages/oneclient_db/migrations/20260715120000_applied_migrations.sql @@ -0,0 +1,4 @@ +CREATE TABLE applied_migrations ( + id TEXT PRIMARY KEY NOT NULL, + applied_at TEXT NOT NULL +); diff --git a/packages/oneclient_db/migrations/20260716120000_session_process_tracking.sql b/packages/oneclient_db/migrations/20260716120000_session_process_tracking.sql new file mode 100644 index 00000000..499251b1 --- /dev/null +++ b/packages/oneclient_db/migrations/20260716120000_session_process_tracking.sql @@ -0,0 +1,6 @@ +-- Track the OS process behind a session so a restarted launcher can tell a +-- still-running game from one that exited while the launcher was closed. +-- `pid_started_at` is the process start time (unix seconds), used to reject +-- a recycled pid that now belongs to an unrelated process. +ALTER TABLE game_sessions ADD COLUMN pid INTEGER; +ALTER TABLE game_sessions ADD COLUMN pid_started_at INTEGER; diff --git a/packages/oneclient_db/migrations/20260801120000_browser_package_updates.sql b/packages/oneclient_db/migrations/20260801120000_browser_package_updates.sql new file mode 100644 index 00000000..0d329e24 --- /dev/null +++ b/packages/oneclient_db/migrations/20260801120000_browser_package_updates.sql @@ -0,0 +1,37 @@ +-- Auto-updates for packages the user installed from the browser, i.e. remote +-- provider content that no bundle provides. Bundles keep their own update flow +-- and are deliberately out of scope here. +-- +-- `browser_update_mode` is the per-profile override of the launcher-wide +-- setting; NULL means "inherit the global value", like every other column on +-- this table. Values are the strings in `PackageUpdateMode::as_str`. +ALTER TABLE setting_profiles ADD COLUMN browser_update_mode TEXT; + +-- Cache of the launch-time update check, so the package manager can mark a +-- package out of date without going back to the provider on every render, and +-- still knows about it after a restart. +-- +-- Keyed by the artifact hash actually installed in the cluster: that is what +-- the package rows are keyed by, and it changes when the update is applied, +-- which is what makes a stale row impossible to mistake for a live one. +-- +-- `skipped` records that the user was asked about *this* newer version and said +-- no. The row stays either way, because a skipped package is still out of date +-- and should still say so in the package manager; it just stops re-opening the +-- modal for the same version. +CREATE TABLE browser_package_updates ( + cluster_id INTEGER NOT NULL, + hash TEXT NOT NULL, + provider INTEGER NOT NULL, + project_id TEXT NOT NULL, + installed_version_id TEXT NOT NULL, + installed_version_name TEXT NOT NULL DEFAULT '', + latest_version_id TEXT NOT NULL, + latest_version_name TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + checked_at TEXT NOT NULL, + skipped INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (cluster_id, hash), + FOREIGN KEY (cluster_id) REFERENCES clusters (id) ON DELETE CASCADE +); +CREATE INDEX browser_package_updates_cluster_id_idx ON browser_package_updates (cluster_id); diff --git a/packages/oneclient_db/migrations/20260802120000_java_is_jdk.sql b/packages/oneclient_db/migrations/20260802120000_java_is_jdk.sql new file mode 100644 index 00000000..0de5e00b --- /dev/null +++ b/packages/oneclient_db/migrations/20260802120000_java_is_jdk.sql @@ -0,0 +1,5 @@ +-- Whether a recorded runtime is a development kit rather than a bare runtime +-- image. Probing for it means spawning the java process, so it is remembered +-- alongside the version instead. Rows written before this migration default to +-- 0 and are corrected the next time they are probed. +ALTER TABLE `java_versions` ADD COLUMN `is_jdk` INTEGER NOT NULL DEFAULT 0; diff --git a/packages/oneclient_db/migrations/20260806120000_repair_orphaned_bundle_disables.sql b/packages/oneclient_db/migrations/20260806120000_repair_orphaned_bundle_disables.sql new file mode 100644 index 00000000..1f24491e --- /dev/null +++ b/packages/oneclient_db/migrations/20260806120000_repair_orphaned_bundle_disables.sql @@ -0,0 +1,44 @@ +-- Repairs bundle content whose two records of one decision came apart. +-- +-- `cluster_artifacts.enabled` and `cluster_bundle_overrides` both record whether +-- a package is wanted, and the launcher was able to write one without the other: +-- duplicate resolution wrote the flag with no override, and re-resolving a +-- package to a different bundle deleted the override with no flag. Either way +-- the artifact ends up off with nothing recording why, which nothing could then +-- repair -- the update flow decides from the override and the manifest default +-- and never reads `enabled`, so it saw the package as installed and enabled and +-- never touched it again. For a bundle's hidden dependencies that meant a +-- library the pack needs never reaching `mods/`. + +-- First, the objections that are no longer about anything. Declining a bundle at +-- onboarding recorded `removed` for its hidden files, including libraries that +-- an accepted bundle right beside it ships too -- so a cluster ends up claiming +-- to have removed a package it demonstrably installed, under a different bundle. +-- Left in place these answer for the package in every lookup that resolves a +-- user's choice across bundles, which is now how a choice is resolved. +DELETE FROM cluster_bundle_overrides +WHERE override_type = 'removed' + AND EXISTS (SELECT 1 FROM cluster_artifacts a + WHERE a.cluster_id = cluster_bundle_overrides.cluster_id + AND a.bundle_name IS NOT NULL + AND a.bundle_name <> cluster_bundle_overrides.bundle_name + AND a.package_id = cluster_bundle_overrides.package_id); + +-- Then the flag itself. A genuine user disable always leaves a `disabled` or +-- `removed` row behind, so those are matched here and stay off -- under any +-- bundle, not just the one the artifact sits under today, because a package that +-- moved bundles keeps its old row and that row still speaks for the user. +-- +-- Deliberately narrower than the runtime pass in `bundles::heal_bundle_activity`, +-- which also frees hidden files from a `disabled` row on the grounds that a +-- dependency the user was never shown cannot have been turned off on purpose. +-- Whether a file is hidden is a manifest fact and there is no manifest here, so +-- those wait for the next bundle apply rather than being guessed at. +UPDATE cluster_artifacts SET enabled = 1 +WHERE bundle_name IS NOT NULL + AND package_id IS NOT NULL + AND enabled = 0 + AND NOT EXISTS (SELECT 1 FROM cluster_bundle_overrides o + WHERE o.cluster_id = cluster_artifacts.cluster_id + AND o.package_id = cluster_artifacts.package_id + AND o.override_type IN ('disabled', 'removed')); diff --git a/packages/oneclient_db/migrations/20260809120000_java_probe_version.sql b/packages/oneclient_db/migrations/20260809120000_java_probe_version.sql new file mode 100644 index 00000000..0b03f787 --- /dev/null +++ b/packages/oneclient_db/migrations/20260809120000_java_probe_version.sql @@ -0,0 +1 @@ +ALTER TABLE `java_versions` ADD COLUMN `probe_version` INTEGER NOT NULL DEFAULT 0; diff --git a/packages/oneclient_db/src/backup.rs b/packages/oneclient_db/src/backup.rs new file mode 100644 index 00000000..15703703 --- /dev/null +++ b/packages/oneclient_db/src/backup.rs @@ -0,0 +1,271 @@ +//! Snapshots taken before a migration + +use std::path::{Path, PathBuf}; + +use crate::{DbError, DbPool}; + +pub const KEEP: usize = 5; + +const SIDECARS: &[&str] = &["-wal", "-shm", "-journal"]; + +pub fn dir(database_path: &Path) -> PathBuf { + database_path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join("backups") +} + +fn stem(database_path: &Path) -> String { + database_path + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| "database".to_owned()) +} + +fn timestamp() -> String { + chrono::Local::now().format("%Y%m%d-%H%M%S").to_string() +} + +/// A consistent copy without blocking on the journal, unlike a file copy +pub async fn snapshot(pool: &DbPool, database_path: &Path) -> Result { + let dir = dir(database_path); + std::fs::create_dir_all(&dir)?; + + let target = dir.join(format!("{}-{}.db", stem(database_path), timestamp())); + + sqlx::query("VACUUM INTO ?") + .bind(target.to_string_lossy().as_ref()) + .execute(pool) + .await?; + + Ok(target) +} + +/// Newest first +pub fn list(database_path: &Path) -> Vec { + let prefix = format!("{}-", stem(database_path)); + + let Ok(entries) = std::fs::read_dir(dir(database_path)) else { + return Vec::new(); + }; + + let mut found: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.ends_with(".db") + && name + .strip_prefix(&prefix) + .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())) + }) + }) + .collect(); + + found.sort(); + found.reverse(); + found +} + +pub fn prune(database_path: &Path, keep: usize) { + for old in list(database_path).into_iter().skip(keep) { + if let Err(err) = std::fs::remove_file(&old) { + tracing::warn!(path = %old.display(), %err, "could not remove old snapshot"); + } + } +} + +/// Moves the live database into `backups/` and returns where it went. The +/// caller is left with no database at `database_path`, which is what both +/// restore and reset want before they continue. +pub fn set_aside(database_path: &Path) -> Result, DbError> { + if !database_path.exists() { + return Ok(None); + } + + let dir = dir(database_path); + std::fs::create_dir_all(&dir)?; + + let target = dir.join(format!( + "{}-replaced-{}.db", + stem(database_path), + timestamp() + )); + std::fs::rename(database_path, &target)?; + + for suffix in SIDECARS { + let sidecar = sidecar(database_path, suffix); + if sidecar.exists() { + let _ = std::fs::remove_file(sidecar); + } + } + + Ok(Some(target)) +} + +fn sidecar(database_path: &Path, suffix: &str) -> PathBuf { + let mut name = database_path.as_os_str().to_os_string(); + name.push(suffix); + PathBuf::from(name) +} + +/// Puts `snapshot` back, keeping the current database in `backups/` first +pub fn restore(database_path: &Path, snapshot: &Path) -> Result, DbError> { + let replaced = set_aside(database_path)?; + std::fs::copy(snapshot, database_path)?; + + tracing::info!( + snapshot = %snapshot.display(), + "restored the database from a snapshot" + ); + + Ok(replaced) +} + +/// Starts over. The old database is moved aside, never deleted, so it is +/// recoverable by hand afterwards. +pub fn reset(database_path: &Path) -> Result, DbError> { + let replaced = set_aside(database_path)?; + + tracing::warn!( + replaced = ?replaced.as_ref().map(|path| path.display().to_string()), + "reset the database" + ); + + Ok(replaced) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_tempfile::TempDir; + use sqlx::SqlitePool; + + async fn pool_at(path: &Path) -> SqlitePool { + SqlitePool::connect(&format!("sqlite://{}?mode=rwc", path.display())) + .await + .expect("open sqlite") + } + + #[tokio::test] + async fn a_snapshot_is_a_usable_database() { + let dir = TempDir::new().await.expect("tempdir"); + let db = dir.dir_path().join("user_data.db"); + + let pool = pool_at(&db).await; + sqlx::query("CREATE TABLE demo (id INTEGER PRIMARY KEY)") + .execute(&pool) + .await + .expect("create"); + + let snapshot = snapshot(&pool, &db).await.expect("snapshot"); + assert!(snapshot.exists()); + + let restored = pool_at(&snapshot).await; + let found: Option = + sqlx::query_scalar("SELECT 1 FROM sqlite_master WHERE name = 'demo'") + .fetch_optional(&restored) + .await + .expect("probe"); + assert!(found.is_some()); + } + + #[tokio::test] + async fn restoring_keeps_the_replaced_database() { + let dir = TempDir::new().await.expect("tempdir"); + let db = dir.dir_path().join("user_data.db"); + + let pool = pool_at(&db).await; + sqlx::query("CREATE TABLE before_snapshot (id INTEGER PRIMARY KEY)") + .execute(&pool) + .await + .expect("create"); + let snapshot = snapshot(&pool, &db).await.expect("snapshot"); + + sqlx::query("CREATE TABLE after_snapshot (id INTEGER PRIMARY KEY)") + .execute(&pool) + .await + .expect("create"); + pool.close().await; + + let replaced = restore(&db, &snapshot).expect("restore").expect("moved"); + + let now = pool_at(&db).await; + let after: Option = + sqlx::query_scalar("SELECT 1 FROM sqlite_master WHERE name = 'after_snapshot'") + .fetch_optional(&now) + .await + .expect("probe"); + assert!(after.is_none(), "the snapshot should be what is live now"); + + let kept = pool_at(&replaced).await; + let after: Option = + sqlx::query_scalar("SELECT 1 FROM sqlite_master WHERE name = 'after_snapshot'") + .fetch_optional(&kept) + .await + .expect("probe"); + assert!( + after.is_some(), + "the replaced database must still be intact" + ); + } + + #[tokio::test] + async fn a_reset_moves_the_database_rather_than_deleting_it() { + let dir = TempDir::new().await.expect("tempdir"); + let db = dir.dir_path().join("user_data.db"); + + let pool = pool_at(&db).await; + sqlx::query("CREATE TABLE demo (id INTEGER PRIMARY KEY)") + .execute(&pool) + .await + .expect("create"); + pool.close().await; + + let moved = reset(&db).expect("reset").expect("moved"); + + assert!(!db.exists()); + assert!(moved.exists()); + } + + #[tokio::test] + async fn a_set_aside_file_is_not_offered_as_a_snapshot() { + let dir = TempDir::new().await.expect("tempdir"); + let db = dir.dir_path().join("user_data.db"); + + let pool = pool_at(&db).await; + sqlx::query("CREATE TABLE demo (id INTEGER PRIMARY KEY)") + .execute(&pool) + .await + .expect("create"); + let snapshot = snapshot(&pool, &db).await.expect("snapshot"); + pool.close().await; + + reset(&db).expect("reset"); + + assert_eq!(list(&db), vec![snapshot]); + } + + #[tokio::test] + async fn pruning_keeps_the_newest() { + let dir = TempDir::new().await.expect("tempdir"); + let db = dir.dir_path().join("user_data.db"); + let backups = self::dir(&db); + std::fs::create_dir_all(&backups).expect("mkdir"); + + for stamp in ["20260101-000000", "20260102-000000", "20260103-000000"] { + std::fs::write(backups.join(format!("user_data-{stamp}.db")), b"").expect("write"); + } + + prune(&db, 2); + + let left = list(&db); + assert_eq!(left.len(), 2); + assert!( + left[0].ends_with("user_data-20260103-000000.db"), + "the newest must survive" + ); + } +} diff --git a/packages/oneclient_db/src/console.rs b/packages/oneclient_db/src/console.rs new file mode 100644 index 00000000..d9c6ecdf --- /dev/null +++ b/packages/oneclient_db/src/console.rs @@ -0,0 +1,172 @@ +use std::collections::HashSet; +use std::sync::Mutex; + +use sqlx::sqlite::SqliteRow; +use sqlx::{Column, Row, ValueRef}; + +use crate::{DbError, DbPool}; + +/// sqlx requires the SQL string to be `'static` interning stops repeated runs +/// of the same text from leaking +fn intern(sql: &str) -> &'static str { + static CACHE: Mutex>> = Mutex::new(None); + + let mut guard = CACHE.lock().unwrap(); + let cache = guard.get_or_insert_with(HashSet::new); + + if let Some(existing) = cache.get(sql) { + return existing; + } + + let leaked: &'static str = Box::leak(sql.to_owned().into_boxed_str()); + cache.insert(leaked); + leaked +} + +#[derive(Debug, Clone, Default)] +pub struct ConsoleQueryResult { + pub columns: Vec, + pub rows: Vec>, + pub rows_affected: u64, + pub is_select: bool, +} + +/// Statements starting with `SELECT` `WITH` `PRAGMA` or `EXPLAIN` are fetched +/// into a table everything else is executed and reports `rows_affected` +pub async fn run_console_query(pool: &DbPool, sql: &str) -> Result { + let sql = intern(sql.trim().trim_end_matches(';').trim()); + + if returns_rows(sql) { + let fetched = sqlx::query(sql).persistent(false).fetch_all(pool).await?; + Ok(rows_to_result(&fetched)) + } else { + let done = sqlx::query(sql).persistent(false).execute(pool).await?; + Ok(ConsoleQueryResult { + rows_affected: done.rows_affected(), + is_select: false, + ..Default::default() + }) + } +} + +fn returns_rows(sql: &str) -> bool { + let head = sql + .trim_start() + .split(|c: char| c.is_whitespace() || c == '(') + .next() + .unwrap_or("") + .to_ascii_uppercase(); + + matches!(head.as_str(), "SELECT" | "WITH" | "PRAGMA" | "EXPLAIN") +} + +fn rows_to_result(fetched: &[SqliteRow]) -> ConsoleQueryResult { + let columns = fetched + .first() + .map(|row| { + row.columns() + .iter() + .map(|c| c.name().to_string()) + .collect::>() + }) + .unwrap_or_default(); + + let rows = fetched + .iter() + .map(|row| { + (0..row.columns().len()) + .map(|idx| cell_to_string(row, idx)) + .collect() + }) + .collect(); + + ConsoleQueryResult { + columns, + rows, + rows_affected: 0, + is_select: true, + } +} + +/// SQLite is dynamically typed so probe the raw value and decode accordingly +fn cell_to_string(row: &SqliteRow, idx: usize) -> String { + match row.try_get_raw(idx) { + Ok(raw) if raw.is_null() => return "NULL".to_string(), + Ok(_) => {} + Err(_) => return "".to_string(), + } + + if let Ok(v) = row.try_get::(idx) { + return v; + } + if let Ok(v) = row.try_get::(idx) { + return v.to_string(); + } + if let Ok(v) = row.try_get::(idx) { + return v.to_string(); + } + if let Ok(v) = row.try_get::, _>(idx) { + return format!("", v.len()); + } + + "".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlx::sqlite::SqlitePoolOptions; + + async fn pool() -> DbPool { + let pool = SqlitePoolOptions::new() + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::query("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT, ratio REAL)") + .execute(&pool) + .await + .unwrap(); + pool + } + + #[tokio::test] + async fn select_returns_columns_and_rows() { + let pool = pool().await; + sqlx::query("INSERT INTO t (name, ratio) VALUES ('a', 1.5), (NULL, 2.0)") + .execute(&pool) + .await + .unwrap(); + + let res = run_console_query(&pool, "SELECT id, name, ratio FROM t ORDER BY id;") + .await + .unwrap(); + + assert!(res.is_select); + assert_eq!(res.columns, vec!["id", "name", "ratio"]); + assert_eq!(res.rows.len(), 2); + assert_eq!(res.rows[0], vec!["1", "a", "1.5"]); + assert_eq!(res.rows[1][1], "NULL"); + } + + #[tokio::test] + async fn non_select_reports_rows_affected() { + let pool = pool().await; + let res = run_console_query(&pool, "INSERT INTO t (name) VALUES ('x'), ('y')") + .await + .unwrap(); + + assert!(!res.is_select); + assert_eq!(res.rows_affected, 2); + assert!(res.rows.is_empty()); + } + + #[tokio::test] + async fn invalid_sql_errors() { + let pool = pool().await; + assert!( + run_console_query(&pool, "SELECT * FROM nope") + .await + .is_err() + ); + } +} diff --git a/packages/oneclient_db/src/dao/applied_migration.rs b/packages/oneclient_db/src/dao/applied_migration.rs new file mode 100644 index 00000000..5d55241e --- /dev/null +++ b/packages/oneclient_db/src/dao/applied_migration.rs @@ -0,0 +1,24 @@ +use chrono::Utc; +use sqlx::SqlitePool; + +pub async fn is_applied(pool: &SqlitePool, id: &str) -> Result { + let row = sqlx::query!("SELECT id FROM applied_migrations WHERE id = ?", id) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) +} + +pub async fn mark_applied(pool: &SqlitePool, id: &str) -> Result<(), sqlx::Error> { + let applied_at = Utc::now().to_rfc3339(); + + sqlx::query!( + "INSERT OR IGNORE INTO applied_migrations (id, applied_at) VALUES (?, ?)", + id, + applied_at + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/packages/oneclient_db/src/dao/artifact.rs b/packages/oneclient_db/src/dao/artifact.rs new file mode 100644 index 00000000..c8a9c168 --- /dev/null +++ b/packages/oneclient_db/src/dao/artifact.rs @@ -0,0 +1,321 @@ +use sqlx::SqlitePool; + +use crate::models::{ArtifactRow, ClusterArtifactRow, ProviderReleaseRow}; + +pub async fn get_artifact_by_hash( + pool: &SqlitePool, + hash: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ArtifactRow, + r#" + SELECT hash, content_type, path, file_name, size_bytes + FROM artifacts + WHERE hash = ? + "#, + hash + ) + .fetch_optional(pool) + .await +} + +pub async fn insert_artifact( + pool: &SqlitePool, + hash: &str, + content_type: i64, + path: &str, + file_name: &str, + size_bytes: Option, +) -> Result { + sqlx::query_as!( + ArtifactRow, + r#" + INSERT INTO artifacts (hash, content_type, path, file_name, size_bytes) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(hash) DO UPDATE SET + content_type = excluded.content_type, + path = excluded.path, + file_name = excluded.file_name, + size_bytes = COALESCE(excluded.size_bytes, artifacts.size_bytes) + RETURNING hash, content_type, path, file_name, size_bytes + "#, + hash, + content_type, + path, + file_name, + size_bytes + ) + .fetch_one(pool) + .await +} + +/// `provider_releases` cascades the cached file is the caller's to delete +/// this layer does not touch the disk +pub async fn delete_artifact_if_unused(pool: &SqlitePool, hash: &str) -> Result { + let linked: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cluster_artifacts WHERE hash = ?") + .bind(hash) + .fetch_one(pool) + .await?; + + if linked.0 > 0 { + return Ok(false); + } + + sqlx::query!("DELETE FROM artifacts WHERE hash = ?", hash) + .execute(pool) + .await?; + + Ok(true) +} + +pub async fn list_unused_artifacts(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as::<_, ArtifactRow>( + r#" + SELECT hash, content_type, path, file_name, size_bytes + FROM artifacts + WHERE hash NOT IN (SELECT hash FROM cluster_artifacts) + "#, + ) + .fetch_all(pool) + .await +} + +pub async fn list_artifact_paths(pool: &SqlitePool) -> Result, sqlx::Error> { + let rows: Vec<(String,)> = sqlx::query_as("SELECT path FROM artifacts") + .fetch_all(pool) + .await?; + + Ok(rows.into_iter().map(|(path,)| path).collect()) +} + +#[allow(clippy::too_many_arguments)] +pub async fn upsert_provider_release( + pool: &SqlitePool, + provider_id: i64, + project_id: &str, + version_id: &str, + hash: &str, + display_name: &str, + display_version: &str, + published_at: Option<&str>, + mc_versions: &str, + mc_loaders: &str, +) -> Result { + sqlx::query_as!( + ProviderReleaseRow, + r#" + INSERT INTO provider_releases ( + provider, project_id, version_id, hash, + display_name, display_version, published_at, mc_versions, mc_loaders + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, project_id, version_id) DO UPDATE SET + hash = excluded.hash, + display_name = excluded.display_name, + display_version = excluded.display_version, + published_at = excluded.published_at, + mc_versions = excluded.mc_versions, + mc_loaders = excluded.mc_loaders + RETURNING + provider as "provider!: i64", project_id, version_id, hash, + display_name, display_version, published_at, mc_versions, mc_loaders + "#, + provider_id, + project_id, + version_id, + hash, + display_name, + display_version, + published_at, + mc_versions, + mc_loaders + ) + .fetch_one(pool) + .await +} + +pub async fn get_provider_release( + pool: &SqlitePool, + provider_id: i64, + project_id: &str, + version_id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ProviderReleaseRow, + r#" + SELECT + provider as "provider!: i64", project_id, version_id, hash, + display_name, display_version, published_at, mc_versions, mc_loaders + FROM provider_releases + WHERE provider = ? AND project_id = ? AND version_id = ? + "#, + provider_id, + project_id, + version_id + ) + .fetch_optional(pool) + .await +} + +pub async fn get_release_by_hash( + pool: &SqlitePool, + hash: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ProviderReleaseRow, + r#" + SELECT + provider as "provider!: i64", project_id, version_id, hash, + display_name, display_version, published_at, mc_versions, mc_loaders + FROM provider_releases + WHERE hash = ? + LIMIT 1 + "#, + hash + ) + .fetch_optional(pool) + .await +} + +pub async fn link_cluster_artifact( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + cluster_file_name: &str, +) -> Result { + sqlx::query_as!( + ClusterArtifactRow, + r#" + INSERT INTO cluster_artifacts (cluster_id, hash, cluster_file_name, enabled) + VALUES (?, ?, ?, 1) + ON CONFLICT(cluster_id, hash) DO UPDATE SET + cluster_file_name = excluded.cluster_file_name + RETURNING cluster_id, hash, cluster_file_name, enabled + "#, + cluster_id, + hash, + cluster_file_name + ) + .fetch_one(pool) + .await +} + +/// `DISTINCT` is required `provider_releases` is keyed by version so one +/// artifact joins several rows and would otherwise be reported repeatedly +pub async fn list_cluster_artifacts_for_project( + pool: &SqlitePool, + cluster_id: i64, + provider: i64, + project_id: &str, + exclude_hash: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterArtifactRow, + r#" + SELECT DISTINCT ca.cluster_id, ca.hash, ca.cluster_file_name, ca.enabled + FROM cluster_artifacts ca + JOIN provider_releases pr ON pr.hash = ca.hash + WHERE ca.cluster_id = ? + AND pr.provider = ? + AND pr.project_id = ? + AND ca.hash <> ? + "#, + cluster_id, + provider, + project_id, + exclude_hash + ) + .fetch_all(pool) + .await +} + +pub async fn is_cluster_linked( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, +) -> Result { + let row: Option<(i64,)> = + sqlx::query_as("SELECT 1 FROM cluster_artifacts WHERE cluster_id = ? AND hash = ? LIMIT 1") + .bind(cluster_id) + .bind(hash) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) +} + +pub async fn unlink_cluster_artifact( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + "DELETE FROM cluster_artifacts WHERE cluster_id = ? AND hash = ?", + cluster_id, + hash + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn list_cluster_artifacts( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterArtifactRow, + r#" + SELECT cluster_id, hash, cluster_file_name, enabled + FROM cluster_artifacts + WHERE cluster_id = ? + "#, + cluster_id + ) + .fetch_all(pool) + .await +} + +pub async fn get_cluster_artifact( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterArtifactRow, + r#" + SELECT cluster_id, hash, cluster_file_name, enabled + FROM cluster_artifacts + WHERE cluster_id = ? AND hash = ? + "#, + cluster_id, + hash + ) + .fetch_optional(pool) + .await +} + +pub async fn update_cluster_artifact( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + cluster_file_name: &str, + enabled: i64, +) -> Result { + sqlx::query_as!( + ClusterArtifactRow, + r#" + UPDATE cluster_artifacts + SET cluster_file_name = ?, enabled = ? + WHERE cluster_id = ? AND hash = ? + RETURNING cluster_id, hash, cluster_file_name, enabled + "#, + cluster_file_name, + enabled, + cluster_id, + hash + ) + .fetch_one(pool) + .await +} diff --git a/packages/oneclient_db/src/dao/browser_package_update.rs b/packages/oneclient_db/src/dao/browser_package_update.rs new file mode 100644 index 00000000..919f8fad --- /dev/null +++ b/packages/oneclient_db/src/dao/browser_package_update.rs @@ -0,0 +1,246 @@ +use chrono::Utc; +use sqlx::{QueryBuilder, Sqlite, SqlitePool}; + +use crate::models::BrowserPackageUpdateRow; + +pub async fn list_for_cluster( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + BrowserPackageUpdateRow, + r#" + SELECT + cluster_id, hash, provider as "provider!: i64", project_id, + installed_version_id, installed_version_name, + latest_version_id, latest_version_name, + display_name, checked_at, skipped + FROM browser_package_updates + WHERE cluster_id = ? + "#, + cluster_id + ) + .fetch_all(pool) + .await +} + +pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + BrowserPackageUpdateRow, + r#" + SELECT + cluster_id, hash, provider as "provider!: i64", project_id, + installed_version_id, installed_version_name, + latest_version_id, latest_version_name, + display_name, checked_at, skipped + FROM browser_package_updates + "#, + ) + .fetch_all(pool) + .await +} + +/// Re-upserting the same `latest_version_id` keeps `skipped` a different one +/// resets it since it is a new question for the user +#[allow(clippy::too_many_arguments)] +pub async fn upsert( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + provider: i64, + project_id: &str, + installed_version_id: &str, + installed_version_name: &str, + latest_version_id: &str, + latest_version_name: &str, + display_name: &str, +) -> Result { + let checked_at = Utc::now().to_rfc3339(); + + sqlx::query_as!( + BrowserPackageUpdateRow, + r#" + INSERT INTO browser_package_updates ( + cluster_id, hash, provider, project_id, + installed_version_id, installed_version_name, + latest_version_id, latest_version_name, + display_name, checked_at, skipped + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0) + ON CONFLICT(cluster_id, hash) DO UPDATE SET + provider = excluded.provider, + project_id = excluded.project_id, + installed_version_id = excluded.installed_version_id, + installed_version_name = excluded.installed_version_name, + latest_version_name = excluded.latest_version_name, + display_name = excluded.display_name, + checked_at = excluded.checked_at, + skipped = CASE + WHEN browser_package_updates.latest_version_id = excluded.latest_version_id + THEN browser_package_updates.skipped + ELSE 0 + END, + latest_version_id = excluded.latest_version_id + RETURNING + cluster_id, hash, provider as "provider!: i64", project_id, + installed_version_id, installed_version_name, + latest_version_id, latest_version_name, + display_name, checked_at, skipped + "#, + cluster_id, + hash, + provider, + project_id, + installed_version_id, + installed_version_name, + latest_version_id, + latest_version_name, + display_name, + checked_at + ) + .fetch_one(pool) + .await +} + +pub async fn set_skipped( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + skipped: bool, +) -> Result<(), sqlx::Error> { + let skipped = i64::from(skipped); + sqlx::query!( + r#" + UPDATE browser_package_updates + SET skipped = ? + WHERE cluster_id = ? AND hash = ? + "#, + skipped, + cluster_id, + hash + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn delete(pool: &SqlitePool, cluster_id: i64, hash: &str) -> Result<(), sqlx::Error> { + sqlx::query!( + "DELETE FROM browser_package_updates WHERE cluster_id = ? AND hash = ?", + cluster_id, + hash + ) + .execute(pool) + .await?; + + Ok(()) +} + +/// Drops rows for hashes the latest check did not report an empty list clears +/// the cluster +pub async fn retain_hashes( + pool: &SqlitePool, + cluster_id: i64, + hashes: &[String], +) -> Result<(), sqlx::Error> { + if hashes.is_empty() { + sqlx::query!( + "DELETE FROM browser_package_updates WHERE cluster_id = ?", + cluster_id + ) + .execute(pool) + .await?; + return Ok(()); + } + + let mut builder: QueryBuilder = + QueryBuilder::new("DELETE FROM browser_package_updates WHERE cluster_id = "); + builder.push_bind(cluster_id); + builder.push(" AND hash NOT IN ("); + let mut separated = builder.separated(", "); + for hash in hashes { + separated.push_bind(hash); + } + separated.push_unseparated(")"); + + builder.build().execute(pool).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!().run(&pool).await.expect("migrations run"); + // Runtime query not the macro the offline cache is library-only + sqlx::query( + r#" + INSERT INTO clusters (id, name, folder_name, mc_version) + VALUES (1, 'test', 'test', '1.21.4') + "#, + ) + .execute(&pool) + .await + .expect("cluster row"); + pool + } + + async fn record(pool: &SqlitePool, hash: &str, latest: &str) -> BrowserPackageUpdateRow { + upsert( + pool, 1, hash, 0, "proj", "v1", "1.0.0", latest, "2.0.0", "Mod", + ) + .await + .expect("upsert") + } + + #[tokio::test] + async fn skip_survives_a_recheck_of_the_same_version() { + let pool = pool().await; + record(&pool, "abc", "v2").await; + set_skipped(&pool, 1, "abc", true).await.expect("skip"); + + let row = record(&pool, "abc", "v2").await; + assert_eq!(row.skipped, 1, "re-running the check must not re-ask"); + } + + #[tokio::test] + async fn a_newer_version_asks_again() { + let pool = pool().await; + record(&pool, "abc", "v2").await; + set_skipped(&pool, 1, "abc", true).await.expect("skip"); + + let row = record(&pool, "abc", "v3").await; + assert_eq!(row.skipped, 0, "a different version is a new question"); + } + + #[tokio::test] + async fn retain_drops_hashes_the_check_no_longer_reports() { + let pool = pool().await; + record(&pool, "abc", "v2").await; + record(&pool, "def", "v2").await; + + retain_hashes(&pool, 1, &["abc".to_string()]) + .await + .expect("retain"); + + let rows = list_for_cluster(&pool, 1).await.expect("list"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].hash, "abc"); + } + + #[tokio::test] + async fn retain_with_nothing_clears_the_cluster() { + let pool = pool().await; + record(&pool, "abc", "v2").await; + + retain_hashes(&pool, 1, &[]).await.expect("retain"); + + assert!(list_for_cluster(&pool, 1).await.expect("list").is_empty()); + } +} diff --git a/packages/oneclient_db/src/dao/bundle.rs b/packages/oneclient_db/src/dao/bundle.rs new file mode 100644 index 00000000..beeecd1c --- /dev/null +++ b/packages/oneclient_db/src/dao/bundle.rs @@ -0,0 +1,152 @@ +use sqlx::QueryBuilder; +use sqlx::SqlitePool; + +use crate::models::{BundleRow, NewBundle}; + +pub async fn upsert_bundle( + pool: &SqlitePool, + bundle: NewBundle<'_>, +) -> Result { + let hidden = i64::from(bundle.hidden); + sqlx::query_as!( + BundleRow, + r#" + INSERT INTO bundles ( + remote_path, mc_version, mc_loader, file_name, name, version_id, + category, loader_version, disk_path, hidden, etag, synced_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(remote_path) DO UPDATE SET + mc_version = excluded.mc_version, + mc_loader = excluded.mc_loader, + file_name = excluded.file_name, + name = COALESCE(excluded.name, bundles.name), + version_id = COALESCE(excluded.version_id, bundles.version_id), + category = COALESCE(excluded.category, bundles.category), + loader_version = COALESCE(excluded.loader_version, bundles.loader_version), + disk_path = excluded.disk_path, + hidden = excluded.hidden, + etag = COALESCE(excluded.etag, bundles.etag), + synced_at = COALESCE(excluded.synced_at, bundles.synced_at) + RETURNING + remote_path, mc_version, mc_loader, file_name, name, version_id, + category, loader_version, disk_path, hidden, etag, synced_at + "#, + bundle.remote_path, + bundle.mc_version, + bundle.mc_loader, + bundle.file_name, + bundle.name, + bundle.version_id, + bundle.category, + bundle.loader_version, + bundle.disk_path, + hidden, + bundle.etag, + bundle.synced_at, + ) + .fetch_one(pool) + .await +} + +pub async fn hide_bundles_not_in( + pool: &SqlitePool, + remote_paths: &[String], +) -> Result { + if remote_paths.is_empty() { + let result = sqlx::query("UPDATE bundles SET hidden = 1 WHERE hidden = 0") + .execute(pool) + .await?; + return Ok(result.rows_affected()); + } + + let mut builder = QueryBuilder::new( + "UPDATE bundles SET hidden = 1 WHERE hidden = 0 AND remote_path NOT IN (", + ); + let mut separated = builder.separated(", "); + for path in remote_paths { + separated.push_bind(path); + } + separated.push_unseparated(")"); + + let result = builder.build().execute(pool).await?; + Ok(result.rows_affected()) +} + +pub async fn list_visible_for_version_loader( + pool: &SqlitePool, + mc_version: &str, + mc_loader: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + BundleRow, + r#" + SELECT + remote_path, mc_version, mc_loader, file_name, name, version_id, + category, loader_version, disk_path, hidden, etag, synced_at + FROM bundles + WHERE mc_version = ? AND mc_loader = ? AND hidden = 0 + ORDER BY name, file_name + "#, + mc_version, + mc_loader, + ) + .fetch_all(pool) + .await +} + +pub async fn get_by_remote_path( + pool: &SqlitePool, + remote_path: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + BundleRow, + r#" + SELECT + remote_path, mc_version, mc_loader, file_name, name, version_id, + category, loader_version, disk_path, hidden, etag, synced_at + FROM bundles + WHERE remote_path = ? + "#, + remote_path + ) + .fetch_optional(pool) + .await +} + +#[derive(Debug, Clone)] +pub struct BundleVersionLoaderGroup { + pub mc_version: String, + pub mc_loader: i64, +} + +pub async fn list_distinct_version_loaders( + pool: &SqlitePool, +) -> Result, sqlx::Error> { + sqlx::query_as!( + BundleVersionLoaderGroup, + r#" + SELECT DISTINCT mc_version, mc_loader + FROM bundles + WHERE hidden = 0 + ORDER BY mc_version, mc_loader + "# + ) + .fetch_all(pool) + .await +} + +pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + BundleRow, + r#" + SELECT + remote_path, mc_version, mc_loader, file_name, name, version_id, + category, loader_version, disk_path, hidden, etag, synced_at + FROM bundles + ORDER BY mc_version, mc_loader, name, file_name + "# + ) + .fetch_all(pool) + .await +} diff --git a/packages/oneclient_db/src/dao/cluster.rs b/packages/oneclient_db/src/dao/cluster.rs new file mode 100644 index 00000000..b2f84eba --- /dev/null +++ b/packages/oneclient_db/src/dao/cluster.rs @@ -0,0 +1,351 @@ +use chrono::Utc; +use sqlx::SqlitePool; + +use crate::models::{ClusterPatch, ClusterRow, NewCluster}; + +pub async fn get_by_id(pool: &SqlitePool, id: i64) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterRow, + r#" + SELECT + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + FROM clusters + WHERE id = ? + "#, + id + ) + .fetch_optional(pool) + .await +} + +pub async fn get_by_folder_name( + pool: &SqlitePool, + folder_name: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterRow, + r#" + SELECT + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + FROM clusters + WHERE folder_name = ? + "#, + folder_name + ) + .fetch_optional(pool) + .await +} + +pub async fn list_all(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterRow, + r#" + SELECT + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + FROM clusters + ORDER BY last_played IS NULL, last_played DESC, name ASC + "# + ) + .fetch_all(pool) + .await +} + +pub async fn find_by_version_loader( + pool: &SqlitePool, + mc_version: &str, + mc_loader: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterRow, + r#" + SELECT + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + FROM clusters + WHERE mc_version = ? AND mc_loader = ? + LIMIT 1 + "#, + mc_version, + mc_loader + ) + .fetch_optional(pool) + .await +} + +pub async fn insert(pool: &SqlitePool, new: &NewCluster<'_>) -> Result { + let created_at = Utc::now().to_rfc3339(); + + sqlx::query_as!( + ClusterRow, + r#" + INSERT INTO clusters ( + name, folder_name, mc_version, mc_loader, mc_loader_version, + setting_profile_name, stage, created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + RETURNING + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + "#, + new.name, + new.folder_name, + new.mc_version, + new.mc_loader, + new.mc_loader_version, + new.setting_profile_name, + new.stage, + created_at + ) + .fetch_one(pool) + .await +} + +pub async fn update( + pool: &SqlitePool, + id: i64, + patch: &ClusterPatch, +) -> Result { + let existing = get_by_id(pool, id).await?.ok_or(sqlx::Error::RowNotFound)?; + + let name = patch.name.as_deref().unwrap_or(&existing.name); + let setting_profile_name = patch + .setting_profile_name + .clone() + .unwrap_or(existing.setting_profile_name); + let mc_loader_version = patch + .mc_loader_version + .clone() + .unwrap_or(existing.mc_loader_version); + let linked_modpack_hash = patch + .linked_modpack_hash + .clone() + .unwrap_or(existing.linked_modpack_hash); + + sqlx::query_as!( + ClusterRow, + r#" + UPDATE clusters + SET name = ?, + setting_profile_name = ?, + mc_loader_version = ?, + linked_modpack_hash = ? + WHERE id = ? + RETURNING + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + "#, + name, + setting_profile_name, + mc_loader_version, + linked_modpack_hash, + id + ) + .fetch_one(pool) + .await +} + +pub async fn migrate_version( + pool: &SqlitePool, + id: i64, + mc_version: &str, + name: Option<&str>, + folder_name: &str, +) -> Result { + let existing = get_by_id(pool, id).await?.ok_or(sqlx::Error::RowNotFound)?; + + let name = name.unwrap_or(&existing.name); + + sqlx::query_as!( + ClusterRow, + r#" + UPDATE clusters + SET mc_version = ?, + name = ?, + folder_name = ? + WHERE id = ? + RETURNING + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + "#, + mc_version, + name, + folder_name, + id + ) + .fetch_one(pool) + .await +} + +pub async fn set_stage(pool: &SqlitePool, id: i64, stage: i64) -> Result { + sqlx::query_as!( + ClusterRow, + r#" + UPDATE clusters + SET stage = ? + WHERE id = ? + RETURNING + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + "#, + stage, + id + ) + .fetch_one(pool) + .await +} + +pub async fn add_playtime( + pool: &SqlitePool, + id: i64, + seconds: i64, +) -> Result { + let now = Utc::now().to_rfc3339(); + + sqlx::query_as!( + ClusterRow, + r#" + UPDATE clusters + SET overall_played = COALESCE(overall_played, 0) + ?, + last_played = ? + WHERE id = ? + RETURNING + id, name, folder_name, setting_profile_name, mc_version, mc_loader, + stage, mc_loader_version, created_at, last_played, overall_played, linked_modpack_hash + "#, + seconds, + now, + id + ) + .fetch_one(pool) + .await +} + +pub async fn delete_by_id(pool: &SqlitePool, id: i64) -> Result { + let result = sqlx::query!("DELETE FROM clusters WHERE id = ?", id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dao::applied_migration; + + async fn pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory sqlite"); + sqlx::migrate!().run(&pool).await.expect("migrations run"); + pool + } + + async fn seed(pool: &SqlitePool, mc_version: &str, folder: &str) -> ClusterRow { + insert( + pool, + &NewCluster { + name: &format!("{mc_version} fabric"), + folder_name: folder, + mc_version, + mc_loader: 1, + mc_loader_version: None, + setting_profile_name: None, + stage: 0, + }, + ) + .await + .expect("insert cluster") + } + + #[tokio::test] + async fn migrate_version_rewrites_identity_and_keeps_history() { + let pool = pool().await; + let cluster = seed(&pool, "26.1", "26.1 fabric").await; + add_playtime(&pool, cluster.id, 3600) + .await + .expect("record playtime"); + + let migrated = migrate_version( + &pool, + cluster.id, + "26.1.2", + Some("26.1.2 fabric"), + "26.1.2 fabric", + ) + .await + .expect("migrate"); + + assert_eq!(migrated.id, cluster.id, "must move the row, not replace it"); + assert_eq!(migrated.mc_version, "26.1.2"); + assert_eq!(migrated.folder_name, "26.1.2 fabric"); + assert_eq!(migrated.name, "26.1.2 fabric"); + assert_eq!(migrated.overall_played, Some(3600)); + assert_eq!(migrated.created_at, cluster.created_at); + + assert!( + find_by_version_loader(&pool, "26.1", 1) + .await + .unwrap() + .is_none() + ); + assert_eq!( + find_by_version_loader(&pool, "26.1.2", 1) + .await + .unwrap() + .unwrap() + .id, + cluster.id + ); + } + + #[tokio::test] + async fn migrate_version_can_leave_name_and_folder_untouched() { + let pool = pool().await; + let cluster = seed(&pool, "26.1", "my cool pack").await; + + let migrated = migrate_version(&pool, cluster.id, "26.1.2", None, "my cool pack") + .await + .expect("migrate"); + + assert_eq!(migrated.mc_version, "26.1.2"); + assert_eq!(migrated.name, "26.1 fabric", "custom name must survive"); + assert_eq!(migrated.folder_name, "my cool pack"); + } + + #[tokio::test] + async fn applied_migrations_ledger_is_idempotent() { + let pool = pool().await; + + assert!( + !applied_migration::is_applied(&pool, "rule-a") + .await + .unwrap() + ); + applied_migration::mark_applied(&pool, "rule-a") + .await + .unwrap(); + assert!( + applied_migration::is_applied(&pool, "rule-a") + .await + .unwrap() + ); + + applied_migration::mark_applied(&pool, "rule-a") + .await + .unwrap(); + assert!( + applied_migration::is_applied(&pool, "rule-a") + .await + .unwrap() + ); + assert!( + !applied_migration::is_applied(&pool, "rule-b") + .await + .unwrap() + ); + } +} diff --git a/packages/oneclient_db/src/dao/cluster_bundle.rs b/packages/oneclient_db/src/dao/cluster_bundle.rs new file mode 100644 index 00000000..96b44f45 --- /dev/null +++ b/packages/oneclient_db/src/dao/cluster_bundle.rs @@ -0,0 +1,226 @@ +use chrono::Utc; +use sqlx::SqlitePool; + +use crate::models::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType}; + +pub async fn track_bundle_artifact( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, + bundle_name: &str, + bundle_version_id: &str, + package_id: &str, +) -> Result { + let installed_at = Utc::now().to_rfc3339(); + sqlx::query_as!( + BundleTrackedArtifactRow, + r#" + UPDATE cluster_artifacts + SET + bundle_name = ?, + bundle_version_id = ?, + package_id = ?, + installed_at = ? + WHERE cluster_id = ? AND hash = ? + RETURNING + cluster_id, hash, cluster_file_name, enabled, + bundle_name, bundle_version_id, package_id, installed_at + "#, + bundle_name, + bundle_version_id, + package_id, + installed_at, + cluster_id, + hash + ) + .fetch_one(pool) + .await +} + +pub async fn clear_bundle_tracking( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + UPDATE cluster_artifacts + SET bundle_name = NULL, bundle_version_id = NULL, package_id = NULL, installed_at = NULL + WHERE cluster_id = ? AND hash = ? + "#, + cluster_id, + hash + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_bundle_tracked( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + BundleTrackedArtifactRow, + r#" + SELECT + cluster_id, hash, cluster_file_name, enabled, + bundle_name, bundle_version_id, package_id, installed_at + FROM cluster_artifacts + WHERE cluster_id = ? AND bundle_name IS NOT NULL + "#, + cluster_id + ) + .fetch_all(pool) + .await +} + +pub async fn get_bundle_tracked( + pool: &SqlitePool, + cluster_id: i64, + hash: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + BundleTrackedArtifactRow, + r#" + SELECT + cluster_id, hash, cluster_file_name, enabled, + bundle_name, bundle_version_id, package_id, installed_at + FROM cluster_artifacts + WHERE cluster_id = ? AND hash = ? AND bundle_name IS NOT NULL + "#, + cluster_id, + hash + ) + .fetch_optional(pool) + .await +} + +pub async fn has_bundle_mapping_for_package( + pool: &SqlitePool, + cluster_id: i64, + package_id: &str, +) -> Result { + let row = sqlx::query_scalar!( + r#" + SELECT 1 FROM cluster_artifacts + WHERE cluster_id = ? AND bundle_name IS NOT NULL AND package_id = ? + LIMIT 1 + "#, + cluster_id, + package_id + ) + .fetch_optional(pool) + .await?; + + Ok(row.is_some()) +} + +pub async fn save_override( + pool: &SqlitePool, + cluster_id: i64, + bundle_name: &str, + package_id: &str, + override_type: OverrideType, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + INSERT INTO cluster_bundle_overrides (cluster_id, bundle_name, package_id, override_type) + VALUES (?, ?, ?, ?) + ON CONFLICT(cluster_id, bundle_name, package_id) DO UPDATE SET + override_type = excluded.override_type + "#, + cluster_id, + bundle_name, + package_id, + override_type.as_str() + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn save_overrides( + pool: &SqlitePool, + cluster_id: i64, + overrides: &[(String, String, OverrideType)], +) -> Result<(), sqlx::Error> { + if overrides.is_empty() { + return Ok(()); + } + + let mut tx = pool.begin().await?; + for (bundle_name, package_id, override_type) in overrides { + sqlx::query!( + r#" + INSERT INTO cluster_bundle_overrides (cluster_id, bundle_name, package_id, override_type) + VALUES (?, ?, ?, ?) + ON CONFLICT(cluster_id, bundle_name, package_id) DO UPDATE SET + override_type = excluded.override_type + "#, + cluster_id, + bundle_name, + package_id, + override_type.as_str() + ) + .execute(&mut *tx) + .await?; + } + tx.commit().await +} + +pub async fn remove_override( + pool: &SqlitePool, + cluster_id: i64, + bundle_name: &str, + package_id: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + DELETE FROM cluster_bundle_overrides + WHERE cluster_id = ? AND bundle_name = ? AND package_id = ? + "#, + cluster_id, + bundle_name, + package_id + ) + .execute(pool) + .await?; + Ok(()) +} + +/// Opt-in (`enabled`) rows are left alone they say the opposite thing +pub async fn clear_suppressing_overrides( + pool: &SqlitePool, + cluster_id: i64, + package_id: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + DELETE FROM cluster_bundle_overrides + WHERE cluster_id = ? AND package_id = ? AND override_type IN ('disabled', 'removed') + "#, + cluster_id, + package_id + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_overrides( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ClusterBundleOverrideRow, + r#" + SELECT id, cluster_id, bundle_name, package_id, override_type + FROM cluster_bundle_overrides + WHERE cluster_id = ? + "#, + cluster_id + ) + .fetch_all(pool) + .await +} diff --git a/packages/oneclient_db/src/dao/game_session.rs b/packages/oneclient_db/src/dao/game_session.rs new file mode 100644 index 00000000..4c179d75 --- /dev/null +++ b/packages/oneclient_db/src/dao/game_session.rs @@ -0,0 +1,300 @@ +use chrono::Utc; +use sqlx::SqlitePool; + +use crate::models::{ + GameSessionRow, GameSessionServerRow, NewGameSession, ServerJoinCount, SessionSpan, + UnfinishedSession, +}; + +pub async fn insert_session( + pool: &SqlitePool, + new: &NewGameSession<'_>, +) -> Result { + sqlx::query_as!( + GameSessionRow, + r#" + INSERT INTO game_sessions ( + cluster_id, started_at, ram_allocated_mb, mods_enabled, java_vendor, java_version + ) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING + cluster_id, started_at, ended_at, exit_code, + ram_allocated_mb, mods_enabled, java_vendor, java_version + "#, + new.cluster_id, + new.started_at, + new.ram_allocated_mb, + new.mods_enabled, + new.java_vendor, + new.java_version + ) + .fetch_one(pool) + .await +} + +pub async fn finish_session( + pool: &SqlitePool, + started_at: &str, + exit_code: Option, +) -> Result<(), sqlx::Error> { + finish_session_at(pool, started_at, &Utc::now().to_rfc3339(), exit_code).await +} + +/// For exit times inferred from the game's log rather than observed live +pub async fn finish_session_at( + pool: &SqlitePool, + started_at: &str, + ended_at: &str, + exit_code: Option, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + UPDATE game_sessions + SET ended_at = ?, exit_code = ? + WHERE started_at = ? AND ended_at IS NULL + "#, + ended_at, + exit_code, + started_at + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn set_session_process( + pool: &SqlitePool, + started_at: &str, + pid: Option, + pid_started_at: Option, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + UPDATE game_sessions + SET pid = ?, pid_started_at = ? + WHERE started_at = ? + "#, + pid, + pid_started_at, + started_at + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn unfinished_sessions(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + UnfinishedSession, + r#" + SELECT cluster_id, started_at, pid, pid_started_at + FROM game_sessions + WHERE ended_at IS NULL + ORDER BY started_at ASC + "# + ) + .fetch_all(pool) + .await +} + +pub async fn insert_server_join( + pool: &SqlitePool, + session_started_at: &str, + address: &str, + port: Option, +) -> Result { + insert_server_join_at( + pool, + session_started_at, + address, + port, + &Utc::now().to_rfc3339(), + ) + .await +} + +pub async fn insert_server_join_at( + pool: &SqlitePool, + session_started_at: &str, + address: &str, + port: Option, + joined_at: &str, +) -> Result { + sqlx::query!( + r#" + INSERT INTO game_session_servers (session_started_at, address, port, joined_at) + VALUES (?, ?, ?, ?) + "#, + session_started_at, + address, + port, + joined_at + ) + .execute(pool) + .await?; + Ok(joined_at.to_string()) +} + +pub async fn finish_server(pool: &SqlitePool, joined_at: &str) -> Result<(), sqlx::Error> { + finish_server_at(pool, joined_at, &Utc::now().to_rfc3339()).await +} + +pub async fn finish_server_at( + pool: &SqlitePool, + joined_at: &str, + disconnected_at: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + UPDATE game_session_servers + SET disconnected_at = ? + WHERE joined_at = ? AND disconnected_at IS NULL + "#, + disconnected_at, + joined_at + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn list_sessions_for_cluster( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + GameSessionRow, + r#" + SELECT + cluster_id, started_at, ended_at, exit_code, + ram_allocated_mb, mods_enabled, java_vendor, java_version + FROM game_sessions + WHERE cluster_id = ? + ORDER BY started_at DESC + "#, + cluster_id + ) + .fetch_all(pool) + .await +} + +pub async fn list_session_servers( + pool: &SqlitePool, + session_started_at: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + GameSessionServerRow, + r#" + SELECT session_started_at, address, port, joined_at, disconnected_at + FROM game_session_servers + WHERE session_started_at = ? + ORDER BY joined_at ASC + "#, + session_started_at + ) + .fetch_all(pool) + .await +} + +/// Lets a log replay rewrite a session's server spans from scratch +pub async fn delete_session_servers( + pool: &SqlitePool, + session_started_at: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + DELETE FROM game_session_servers + WHERE session_started_at = ? + "#, + session_started_at + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn server_join_counts( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + ServerJoinCount, + r#" + SELECT s.address AS "address!", COUNT(*) AS "joins!: i64" + FROM game_session_servers s + JOIN game_sessions g ON g.started_at = s.session_started_at + WHERE g.cluster_id = ? + GROUP BY s.address + ORDER BY COUNT(*) DESC + "#, + cluster_id + ) + .fetch_all(pool) + .await +} + +pub async fn all_session_spans(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as!( + SessionSpan, + r#" + SELECT started_at, ended_at + FROM game_sessions + WHERE ended_at IS NOT NULL + ORDER BY started_at ASC + "# + ) + .fetch_all(pool) + .await +} + +pub async fn session_spans_for_cluster( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + SessionSpan, + r#" + SELECT started_at, ended_at + FROM game_sessions + WHERE cluster_id = ? AND ended_at IS NOT NULL + ORDER BY started_at ASC + "#, + cluster_id + ) + .fetch_all(pool) + .await +} + +pub async fn all_session_servers( + pool: &SqlitePool, +) -> Result, sqlx::Error> { + sqlx::query_as!( + GameSessionServerRow, + r#" + SELECT session_started_at, address, port, joined_at, disconnected_at + FROM game_session_servers + ORDER BY joined_at ASC + "# + ) + .fetch_all(pool) + .await +} + +pub async fn session_servers_for_cluster( + pool: &SqlitePool, + cluster_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as!( + GameSessionServerRow, + r#" + SELECT s.session_started_at, s.address, s.port, s.joined_at, s.disconnected_at + FROM game_session_servers s + JOIN game_sessions g ON g.started_at = s.session_started_at + WHERE g.cluster_id = ? + ORDER BY s.joined_at ASC + "#, + cluster_id + ) + .fetch_all(pool) + .await +} diff --git a/packages/oneclient_db/src/dao/java.rs b/packages/oneclient_db/src/dao/java.rs new file mode 100644 index 00000000..4138c1f0 --- /dev/null +++ b/packages/oneclient_db/src/dao/java.rs @@ -0,0 +1,110 @@ +use sqlx::SqlitePool; + +use crate::DbError; +use crate::models::JavaVersionRow; + +#[allow(clippy::too_many_arguments)] +pub async fn insert( + pool: &SqlitePool, + absolute_path: &str, + major: u32, + version: &str, + vendor: &str, + os_arch: &str, + is_jdk: bool, + probe_version: u32, +) -> Result { + sqlx::query_as!( + JavaVersionRow, + r#" + INSERT INTO java_versions (absolute_path, major, version, vendor, os_arch, is_jdk, probe_version) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(absolute_path) DO UPDATE SET + major = excluded.major, + version = excluded.version, + vendor = excluded.vendor, + os_arch = excluded.os_arch, + is_jdk = excluded.is_jdk, + probe_version = excluded.probe_version + RETURNING absolute_path, major, version, vendor, os_arch, is_jdk as "is_jdk: bool", probe_version + "#, + absolute_path, + i64::from(major), + version, + vendor, + os_arch, + is_jdk, + i64::from(probe_version), + ) + .fetch_one(pool) + .await + .map_err(Into::into) +} + +pub async fn get_by_path( + pool: &SqlitePool, + absolute_path: &str, +) -> Result, DbError> { + let row = sqlx::query_as!( + JavaVersionRow, + r#" + SELECT absolute_path, major, version, vendor, os_arch, is_jdk as "is_jdk: bool", probe_version + FROM java_versions + WHERE absolute_path = ? + "#, + absolute_path, + ) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +pub async fn get_latest_by_major( + pool: &SqlitePool, + major: u32, +) -> Result, DbError> { + let row = sqlx::query_as!( + JavaVersionRow, + r#" + SELECT absolute_path, major, version, vendor, os_arch, is_jdk as "is_jdk: bool", probe_version + FROM java_versions + WHERE major = ? + ORDER BY version DESC + LIMIT 1 + "#, + i64::from(major) + ) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +pub async fn delete_by_path(pool: &SqlitePool, absolute_path: &str) -> Result<(), DbError> { + sqlx::query!( + r#" + DELETE FROM java_versions WHERE absolute_path = ? + "#, + absolute_path, + ) + .execute(pool) + .await?; + + Ok(()) +} + +pub async fn list_all(pool: &SqlitePool) -> Result, DbError> { + let rows = sqlx::query_as!( + JavaVersionRow, + r#" + SELECT absolute_path, major, version, vendor, os_arch, is_jdk as "is_jdk: bool", probe_version + FROM java_versions + ORDER BY major DESC, version DESC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(rows) +} diff --git a/packages/oneclient_db/src/dao/mod.rs b/packages/oneclient_db/src/dao/mod.rs new file mode 100644 index 00000000..63874833 --- /dev/null +++ b/packages/oneclient_db/src/dao/mod.rs @@ -0,0 +1,10 @@ +pub mod applied_migration; +pub mod artifact; +pub mod browser_package_update; +pub mod bundle; +pub mod cluster; +pub mod cluster_bundle; +pub mod game_session; +pub mod java; +pub mod package_metadata; +pub mod setting_profile; diff --git a/packages/oneclient_db/src/dao/package_metadata.rs b/packages/oneclient_db/src/dao/package_metadata.rs new file mode 100644 index 00000000..34210171 --- /dev/null +++ b/packages/oneclient_db/src/dao/package_metadata.rs @@ -0,0 +1,63 @@ +use sqlx::{QueryBuilder, Sqlite, SqlitePool}; + +use crate::models::PackageMetadataRow; + +pub async fn get_package_metadata_batch( + pool: &SqlitePool, + provider: i64, + project_ids: &[String], +) -> Result, sqlx::Error> { + if project_ids.is_empty() { + return Ok(Vec::new()); + } + + let mut builder: QueryBuilder = QueryBuilder::new( + "SELECT provider, project_id, name, summary, author, icon_url \ + FROM package_metadata WHERE provider = ", + ); + builder.push_bind(provider); + builder.push(" AND project_id IN ("); + let mut separated = builder.separated(", "); + for id in project_ids { + separated.push_bind(id); + } + separated.push_unseparated(")"); + + builder + .build_query_as::() + .fetch_all(pool) + .await +} + +pub async fn upsert_package_metadata( + pool: &SqlitePool, + provider: i64, + project_id: &str, + name: &str, + summary: &str, + author: &str, + icon_url: Option<&str>, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + INSERT INTO package_metadata (provider, project_id, name, summary, author, icon_url, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(provider, project_id) DO UPDATE SET + name = excluded.name, + summary = excluded.summary, + author = excluded.author, + icon_url = excluded.icon_url, + updated_at = excluded.updated_at + "#, + provider, + project_id, + name, + summary, + author, + icon_url + ) + .execute(pool) + .await?; + + Ok(()) +} diff --git a/packages/oneclient_db/src/dao/setting_profile.rs b/packages/oneclient_db/src/dao/setting_profile.rs new file mode 100644 index 00000000..0511ece1 --- /dev/null +++ b/packages/oneclient_db/src/dao/setting_profile.rs @@ -0,0 +1,108 @@ +use sqlx::SqlitePool; + +use crate::DbError; +use crate::error::DbResult; +use crate::models::SettingProfileRow; + +const GLOBAL_PROFILE_NAME: &str = "Global"; + +pub fn is_reserved_global_name(name: &str) -> bool { + name == GLOBAL_PROFILE_NAME +} + +pub async fn list_all(pool: &SqlitePool) -> DbResult> { + let rows = sqlx::query_as!( + SettingProfileRow, + r#" + SELECT name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env, + hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode + FROM setting_profiles + ORDER BY name ASC + "#, + ) + .fetch_all(pool) + .await?; + + Ok(rows) +} + +pub async fn get_by_name(pool: &SqlitePool, name: &str) -> DbResult> { + let row = sqlx::query_as!( + SettingProfileRow, + r#" + SELECT name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env, + hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode + FROM setting_profiles + WHERE name = ? + "#, + name, + ) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +pub async fn upsert( + pool: &SqlitePool, + row: &SettingProfileRow, +) -> Result { + if row.name.is_empty() || is_reserved_global_name(&row.name) { + return Err(DbError::InvalidValue { + field: "name".into(), + value: row.name.clone(), + }); + } + + sqlx::query_as!( + SettingProfileRow, + r#" + INSERT INTO setting_profiles ( + name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env, + hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + java_path = excluded.java_path, + resolution = excluded.resolution, + force_fullscreen = excluded.force_fullscreen, + mem_max = excluded.mem_max, + launch_args = excluded.launch_args, + launch_env = excluded.launch_env, + hook_pre = excluded.hook_pre, + hook_wrapper = excluded.hook_wrapper, + hook_post = excluded.hook_post, + os_extra = excluded.os_extra, + browser_update_mode = excluded.browser_update_mode + RETURNING name, java_path, resolution, force_fullscreen, mem_max, launch_args, launch_env, + hook_pre, hook_wrapper, hook_post, os_extra, browser_update_mode + "#, + &row.name, + &row.java_path, + &row.resolution, + row.force_fullscreen, + row.mem_max, + &row.launch_args, + &row.launch_env, + &row.hook_pre, + &row.hook_wrapper, + &row.hook_post, + &row.os_extra, + &row.browser_update_mode, + ) + .fetch_one(pool) + .await + .map_err(Into::into) +} + +pub async fn delete_by_name(pool: &SqlitePool, name: &str) -> Result<(), DbError> { + let result = sqlx::query!(r#"DELETE FROM setting_profiles WHERE name = ?"#, name,) + .execute(pool) + .await?; + + if result.rows_affected() == 0 { + return Err(DbError::NotFound); + } + + Ok(()) +} diff --git a/packages/oneclient_db/src/error.rs b/packages/oneclient_db/src/error.rs new file mode 100644 index 00000000..19278210 --- /dev/null +++ b/packages/oneclient_db/src/error.rs @@ -0,0 +1,21 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DbError { + #[error(transparent)] + Io(#[from] std::io::Error), + + #[error(transparent)] + Sqlx(#[from] sqlx::Error), + + #[error(transparent)] + Migrate(#[from] sqlx::migrate::MigrateError), + + #[error("record not found")] + NotFound, + + #[error("invalid value for {field}: {value}")] + InvalidValue { field: String, value: String }, +} + +pub(crate) type DbResult = Result; diff --git a/packages/oneclient_db/src/lib.rs b/packages/oneclient_db/src/lib.rs new file mode 100644 index 00000000..ff5e44ab --- /dev/null +++ b/packages/oneclient_db/src/lib.rs @@ -0,0 +1,11 @@ +mod error; +mod migrate; +mod pool; + +pub mod backup; +pub mod console; +pub mod dao; +pub mod models; + +pub use error::DbError; +pub use pool::{DbPool, connect}; diff --git a/packages/oneclient_db/src/migrate.rs b/packages/oneclient_db/src/migrate.rs new file mode 100644 index 00000000..8bb88a43 --- /dev/null +++ b/packages/oneclient_db/src/migrate.rs @@ -0,0 +1,136 @@ +use std::path::Path; + +use sqlx::migrate::{MigrateError, Migrator}; + +use crate::{DbError, DbPool, backup}; + +const MAX_ATTEMPTS: u32 = 3; + +pub async fn run(pool: &DbPool, database_path: &Path) -> Result<(), DbError> { + let mut migrator = sqlx::migrate!(); + migrator.set_ignore_missing(true); + + if has_pending(pool, &migrator).await { + match backup::snapshot(pool, database_path).await { + Ok(path) => { + tracing::info!(path = %path.display(), "snapshotted the database before migrating"); + backup::prune(database_path, backup::KEEP); + } + // A migration that then succeeds is the common case, so a failed + // snapshot is not worth refusing to start over + Err(err) => tracing::warn!(%err, "could not snapshot the database before migrating"), + } + } + + let mut attempt = 1; + loop { + let error = match migrator.run(pool).await { + Ok(()) => return Ok(()), + Err(error) => error, + }; + + if attempt >= MAX_ATTEMPTS || !matches!(error, MigrateError::ExecuteMigration(..)) { + return Err(error.into()); + } + + tracing::warn!(attempt, %error, "migration failed, retrying"); + attempt += 1; + } +} + +/// False on a fresh database, where there is nothing yet worth snapshotting +async fn has_pending(pool: &DbPool, migrator: &Migrator) -> bool { + let applied: Vec = sqlx::query_scalar("SELECT version FROM _sqlx_migrations") + .fetch_all(pool) + .await + .unwrap_or_default(); + + !applied.is_empty() && migrator.iter().any(|m| !applied.contains(&m.version)) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_tempfile::TempDir; + use sqlx::SqlitePool; + + async fn migrated() -> (TempDir, std::path::PathBuf, SqlitePool) { + let dir = TempDir::new().await.expect("tempdir"); + let path = dir.dir_path().join("user_data.db"); + let pool = SqlitePool::connect(&format!("sqlite://{}?mode=rwc", path.display())) + .await + .expect("open sqlite"); + + run(&pool, &path).await.expect("migrations run"); + (dir, path, pool) + } + + #[tokio::test] + async fn migrates_a_fresh_database() { + let (_dir, _path, pool) = migrated().await; + + let column: Option = sqlx::query_scalar( + "SELECT 1 FROM pragma_table_info('setting_profiles') WHERE name = ?", + ) + .bind("browser_update_mode") + .fetch_optional(&pool) + .await + .expect("probe"); + + assert!(column.is_some()); + } + + #[tokio::test] + async fn running_twice_is_a_no_op() { + let (_dir, path, pool) = migrated().await; + run(&pool, &path).await.expect("second run"); + } + + #[tokio::test] + async fn tolerates_a_migration_this_build_does_not_ship() { + let (_dir, path, pool) = migrated().await; + + sqlx::query( + "INSERT INTO _sqlx_migrations + (version, description, success, checksum, execution_time) + VALUES (?, 'from a newer build', TRUE, x'00', -1)", + ) + .bind(20990101120000i64) + .execute(&pool) + .await + .expect("record a migration from the future"); + + run(&pool, &path) + .await + .expect("a downgrade must still start"); + } + + #[tokio::test] + async fn a_fresh_database_is_not_snapshotted() { + let (_dir, path, _pool) = migrated().await; + assert!(backup::list(&path).is_empty()); + } + + #[tokio::test] + async fn nothing_outstanding_once_migrated() { + let (_dir, _path, pool) = migrated().await; + assert!(!has_pending(&pool, &sqlx::migrate!()).await); + } + + #[tokio::test] + async fn an_unapplied_migration_is_outstanding() { + let (_dir, _path, pool) = migrated().await; + + let latest: i64 = sqlx::query_scalar("SELECT MAX(version) FROM _sqlx_migrations") + .fetch_one(&pool) + .await + .expect("latest version"); + sqlx::query("DELETE FROM _sqlx_migrations WHERE version = ?") + .bind(latest) + .execute(&pool) + .await + .expect("forget it"); + + assert!(has_pending(&pool, &sqlx::migrate!()).await); + } +} diff --git a/packages/oneclient_db/src/models/artifact.rs b/packages/oneclient_db/src/models/artifact.rs new file mode 100644 index 00000000..7bc31713 --- /dev/null +++ b/packages/oneclient_db/src/models/artifact.rs @@ -0,0 +1,31 @@ +use sqlx::FromRow; + +#[derive(Debug, Clone, FromRow)] +pub struct ArtifactRow { + pub hash: String, + pub content_type: i64, + pub path: String, + pub file_name: String, + pub size_bytes: Option, +} + +#[derive(Debug, Clone, FromRow)] +pub struct ProviderReleaseRow { + pub provider: i64, + pub project_id: String, + pub version_id: String, + pub hash: String, + pub display_name: String, + pub display_version: String, + pub published_at: Option, + pub mc_versions: String, + pub mc_loaders: String, +} + +#[derive(Debug, Clone, FromRow)] +pub struct ClusterArtifactRow { + pub cluster_id: i64, + pub hash: String, + pub cluster_file_name: String, + pub enabled: i64, +} diff --git a/packages/oneclient_db/src/models/browser_package_update.rs b/packages/oneclient_db/src/models/browser_package_update.rs new file mode 100644 index 00000000..7fb36a50 --- /dev/null +++ b/packages/oneclient_db/src/models/browser_package_update.rs @@ -0,0 +1,18 @@ +use sqlx::FromRow; + +/// `hash` is the artifact currently linked into the cluster so a row can never +/// be attached to a file the cluster no longer has +#[derive(Debug, Clone, FromRow)] +pub struct BrowserPackageUpdateRow { + pub cluster_id: i64, + pub hash: String, + pub provider: i64, + pub project_id: String, + pub installed_version_id: String, + pub installed_version_name: String, + pub latest_version_id: String, + pub latest_version_name: String, + pub display_name: String, + pub checked_at: String, + pub skipped: i64, +} diff --git a/packages/oneclient_db/src/models/bundle.rs b/packages/oneclient_db/src/models/bundle.rs new file mode 100644 index 00000000..bf5047fa --- /dev/null +++ b/packages/oneclient_db/src/models/bundle.rs @@ -0,0 +1,32 @@ +use sqlx::FromRow; + +#[derive(Debug, Clone, FromRow)] +pub struct BundleRow { + pub remote_path: String, + pub mc_version: String, + pub mc_loader: i64, + pub file_name: String, + pub name: Option, + pub version_id: Option, + pub category: Option, + pub loader_version: Option, + pub disk_path: String, + pub hidden: i64, + pub etag: Option, + pub synced_at: Option, +} + +pub struct NewBundle<'a> { + pub remote_path: &'a str, + pub mc_version: &'a str, + pub mc_loader: i64, + pub file_name: &'a str, + pub name: Option<&'a str>, + pub version_id: Option<&'a str>, + pub category: Option<&'a str>, + pub loader_version: Option<&'a str>, + pub disk_path: &'a str, + pub hidden: bool, + pub etag: Option<&'a str>, + pub synced_at: Option<&'a str>, +} diff --git a/packages/oneclient_db/src/models/cluster.rs b/packages/oneclient_db/src/models/cluster.rs new file mode 100644 index 00000000..0a7d07c8 --- /dev/null +++ b/packages/oneclient_db/src/models/cluster.rs @@ -0,0 +1,37 @@ +use sqlx::FromRow; + +pub type ClusterId = i64; + +#[derive(Debug, Clone, FromRow)] +pub struct ClusterRow { + pub id: ClusterId, + pub name: String, + pub folder_name: String, + pub setting_profile_name: Option, + pub mc_version: String, + pub mc_loader: i64, + pub stage: i64, + pub mc_loader_version: Option, + pub created_at: Option, + pub last_played: Option, + pub overall_played: Option, + pub linked_modpack_hash: Option, +} + +pub struct NewCluster<'a> { + pub name: &'a str, + pub folder_name: &'a str, + pub mc_version: &'a str, + pub mc_loader: i64, + pub mc_loader_version: Option<&'a str>, + pub setting_profile_name: Option<&'a str>, + pub stage: i64, +} + +#[derive(Debug, Clone, Default)] +pub struct ClusterPatch { + pub name: Option, + pub setting_profile_name: Option>, + pub mc_loader_version: Option>, + pub linked_modpack_hash: Option>, +} diff --git a/packages/oneclient_db/src/models/cluster_bundle.rs b/packages/oneclient_db/src/models/cluster_bundle.rs new file mode 100644 index 00000000..3cb80206 --- /dev/null +++ b/packages/oneclient_db/src/models/cluster_bundle.rs @@ -0,0 +1,49 @@ +use sqlx::FromRow; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverrideType { + Removed, + Disabled, + /// Opt-in install a file the bundle manifest ships with `enabled: false` + Enabled, +} + +impl OverrideType { + pub fn as_str(self) -> &'static str { + match self { + Self::Removed => "removed", + Self::Disabled => "disabled", + Self::Enabled => "enabled", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "removed" => Some(Self::Removed), + "disabled" => Some(Self::Disabled), + "enabled" => Some(Self::Enabled), + _ => None, + } + } +} + +#[derive(Debug, Clone, FromRow)] +pub struct ClusterBundleOverrideRow { + pub id: i64, + pub cluster_id: i64, + pub bundle_name: String, + pub package_id: String, + pub override_type: String, +} + +#[derive(Debug, Clone, FromRow)] +pub struct BundleTrackedArtifactRow { + pub cluster_id: i64, + pub hash: String, + pub cluster_file_name: String, + pub enabled: i64, + pub bundle_name: Option, + pub bundle_version_id: Option, + pub package_id: Option, + pub installed_at: Option, +} diff --git a/packages/oneclient_db/src/models/game_session.rs b/packages/oneclient_db/src/models/game_session.rs new file mode 100644 index 00000000..26beed9b --- /dev/null +++ b/packages/oneclient_db/src/models/game_session.rs @@ -0,0 +1,53 @@ +use sqlx::FromRow; + +pub type GameSessionId = String; + +#[derive(Debug, Clone, FromRow)] +pub struct GameSessionRow { + pub cluster_id: i64, + pub started_at: String, + pub ended_at: Option, + pub exit_code: Option, + pub ram_allocated_mb: i64, + pub mods_enabled: i64, + pub java_vendor: Option, + pub java_version: Option, +} + +pub struct NewGameSession<'a> { + pub cluster_id: i64, + pub started_at: &'a str, + pub ram_allocated_mb: i64, + pub mods_enabled: i64, + pub java_vendor: Option<&'a str>, + pub java_version: Option<&'a str>, +} + +#[derive(Debug, Clone, FromRow)] +pub struct UnfinishedSession { + pub cluster_id: i64, + pub started_at: String, + pub pid: Option, + pub pid_started_at: Option, +} + +#[derive(Debug, Clone, FromRow)] +pub struct GameSessionServerRow { + pub session_started_at: String, + pub address: String, + pub port: Option, + pub joined_at: String, + pub disconnected_at: Option, +} + +#[derive(Debug, Clone, FromRow)] +pub struct ServerJoinCount { + pub address: String, + pub joins: i64, +} + +#[derive(Debug, Clone, FromRow)] +pub struct SessionSpan { + pub started_at: String, + pub ended_at: Option, +} diff --git a/packages/oneclient_db/src/models/java.rs b/packages/oneclient_db/src/models/java.rs new file mode 100644 index 00000000..db77db85 --- /dev/null +++ b/packages/oneclient_db/src/models/java.rs @@ -0,0 +1,12 @@ +use sqlx::FromRow; + +#[derive(Debug, Clone, FromRow)] +pub struct JavaVersionRow { + pub absolute_path: String, + pub major: i64, + pub version: String, + pub vendor: String, + pub os_arch: String, + pub is_jdk: bool, + pub probe_version: i64, +} diff --git a/packages/oneclient_db/src/models/mod.rs b/packages/oneclient_db/src/models/mod.rs new file mode 100644 index 00000000..9bc61da2 --- /dev/null +++ b/packages/oneclient_db/src/models/mod.rs @@ -0,0 +1,22 @@ +mod artifact; +mod browser_package_update; +mod bundle; +mod cluster; +mod cluster_bundle; +mod game_session; +mod java; +mod package_metadata; +mod setting_profile; + +pub use artifact::{ArtifactRow, ClusterArtifactRow, ProviderReleaseRow}; +pub use browser_package_update::BrowserPackageUpdateRow; +pub use bundle::{BundleRow, NewBundle}; +pub use cluster::{ClusterId, ClusterPatch, ClusterRow, NewCluster}; +pub use cluster_bundle::{BundleTrackedArtifactRow, ClusterBundleOverrideRow, OverrideType}; +pub use game_session::{ + GameSessionId, GameSessionRow, GameSessionServerRow, NewGameSession, ServerJoinCount, + SessionSpan, UnfinishedSession, +}; +pub use java::JavaVersionRow; +pub use package_metadata::PackageMetadataRow; +pub use setting_profile::SettingProfileRow; diff --git a/packages/oneclient_db/src/models/package_metadata.rs b/packages/oneclient_db/src/models/package_metadata.rs new file mode 100644 index 00000000..fa922345 --- /dev/null +++ b/packages/oneclient_db/src/models/package_metadata.rs @@ -0,0 +1,11 @@ +use sqlx::FromRow; + +#[derive(Debug, Clone, FromRow)] +pub struct PackageMetadataRow { + pub provider: i64, + pub project_id: String, + pub name: String, + pub summary: String, + pub author: String, + pub icon_url: Option, +} diff --git a/packages/oneclient_db/src/models/setting_profile.rs b/packages/oneclient_db/src/models/setting_profile.rs new file mode 100644 index 00000000..66e2d213 --- /dev/null +++ b/packages/oneclient_db/src/models/setting_profile.rs @@ -0,0 +1,18 @@ +use sqlx::FromRow; + +#[derive(Debug, Clone, FromRow)] +pub struct SettingProfileRow { + pub name: String, + pub java_path: Option, + pub resolution: Option, + pub force_fullscreen: Option, + pub mem_max: Option, + pub launch_args: Option, + pub launch_env: Option, + pub hook_pre: Option, + pub hook_wrapper: Option, + pub hook_post: Option, + pub os_extra: Option, + /// `PackageUpdateMode::as_str` NULL inherits the global setting + pub browser_update_mode: Option, +} diff --git a/packages/oneclient_db/src/pool.rs b/packages/oneclient_db/src/pool.rs new file mode 100644 index 00000000..1cab41c2 --- /dev/null +++ b/packages/oneclient_db/src/pool.rs @@ -0,0 +1,38 @@ +use sqlx::SqlitePool; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use std::path::Path; +use std::str::FromStr; + +use crate::DbError; + +pub type DbPool = SqlitePool; + +#[tracing::instrument( + skip(database_path), + fields(database_path = %database_path.as_ref().display()) +)] +pub async fn connect(database_path: impl AsRef) -> Result { + let database_path = database_path.as_ref(); + if let Some(parent) = database_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let options = SqliteConnectOptions::from_str(&format!( + "sqlite://{}?mode=rwc", + dunce::canonicalize(database_path) + .unwrap_or_else(|_| database_path.to_path_buf()) + .display() + ))? + .create_if_missing(true); + + let pool = SqlitePoolOptions::new() + .max_connections(4) + .connect_with(options) + .await?; + + crate::migrate::run(&pool, database_path).await?; + + tracing::info!("database ready"); + + Ok(pool) +} diff --git a/packages/oneclient_discord/Cargo.toml b/packages/oneclient_discord/Cargo.toml new file mode 100644 index 00000000..f53dce78 --- /dev/null +++ b/packages/oneclient_discord/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "oneclient_discord" +description = "Discord rich presence for OneClient" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true + +chrono.workspace = true +discord-rich-presence.workspace = true +tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +tracing-subscriber.workspace = true diff --git a/packages/oneclient_discord/examples/rpc.rs b/packages/oneclient_discord/examples/rpc.rs new file mode 100644 index 00000000..5b542250 --- /dev/null +++ b/packages/oneclient_discord/examples/rpc.rs @@ -0,0 +1,33 @@ +use std::time::Duration; + +use oneclient_discord::{DiscordRpc, Presence}; + +fn main() { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::DEBUG) + .init(); + + let rpc = DiscordRpc::spawn(true); + + println!("idle presence for 10s..."); + std::thread::sleep(Duration::from_secs(10)); + + println!("playing presence for 10s..."); + rpc.set_presence(Presence::Playing { + cluster: "Example Cluster".to_owned(), + mc_version: "1.8.9".to_owned(), + }); + std::thread::sleep(Duration::from_secs(10)); + + println!("disabling for 5s..."); + rpc.set_enabled(false); + std::thread::sleep(Duration::from_secs(5)); + + println!("re-enabling for 10s..."); + rpc.set_enabled(true); + std::thread::sleep(Duration::from_secs(10)); + + println!("shutting down"); + rpc.shutdown(); + std::thread::sleep(Duration::from_secs(1)); +} diff --git a/packages/oneclient_discord/src/lib.rs b/packages/oneclient_discord/src/lib.rs new file mode 100644 index 00000000..f8b41280 --- /dev/null +++ b/packages/oneclient_discord/src/lib.rs @@ -0,0 +1,226 @@ +use std::sync::mpsc::{self, RecvTimeoutError, Sender}; +use std::time::{Duration, Instant}; + +use discord_rich_presence::activity::{Activity, Assets, Button, Timestamps}; +use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; + +use oneclient_common::constants::DISCORD_CLIENT_ID; + +const RECONNECT_BACKOFF: Duration = Duration::from_secs(60); + +const IDLE_POLL: Duration = Duration::from_secs(3600); + +const LARGE_IMAGE: &str = "onelauncher_logo_512"; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Presence { + #[default] + Idle, + Playing { + cluster: String, + mc_version: String, + }, +} + +impl Presence { + fn details(&self) -> &'static str { + match self { + Self::Idle => "Idle", + Self::Playing { .. } => "Playing Minecraft", + } + } + + fn state(&self) -> String { + match self { + Self::Idle => "In the launcher".to_owned(), + Self::Playing { + cluster, + mc_version, + } => format!("{cluster} ({mc_version})"), + } + } +} + +enum Command { + SetPresence(Presence), + SetEnabled(bool), + Shutdown, +} + +#[derive(Clone)] +pub struct DiscordRpc { + tx: Sender, +} + +impl DiscordRpc { + pub fn spawn(enabled: bool) -> Self { + let (tx, rx) = mpsc::channel(); + + std::thread::Builder::new() + .name("discord-rpc".to_owned()) + .spawn(move || Worker::new(enabled).run(&rx)) + .expect("failed to spawn the Discord RPC thread"); + + Self { tx } + } + + pub fn set_presence(&self, presence: Presence) { + self.send(Command::SetPresence(presence)); + } + + pub fn set_enabled(&self, enabled: bool) { + self.send(Command::SetEnabled(enabled)); + } + + pub fn shutdown(&self) { + self.send(Command::Shutdown); + } + + fn send(&self, command: Command) { + if self.tx.send(command).is_err() { + tracing::debug!("Discord RPC worker is gone; dropping command"); + } + } +} + +struct Worker { + client: DiscordIpcClient, + enabled: bool, + connected: bool, + ever_connected: bool, + presence: Presence, + presence_since: i64, + next_attempt: Instant, +} + +impl Worker { + fn new(enabled: bool) -> Self { + Self { + client: DiscordIpcClient::new(DISCORD_CLIENT_ID), + enabled, + connected: false, + ever_connected: false, + presence: Presence::Idle, + presence_since: chrono::Utc::now().timestamp(), + next_attempt: Instant::now(), + } + } + + fn run(mut self, rx: &mpsc::Receiver) { + self.publish(); + + loop { + match rx.recv_timeout(self.poll_interval()) { + Ok(Command::SetPresence(presence)) => self.set_presence(presence), + Ok(Command::SetEnabled(enabled)) => self.set_enabled(enabled), + Ok(Command::Shutdown) | Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => self.publish(), + } + } + + self.disconnect(); + } + + fn poll_interval(&self) -> Duration { + if !self.enabled || self.connected { + return IDLE_POLL; + } + + self.next_attempt + .saturating_duration_since(Instant::now()) + .max(Duration::from_millis(100)) + } + + fn set_presence(&mut self, presence: Presence) { + if self.presence == presence { + return; + } + + self.presence = presence; + self.presence_since = chrono::Utc::now().timestamp(); + self.publish(); + } + + fn set_enabled(&mut self, enabled: bool) { + if self.enabled == enabled { + return; + } + + self.enabled = enabled; + + if enabled { + self.next_attempt = Instant::now(); + self.publish(); + } else { + self.disconnect(); + } + } + + fn publish(&mut self) { + if !self.enabled || !self.ensure_connected() { + return; + } + + let state = self.presence.state(); + let activity = Activity::new() + .details(self.presence.details()) + .state(&state) + .timestamps(Timestamps::new().start(self.presence_since)) + .assets( + Assets::new() + .large_image(LARGE_IMAGE) + .large_text("OneClient"), + ) + .buttons(vec![Button::new("Website", "https://polyfrost.org/")]); + + if let Err(err) = self.client.set_activity(activity) { + tracing::warn!("Discord RPC set_activity failed: {err}"); + self.drop_connection(); + } + } + + fn ensure_connected(&mut self) -> bool { + if self.connected { + return true; + } + + if Instant::now() < self.next_attempt { + return false; + } + self.next_attempt = Instant::now() + RECONNECT_BACKOFF; + + let result = if self.ever_connected { + self.client.reconnect() + } else { + self.client.connect() + }; + + match result { + Ok(()) => { + tracing::info!("Discord RPC connected"); + self.connected = true; + self.ever_connected = true; + true + } + Err(err) => { + tracing::debug!("Discord RPC connect failed (is Discord running?): {err}"); + false + } + } + } + + fn drop_connection(&mut self) { + self.connected = false; + self.next_attempt = Instant::now() + RECONNECT_BACKOFF; + } + + fn disconnect(&mut self) { + if !self.connected { + return; + } + + let _ = self.client.clear_activity(); + let _ = self.client.close(); + self.connected = false; + } +} diff --git a/packages/oneclient_events/Cargo.toml b/packages/oneclient_events/Cargo.toml new file mode 100644 index 00000000..d7acf940 --- /dev/null +++ b/packages/oneclient_events/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "oneclient_events" +description = "The OneClient event bus: notifications, progress, prompts and signals" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +thiserror.workspace = true +tokio = { workspace = true } +tracing.workspace = true +uuid.workspace = true diff --git a/packages/oneclient_events/src/bus.rs b/packages/oneclient_events/src/bus.rs new file mode 100644 index 00000000..54be020e --- /dev/null +++ b/packages/oneclient_events/src/bus.rs @@ -0,0 +1,301 @@ +use tokio::sync::{mpsc, oneshot}; +use uuid::Uuid; + +use crate::error::{EventError, EventResult}; +use crate::event::{ + Event, GameEvent, LaunchStage, Level, Message, Notification, ProgressEvent, Signal, +}; +use crate::prompt::{Answer, Chosen, Prompt, PromptRequest}; + +/// Cheap to clone every clone feeds the same channel +/// Hand clones to subsystems rather than wrapping this in an `Arc` +#[derive(Clone, Debug)] +pub struct EventBus { + tx: mpsc::UnboundedSender, +} + +pub type EventReceiver = mpsc::UnboundedReceiver; + +impl EventBus { + #[must_use] + pub fn new(tx: mpsc::UnboundedSender) -> Self { + Self { tx } + } + + #[must_use] + pub fn channel() -> (Self, EventReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + (Self::new(tx), rx) + } + + /// A closed bus is logged rather than returned use [`EventBus::ask`] when + /// you need to know whether the other end is alive + pub fn emit(&self, event: impl Into) { + if let Err(err) = self.tx.send(event.into()) { + tracing::debug!("dropping event, bus is closed: {err}"); + } + } + + #[must_use] + pub fn is_open(&self) -> bool { + !self.tx.is_closed() + } + + /// ```ignore + /// events.notify("Install failed").body(err.to_string()).error().send(); + /// ``` + pub fn notify(&self, title: impl Into) -> NotificationBuilder<'_> { + NotificationBuilder { + bus: self, + message: Message { + title: title.into(), + body: String::new(), + level: Level::Info, + }, + } + } + + /// Repeated calls with the same `id` replace one another + pub fn progress(&self, id: Uuid, label: impl Into, current: u64, total: u64) { + self.emit(ProgressEvent::Update { + id, + label: label.into(), + current, + total, + }); + } + + pub fn finish_progress(&self, id: Uuid, title: impl Into, body: impl Into) { + self.emit(ProgressEvent::Complete { + id, + title: title.into(), + body: body.into(), + }); + } + + pub fn game_stage(&self, cluster_id: i64, stage: LaunchStage) { + self.emit(GameEvent::Stage { cluster_id, stage }); + } + + pub fn game_log(&self, cluster_id: i64, line: impl Into) { + self.emit(GameEvent::Log { + cluster_id, + line: line.into(), + }); + } + + pub fn game_failed(&self, cluster_id: i64, message: impl Into) { + self.emit(GameEvent::Failed { + cluster_id, + message: message.into(), + }); + } + + pub fn signal(&self, signal: Signal) { + self.emit(signal); + } + + /// Returns `Ok(None)` when the prompt was dismissed + #[tracing::instrument(level = "debug", skip_all, fields(title = %prompt.title))] + pub async fn ask(&self, prompt: Prompt) -> EventResult>> { + let Prompt { + title, + body, + options, + dismiss, + } = prompt; + + let (choices, values): (Vec<_>, Vec<_>) = options.into_iter().unzip(); + let (reply, reply_rx) = oneshot::channel(); + + self.tx + .send(Event::Notification(Notification::Prompt(PromptRequest { + title, + body, + choices: choices.clone(), + dismiss, + reply, + }))) + .map_err(|_| EventError::BusClosed)?; + + // A dropped sender means the UI went away without answering + let Some(answer) = reply_rx.await.map_err(|_| EventError::BusClosed)? else { + return Ok(None); + }; + + let Answer { id, input } = answer; + let index = choices + .iter() + .position(|choice| choice.id == id) + .ok_or(EventError::UnknownChoice(id))?; + + let value = values + .into_iter() + .nth(index) + .ok_or(EventError::UnknownChoice(id))?; + + Ok(Some(Chosen { value, input })) + } +} + +#[must_use = "the notification is not emitted until `.send()` is called"] +pub struct NotificationBuilder<'a> { + bus: &'a EventBus, + message: Message, +} + +impl NotificationBuilder<'_> { + pub fn body(mut self, body: impl Into) -> Self { + self.message.body = body.into(); + self + } + + pub fn level(mut self, level: Level) -> Self { + self.message.level = level; + self + } + + pub fn info(self) -> Self { + self.level(Level::Info) + } + + pub fn error(self) -> Self { + self.level(Level::Error) + } + + pub fn send(self) { + self.bus.emit(self.message); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::prompt::{Choice, InputValue}; + + #[derive(Debug, PartialEq, Eq)] + enum Answered { + Download, + Folder, + } + + fn prompt() -> Prompt { + Prompt::new("Java required", "No Java 21 runtime was found.") + .option( + Choice::primary("download", "Download").picks_selection("java-vendor"), + Answered::Download, + ) + .option( + Choice::new("folder", "Choose folder").picks_folder("Pick a folder"), + Answered::Folder, + ) + .dismiss("Cancel") + } + + #[tokio::test] + async fn ask_maps_the_answer_back_to_the_callers_value() { + let (bus, mut rx) = EventBus::channel(); + + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + assert_eq!(request.choices.len(), 2); + assert_eq!(request.dismiss.as_deref(), Some("Cancel")); + request + .reply + .send(Some(Answer::new("folder").with_folder("/opt/jdk21"))) + .unwrap(); + + let chosen = task.await.unwrap().unwrap().unwrap(); + assert_eq!(chosen.value, Answered::Folder); + assert_eq!(chosen.folder(), Some(std::path::Path::new("/opt/jdk21"))); + } + + #[tokio::test] + async fn selection_input_round_trips() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request + .reply + .send(Some(Answer::new("download").with_selection("adoptium"))) + .unwrap(); + + let chosen = task.await.unwrap().unwrap().unwrap(); + assert_eq!(chosen.value, Answered::Download); + assert_eq!(chosen.selection(), Some("adoptium")); + assert_eq!( + chosen.input, + Some(InputValue::Selection("adoptium".to_string())) + ); + } + + #[tokio::test] + async fn dismissing_yields_none() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request.reply.send(None).unwrap(); + + assert!(task.await.unwrap().unwrap().is_none()); + } + + #[tokio::test] + async fn unknown_choice_is_an_error_not_a_dismissal() { + let (bus, mut rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + + let Some(Event::Notification(Notification::Prompt(request))) = rx.recv().await else { + panic!("expected a prompt"); + }; + request.reply.send(Some(Answer::new("nonsense"))).unwrap(); + + assert_eq!( + task.await.unwrap().unwrap_err(), + EventError::UnknownChoice("nonsense") + ); + } + + #[tokio::test] + async fn dropping_the_receiver_unblocks_the_asker() { + let (bus, rx) = EventBus::channel(); + let task = tokio::spawn(async move { bus.ask(prompt()).await }); + drop(rx); + + assert_eq!(task.await.unwrap().unwrap_err(), EventError::BusClosed); + } + + #[test] + fn emitting_on_a_closed_bus_does_not_panic() { + let (bus, rx) = EventBus::channel(); + drop(rx); + + assert!(!bus.is_open()); + bus.notify("gone") + .body("nobody is listening") + .error() + .send(); + bus.game_log(1, "still fine"); + } + + #[tokio::test] + async fn notification_builder_defaults_to_info_with_an_empty_body() { + let (bus, mut rx) = EventBus::channel(); + bus.notify("Done").send(); + + let Some(Event::Notification(Notification::Message(message))) = rx.recv().await else { + panic!("expected a message"); + }; + assert_eq!(message.title, "Done"); + assert_eq!(message.body, ""); + assert_eq!(message.level, Level::Info); + } +} diff --git a/packages/oneclient_events/src/error.rs b/packages/oneclient_events/src/error.rs new file mode 100644 index 00000000..048199c8 --- /dev/null +++ b/packages/oneclient_events/src/error.rs @@ -0,0 +1,12 @@ +pub type EventResult = Result; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum EventError { + #[error("the event bus is closed")] + BusClosed, + + /// A wrong answer rather than a missing one so it must not be read as + /// "dismissed" + #[error("prompt answered with an unknown choice '{0}'")] + UnknownChoice(&'static str), +} diff --git a/packages/oneclient_events/src/event.rs b/packages/oneclient_events/src/event.rs new file mode 100644 index 00000000..d118a802 --- /dev/null +++ b/packages/oneclient_events/src/event.rs @@ -0,0 +1,131 @@ +use uuid::Uuid; + +use crate::progress::GroupedProgressEvent; +use crate::prompt::PromptRequest; + +/// One channel carries all of it so ordering between a progress update and the +/// notification that follows it is preserved +/// Nothing here names a screen or a surface rendering is entirely the +/// front-end's call +#[derive(Debug)] +pub enum Event { + Notification(Notification), + Progress(ProgressEvent), + Game(GameEvent), + /// State changed elsewhere whoever caches it should refetch + Signal(Signal), +} + +#[derive(Debug)] +pub enum Notification { + Message(Message), + Prompt(PromptRequest), +} + +impl From for Event { + fn from(value: Notification) -> Self { + Self::Notification(value) + } +} + +impl From for Event { + fn from(value: Message) -> Self { + Self::Notification(Notification::Message(value)) + } +} + +impl From for Event { + fn from(value: PromptRequest) -> Self { + Self::Notification(Notification::Prompt(value)) + } +} + +impl From for Event { + fn from(value: ProgressEvent) -> Self { + Self::Progress(value) + } +} + +impl From for Event { + fn from(value: GameEvent) -> Self { + Self::Game(value) + } +} + +impl From for Event { + fn from(value: Signal) -> Self { + Self::Signal(value) + } +} + +impl From for Event { + fn from(value: GroupedProgressEvent) -> Self { + Self::Progress(ProgressEvent::Grouped(value)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + pub title: String, + pub body: String, + pub level: Level, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub enum Level { + #[default] + Info, + Error, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProgressEvent { + /// Keyed by `id` so repeated updates replace one another rather than stack + Update { + id: Uuid, + label: String, + current: u64, + total: u64, + }, + + /// Turns the in-flight [`ProgressEvent::Update`] with this `id` into a + /// finished message in place rather than emitting a second card + Complete { + id: Uuid, + title: String, + body: String, + }, + + Grouped(GroupedProgressEvent), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GameEvent { + Stage { cluster_id: i64, stage: LaunchStage }, + Log { cluster_id: i64, line: String }, + Failed { cluster_id: i64, message: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LaunchStage { + Checking, + Downloading, + Launching, + Running, + Exited, +} + +impl LaunchStage { + #[must_use] + pub fn is_busy(self) -> bool { + matches!(self, Self::Checking | Self::Downloading | Self::Launching) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Signal { + ClustersChanged, + JavaChanged, + /// Initial background sync finished + SyncComplete, +} diff --git a/packages/oneclient_events/src/lib.rs b/packages/oneclient_events/src/lib.rs new file mode 100644 index 00000000..43d79479 --- /dev/null +++ b/packages/oneclient_events/src/lib.rs @@ -0,0 +1,23 @@ +//! This crate is a leaf +//! It must not learn what a Java vendor a package provider or a cluster is +//! prompts carry caller-defined values through [`Prompt`] and +//! [`TaskCategory`] holds display labels rather than types + +mod bus; +mod error; +mod event; + +pub mod progress; +pub mod prompt; + +pub use bus::{EventBus, EventReceiver, NotificationBuilder}; +pub use error::{EventError, EventResult}; +pub use event::{ + Event, GameEvent, LaunchStage, Level, Message, Notification, ProgressEvent, Signal, +}; +pub use progress::{ + GroupedProgressChild, GroupedProgressEvent, GroupedProgressSession, TaskCategory, TaskPhase, +}; +pub use prompt::{ + Answer, Choice, ChoiceInput, ChoiceStyle, Chosen, InputValue, Prompt, PromptRequest, +}; diff --git a/packages/oneclient_events/src/progress.rs b/packages/oneclient_events/src/progress.rs new file mode 100644 index 00000000..f810dd20 --- /dev/null +++ b/packages/oneclient_events/src/progress.rs @@ -0,0 +1,398 @@ +//! Both handles are RAII dropping the last one ends the session or finishes +//! the child so an early return on an error path cannot leave the UI showing a +//! task that will never complete + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +use uuid::Uuid; + +use crate::bus::EventBus; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GroupedProgressEvent { + Start { + session_id: Uuid, + title: String, + }, + /// Pre-announces a category's children so the aggregate total is known + /// up-front instead of climbing during a `buffer_unordered` fan-out + Expect { + session_id: Uuid, + category: TaskCategory, + count: u64, + total: u64, + }, + AddChild { + session_id: Uuid, + child_id: Uuid, + label: String, + total: u64, + category: TaskCategory, + }, + UpdateChild { + session_id: Uuid, + child_id: Uuid, + current: u64, + total: u64, + }, + SetChildPhase { + session_id: Uuid, + child_id: Uuid, + phase: TaskPhase, + }, + FinishChild { + session_id: Uuid, + child_id: Uuid, + }, + End { + session_id: Uuid, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TaskPhase { + #[default] + Downloading, + Verifying, + Extracting, + Installing, +} + +impl TaskPhase { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Downloading => "Downloading", + Self::Verifying => "Verifying", + Self::Extracting => "Extracting", + Self::Installing => "Installing", + } + } +} + +/// Display labels only not a dependency on the subsystems they name +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum TaskCategory { + Client, + Metadata, + Libraries, + Natives, + Assets, + Java, + #[default] + Packages, +} + +impl TaskCategory { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Client => "Client", + Self::Metadata => "Metadata", + Self::Libraries => "Libraries", + Self::Natives => "Natives", + Self::Assets => "Assets", + Self::Java => "Java runtime", + Self::Packages => "Packages", + } + } + + #[must_use] + pub fn is_minecraft(self) -> bool { + !matches!(self, Self::Packages) + } +} + +struct SessionInner { + session_id: Uuid, + events: EventBus, + ended: AtomicBool, +} + +impl SessionInner { + fn end(&self) { + if self.ended.swap(true, Ordering::Relaxed) { + return; + } + + self.events.emit(GroupedProgressEvent::End { + session_id: self.session_id, + }); + } +} + +impl Drop for SessionInner { + fn drop(&mut self) { + self.end(); + } +} + +struct ChildInner { + session_id: Uuid, + child_id: Uuid, + events: EventBus, + label: String, + total: AtomicU64, + finished: Arc, +} + +#[derive(Clone)] +pub struct GroupedProgressSession { + inner: Arc, +} + +#[derive(Clone)] +pub struct GroupedProgressChild { + inner: Arc, +} + +impl GroupedProgressSession { + pub fn start(events: &EventBus, title: impl Into) -> Self { + let session_id = Uuid::new_v4(); + let title = title.into(); + + events.emit(GroupedProgressEvent::Start { session_id, title }); + + Self { + inner: Arc::new(SessionInner { + session_id, + events: events.clone(), + ended: AtomicBool::new(false), + }), + } + } + + pub fn id(&self) -> Uuid { + self.inner.session_id + } + + /// `count` = number of files `total` = sum of their sizes in bytes + pub fn expect(&self, category: TaskCategory, count: u64, total: u64) { + if count == 0 { + return; + } + self.inner.events.emit(GroupedProgressEvent::Expect { + session_id: self.inner.session_id, + category, + count, + total, + }); + } + + pub fn child( + &self, + label: impl Into, + total: u64, + category: TaskCategory, + ) -> GroupedProgressChild { + let child_id = Uuid::new_v4(); + let label = label.into(); + let total = total.max(1); + + self.inner.events.emit(GroupedProgressEvent::AddChild { + session_id: self.inner.session_id, + child_id, + label: label.clone(), + total, + category, + }); + + GroupedProgressChild { + inner: Arc::new(ChildInner { + session_id: self.inner.session_id, + child_id, + events: self.inner.events.clone(), + label, + total: AtomicU64::new(total), + finished: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }), + } + } + + pub async fn run_child( + &self, + label: impl Into, + total: u64, + category: TaskCategory, + f: F, + ) -> Result + where + F: FnOnce(GroupedProgressChild) -> Fut, + Fut: std::future::Future>, + { + let child = self.child(label, total, category); + let result = f(child.clone()).await; + if result.is_ok() { + child.finish(); + } + result + } + + pub fn finish(self) { + self.inner.end(); + } + + /// Consumes the session without emitting an `End` event + /// Use when another actor will take over the session's notification entry + pub fn detach(self) -> Uuid { + self.inner.ended.store(true, Ordering::Relaxed); + self.inner.session_id + } +} + +impl GroupedProgressChild { + pub fn label(&self) -> &str { + &self.inner.label + } + + pub fn set_phase(&self, phase: TaskPhase) { + if self + .inner + .finished + .load(std::sync::atomic::Ordering::Relaxed) + { + return; + } + self.inner.events.emit(GroupedProgressEvent::SetChildPhase { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + phase, + }); + } + + pub fn set_progress(&self, current: u64, total: Option) { + if self + .inner + .finished + .load(std::sync::atomic::Ordering::Relaxed) + { + return; + } + + // Prefer the largest known total Content-Length is often missing (1) for + // chunked responses and would clobber a real manifest size freezing the bar + let stored = self.inner.total.load(Ordering::Relaxed); + let total = total.unwrap_or(0).max(stored).max(1); + if total > stored { + self.inner.total.store(total, Ordering::Relaxed); + } + self.inner.events.emit(GroupedProgressEvent::UpdateChild { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + current, + total, + }); + } + + pub fn finish(&self) { + if self + .inner + .finished + .swap(true, std::sync::atomic::Ordering::Relaxed) + { + return; + } + + self.inner.events.emit(GroupedProgressEvent::FinishChild { + session_id: self.inner.session_id, + child_id: self.inner.child_id, + }); + } +} + +impl Drop for GroupedProgressChild { + /// Only the *last* handle finishes the task clones are handed around + /// routinely and finishing on any clone's drop would end the task early + fn drop(&mut self) { + if Arc::strong_count(&self.inner) == 1 { + self.finish(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::event::{Event, ProgressEvent}; + + fn drain(rx: &mut crate::bus::EventReceiver) -> Vec { + let mut events = Vec::new(); + while let Ok(event) = rx.try_recv() { + if let Event::Progress(ProgressEvent::Grouped(event)) = event { + events.push(event); + } + } + events + } + + fn has_finish(events: &[GroupedProgressEvent]) -> bool { + events + .iter() + .any(|e| matches!(e, GroupedProgressEvent::FinishChild { .. })) + } + + #[test] + fn dropping_a_clone_does_not_finish_the_task() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let events = EventBus::new(tx); + let session = GroupedProgressSession::start(&events, "test"); + let child = session.child("file", 100, TaskCategory::Assets); + let _ = drain(&mut rx); + + drop(child.clone()); + assert!( + !has_finish(&drain(&mut rx)), + "a cloned handle going out of scope must not finish the task" + ); + + child.set_progress(50, Some(100)); + let events = drain(&mut rx); + assert!( + events + .iter() + .any(|e| matches!(e, GroupedProgressEvent::UpdateChild { current: 50, .. })), + "progress must still be reported after a clone is dropped: {events:?}" + ); + + drop(child); + assert!( + has_finish(&drain(&mut rx)), + "the last handle going out of scope should finish the task" + ); + } + + #[test] + fn explicit_finish_reports_once() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let events = EventBus::new(tx); + let session = GroupedProgressSession::start(&events, "test"); + let child = session.child("file", 100, TaskCategory::Assets); + let _ = drain(&mut rx); + + child.finish(); + assert_eq!( + drain(&mut rx) + .iter() + .filter(|e| matches!(e, GroupedProgressEvent::FinishChild { .. })) + .count(), + 1 + ); + + drop(child); + assert!(!has_finish(&drain(&mut rx)), "drop must not finish twice"); + } + + #[test] + fn progress_after_finish_is_ignored() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let events = EventBus::new(tx); + let session = GroupedProgressSession::start(&events, "test"); + let child = session.child("file", 100, TaskCategory::Assets); + child.finish(); + let _ = drain(&mut rx); + + child.set_progress(75, Some(100)); + assert!(drain(&mut rx).is_empty()); + } +} diff --git a/packages/oneclient_events/src/prompt.rs b/packages/oneclient_events/src/prompt.rs new file mode 100644 index 00000000..4491b14f --- /dev/null +++ b/packages/oneclient_events/src/prompt.rs @@ -0,0 +1,190 @@ +//! A prompt carries its own [`Choice`] list +//! The wire format is untyped (a choice id plus optional collected input) but +//! [`Prompt`] pairs each choice with a caller-defined value keeping this +//! crate free of any dependency on the subsystems that raise prompts + +use std::path::{Path, PathBuf}; + +use tokio::sync::oneshot; + +/// Only a hint the event layer has no business knowing what a button looks like +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ChoiceStyle { + Primary, + #[default] + Secondary, + Danger, +} + +/// Something the UI must collect before the choice can be answered +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChoiceInput { + Folder { + title: String, + }, + /// `hint` names the kind of thing being picked so the front-end can route to + /// the right picker its meaning belongs to whoever built the prompt + Selection { + hint: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InputValue { + Folder(PathBuf), + Selection(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Choice { + /// Stable identifier matched when the answer comes back + pub id: &'static str, + pub label: String, + pub style: ChoiceStyle, + pub input: Option, +} + +impl Choice { + pub fn new(id: &'static str, label: impl Into) -> Self { + Self { + id, + label: label.into(), + style: ChoiceStyle::Secondary, + input: None, + } + } + + #[must_use] + pub fn primary(id: &'static str, label: impl Into) -> Self { + Self::new(id, label).style(ChoiceStyle::Primary) + } + + #[must_use] + pub fn danger(id: &'static str, label: impl Into) -> Self { + Self::new(id, label).style(ChoiceStyle::Danger) + } + + #[must_use] + pub fn style(mut self, style: ChoiceStyle) -> Self { + self.style = style; + self + } + + #[must_use] + pub fn picks_folder(mut self, title: impl Into) -> Self { + self.input = Some(ChoiceInput::Folder { + title: title.into(), + }); + self + } + + #[must_use] + pub fn picks_selection(mut self, hint: impl Into) -> Self { + self.input = Some(ChoiceInput::Selection { hint: hint.into() }); + self + } +} + +/// ```ignore +/// enum JavaAnswer { Download, PickFolder } +/// +/// let answer = events.ask( +/// Prompt::new("Java required", "No Java 21 runtime was found.") +/// .option( +/// Choice::primary("download", "Download").picks_selection("java-vendor"), +/// JavaAnswer::Download, +/// ) +/// .option( +/// Choice::new("folder", "Choose folder").picks_folder("Select a Java 21 folder"), +/// JavaAnswer::PickFolder, +/// ) +/// .dismiss("Cancel"), +/// ).await?; +/// ``` +#[must_use = "a prompt is not shown until it is passed to `EventBus::ask`"] +pub struct Prompt { + pub(crate) title: String, + pub(crate) body: String, + pub(crate) options: Vec<(Choice, T)>, + pub(crate) dismiss: Option, +} + +impl Prompt { + pub fn new(title: impl Into, body: impl Into) -> Self { + Self { + title: title.into(), + body: body.into(), + options: Vec::new(), + dismiss: None, + } + } + + pub fn option(mut self, choice: Choice, value: T) -> Self { + self.options.push((choice, value)); + self + } + + /// A prompt without this can still be dismissed by closing it so callers + /// must always handle `None` + pub fn dismiss(mut self, label: impl Into) -> Self { + self.dismiss = Some(label.into()); + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Chosen { + pub value: T, + pub input: Option, +} + +impl Chosen { + pub fn folder(&self) -> Option<&Path> { + match &self.input { + Some(InputValue::Folder(path)) => Some(path), + _ => None, + } + } + + pub fn selection(&self) -> Option<&str> { + match &self.input { + Some(InputValue::Selection(value)) => Some(value), + _ => None, + } + } +} + +/// The untyped prompt as it crosses the bus `T` never leaves the caller's crate +#[derive(Debug)] +pub struct PromptRequest { + pub title: String, + pub body: String, + pub choices: Vec, + pub dismiss: Option, + pub reply: oneshot::Sender>, +} + +/// `None` on the wire means dismissed +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Answer { + pub id: &'static str, + pub input: Option, +} + +impl Answer { + pub fn new(id: &'static str) -> Self { + Self { id, input: None } + } + + #[must_use] + pub fn with_folder(mut self, path: impl Into) -> Self { + self.input = Some(InputValue::Folder(path.into())); + self + } + + #[must_use] + pub fn with_selection(mut self, value: impl Into) -> Self { + self.input = Some(InputValue::Selection(value.into())); + self + } +} diff --git a/packages/oneclient_java/Cargo.toml b/packages/oneclient_java/Cargo.toml new file mode 100644 index 00000000..b677d596 --- /dev/null +++ b/packages/oneclient_java/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "oneclient_java" +description = "Locating, probing and installing Java runtimes" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_events.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +async-trait.workspace = true +async-tempfile.workspace = true +async_zip.workspace = true +astral-tokio-tar.workspace = true +futures-lite.workspace = true +tokio.workspace = true + +reqwest.workspace = true +url.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +tokio = { workspace = true } diff --git a/packages/oneclient_java/assets/java/JavaInfo.class b/packages/oneclient_java/assets/java/JavaInfo.class new file mode 100644 index 00000000..b4d13ae6 Binary files /dev/null and b/packages/oneclient_java/assets/java/JavaInfo.class differ diff --git a/packages/oneclient_java/assets/java/JavaInfo.java b/packages/oneclient_java/assets/java/JavaInfo.java new file mode 100644 index 00000000..10d19de5 --- /dev/null +++ b/packages/oneclient_java/assets/java/JavaInfo.java @@ -0,0 +1,98 @@ +import java.io.File; +import java.util.Locale; + +public final class JavaInfo { + private static final String[] CHECKED_PROPERTIES = new String[] {"os.arch", "java.version", "java.vendor", "java.home"}; + + public static void main(String[] args) { + int returnCode = 0; + + for (String key : CHECKED_PROPERTIES) { + String property = System.getProperty(key); + + if (property != null) { + System.out.println(key + "=" + property); + } else { + returnCode = 1; + } + } + + System.out.println("java.awt=" + hasClass("java.awt.Toolkit")); + System.out.println("java.awt.natives=" + hasAwtNatives()); + System.out.println("java.awt.link=" + awtLinks()); + System.out.println("java.jdk=" + hasCompiler()); + + System.exit(returnCode); + } + + /** Loads the class without initialising it, so no display is ever needed. */ + private static boolean hasClass(String name) { + try { + Class.forName(name, false, JavaInfo.class.getClassLoader()); + return true; + } catch (Throwable ignored) { + return false; + } + } + + private static boolean hasAwtNatives() { + try { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + String[] required; + + if (os.contains("win")) { + required = new String[] {"awt.dll", "fontmanager.dll"}; + } else if (os.contains("mac") || os.contains("darwin")) { + required = new String[] {"libawt_lwawt.dylib", "libfontmanager.dylib"}; + } else { + required = new String[] {"libawt_xawt.so", "libfontmanager.so"}; + } + + for (String name : required) { + if (!findNative(name)) { + return false; + } + } + + return true; + } catch (Throwable ignored) { + return false; + } + } + + private static boolean findNative(String name) { + File home = new File(System.getProperty("java.home", ".")); + File[] roots = new File[] { + new File(home, "lib"), + new File(home, "bin"), + new File(new File(home, "lib"), System.getProperty("os.arch", "")), + }; + + for (File root : roots) { + if (new File(root, name).isFile()) { + return true; + } + } + + return false; + } + + private static boolean awtLinks() { + try { + java.awt.GraphicsEnvironment.getLocalGraphicsEnvironment().getClass(); + return true; + } catch (LinkageError broken) { + return false; + } catch (Throwable ignored) { + return true; + } + } + + private static boolean hasCompiler() { + try { + return javax.tools.ToolProvider.getSystemJavaCompiler() != null; + } catch (Throwable ignored) { + return false; + } + } +} diff --git a/packages/oneclient_java/assets/java/README.md b/packages/oneclient_java/assets/java/README.md new file mode 100644 index 00000000..eb9180c4 --- /dev/null +++ b/packages/oneclient_java/assets/java/README.md @@ -0,0 +1,13 @@ +[`JavaInfo`](./JavaInfo.java) compiled by [`LynithDev`](https://github.com/LynithDev) using +``` +os.arch=amd64 +java.version=1.8.0_382 +java.vendor=Azul Systems, Inc. +``` + +> [!NOTE] +> The checked-in `JavaInfo.class` predates the `java.home`, `java.awt` and `java.jdk` +> lines and does not print them yet. Recompiling it against a Java 8 target +> (`javac --release 8 JavaInfo.java`) is what turns the probe-side answers on; until +> then [`checker.rs`](../../src/checker.rs) falls back to inspecting the installation +> on disk, which reaches the same verdict for every image seen in the wild. diff --git a/packages/oneclient_java/examples/install_provider.rs b/packages/oneclient_java/examples/install_provider.rs new file mode 100644 index 00000000..b9ef8b9c --- /dev/null +++ b/packages/oneclient_java/examples/install_provider.rs @@ -0,0 +1,66 @@ +//! Downloads and probes one vendor's JDK then records it in an in-memory store + +use std::env; + +use oneclient_common::paths::java_dir; +use oneclient_events::EventBus; +use oneclient_java::vendors::{ + AdoptiumRuntimeProvider, CorrettoRuntimeProvider, JavaRuntimeProvider, LibericaRuntimeProvider, + ZuluRuntimeProvider, +}; +use oneclient_java::{JavaResult, JavaService, MemoryJavaStore, check_java_runtime}; +use oneclient_net::{NetConfig, RequestClient}; + +#[tokio::main] +async fn main() -> JavaResult<()> { + let mut args = env::args().skip(1); + let vendor = args.next().unwrap_or_else(|| { + eprintln!("usage: install_provider [major]"); + std::process::exit(1); + }); + + let major: u32 = args + .next() + .unwrap_or_else(|| "21".to_string()) + .parse() + .expect("major version must be a number"); + + let net = RequestClient::new(NetConfig::default())?; + // Nothing drains this so events are dropped + let (events, _rx) = EventBus::channel(); + + let provider: Box = match vendor.as_str() { + "zulu" => Box::new(ZuluRuntimeProvider), + "adoptium" => Box::new(AdoptiumRuntimeProvider), + "corretto" => Box::new(CorrettoRuntimeProvider), + "liberica" => Box::new(LibericaRuntimeProvider), + other => { + eprintln!("unknown vendor '{other}', use zulu, adoptium, corretto, or liberica"); + std::process::exit(1); + } + }; + + let packages = provider.list_packages(Some(major), &net).await?; + let package = packages + .into_iter() + .find(|p| p.java_version.contains(&major)) + .unwrap_or_else(|| panic!("no {vendor} package for Java {major}")); + + println!("Installing package {:#?} to '{:?}'", package, java_dir()?); + + let executable = provider + .install_package(&package, &net, &events, None) + .await?; + println!("Installed: {}", executable.display()); + + let info = check_java_runtime(executable.display().to_string()).await?; + println!(" version: {}", info.version); + println!(" vendor: {}", info.vendor); + println!(" arch: {}", info.os_arch); + + let service = JavaService::new(MemoryJavaStore::new(), net, events); + service.add_custom_runtime(executable).await?; + println!("Registered runtimes: {:?}", service.list_runtimes().await?); + + Ok(()) +} diff --git a/packages/oneclient_java/examples/list_providers.rs b/packages/oneclient_java/examples/list_providers.rs new file mode 100644 index 00000000..c85cb345 --- /dev/null +++ b/packages/oneclient_java/examples/list_providers.rs @@ -0,0 +1,55 @@ +//! Lists the JDK packages each vendor currently publishes + +use std::env; + +use oneclient_java::vendors::{JavaVendor, runtime_providers}; +use oneclient_java::{JavaPackage, JavaResult}; +use oneclient_net::{NetConfig, RequestClient}; + +#[tokio::main] +async fn main() -> JavaResult<()> { + let arg = env::args().nth(1).unwrap_or_else(|| "21".to_string()); + let major: Option = if arg == "all" { + None + } else { + Some(arg.parse().expect("usage: list_providers [major|all]")) + }; + + let net = RequestClient::new(NetConfig::default())?; + + match major { + Some(major) => println!("Listing JDK packages for Java {major} (metadata only):\n"), + None => println!("Listing JDK packages for all majors (metadata only):\n"), + } + + for provider in runtime_providers() { + match provider.list_packages(major, &net).await { + Ok(packages) => print_vendor(provider.vendor(), &packages), + Err(err) => println!("{:?}: ERROR {err}", provider.vendor()), + } + } + + Ok(()) +} + +fn print_vendor(vendor: JavaVendor, packages: &[JavaPackage]) { + let label = format!("{vendor:?}"); + + if packages.is_empty() { + println!("{label}: (none)"); + return; + } + + for package in packages { + println!( + "{label}: {} [{}] {}", + package.name, + package + .java_version + .first() + .map(|v| v.to_string()) + .unwrap_or_default(), + package.download_url + ); + } +} diff --git a/packages/oneclient_java/src/checker.rs b/packages/oneclient_java/src/checker.rs new file mode 100644 index 00000000..825557f6 --- /dev/null +++ b/packages/oneclient_java/src/checker.rs @@ -0,0 +1,182 @@ +use std::{collections::HashMap, path::PathBuf}; + +use async_tempfile::TempDir; +use tokio::sync::OnceCell; + +use crate::error::{JavaError, JavaResult}; + +const JAVA_INFO_CLASS: &[u8] = include_bytes!("../assets/java/JavaInfo.class"); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JavaCheckInfo { + pub version: String, + pub vendor: String, + pub os_arch: String, + /// Mod loaders and game tooling expect a JDK so the locator prefers one + /// when both satisfy the requested major + pub is_jdk: bool, +} + +#[tracing::instrument(level = "debug", skip(absolute_path))] +pub async fn check_java_runtime(absolute_path: String) -> JavaResult { + let temp_dir = get_java_info_dir().await?; + + let mut command = tokio::process::Command::new(&absolute_path); + command + .arg("-cp") + .arg(temp_dir) + .arg("JavaInfo") + .env_remove("_JAVA_OPTIONS") + .env_remove("JAVA_TOOL_OPTIONS") + .env_remove("JDK_JAVA_OPTIONS"); + + let program = command.as_std().get_program().to_string_lossy(); + let args: Vec = command + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + + tracing::debug!("running command: {} {}", program, args.join(" ")); + + let output = command + .output() + .await + .map_err(|e| JavaError::RuntimeCheckError { + source: e, + path: absolute_path.clone(), + })?; + + let java_info = String::from_utf8_lossy(&output.stdout); + + let info = java_info + .lines() + .filter_map(|line| { + let (key, value) = line.split_once('=')?; + + Some((key.to_string(), value.to_string())) + }) + .collect::>(); + + let Some(version) = info.get("java.version").cloned() else { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + path = %absolute_path, + status = ?output.status.code(), + "java probe did not report java.version; stderr: {}", + stderr.trim() + ); + return Err(JavaError::InvalidJavaPath { + path: absolute_path, + }); + }; + + // Minecraft needs AWT a headless image launches fine and then dies mid-game + if !has_usable_awt(&info) { + tracing::warn!( + path = %absolute_path, + classes = probe_flag(&info, "java.awt"), + natives = probe_flag(&info, "java.awt.natives"), + links = probe_flag(&info, "java.awt.link"), + "java installation has no usable java.awt support" + ); + return Err(JavaError::MissingAwtSupport { + path: absolute_path, + }); + } + + Ok(JavaCheckInfo { + os_arch: info + .get("os.arch") + .cloned() + .unwrap_or_else(|| String::from("unknown")), + version, + vendor: info + .get("java.vendor") + .cloned() + .unwrap_or_else(|| String::from("unknown")), + is_jdk: probe_flag(&info, "java.jdk"), + }) +} + +fn has_usable_awt(info: &HashMap) -> bool { + probe_flag(info, "java.awt") + && probe_flag(info, "java.awt.natives") + && probe_flag(info, "java.awt.link") +} + +fn probe_flag(info: &HashMap, key: &str) -> bool { + info.get(key) + .is_some_and(|value| value.eq_ignore_ascii_case("true")) +} + +static TEMP_JAVA_INFO: OnceCell = OnceCell::const_new(); + +#[tracing::instrument(level = "debug")] +async fn get_java_info_dir() -> Result<&'static PathBuf, polyio::IOError> { + let dir: Result<&TempDir, polyio::IOError> = TEMP_JAVA_INFO + .get_or_try_init(async || { + let dir = polyio::tempdir().await?; + let file = dir.dir_path().join("JavaInfo.class"); + + polyio::write(&file, JAVA_INFO_CLASS).await?; + + Ok(dir) + }) + .await; + + Ok(dir?.dir_path()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn info(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect() + } + + #[test] + fn a_flag_the_probe_never_printed_is_false() { + assert!(!probe_flag(&info(&[]), "java.awt")); + } + + #[test] + fn a_flag_is_read_regardless_of_case() { + assert!(probe_flag(&info(&[("java.awt", "TRUE")]), "java.awt")); + assert!(!probe_flag(&info(&[("java.awt", "false")]), "java.awt")); + } + + fn awt(classes: &str, natives: &str, link: &str) -> HashMap { + info(&[ + ("java.awt", classes), + ("java.awt.natives", natives), + ("java.awt.link", link), + ]) + } + + #[test] + fn a_complete_image_is_usable() { + assert!(has_usable_awt(&awt("true", "true", "true"))); + } + + #[test] + fn awt_classes_without_their_natives_are_not_usable() { + assert!(!has_usable_awt(&awt("true", "false", "true"))); + } + + #[test] + fn natives_that_refuse_to_link_are_not_usable() { + assert!(!has_usable_awt(&awt("true", "true", "false"))); + } + + #[test] + fn every_half_is_required() { + assert!(!has_usable_awt(&info(&[("java.awt", "true")]))); + assert!(!has_usable_awt(&info(&[("java.awt.natives", "true")]))); + assert!(!has_usable_awt(&info(&[("java.awt.link", "true")]))); + } +} diff --git a/packages/oneclient_java/src/data.rs b/packages/oneclient_java/src/data.rs new file mode 100644 index 00000000..c4618e60 --- /dev/null +++ b/packages/oneclient_java/src/data.rs @@ -0,0 +1,79 @@ +use std::path::PathBuf; + +use polyio::Checksum; +use serde::{Deserialize, Serialize}; + +use crate::vendors::JavaVendor; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JavaRuntime { + pub absolute_path: String, + pub major: u32, + pub version: String, + pub vendor: JavaVendor, + pub os_arch: String, + pub is_jdk: bool, + pub probe_version: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct JavaPackage { + pub download_url: String, + pub name: String, + pub java_version: Vec, + pub vendor: JavaVendor, + pub archive: PackageArchive, + /// Algorithm varies per vendor + /// `None` when vendor metadata omitted or malformed it + /// installs unverified rather than failing + pub checksum: Option, + /// Bytes when the vendor publishes it + pub size: Option, +} + +impl JavaPackage { + /// Drops malformed vendor checksums which would otherwise make every + /// install fail as a corrupt download + #[must_use] + pub fn with_checksum(mut self, checksum: Option) -> Self { + self.checksum = checksum.filter(|sum| { + let ok = sum.is_well_formed(); + if !ok { + tracing::warn!( + algorithm = sum.algorithm.name(), + hex = %sum.hex, + "vendor published a malformed checksum; installing unverified" + ); + } + ok + }); + self + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PackageArchive { + Zip, + TarGz, +} + +impl PackageArchive { + pub fn from_filename(name: &str) -> Self { + if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + Self::TarGz + } else { + Self::Zip + } + } +} + +pub fn java_executable_relative_path() -> PathBuf { + #[cfg(target_os = "macos")] + { + PathBuf::from("Contents/Home/bin").join(oneclient_common::constants::JAVA_BIN) + } + #[cfg(not(target_os = "macos"))] + { + PathBuf::from("bin").join(oneclient_common::constants::JAVA_BIN) + } +} diff --git a/packages/oneclient_java/src/error.rs b/packages/oneclient_java/src/error.rs new file mode 100644 index 00000000..ecd4ec0e --- /dev/null +++ b/packages/oneclient_java/src/error.rs @@ -0,0 +1,119 @@ +use thiserror::Error; + +pub type JavaResult = Result; + +#[derive(Debug, Error)] +pub enum JavaError { + #[error(transparent)] + Store(#[from] crate::store::StoreError), + + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), + + #[error(transparent)] + Events(#[from] oneclient_events::EventError), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), + + #[error("Error spawning Java runtime from path '{path}': {source}")] + RuntimeCheckError { + #[source] + source: std::io::Error, + path: String, + }, + + #[error("IO Error: {0}")] + PolyIOError(#[from] polyio::IOError), + + #[error("no java package found for major version {major}")] + PackageNotFound { major: u32 }, + + #[error("no java installations available")] + MissingJava, + + #[error("java setup was cancelled")] + Cancelled, + + #[error("selected path is not a valid java installation: {path}")] + InvalidJavaPath { path: String }, + + #[error( + "java installation at '{path}' has no usable java.awt (the java.desktop module or its native libraries are missing)" + )] + MissingAwtSupport { path: String }, + + #[error("selected java is version {found}, but version {expected} is required")] + VersionMismatch { expected: u32, found: u32 }, + + #[error("failed to parse java version '{version}'")] + ParseVersion { version: String }, + + #[error("failed to extract archive '{archive}': {source}")] + ArchiveExtract { + archive: String, + source: std::io::Error, + }, + + #[error("failed to extract archive '{archive}'")] + ArchiveExtractFailed { archive: String }, +} + +impl JavaError { + #[must_use] + pub fn is_invalid_installation(&self) -> bool { + matches!( + self, + JavaError::InvalidJavaPath { .. } + | JavaError::MissingAwtSupport { .. } + | JavaError::ParseVersion { .. } + ) + } + + /// Whether this is an expected outcome or the user's environment rather + /// than a launcher bug + /// Callers decide what to do with that (e.g. suppress error reporting) + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + JavaError::PackageNotFound { .. } | JavaError::Cancelled => true, + JavaError::RuntimeCheckError { source, .. } + | JavaError::ArchiveExtract { source, .. } => is_transient_io(source), + JavaError::PolyIOError(polyio::IOError::IOError(source)) + | JavaError::PolyIOError(polyio::IOError::PathIOError { source, .. }) => { + is_transient_io(source) + } + JavaError::Request(source) => source.is_transient(), + _ => false, + } + } +} + +fn is_transient_io(error: &std::io::Error) -> bool { + use std::io::ErrorKind; + + if let Some(code) = error.raw_os_error() { + #[cfg(unix)] + if code == 28 { + // ENOSPC + return true; + } + #[cfg(windows)] + if code == 112 || code == 39 { + return true; + } + let _ = code; + } + + matches!( + error.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) +} diff --git a/packages/oneclient_java/src/install.rs b/packages/oneclient_java/src/install.rs new file mode 100644 index 00000000..3c1e6c9d --- /dev/null +++ b/packages/oneclient_java/src/install.rs @@ -0,0 +1,161 @@ +use std::path::{Path, PathBuf}; + +use crate::data::{JavaPackage, PackageArchive, java_executable_relative_path}; +use crate::error::JavaResult; +use oneclient_common::paths; +use oneclient_events::EventBus; +use oneclient_events::{GroupedProgressSession, TaskCategory, TaskPhase}; +use oneclient_net::RequestClient; +use oneclient_net::ResponseNotifyOptions; + +#[tracing::instrument(level = "debug", skip(net, events, progress))] +pub async fn install_package( + package: &JavaPackage, + net: &RequestClient, + events: &EventBus, + progress: Option<&GroupedProgressSession>, +) -> JavaResult { + let java_dir = paths::java_dir()?; + polyio::create_dir_all(&java_dir).await?; + + let archive_path = java_dir.join(polyio::sanitize_path(&package.name)); + + let major = package.java_version.first().copied().unwrap_or(0); + + // 0 means unknown the real total comes from Content-Length once headers land + let expected_size = package.size.unwrap_or(0); + let child = progress.map(|session| { + session.child( + format!("{} {}", package.vendor, major), + expected_size, + TaskCategory::Java, + ) + }); + + let notify = match &child { + Some(child) => ResponseNotifyOptions::grouped(child.clone()), + None => { + ResponseNotifyOptions::standalone(format!("Installing {} {}", package.vendor, major)) + .done_label(format!("Installed {} {}", package.vendor, major)) + } + }; + + if package.checksum.is_none() { + tracing::warn!( + vendor = %package.vendor, + major, + "vendor published no usable checksum; installing runtime unverified" + ); + } + + oneclient_net::download_verified( + net, + events, + &package.download_url, + &archive_path, + package.checksum.as_ref(), + expected_size, + Some(notify), + ) + .await?; + + let extract_root = java_dir.join(stem_without_archive(&package.name)); + + if let Some(child) = &child { + child.set_phase(TaskPhase::Extracting); + } + //events.notify("Extracting package...").body(format!("{} {}", package.vendor, major)).send(); + + match package.archive { + PackageArchive::Zip => polyio::extract_zip(&archive_path, &extract_root).await?, + PackageArchive::TarGz => polyio::extract_tar_gz(&archive_path, &extract_root).await?, + } + + let executable = resolve_installed_executable(&extract_root, package); + + #[cfg(unix)] + { + let _ = tokio::process::Command::new("chmod") + .arg("755") + .arg(&executable) + .output() + .await; + } + + let _ = polyio::remove_file(&archive_path).await; + + tracing::info!(vendor = %package.vendor, major, "installed Java runtime"); + + Ok(executable) +} + +#[tracing::instrument(level = "debug")] +fn resolve_installed_executable(extract_root: &Path, package: &JavaPackage) -> PathBuf { + let mut base_path = extract_root.to_path_buf(); + + if let Ok(entries) = std::fs::read_dir(extract_root) { + let valid_dirs: Vec = entries + .flatten() + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .map(|e| e.path()) + .collect(); + + if valid_dirs.len() == 1 { + base_path = valid_dirs[0].clone(); + } + } + + #[cfg(not(target_os = "macos"))] + { + let _ = package; + let subfolder_exec = base_path.join(java_executable_relative_path()); + if subfolder_exec.exists() { + subfolder_exec + } else { + extract_root.join(java_executable_relative_path()) + } + } + + #[cfg(target_os = "macos")] + { + use crate::vendors::JavaVendor; + + if !base_path.join("Contents").join("Home").exists() + && let Ok(entries) = std::fs::read_dir(&base_path) + && let Some(dir) = entries.flatten().find(|entry| { + let file_name = entry.file_name(); + let name = file_name.to_string_lossy(); + (name.ends_with(".jre") || name.ends_with(".jdk") || name.contains("zulu")) + && entry.path().join("Contents").join("Home").exists() + }) + { + base_path = dir.path(); + } + + if package.vendor == JavaVendor::Zulu + && let Some(major) = package.java_version.first() + { + let zulu_bundle = base_path.join(format!("zulu-{major}.jre")); + if zulu_bundle.join("Contents").join("Home").exists() { + base_path = zulu_bundle; + } + } + + base_path.join(java_executable_relative_path()) + } +} + +fn stem_without_archive(name: &str) -> String { + let path = std::path::Path::new(name); + let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(name); + + if stem.ends_with(".tar") { + std::path::Path::new(stem) + .file_stem() + .and_then(|s| s.to_str()) + .map(String::from) + .unwrap_or_else(|| stem.to_string()) + } else { + stem.to_string() + } +} diff --git a/packages/oneclient_java/src/lib.rs b/packages/oneclient_java/src/lib.rs new file mode 100644 index 00000000..4a43860f --- /dev/null +++ b/packages/oneclient_java/src/lib.rs @@ -0,0 +1,27 @@ +//! [`JavaService`] takes a [`JavaStore`] rather than a database handle so this +//! crate has no schema dependency + +mod checker; +mod data; +mod error; +mod install; +mod locate; +mod platform; +mod resolve; +mod service; +mod store; + +pub mod vendors; + +pub use checker::{JavaCheckInfo, check_java_runtime}; +pub use data::{JavaPackage, JavaRuntime, PackageArchive, java_executable_relative_path}; +pub use error::{JavaError, JavaResult}; +pub use install::install_package; +pub use locate::{LocatedJava, best_for_major, locate_java}; +pub use platform::{HostArch, HostOs, HostTarget}; +pub use service::{ + AvailableJava, INSTALLABLE_MAJORS, JAVA_CHOICE_DOWNLOAD, JAVA_CHOICE_FOLDER, JAVA_VENDOR_HINT, + JavaService, +}; +pub use store::{JavaStore, MemoryJavaStore, StoreError, StoreResult}; +pub use vendors::JavaVendor; diff --git a/packages/oneclient_java/src/locate.rs b/packages/oneclient_java/src/locate.rs new file mode 100644 index 00000000..88341500 --- /dev/null +++ b/packages/oneclient_java/src/locate.rs @@ -0,0 +1,288 @@ +use std::cmp::Ordering; +use std::path::PathBuf; + +use crate::checker::{JavaCheckInfo, check_java_runtime}; +use crate::data::java_executable_relative_path; +use crate::error::JavaResult; +use crate::service::parse_major_version; +use oneclient_common::constants::JAVA_BIN; + +pub type LocatedJava = (PathBuf, JavaCheckInfo); + +/// Best first +#[tracing::instrument(level = "debug")] +pub async fn locate_java() -> JavaResult> { + let paths = internal_locate_java(); + let mut valid: Vec = Vec::new(); + + for path in paths { + if valid.iter().any(|(existing, _)| existing == &path) { + continue; + } + + let Ok(info) = check_java_runtime(path.display().to_string()).await else { + tracing::warn!("java installation at '{}' is not valid", path.display()); + continue; + }; + valid.push((path, info)); + } + + valid.sort_by(|(_, a), (_, b)| compare_candidates(a, b)); + + tracing::debug!(count = valid.len(), "located Java runtimes"); + + Ok(valid) +} + +/// `major` is a filter not a ranking input +/// a newer JDK never stands in for the requested major +#[must_use] +pub fn best_for_major(candidates: &[LocatedJava], major: u32) -> Option<&LocatedJava> { + candidates + .iter() + .filter(|(_, info)| parse_major_version(&info.version).is_ok_and(|found| found == major)) + .min_by(|(_, a), (_, b)| compare_candidates(a, b)) +} + +/// Best first a JDK beats a JRE outright then the newer version wins +fn compare_candidates(a: &JavaCheckInfo, b: &JavaCheckInfo) -> Ordering { + b.is_jdk + .cmp(&a.is_jdk) + .then_with(|| version_key(b).cmp(&version_key(a))) +} + +/// Numeric so `21.0.10` sorts above `21.0.9` +fn version_key(info: &JavaCheckInfo) -> (u32, Vec) { + let major = parse_major_version(&info.version).unwrap_or(0); + let parts = info + .version + .split(|c: char| !c.is_ascii_digit()) + .filter_map(|part| part.parse::().ok()) + .collect(); + + (major, parts) +} + +#[cfg(target_os = "windows")] +#[tracing::instrument(level = "debug")] +fn internal_locate_java() -> Vec { + let mut java_homes = Vec::new(); + + let windows_paths = { + let mut paths = vec![ + PathBuf::from(r"C:\Program Files\Java"), + PathBuf::from(r"C:\Program Files (x86)\Java"), + PathBuf::from(r"C:\Program Files\Eclipse Adoptium"), + PathBuf::from(r"C:\Program Files\Amazon Corretto"), + PathBuf::from(r"C:\Program Files\Zulu"), + PathBuf::from(r"C:\Program Files\OpenJDK"), + PathBuf::from(r"C:\Program Files\Microsoft"), + PathBuf::from(r"C:\ProgramData\chocolatey\lib"), + ]; + if let Some(profile) = std::env::var_os("USERPROFILE").map(PathBuf::from) { + paths.push(profile.join(".jdks")); + paths.push(profile.join(".gradle").join("jdks")); + paths.push(profile.join(".m2").join("wrapper").join("dists")); + paths.push(profile.join("scoop").join("apps")); + } + paths + }; + + for base_path in windows_paths { + if let Ok(entries) = std::fs::read_dir(base_path) { + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|ft| ft.is_dir()) { + let java_exe = entry.path().join(java_executable_relative_path()); + if java_exe.exists() { + java_homes.push(java_exe); + } + } + } + } + } + + if let Ok(java_home) = std::env::var("JAVA_HOME") { + let java_exe = PathBuf::from(java_home).join(java_executable_relative_path()); + if java_exe.exists() { + java_homes.push(java_exe); + } + } + + if let Ok(path) = std::env::var("PATH") { + for path_entry in path.split(';') { + let java_exe = PathBuf::from(path_entry).join(JAVA_BIN); + if java_exe.exists() && !java_homes.contains(&java_exe) { + java_homes.push(java_exe); + } + } + } + + java_homes +} + +#[cfg(target_os = "macos")] +#[tracing::instrument(level = "debug")] +fn internal_locate_java() -> Vec { + let mut found = Vec::new(); + + let macos_paths = { + let mut paths = vec![ + PathBuf::from("/Library/Java/JavaVirtualMachines"), + PathBuf::from("/System/Library/Java/JavaVirtualMachines"), + PathBuf::from("/System/Library/Frameworks/JavaVM.framework/Versions/Current/Commands"), + PathBuf::from("/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home"), + PathBuf::from("/opt/homebrew/Cellar"), + PathBuf::from("/opt/homebrew/opt"), + PathBuf::from("/usr/local/Cellar"), + PathBuf::from("/usr/local/opt"), + ]; + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + paths.push( + home.join("Library") + .join("Java") + .join("JavaVirtualMachines"), + ); + paths.push(home.join(".jdks")); + paths.push(home.join(".sdkman").join("candidates").join("java")); + } + paths + }; + + for path in macos_paths { + if let Ok(children) = std::fs::read_dir(path) { + for child in children.flatten() { + let java_exe = child.path().join(java_executable_relative_path()); + if java_exe.exists() { + found.push(java_exe); + } + } + } + } + + find_java_in_path(found) +} + +#[cfg(target_os = "linux")] +#[tracing::instrument(level = "debug")] +fn internal_locate_java() -> Vec { + let mut found = Vec::new(); + + let linux_paths = { + let mut paths = vec![ + PathBuf::from("/usr/lib/jvm"), + PathBuf::from("/usr/lib64/jvm"), + PathBuf::from("/usr/lib32/jvm"), + PathBuf::from("/usr/java"), + PathBuf::from("/opt/jdk"), + PathBuf::from("/opt/jdks"), + PathBuf::from("/app/jdk"), + ]; + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + paths.push(home.join(".jdks")); + paths.push(home.join(".gradle").join("jdks")); + paths.push(home.join(".sdkman").join("candidates").join("java")); + } + paths + }; + + for path in linux_paths { + if let Ok(children) = std::fs::read_dir(path) { + for child in children.flatten() { + let java_exe = child.path().join(java_executable_relative_path()); + if java_exe.exists() { + found.push(java_exe); + } + } + } + } + + find_java_in_path(found) +} + +#[cfg(unix)] +fn find_java_in_path(mut found: Vec) -> Vec { + if let Ok(path) = std::env::var("PATH") { + for path_entry in path.split(':') { + let java = PathBuf::from(path_entry).join(JAVA_BIN); + if java.exists() && java.is_file() && !found.contains(&java) { + found.push(java); + } + } + } + found +} + +#[cfg(test)] +mod tests { + use super::*; + + fn candidate(path: &str, version: &str, is_jdk: bool) -> LocatedJava { + ( + PathBuf::from(path), + JavaCheckInfo { + version: version.to_string(), + vendor: String::from("Test"), + os_arch: String::from("x64"), + is_jdk, + }, + ) + } + + fn ranked(mut candidates: Vec) -> Vec { + candidates.sort_by(|(_, a), (_, b)| compare_candidates(a, b)); + candidates + .into_iter() + .map(|(path, _)| path.display().to_string()) + .collect() + } + + #[test] + fn a_kit_outranks_a_runtime_even_when_it_is_older() { + let ranking = ranked(vec![ + candidate("/jre-21", "21.0.9", false), + candidate("/jdk-21", "21.0.1", true), + ]); + + assert_eq!(ranking, vec!["/jdk-21", "/jre-21"]); + } + + #[test] + fn patch_levels_are_compared_as_numbers() { + let ranking = ranked(vec![ + candidate("/jdk-21.0.9", "21.0.9", true), + candidate("/jdk-21.0.10", "21.0.10", true), + ]); + + assert_eq!(ranking, vec!["/jdk-21.0.10", "/jdk-21.0.9"]); + } + + #[test] + fn the_requested_major_still_wins_over_a_newer_kit() { + let candidates = vec![ + candidate("/jdk-21", "21.0.3", true), + candidate("/jre-8", "1.8.0_412", false), + ]; + + let (path, _) = best_for_major(&candidates, 8).expect("a java 8 candidate"); + assert_eq!(path, &PathBuf::from("/jre-8")); + } + + #[test] + fn the_kit_wins_among_candidates_of_the_requested_major() { + let candidates = vec![ + candidate("/jre-17-newer", "17.0.11", false), + candidate("/jdk-17", "17.0.2", true), + candidate("/jdk-21", "21.0.3", true), + ]; + + let (path, _) = best_for_major(&candidates, 17).expect("a java 17 candidate"); + assert_eq!(path, &PathBuf::from("/jdk-17")); + } + + #[test] + fn a_major_nobody_satisfies_has_no_best() { + let candidates = vec![candidate("/jdk-21", "21.0.3", true)]; + + assert!(best_for_major(&candidates, 8).is_none()); + } +} diff --git a/packages/oneclient_java/src/platform.rs b/packages/oneclient_java/src/platform.rs new file mode 100644 index 00000000..208a9d3a --- /dev/null +++ b/packages/oneclient_java/src/platform.rs @@ -0,0 +1,110 @@ +//! Host detection is shared here but the *naming* stays per-vendor +//! each API spells the same platform differently (e.g. Adoptium `mac`/`alpine-linux`/`x32`) + +use crate::data::PackageArchive; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostArch { + X86, + X86_64, + Arm, + Aarch64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostOs { + Windows, + MacOs, + /// Several vendors publish musl builds under a separate name + Linux { + musl: bool, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HostTarget { + pub arch: HostArch, + pub os: HostOs, +} + +impl HostTarget { + pub const CURRENT: Self = Self { + arch: cfg_select! { + target_arch = "x86" => HostArch::X86, + target_arch = "x86_64" => HostArch::X86_64, + target_arch = "arm" => HostArch::Arm, + target_arch = "aarch64" => HostArch::Aarch64, + }, + os: cfg_select! { + target_os = "windows" => HostOs::Windows, + target_os = "macos" => HostOs::MacOs, + target_os = "linux" => cfg_select! { + target_env = "musl" => HostOs::Linux { musl: true }, + _ => HostOs::Linux { musl: false }, + }, + }, + }; + + /// Liberica takes architecture and bitness as separate query parameters + #[must_use] + pub const fn bitness(self) -> &'static str { + match self.arch { + HostArch::X86 | HostArch::Arm => "32", + HostArch::X86_64 | HostArch::Aarch64 => "64", + } + } + + #[must_use] + pub const fn archive(self) -> PackageArchive { + match self.os { + HostOs::Windows => PackageArchive::Zip, + _ => PackageArchive::TarGz, + } + } + + #[must_use] + pub const fn archive_ext(self) -> &'static str { + match self.os { + HostOs::Windows => "zip", + _ => "tar.gz", + } + } + + #[must_use] + pub const fn is_musl(self) -> bool { + matches!(self.os, HostOs::Linux { musl: true }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bitness_matches_the_architecture() { + assert_eq!(HostTarget::CURRENT.bitness(), { + if cfg!(target_pointer_width = "64") { + "64" + } else { + "32" + } + }); + } + + #[test] + fn windows_ships_zips_and_everything_else_tarballs() { + let expected = if cfg!(windows) { + PackageArchive::Zip + } else { + PackageArchive::TarGz + }; + assert_eq!(HostTarget::CURRENT.archive(), expected); + } + + #[test] + fn musl_is_only_ever_reported_on_linux() { + if HostTarget::CURRENT.is_musl() { + assert!(matches!(HostTarget::CURRENT.os, HostOs::Linux { .. })); + } + } +} diff --git a/packages/oneclient_java/src/resolve.rs b/packages/oneclient_java/src/resolve.rs new file mode 100644 index 00000000..0c4b85b5 --- /dev/null +++ b/packages/oneclient_java/src/resolve.rs @@ -0,0 +1,63 @@ +use std::path::{Path, PathBuf}; + +use crate::data::java_executable_relative_path; +use crate::error::{JavaError, JavaResult}; +use oneclient_common::constants::JAVA_BIN; + +#[tracing::instrument(level = "debug", skip_all)] +pub fn resolve_java_executable(selection: impl AsRef) -> JavaResult { + let selection = selection.as_ref(); + + if selection.is_file() { + return validate_executable(selection); + } + + let relative = java_executable_relative_path(); + let candidates = [ + selection.join(&relative), + selection.join(JAVA_BIN), + #[cfg(target_os = "macos")] + selection.join("Contents").join("Home").join(&relative), + ]; + + for candidate in candidates { + if candidate.is_file() { + return validate_executable(&candidate); + } + } + + if let Ok(entries) = std::fs::read_dir(selection) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let nested = path.join(&relative); + if nested.is_file() { + return validate_executable(&nested); + } + } + } + + tracing::warn!(path = %selection.display(), "no Java executable found in selection"); + + Err(JavaError::InvalidJavaPath { + path: selection.display().to_string(), + }) +} + +fn validate_executable(path: &Path) -> JavaResult { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + + if file_name != JAVA_BIN && file_name != "java.exe" && file_name != "javaw.exe" { + return Err(JavaError::InvalidJavaPath { + path: path.display().to_string(), + }); + } + + Ok(path.to_path_buf()) +} diff --git a/packages/oneclient_java/src/service.rs b/packages/oneclient_java/src/service.rs new file mode 100644 index 00000000..11242e96 --- /dev/null +++ b/packages/oneclient_java/src/service.rs @@ -0,0 +1,437 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::Arc; + +use oneclient_events::{Choice, EventBus, GroupedProgressSession, Prompt}; +use oneclient_net::RequestClient; + +use crate::checker::{self, JavaCheckInfo}; +use crate::data::{JavaPackage, JavaRuntime}; +use crate::error::{JavaError, JavaResult}; +use crate::resolve::resolve_java_executable; +use crate::store::JavaStore; +use crate::vendors::{self, JavaVendor}; + +/// Choice ids for the "Java required" prompt front-ends match on these +pub const JAVA_CHOICE_DOWNLOAD: &str = "java.download"; +pub const JAVA_CHOICE_FOLDER: &str = "java.folder"; + +/// Tells the front-end to show its vendor list rather than another picker +pub const JAVA_VENDOR_HINT: &str = "java-vendor"; + +pub const INSTALLABLE_MAJORS: &[u32] = &[8, 11, 16, 17, 18, 19, 20, 21, 22, 23]; + +pub const PROBE_VERSION: u32 = 1; + +const _: () = assert!(PROBE_VERSION > 0); + +enum JavaPromptAnswer { + Download, + PickFolder, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AvailableJava { + pub major: u32, + pub package: JavaPackage, +} + +pub struct JavaService { + store: Arc, + net: RequestClient, + events: EventBus, +} + +impl JavaService { + #[must_use] + pub fn new(store: Arc, net: RequestClient, events: EventBus) -> Self { + Self { store, net, events } + } + + #[must_use] + pub fn net(&self) -> &RequestClient { + &self.net + } + + #[must_use] + pub fn events(&self) -> &EventBus { + &self.events + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn list_runtimes(&self) -> JavaResult> { + Ok(self.store.list().await?) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn runtime_for_profile( + &self, + java_path: Option<&str>, + ) -> JavaResult> { + let Some(path) = java_path else { + return Ok(None); + }; + let Some(runtime) = self.store.get_by_path(path).await? else { + return Ok(None); + }; + self.revalidate(runtime).await + } + + #[tracing::instrument(level = "debug", skip(self))] + async fn revalidate(&self, runtime: JavaRuntime) -> JavaResult> { + if runtime.probe_version == PROBE_VERSION { + if Path::new(&runtime.absolute_path).is_file() { + return Ok(Some(runtime)); + } + + tracing::warn!( + path = %runtime.absolute_path, + "forgetting recorded Java runtime: the executable is gone" + ); + self.store.delete_by_path(&runtime.absolute_path).await?; + return Ok(None); + } + + match checker::check_java_runtime(runtime.absolute_path.clone()).await { + Ok(info) => Ok(Some( + self.persist(Path::new(&runtime.absolute_path), &info) + .await?, + )), + Err(err) if err.is_invalid_installation() => { + tracing::warn!( + path = %runtime.absolute_path, + "forgetting recorded Java runtime: {err}" + ); + self.store.delete_by_path(&runtime.absolute_path).await?; + Ok(None) + } + Err(err) => { + tracing::warn!( + path = %runtime.absolute_path, + "could not re-probe recorded Java runtime, trusting the record: {err}" + ); + Ok(Some(runtime)) + } + } + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn available_versions(&self, vendor: &JavaVendor) -> JavaResult> { + let Some(provider) = provider_for_vendor(vendor) else { + return Ok(Vec::new()); + }; + + let packages = provider.list_packages(None, &self.net).await?; + + let mut by_major = BTreeMap::::new(); + for package in packages { + let Some(&major) = package.java_version.first() else { + continue; + }; + by_major.entry(major).or_insert(package); + } + + let mut available: Vec = by_major + .into_iter() + .map(|(major, package)| AvailableJava { major, package }) + .collect(); + + available.sort_by_key(|entry| std::cmp::Reverse(entry.major)); + Ok(available) + } + + #[tracing::instrument(level = "debug", skip(self))] + pub async fn rescan(&self) -> JavaResult<()> { + self.register_located(&crate::locate::locate_java().await?) + .await + } + + #[tracing::instrument(skip(self))] + pub async fn add_custom_runtime(&self, folder: PathBuf) -> JavaResult { + let executable = resolve_java_executable(&folder)?; + self.register_checked(&executable, None).await + } + + #[tracing::instrument(skip(self))] + pub async fn remove_runtime(&self, absolute_path: &str) -> JavaResult<()> { + self.store.delete_by_path(absolute_path).await?; + tracing::info!("removed Java runtime"); + Ok(()) + } + + /// A recorded JRE is kept only as a fallback so a system scan gets its + /// chance to turn up a JDK of the same major first + #[tracing::instrument(level = "debug", skip(self, progress))] + pub async fn prepare( + &self, + major: u32, + search_system: bool, + auto_install: bool, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + let recorded = loop { + let Some(runtime) = self.store.latest_by_major(major).await? else { + break None; + }; + if let Some(valid) = self.revalidate(runtime).await? { + break Some(valid); + } + }; + + if let Some(runtime) = &recorded + && runtime.is_jdk + { + return Ok(runtime.clone()); + } + + if search_system { + let located = crate::locate::locate_java().await?; + self.register_located(&located).await?; + + // A located runtime only displaces a recorded one by being a JDK + if let Some((path, info)) = crate::locate::best_for_major(&located, major) + && (info.is_jdk || recorded.is_none()) + { + return self.persist(path, info).await; + } + } + + if let Some(runtime) = recorded { + return Ok(runtime); + } + + if auto_install { + return self.download_and_register(major, progress).await; + } + + self.prompt_and_install(major, progress).await + } + + #[tracing::instrument(skip(self, progress))] + pub async fn install_runtime( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + self.download_and_register(major, progress).await + } + + #[tracing::instrument(skip(self))] + pub async fn install_runtime_from( + &self, + vendor: &JavaVendor, + major: u32, + ) -> JavaResult { + self.install_vendor_runtime(vendor, major, None).await + } + + #[tracing::instrument(level = "debug", skip(self, progress))] + async fn install_vendor_runtime( + &self, + vendor: &JavaVendor, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + let provider = provider_for_vendor(vendor).ok_or(JavaError::PackageNotFound { major })?; + let package = provider + .latest_package_by_major(major, &self.net) + .await? + .ok_or(JavaError::PackageNotFound { major })?; + + let owned = progress.is_none().then(|| { + GroupedProgressSession::start(&self.events, format!("Installing Java {major}")) + }); + let session = progress.or(owned.as_ref()).expect("session present"); + + let executable = provider + .install_package(&package, &self.net, &self.events, Some(session)) + .await?; + + self.register_checked(&executable, Some(major)).await + } + + async fn prompt_and_install( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + let prompt = Prompt::new( + "Java required", + format!( + "No Java {major} runtime was found. Download it automatically, choose an existing installation folder, or cancel?" + ), + ) + .option( + Choice::primary(JAVA_CHOICE_DOWNLOAD, "Download").picks_selection(JAVA_VENDOR_HINT), + JavaPromptAnswer::Download, + ) + .option( + Choice::new(JAVA_CHOICE_FOLDER, "Choose folder") + .picks_folder(format!("Select a Java {major} installation folder")), + JavaPromptAnswer::PickFolder, + ) + .dismiss("Cancel"); + + let Some(chosen) = self.events.ask(prompt).await? else { + return Err(JavaError::Cancelled); + }; + + match chosen.value { + JavaPromptAnswer::Download => match chosen.selection() { + Some(vendor) => { + let vendor = JavaVendor::from_str(vendor) + .unwrap_or_else(|_| JavaVendor::Other(vendor.to_string())); + self.install_vendor_runtime(&vendor, major, progress).await + } + None => self.download_and_register(major, progress).await, + }, + JavaPromptAnswer::PickFolder => match chosen.folder() { + Some(folder) => { + let executable = resolve_java_executable(folder)?; + self.register_checked(&executable, Some(major)).await + } + None => Err(JavaError::Cancelled), + }, + } + } + + #[tracing::instrument(level = "debug", skip(self, progress))] + async fn download_and_register( + &self, + major: u32, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + for provider in vendors::runtime_providers() { + let vendor = provider.vendor(); + + let packages = provider.list_packages(Some(major), &self.net).await; + let package = match &packages { + Ok(packages) => match packages + .iter() + .find(|p| p.java_version.first() == Some(&major)) + .or_else(|| packages.first()) + { + Some(package) => package, + None => { + tracing::warn!(?vendor, major, "no packages found"); + continue; + } + }, + Err(err) => { + tracing::warn!(?vendor, major, "failed to query packages: {err}"); + continue; + } + }; + + tracing::info!(?vendor, major, "downloading Java runtime"); + match provider + .install_package(package, &self.net, &self.events, progress) + .await + { + Ok(executable) => return self.register_checked(&executable, Some(major)).await, + Err(err) => tracing::warn!(?vendor, major, "install failed: {err}"), + } + } + + Err(JavaError::PackageNotFound { major }) + } + + #[tracing::instrument(level = "debug", skip(self))] + async fn register_checked( + &self, + executable: &Path, + expected_major: Option, + ) -> JavaResult { + let info = checker::check_java_runtime(executable.display().to_string()).await?; + let major = parse_major_version(&info.version)?; + + if let Some(expected) = expected_major + && major != expected + { + return Err(JavaError::VersionMismatch { + expected, + found: major, + }); + } + + self.persist(executable, &info).await + } + + /// Skips anything that fails to probe a broken JDK must not abort the scan + #[tracing::instrument(level = "debug", skip_all)] + async fn register_located(&self, located: &[(PathBuf, JavaCheckInfo)]) -> JavaResult<()> { + for (path, info) in located { + if let Err(err) = self.persist(path, info).await { + tracing::warn!( + path = %path.display(), + version = %info.version, + "skipping located Java runtime: {err}" + ); + } + } + Ok(()) + } + + async fn persist(&self, executable: &Path, info: &JavaCheckInfo) -> JavaResult { + let runtime = JavaRuntime { + absolute_path: executable.to_string_lossy().into_owned(), + major: parse_major_version(&info.version)?, + version: info.version.clone(), + vendor: JavaVendor::from_str(&info.vendor) + .unwrap_or_else(|_| JavaVendor::Other(info.vendor.clone())), + os_arch: info.os_arch.clone(), + is_jdk: info.is_jdk, + probe_version: PROBE_VERSION, + }; + + Ok(self.store.upsert(&runtime).await?) + } +} + +fn provider_for_vendor(vendor: &JavaVendor) -> Option> { + vendors::runtime_providers() + .into_iter() + .find(|provider| &provider.vendor() == vendor) +} + +/// Java 8 and earlier report `1.8.0_412` where the major is the *second* +/// component 9 and later report `21.0.3` where it is the first +pub(crate) fn parse_major_version(version: &str) -> Result { + if let Some(rest) = version.strip_prefix("1.") { + let digit = rest.chars().next().ok_or_else(|| JavaError::ParseVersion { + version: version.to_string(), + })?; + return digit.to_digit(10).ok_or_else(|| JavaError::ParseVersion { + version: version.to_string(), + }); + } + + let head = version.split('.').next().unwrap_or(version); + head.parse::().map_err(|_| JavaError::ParseVersion { + version: version.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn legacy_versions_take_the_second_component() { + assert_eq!(parse_major_version("1.8.0_412").unwrap(), 8); + assert_eq!(parse_major_version("1.7.0").unwrap(), 7); + } + + #[test] + fn modern_versions_take_the_first() { + assert_eq!(parse_major_version("21.0.3").unwrap(), 21); + assert_eq!(parse_major_version("17").unwrap(), 17); + } + + #[test] + fn garbage_is_rejected_rather_than_defaulted() { + assert!(parse_major_version("not-a-version").is_err()); + assert!(parse_major_version("1.x").is_err()); + } +} diff --git a/packages/oneclient_java/src/store.rs b/packages/oneclient_java/src/store.rs new file mode 100644 index 00000000..abaae97e --- /dev/null +++ b/packages/oneclient_java/src/store.rs @@ -0,0 +1,88 @@ +//! A port rather than a concrete database handle so this crate does not depend +//! on the launcher's schema + +use std::sync::Arc; + +use crate::data::JavaRuntime; + +/// Opaque so this crate never learns what is behind the port +#[derive(Debug, thiserror::Error)] +#[error("java store: {0}")] +pub struct StoreError(#[source] pub Box); + +impl StoreError { + pub fn new(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self(Box::new(source)) + } +} + +pub type StoreResult = Result; + +#[async_trait::async_trait] +pub trait JavaStore: Send + Sync { + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult; + + async fn get_by_path(&self, absolute_path: &str) -> StoreResult>; + + async fn latest_by_major(&self, major: u32) -> StoreResult>; + + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()>; + + async fn list(&self) -> StoreResult>; +} + +#[derive(Debug, Default)] +pub struct MemoryJavaStore { + runtimes: std::sync::Mutex>, +} + +impl MemoryJavaStore { + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } +} + +#[async_trait::async_trait] +impl JavaStore for MemoryJavaStore { + async fn upsert(&self, runtime: &JavaRuntime) -> StoreResult { + let mut runtimes = self.runtimes.lock().expect("java store poisoned"); + runtimes.retain(|existing| existing.absolute_path != runtime.absolute_path); + runtimes.push(runtime.clone()); + Ok(runtime.clone()) + } + + async fn get_by_path(&self, absolute_path: &str) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + Ok(runtimes + .iter() + .find(|runtime| runtime.absolute_path == absolute_path) + .cloned()) + } + + async fn latest_by_major(&self, major: u32) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + Ok(runtimes + .iter() + .filter(|runtime| runtime.major == major) + .max_by(|a, b| a.version.cmp(&b.version)) + .cloned()) + } + + async fn delete_by_path(&self, absolute_path: &str) -> StoreResult<()> { + let mut runtimes = self.runtimes.lock().expect("java store poisoned"); + runtimes.retain(|runtime| runtime.absolute_path != absolute_path); + Ok(()) + } + + async fn list(&self) -> StoreResult> { + let runtimes = self.runtimes.lock().expect("java store poisoned"); + let mut out = runtimes.clone(); + out.sort_by(|a, b| { + b.major + .cmp(&a.major) + .then_with(|| b.version.cmp(&a.version)) + }); + Ok(out) + } +} diff --git a/packages/oneclient_java/src/vendors/adoptium.rs b/packages/oneclient_java/src/vendors/adoptium.rs new file mode 100644 index 00000000..7149832b --- /dev/null +++ b/packages/oneclient_java/src/vendors/adoptium.rs @@ -0,0 +1,170 @@ +use reqwest::{Method, Request}; +use serde::Deserialize; +use url::Url; + +use oneclient_net::RequestClient; +use polyio::Checksum; + +use crate::data::{JavaPackage, PackageArchive}; +use crate::error::JavaResult; +use crate::platform::{HostArch, HostOs, HostTarget}; +use crate::vendors::{JavaRuntimeProvider, JavaVendor}; + +pub struct AdoptiumRuntimeProvider; + +#[derive(Debug, Deserialize)] +struct AdoptiumAvailableReleases { + available_releases: Vec, +} + +#[derive(Debug, Deserialize)] +struct AdoptiumRelease { + version_data: AdoptiumVersionData, + binaries: Vec, +} + +#[derive(Debug, Deserialize)] +struct AdoptiumVersionData { + semver: String, + major: u32, +} + +#[derive(Debug, Deserialize)] +struct AdoptiumBinary { + package: AdoptiumPackage, +} + +#[derive(Debug, Deserialize)] +struct AdoptiumPackage { + name: String, + link: String, + /// Always SHA-256 the API has no algorithm field + #[serde(default)] + checksum: Option, + #[serde(default)] + size: Option, +} + +#[async_trait::async_trait] +impl JavaRuntimeProvider for AdoptiumRuntimeProvider { + fn vendor(&self) -> JavaVendor { + JavaVendor::Adoptium + } + + #[tracing::instrument(level = "debug", skip(self, net))] + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult> { + let (low, high) = match major { + Some(major) => (major, major), + None => { + let info = net + .send_as::(Request::new( + Method::GET, + Url::parse("https://api.adoptium.net/v3/info/available_releases")?, + )) + .await?; + let low = info.available_releases.iter().copied().min().unwrap_or(8); + let high = info.available_releases.iter().copied().max().unwrap_or(low); + (low, high) + } + }; + + let url = adoptium_url(low, high)?; + let releases = net + .send_as::>(Request::new(Method::GET, url)) + .await?; + + let mut packages = Vec::new(); + + for release in releases { + let mut java_version: Vec = release + .version_data + .semver + .split(|c: char| !c.is_numeric()) + .filter_map(|s| s.parse::().ok()) + .collect(); + if java_version.first() != Some(&release.version_data.major) { + java_version.insert(0, release.version_data.major); + } + + for binary in release.binaries { + let checksum = binary.package.checksum.as_deref().map(Checksum::sha256); + + packages.push( + JavaPackage { + archive: PackageArchive::from_filename(&binary.package.name), + download_url: binary.package.link, + java_version: java_version.clone(), + name: binary.package.name, + vendor: JavaVendor::Adoptium, + checksum: None, + size: binary.package.size, + } + .with_checksum(checksum), + ); + } + } + + tracing::debug!(count = packages.len(), "listed Adoptium packages"); + + Ok(packages) + } +} + +// A version range spans every GA major in one request instead of one per major +fn adoptium_url(low: u32, high: u32) -> JavaResult { + let range = format!("%5B{low}%2C{}%5D", high + 1); + + let mut url = Url::parse(&format!( + "https://api.adoptium.net/v3/assets/version/{range}" + ))?; + + url.query_pairs_mut() + .append_pair("os", ADOPTIUM_OS) + .append_pair("architecture", ADOPTIUM_ARCH) + // Kits only a JRE lacks tooling mod loaders expect + .append_pair("image_type", "jdk") + .append_pair("jvm_impl", "hotspot") + .append_pair("project", "jdk") + .append_pair("heap_size", "normal") + .append_pair("vendor", "eclipse") + .append_pair("release_type", "ga") + .append_pair("sort_method", "DATE") + .append_pair("sort_order", "DESC") + .append_pair("page", "0") + .append_pair("page_size", "50"); + Ok(url) +} + +/// Adoptium alone spells 32-bit x86 `x32` +const ADOPTIUM_ARCH: &str = match HostTarget::CURRENT.arch { + HostArch::X86 => "x32", + HostArch::X86_64 => "x64", + HostArch::Arm => "arm", + HostArch::Aarch64 => "aarch64", +}; + +/// Adoptium says `mac` where the other vendors say `macos` and publishes musl +/// builds under `alpine-linux` rather than `linux-musl` +const ADOPTIUM_OS: &str = match HostTarget::CURRENT.os { + HostOs::Windows => "windows", + HostOs::MacOs => "mac", + HostOs::Linux { musl: true } => "alpine-linux", + HostOs::Linux { musl: false } => "linux", +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_kits_are_ever_requested() { + let url = adoptium_url(21, 21).expect("a valid url"); + + assert!(url.query().is_some_and(|q| q.contains("image_type=jdk"))); + assert!(!url.query().is_some_and(|q| q.contains("image_type=jre"))); + } +} diff --git a/packages/oneclient_java/src/vendors/corretto.rs b/packages/oneclient_java/src/vendors/corretto.rs new file mode 100644 index 00000000..b62a23d8 --- /dev/null +++ b/packages/oneclient_java/src/vendors/corretto.rs @@ -0,0 +1,120 @@ +use reqwest::{Method, Request}; +use serde_json::Value; +use url::Url; + +use oneclient_net::RequestClient; +use polyio::Checksum; + +use crate::data::{JavaPackage, PackageArchive}; +use crate::error::JavaResult; +use crate::platform::{HostArch, HostOs, HostTarget}; +use crate::vendors::{JavaRuntimeProvider, JavaVendor}; + +pub struct CorrettoRuntimeProvider; + +// { os: { arch: { "jdk": { major: { ext: ... } } } } } +const INDEX_URL: &str = + "https://corretto.github.io/corretto-downloads/latest_links/indexmap_with_checksum.json"; + +#[async_trait::async_trait] +impl JavaRuntimeProvider for CorrettoRuntimeProvider { + fn vendor(&self) -> JavaVendor { + JavaVendor::Corretto + } + + #[tracing::instrument(level = "debug", skip(self, net))] + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult> { + let index = net + .send_as::(Request::new(Method::GET, Url::parse(INDEX_URL)?)) + .await?; + + // Corretto ships JDK only + let Some(by_major) = index + .get(CORRETTO_OS) + .and_then(|by_arch| by_arch.get(CORRETTO_ARCH)) + .and_then(|by_type| by_type.get("jdk")) + .and_then(Value::as_object) + else { + return Ok(Vec::new()); + }; + + let mut packages = Vec::new(); + for (major_str, by_ext) in by_major { + let Ok(this_major) = major_str.parse::() else { + continue; + }; + if let Some(filter) = major + && filter != this_major + { + continue; + } + + let Some(entry) = by_ext.get(CORRETTO_EXT.0) else { + continue; + }; + + // Prefer the versioned URL the `latest` redirect resolves at + // download time and can move away from this index's checksum + let download_url = entry.get("resource").and_then(Value::as_str).map_or_else( + || latest_url(this_major, CORRETTO_EXT.0), + |resource| format!("https://corretto.aws{resource}"), + ); + + let checksum = entry + .get("checksum_sha256") + .and_then(Value::as_str) + .map(Checksum::sha256); + + let name = format!( + "amazon-corretto-{this_major}-{CORRETTO_ARCH}-{CORRETTO_OS}-jdk.{}", + CORRETTO_EXT.0 + ); + + packages.push( + JavaPackage { + archive: CORRETTO_EXT.1, + download_url, + java_version: vec![this_major], + name, + vendor: JavaVendor::Corretto, + checksum: None, + size: None, + } + .with_checksum(checksum), + ); + } + + packages.sort_by_key(|p| std::cmp::Reverse(p.java_version.first().copied().unwrap_or(0))); + tracing::debug!(count = packages.len(), "listed Corretto packages"); + Ok(packages) + } +} + +fn latest_url(major: u32, ext: &str) -> String { + format!( + "https://corretto.aws/downloads/latest/amazon-corretto-{major}-{CORRETTO_ARCH}-{CORRETTO_OS}-jdk.{ext}" + ) +} + +const CORRETTO_ARCH: &str = match HostTarget::CURRENT.arch { + HostArch::X86 => "x86", + HostArch::X86_64 => "x64", + HostArch::Arm => "arm", + HostArch::Aarch64 => "aarch64", +}; + +/// Corretto publishes no separate musl build so Alpine gets the glibc one +const CORRETTO_OS: &str = match HostTarget::CURRENT.os { + HostOs::Windows => "windows", + HostOs::MacOs => "macos", + HostOs::Linux { .. } => "linux", +}; + +const CORRETTO_EXT: (&str, PackageArchive) = ( + HostTarget::CURRENT.archive_ext(), + HostTarget::CURRENT.archive(), +); diff --git a/packages/oneclient_java/src/vendors/liberica.rs b/packages/oneclient_java/src/vendors/liberica.rs new file mode 100644 index 00000000..8db786e6 --- /dev/null +++ b/packages/oneclient_java/src/vendors/liberica.rs @@ -0,0 +1,142 @@ +use reqwest::{Method, Request}; +use serde::Deserialize; +use url::Url; + +use oneclient_net::RequestClient; +use polyio::Checksum; + +use crate::data::{JavaPackage, PackageArchive}; +use crate::error::JavaResult; +use crate::platform::{HostArch, HostOs, HostTarget}; +use crate::vendors::{JavaRuntimeProvider, JavaVendor}; + +pub struct LibericaRuntimeProvider; + +#[derive(Debug, Deserialize)] +struct LibericaRelease { + #[serde(rename = "downloadUrl")] + download_url: String, + filename: String, + #[serde(rename = "featureVersion")] + feature_version: u32, + #[serde(rename = "updateVersion", default)] + update_version: u32, + #[serde(rename = "buildVersion", default)] + build_version: u32, + version: String, + /// Liberica is the one vendor that publishes SHA-1 rather than SHA-256 + #[serde(default)] + sha1: Option, + #[serde(default)] + size: Option, +} + +#[async_trait::async_trait] +impl JavaRuntimeProvider for LibericaRuntimeProvider { + fn vendor(&self) -> JavaVendor { + JavaVendor::Liberica + } + + #[tracing::instrument(level = "debug", skip(self, net))] + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult> { + let url = liberica_url(major)?; + let releases = net + .send_as::>(Request::new(Method::GET, url)) + .await?; + + let mut releases = releases; + releases.sort_by_key(|r| { + std::cmp::Reverse((r.feature_version, r.update_version, r.build_version)) + }); + + let packages: Vec = releases + .into_iter() + .map(|r| { + let mut java_version: Vec = r + .version + .split(|c: char| !c.is_numeric()) + .filter_map(|s| s.parse::().ok()) + .collect(); + if java_version.first() != Some(&r.feature_version) { + java_version.insert(0, r.feature_version); + } + + let checksum = r.sha1.as_deref().map(Checksum::sha1); + + JavaPackage { + archive: PackageArchive::from_filename(&r.filename), + download_url: r.download_url, + java_version, + name: r.filename, + vendor: JavaVendor::Liberica, + checksum: None, + size: r.size, + } + .with_checksum(checksum) + }) + .collect(); + + tracing::debug!(count = packages.len(), "listed Liberica packages"); + + Ok(packages) + } +} + +fn liberica_url(major: Option) -> JavaResult { + let mut url = Url::parse("https://api.bell-sw.com/v1/liberica/releases")?; + { + let mut q = url.query_pairs_mut(); + q.append_pair("os", LIBERICA_OS) + .append_pair("arch", LIBERICA_ARCH) + // Kits only never a bare runtime image + .append_pair("bundle-type", "jdk") + .append_pair("bitness", LIBERICA_BITNESS) + .append_pair("package-type", LIBERICA_PACKAGE) + .append_pair("installation-type", "archive") + .append_pair( + "fields", + // `sha1` and `size` are only returned when named here + "downloadUrl,filename,featureVersion,updateVersion,buildVersion,version,sha1,size", + ) + .append_pair("output", "json"); + if let Some(major) = major { + q.append_pair("version-feature", &major.to_string()); + } + } + Ok(url) +} + +/// Liberica takes architecture family and bitness as separate parameters so +/// x86/x86_64 collapse to `x86` and both ARM variants to `arm` +const LIBERICA_ARCH: &str = match HostTarget::CURRENT.arch { + HostArch::X86 | HostArch::X86_64 => "x86", + HostArch::Arm | HostArch::Aarch64 => "arm", +}; + +const LIBERICA_BITNESS: &str = HostTarget::CURRENT.bitness(); + +const LIBERICA_OS: &str = match HostTarget::CURRENT.os { + HostOs::Windows => "windows", + HostOs::MacOs => "macos", + HostOs::Linux { musl: true } => "linux-musl", + HostOs::Linux { musl: false } => "linux", +}; + +const LIBERICA_PACKAGE: &str = HostTarget::CURRENT.archive_ext(); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_kits_are_ever_requested() { + let url = liberica_url(Some(21)).expect("a valid url"); + + assert!(url.query().is_some_and(|q| q.contains("bundle-type=jdk"))); + assert!(!url.query().is_some_and(|q| q.contains("bundle-type=jre"))); + } +} diff --git a/packages/oneclient_java/src/vendors/mod.rs b/packages/oneclient_java/src/vendors/mod.rs new file mode 100644 index 00000000..5bc5b544 --- /dev/null +++ b/packages/oneclient_java/src/vendors/mod.rs @@ -0,0 +1,126 @@ +use std::{path::PathBuf, str::FromStr}; + +use oneclient_events::{EventBus, GroupedProgressSession}; +use oneclient_net::RequestClient; + +use crate::data::JavaPackage; +use crate::error::JavaResult; + +mod adoptium; +mod corretto; +mod liberica; +mod zulu; + +pub use adoptium::AdoptiumRuntimeProvider; +pub use corretto::CorrettoRuntimeProvider; +pub use liberica::LibericaRuntimeProvider; +use serde::{Deserialize, Deserializer, Serialize}; +pub use zulu::ZuluRuntimeProvider; + +pub fn runtime_providers() -> Vec> { + vec![ + Box::new(ZuluRuntimeProvider), + Box::new(AdoptiumRuntimeProvider), + Box::new(CorrettoRuntimeProvider), + Box::new(LibericaRuntimeProvider), + ] +} + +#[async_trait::async_trait] +pub trait JavaRuntimeProvider: Send + Sync { + fn vendor(&self) -> JavaVendor; + + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult>; + + #[tracing::instrument(level = "debug", skip(self, net))] + async fn latest_package_by_major( + &self, + major: u32, + net: &RequestClient, + ) -> JavaResult> { + let packages = self.list_packages(Some(major), net).await?; + Ok(packages + .into_iter() + .find(|p| p.java_version.first() == Some(&major))) + } + + #[tracing::instrument(level = "debug", skip_all)] + async fn install_package( + &self, + package: &JavaPackage, + net: &RequestClient, + events: &EventBus, + progress: Option<&GroupedProgressSession>, + ) -> JavaResult { + crate::install::install_package(package, net, events, progress).await + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash)] +pub enum JavaVendor { + Zulu, + Corretto, + Oracle, + Microsoft, + Adoptium, + Liberica, + OpenJDK, + Other(String), +} + +impl std::fmt::Display for JavaVendor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + JavaVendor::Zulu => f.write_str("Zulu"), + JavaVendor::Adoptium => f.write_str("Temurin"), + JavaVendor::Liberica => f.write_str("Liberica"), + JavaVendor::Corretto => f.write_str("Corretto"), + JavaVendor::Microsoft => f.write_str("Microsoft"), + JavaVendor::Oracle => f.write_str("Oracle"), + JavaVendor::OpenJDK => f.write_str("OpenJDK"), + JavaVendor::Other(other) => f.write_str(other), + } + } +} + +impl FromStr for JavaVendor { + type Err = (); + + fn from_str(s: &str) -> Result { + let v = s.to_lowercase(); + + Ok( + if v.contains("adoptium") || v.contains("adoptopenjdk") || v.contains("temurin") { + Self::Adoptium + } else if v.contains("liberica") || v.contains("bellsoft") { + Self::Liberica + } else if v.contains("microsoft") { + Self::Microsoft + } else if v.contains("openjdk") { + Self::OpenJDK + } else if v.contains("oracle") { + Self::Oracle + } else if v.contains("amazon") || v.contains("corretto") { + Self::Corretto + } else if v.contains("azul") || v.contains("zulu") { + Self::Zulu + } else { + Self::Other(s.to_string()) + }, + ) + } +} + +impl<'de> Deserialize<'de> for JavaVendor { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Self::from_str(&s).unwrap()) + } +} diff --git a/packages/oneclient_java/src/vendors/zulu.rs b/packages/oneclient_java/src/vendors/zulu.rs new file mode 100644 index 00000000..6eab2afe --- /dev/null +++ b/packages/oneclient_java/src/vendors/zulu.rs @@ -0,0 +1,122 @@ +use reqwest::{Method, Request}; +use serde::Deserialize; +use url::Url; + +use oneclient_net::RequestClient; +use polyio::Checksum; + +use crate::data::{JavaPackage, PackageArchive}; +use crate::error::JavaResult; +use crate::platform::{HostArch, HostOs, HostTarget}; +use crate::vendors::{JavaRuntimeProvider, JavaVendor}; + +pub struct ZuluRuntimeProvider; + +#[derive(Debug, Deserialize)] +struct ZuluPackage { + download_url: String, + name: String, + java_version: Vec, + /// Only present because `include_fields` asks for it + #[serde(default)] + sha256_hash: Option, + #[serde(default)] + size: Option, +} + +#[async_trait::async_trait] +impl JavaRuntimeProvider for ZuluRuntimeProvider { + fn vendor(&self) -> JavaVendor { + JavaVendor::Zulu + } + + #[tracing::instrument(level = "debug", skip(self, net))] + async fn list_packages( + &self, + major: Option, + net: &RequestClient, + ) -> JavaResult> { + let url = zulu_url(major)?; + let packages = net + .send_as::>(Request::new(Method::GET, url)) + .await?; + let packages: Vec = packages.into_iter().map(map_zulu_package).collect(); + + tracing::debug!(count = packages.len(), "listed Zulu packages"); + + Ok(packages) + } +} + +fn map_zulu_package(pkg: ZuluPackage) -> JavaPackage { + let checksum = pkg.sha256_hash.as_deref().map(Checksum::sha256); + + JavaPackage { + archive: PackageArchive::Zip, + download_url: pkg.download_url, + java_version: pkg.java_version, + name: pkg.name, + vendor: JavaVendor::Zulu, + checksum: None, + size: pkg.size, + } + .with_checksum(checksum) +} + +fn zulu_url(major: Option) -> JavaResult { + let mut url = Url::parse("https://api.azul.com/metadata/v1/zulu/packages/")?; + { + let mut q = url.query_pairs_mut(); + q.append_pair("os", ZULU_OS) + .append_pair("arch", ZULU_ARCH) + .append_pair("archive_type", "zip") + // Kits only never a bare runtime image + .append_pair("java_package_type", "jdk") + .append_pair("javafx_bundled", "false") + .append_pair("release_status", "ga") + .append_pair("availability_types", "CA") + .append_pair("certifications", "tck") + .append_pair("latest", "true") + // Azul's default projection omits the hash + .append_pair("include_fields", "sha256_hash,size") + .append_pair("page", "1") + .append_pair("page_size", "100"); + if let Some(major) = major { + q.append_pair("java_version", &major.to_string()); + } + } + Ok(url) +} + +const ZULU_ARCH: &str = match HostTarget::CURRENT.arch { + HostArch::X86 => "x86", + HostArch::X86_64 => "x64", + HostArch::Arm => "arm", + HostArch::Aarch64 => "aarch64", +}; + +const ZULU_OS: &str = match HostTarget::CURRENT.os { + HostOs::Windows => "windows", + HostOs::MacOs => "macos", + HostOs::Linux { musl: true } => "linux-musl", + HostOs::Linux { musl: false } => "linux", +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_kits_are_ever_requested() { + let url = zulu_url(Some(21)).expect("a valid url"); + + assert!( + url.query() + .is_some_and(|q| q.contains("java_package_type=jdk")) + ); + assert!( + !url.query() + .is_some_and(|q| q.contains("java_package_type=jre")) + ); + } +} diff --git a/packages/oneclient_java/tests/vendor_checksums.rs b/packages/oneclient_java/tests/vendor_checksums.rs new file mode 100644 index 00000000..6746c23f --- /dev/null +++ b/packages/oneclient_java/tests/vendor_checksums.rs @@ -0,0 +1,95 @@ +use oneclient_java::vendors::runtime_providers; +use oneclient_net::{NetConfig, RequestClient}; +use polyio::ChecksumAlgorithm; + +/// A major every vendor still publishes so a listing coming back empty means +/// the query is wrong rather than the release being retired +const MAJOR: u32 = 21; + +#[tokio::test] +#[ignore = "requires network"] +async fn every_vendor_publishes_a_usable_checksum() { + let net = RequestClient::new(NetConfig::default()).expect("a client"); + let mut failures = Vec::new(); + + for provider in runtime_providers() { + let vendor = provider.vendor(); + + let packages = match provider.list_packages(Some(MAJOR), &net).await { + Ok(packages) => packages, + Err(err) => { + failures.push(format!("{vendor}: listing failed: {err}")); + continue; + } + }; + + if packages.is_empty() { + failures.push(format!("{vendor}: no packages listed for major {MAJOR}")); + continue; + } + + let unverifiable = packages + .iter() + .filter(|package| package.checksum.is_none()) + .count(); + + if unverifiable > 0 { + failures.push(format!( + "{vendor}: {unverifiable}/{} packages carry no usable checksum", + packages.len() + )); + continue; + } + + for package in &packages { + let checksum = package.checksum.as_ref().expect("checked above"); + assert!( + checksum.is_well_formed(), + "{vendor}: malformed checksum on {}: {}", + package.name, + checksum.hex + ); + } + + println!( + "{vendor}: {} packages, all carrying {}", + packages.len(), + packages[0] + .checksum + .as_ref() + .expect("checked above") + .algorithm + .name() + ); + } + + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[tokio::test] +#[ignore = "requires network"] +async fn liberica_publishes_sha1_and_the_rest_publish_sha256() { + let net = RequestClient::new(NetConfig::default()).expect("a client"); + + for provider in runtime_providers() { + let vendor = provider.vendor(); + let packages = provider + .list_packages(Some(MAJOR), &net) + .await + .unwrap_or_else(|err| panic!("{vendor}: listing failed: {err}")); + + let Some(checksum) = packages.first().and_then(|p| p.checksum.as_ref()) else { + panic!("{vendor}: no checksum to check the algorithm of"); + }; + + let expected = match vendor.to_string().as_str() { + "Liberica" => ChecksumAlgorithm::Sha1, + _ => ChecksumAlgorithm::Sha256, + }; + + assert_eq!( + checksum.algorithm, expected, + "{vendor} changed the algorithm it publishes" + ); + } +} diff --git a/packages/oneclient_macro/.gitignore b/packages/oneclient_macro/.gitignore new file mode 100644 index 00000000..c41cc9e3 --- /dev/null +++ b/packages/oneclient_macro/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/packages/oneclient_macro/Cargo.toml b/packages/oneclient_macro/Cargo.toml new file mode 100644 index 00000000..b8105117 --- /dev/null +++ b/packages/oneclient_macro/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "oneclient_macro" +description = "Macros for OneClient" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } +documentation = { workspace = true } +readme = { workspace = true } +homepage = { workspace = true } +authors = { workspace = true } + +[lib] +name = "oneclient_macro" +path = "src/lib.rs" +proc-macro = true + +[features] +default = [] +freya = [] + +[dependencies] +syn = { workspace = true } +quote = { workspace = true } +proc-macro2 = { workspace = true } \ No newline at end of file diff --git a/packages/oneclient_macro/src/freya.rs b/packages/oneclient_macro/src/freya.rs new file mode 100644 index 00000000..f82c31ca --- /dev/null +++ b/packages/oneclient_macro/src/freya.rs @@ -0,0 +1,64 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Fields}; + +pub(crate) fn expand_icon_named(input: DeriveInput) -> Result { + let name = &input.ident; + + let data_enum = match input.data { + Data::Enum(data) => data, + _ => { + return Err(syn::Error::new_spanned( + name, + "IconNamed can only be derived for enums", + )); + } + }; + + let mut match_arms = Vec::new(); + + for variant in data_enum.variants { + if !matches!(variant.fields, Fields::Unit) { + return Err(syn::Error::new_spanned( + variant, + "IconNamed only supports unit enum variants", + )); + } + + let variant_ident = &variant.ident; + let file_name = format!("icons/{}.svg", to_kebab_case(&variant_ident.to_string())); + + let doc_comment = format!("Icon preview:\n\n![{}](../{})", variant_ident, file_name); + + match_arms.push(quote! { + #[doc = #doc_comment] + Self::#variant_ident => #file_name, + }); + } + + let expanded = quote! { + impl #name { + pub fn path(self) -> &'static str { + match self { + #(#match_arms)* + } + } + } + }; + + Ok(expanded) +} + +fn to_kebab_case(s: &str) -> String { + let mut kebab = String::new(); + for (i, ch) in s.chars().enumerate() { + if i > 0 + && (ch.is_uppercase() + || (ch.is_numeric() && !s.chars().nth(i - 1).unwrap().is_numeric())) + { + kebab.push('-'); + } + kebab.push(ch.to_ascii_lowercase()); + } + kebab +} diff --git a/packages/oneclient_macro/src/lib.rs b/packages/oneclient_macro/src/lib.rs new file mode 100644 index 00000000..efae3076 --- /dev/null +++ b/packages/oneclient_macro/src/lib.rs @@ -0,0 +1,12 @@ +#[cfg(feature = "freya")] +mod freya; + +#[cfg(feature = "freya")] +#[proc_macro_derive(IconNamed)] +pub fn derive_icon_named(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let input = syn::parse_macro_input!(input as syn::DeriveInput); + + freya::expand_icon_named(input) + .unwrap_or_else(|err| err.to_compile_error()) + .into() +} diff --git a/packages/oneclient_mc/Cargo.toml b/packages/oneclient_mc/Cargo.toml new file mode 100644 index 00000000..36510292 --- /dev/null +++ b/packages/oneclient_mc/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "oneclient_mc" +description = "Minecraft content: assets, libraries, natives, client jars, launch arguments and player profiles" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_events.workspace = true +oneclient_net.workspace = true +polyio.workspace = true + +# Mojang / modded metadata schemas +interfrost.workspace = true + +futures-util.workspace = true +tokio.workspace = true + +base64.workspace = true +chrono.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true } diff --git a/packages/oneclient_mc/src/arguments.rs b/packages/oneclient_mc/src/arguments.rs new file mode 100644 index 00000000..7bc699a1 --- /dev/null +++ b/packages/oneclient_mc/src/arguments.rs @@ -0,0 +1,780 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use interfrost::api::minecraft::{Argument, ArgumentValue, Library, VersionType}; +use interfrost::api::modded::SidedDataEntry; +use interfrost::utils::get_path_from_artifact; + +use crate::error::McError; +use crate::error::McResult; +use crate::rules::validate_rules; +use oneclient_common::Resolution; +use oneclient_common::constants::{self, DUMMY_REPLACE_NEWLINE}; + +#[allow(clippy::too_many_arguments)] +pub fn java_arguments( + version_updated: bool, + arguments: Option<&[Argument]>, + natives_path: &Path, + libraries_path: &Path, + classpaths: &str, + version: &str, + mem_max: u32, + custom_args: String, + java_arch: &str, + java_major: u32, +) -> McResult> { + let mut parsed = Vec::new(); + if let Some(args) = arguments { + parse_arguments( + version_updated, + args, + &mut parsed, + |a| { + parse_java_argument( + a.to_string(), + natives_path, + libraries_path, + classpaths, + version, + ) + }, + java_arch, + )?; + } else { + parsed.push(format!( + "-Djava.library.path={}", + polyio::canonicalize(natives_path) + .map_err(|_| McError::LibraryPath(natives_path.display().to_string()))? + .display() + )); + parsed.push("-cp".to_string()); + parsed.push(classpaths.to_string()); + } + + let custom = split_custom_args(&custom_args); + + parsed.extend(performance_flags(java_major, java_arch, mem_max, &custom)); + + parsed.push(format!("-Xmx{mem_max}M")); + parsed.extend(custom); + + Ok(parsed) +} + +const ZGC_MIN_HEAP_MB: u32 = 8192; + +const INITIAL_HEAP_MB: u32 = 512; + +#[must_use] +pub fn performance_flags( + java_major: u32, + java_arch: &str, + mem_max: u32, + custom_args: &[String], +) -> Vec { + let mut flags = vec![format!("-Xms{}M", mem_max.min(INITIAL_HEAP_MB))]; + + let collector_chosen = custom_args.iter().any(|arg| is_collector_flag(arg)); + + let use_zgc = + !collector_chosen && java_major >= 21 && is_64_bit(java_arch) && mem_max >= ZGC_MIN_HEAP_MB; + let use_g1 = !collector_chosen && !use_zgc && java_major >= 8; + + if (use_zgc || use_g1) && java_major == 24 { + flags.push("-XX:+UnlockExperimentalVMOptions".to_string()); + } + + if use_zgc { + flags.push("-XX:+UseZGC".to_string()); + if java_major <= 22 { + flags.push("-XX:+ZGenerational".to_string()); + } + } else if use_g1 { + flags.push("-XX:+UseG1GC".to_string()); + flags.push("-XX:+ParallelRefProcEnabled".to_string()); + } + + if (use_zgc || use_g1) && java_major >= 24 && is_64_bit(java_arch) { + flags.push("-XX:+UseCompactObjectHeaders".to_string()); + } + + flags +} + +fn is_collector_flag(arg: &str) -> bool { + const COLLECTORS: [&str; 8] = [ + "UseG1GC", + "UseZGC", + "UseShenandoahGC", + "UseParallelGC", + "UseParallelOldGC", + "UseConcMarkSweepGC", + "UseSerialGC", + "UseEpsilonGC", + ]; + + arg.starts_with("-XX:") && COLLECTORS.iter().any(|name| arg.contains(name)) +} + +fn is_64_bit(java_arch: &str) -> bool { + matches!( + java_arch, + "amd64" | "x86_64" | "x64" | "aarch64" | "arm64" | "ppc64le" | "s390x" | "riscv64" + ) +} + +/// Whitespace-separated double quotes for values containing spaces +/// Must not be passed as one argv entry or the JVM sees one unrecognised option +fn split_custom_args(raw: &str) -> Vec { + let mut args = Vec::new(); + let mut current = String::new(); + let mut quoted = false; + let mut started = false; + + for ch in raw.chars() { + match ch { + '"' => { + quoted = !quoted; + started = true; + } + c if c.is_whitespace() && !quoted => { + if started { + args.push(std::mem::take(&mut current)); + started = false; + } + } + c => { + current.push(c); + started = true; + } + } + } + + if started { + args.push(current); + } + + args +} + +#[allow(clippy::too_many_arguments)] +pub fn minecraft_arguments( + version_updated: bool, + args: Option<&[Argument]>, + legacy_args: Option<&str>, + access_token: &str, + username: &str, + uuid: uuid::Uuid, + version: &str, + asset_index: &str, + game_directory: &Path, + assets_directory: &Path, + version_type: VersionType, + resolution: Resolution, + java_arch: &str, +) -> McResult> { + if let Some(args) = args { + let mut parsed = Vec::new(); + parse_arguments( + version_updated, + args, + &mut parsed, + |arg| { + parse_minecraft_argument( + arg, + access_token, + username, + uuid, + version, + asset_index, + game_directory, + assets_directory, + version_type, + resolution, + ) + }, + java_arch, + )?; + + Ok(parsed) + } else if let Some(legacy_args) = legacy_args { + let mut parsed = Vec::new(); + for arg in legacy_args.split(' ') { + parsed.push(parse_minecraft_argument( + &arg.replace(' ', DUMMY_REPLACE_NEWLINE), + access_token, + username, + uuid, + version, + asset_index, + game_directory, + assets_directory, + version_type, + resolution, + )?); + } + + Ok(parsed) + } else { + Ok(Vec::new()) + } +} + +pub fn append_profile_game_arguments( + args: &mut Vec, + force_fullscreen: Option, + launch_args: Option<&str>, +) { + if force_fullscreen.unwrap_or(false) { + args.push("--fullscreen".to_string()); + } + + if let Some(extra) = launch_args.map(str::trim).filter(|s| !s.is_empty()) { + args.push(extra.to_string()); + } +} + +pub fn processor_arguments, S: std::hash::BuildHasher>( + libraries_path: &Path, + args: &[T], + data: &HashMap, +) -> McResult> { + let mut parsed = Vec::new(); + + for arg in args { + let a = &arg.as_ref()[1..arg.as_ref().len() - 1]; + if arg.as_ref().starts_with('{') { + if let Some(entry) = data.get(a) { + parsed.push(if entry.client.starts_with('[') { + get_library( + libraries_path, + &entry.client[1..entry.client.len() - 1], + true, + )? + } else { + entry.client.clone() + }); + } + } else if arg.as_ref().starts_with('[') { + parsed.push(get_library(libraries_path, a, true)?); + } else { + parsed.push(arg.as_ref().to_string()); + } + } + + Ok(parsed) +} + +#[tracing::instrument(skip_all, level = "debug")] +pub async fn main_class(path: impl AsRef) -> McResult> { + let data = polyio::read(path.as_ref()).await?; + let mut class_name = None; + + use futures_util::TryStreamExt; + + let stream = polyio::stream_zip_entries_bytes(data); + let mut stream = std::pin::pin!(stream); + + while let Some(item) = stream.try_next().await? { + let (index, entry, reader) = item; + if entry.dir().map_err(polyio::IOError::from)? { + continue; + } + + if entry.filename().as_str().map_err(polyio::IOError::from)? != "META-INF/MANIFEST.MF" { + continue; + } + + let mut buf = String::new(); + let mut entry_reader = reader + .reader_without_entry(index) + .await + .map_err(polyio::IOError::from)?; + futures_util::AsyncReadExt::read_to_string(&mut entry_reader, &mut buf) + .await + .map_err(polyio::IOError::from)?; + + for line in buf.lines() { + let line = line.trim(); + if line.starts_with("Main-Class:") + && let Some(class) = line.split(':').nth(1) + { + class_name = Some(class.trim().to_string()); + break; + } + } + } + + Ok(class_name) +} + +#[allow(clippy::too_many_arguments)] +pub fn parse_minecraft_argument( + argument: &str, + access_token: &str, + username: &str, + uuid: uuid::Uuid, + version: &str, + asset_index: &str, + game_directory: &Path, + assets_directory: &Path, + version_type: VersionType, + resolution: Resolution, +) -> McResult { + Ok(argument + .replace("${accessToken}", access_token) + .replace("${auth_access_token}", access_token) + .replace("${auth_session}", access_token) + .replace("${auth_player_name}", username) + .replace("${auth_xuid}", "0") + .replace("${auth_uuid}", &uuid.simple().to_string()) + .replace("${uuid}", &uuid.simple().to_string()) + .replace( + "${clientid}", + oneclient_common::constants::MICROSOFT_CLIENT_ID, + ) + .replace("${user_properties}", "{}") + .replace("${user_type}", "msa") + .replace("${version_name}", version) + .replace("${assets_index_name}", asset_index) + .replace( + "${game_directory}", + &polyio::canonicalize(game_directory)?.display().to_string(), + ) + .replace( + "${assets_root}", + &polyio::canonicalize(assets_directory)? + .display() + .to_string(), + ) + .replace( + "${game_assets}", + &polyio::canonicalize(assets_directory)? + .display() + .to_string(), + ) + .replace("${version_type}", version_type.as_str()) + .replace("${resolution_width}", &resolution.width.to_string()) + .replace("${resolution_height}", &resolution.height.to_string())) +} + +fn parse_java_argument( + mut argument: String, + natives_path: &Path, + libraries_path: &Path, + classpaths: &str, + version: &str, +) -> McResult { + argument.retain(|c| !c.is_whitespace()); + Ok(argument + .replace( + "${natives_directory}", + &polyio::canonicalize(natives_path)?.display().to_string(), + ) + .replace( + "${library_directory}", + &polyio::canonicalize(libraries_path)?.display().to_string(), + ) + .replace("${classpath_separator}", constants::CLASSPATH_SEPARATOR) + .replace("${launcher_name}", "OneClient") + .replace("${launcher_version}", env!("CARGO_PKG_VERSION")) + .replace("${version_name}", version) + .replace("${classpath}", classpaths)) +} + +pub fn classpaths( + libraries_path: &Path, + libraries: &[Library], + client_path: &Path, + java_arch: &str, + updated: bool, +) -> McResult { + let mut chosen: HashMap, &str)> = HashMap::new(); + for lib in libraries { + if let Some(rules) = &lib.rules + && !validate_rules(rules, java_arch, updated) + { + continue; + } + if !lib.include_in_classpath { + continue; + } + + let (artifact, version) = split_artifact_version(&lib.name); + let ver_key = version_key(version); + match chosen.get(&artifact) { + Some((existing, existing_name)) if *existing >= ver_key => { + tracing::debug!( + skipped = %lib.name, + kept = %existing_name, + "classpath: dropping older duplicate library" + ); + } + _ => { + chosen.insert(artifact, (ver_key, &lib.name)); + } + } + } + + let mut classpaths = chosen + .values() + .map(|(_, name)| get_library(libraries_path, name, false)) + .collect::, _>>()?; + + classpaths.insert(polyio::canonicalize(client_path)?.display().to_string()); + + tracing::debug!(entries = classpaths.len(), "classpath resolved"); + + Ok(classpaths + .into_iter() + .collect::>() + .join(constants::CLASSPATH_SEPARATOR)) +} + +fn split_artifact_version(name: &str) -> (String, &str) { + match name.rsplit_once(':') { + Some((key, version)) if version.starts_with(|c: char| c.is_ascii_digit()) => { + (key.to_string(), version) + } + _ => (name.to_string(), ""), + } +} + +fn version_key(version: &str) -> Vec { + version + .split(['.', '-', '+', '_']) + .map(|seg| seg.parse::().unwrap_or(0)) + .collect() +} + +pub fn get_classpath_library>( + libraries_path: &Path, + libraries: &[T], +) -> McResult { + let classpaths = libraries + .iter() + .map(|lib| get_library(libraries_path, lib.as_ref(), false)) + .collect::, _>>()?; + + Ok(classpaths.join(constants::CLASSPATH_SEPARATOR)) +} + +pub fn get_library(libraries_path: &Path, library: &str, error_exist: bool) -> McResult { + let mut path = libraries_path.to_path_buf(); + path.push( + get_path_from_artifact(library).map_err(|_| McError::LibraryPath(library.to_string()))?, + ); + + if !path.exists() && error_exist { + return Ok(path.display().to_string()); + } + + Ok(polyio::canonicalize(&path)?.display().to_string()) +} + +fn parse_arguments( + version_updated: bool, + args: &[Argument], + parsed: &mut Vec, + parse_function: ParseFn, + java_arch: &str, +) -> McResult<()> +where + ParseFn: Fn(&str) -> McResult, +{ + for arg in args { + match arg { + Argument::Normal(a) => { + let p = parse_function(&a.replace(' ', DUMMY_REPLACE_NEWLINE))?; + for split_p in p.split(DUMMY_REPLACE_NEWLINE) { + parsed.push(split_p.to_string()); + } + } + Argument::Ruled { rules, value } => { + if validate_rules(rules, java_arch, version_updated) { + match value { + ArgumentValue::Single(arg) => { + parsed.push(parse_function(&arg.replace(' ', DUMMY_REPLACE_NEWLINE))?); + } + ArgumentValue::Many(args) => { + for arg in args { + parsed.push(parse_function( + &arg.replace(' ', DUMMY_REPLACE_NEWLINE), + )?); + } + } + } + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ZGC_MIN_HEAP_MB, is_collector_flag, performance_flags, split_custom_args}; + + #[test] + fn blank_input_contributes_nothing() { + assert!(split_custom_args("").is_empty()); + assert!(split_custom_args(" \t ").is_empty()); + } + + #[test] + fn each_flag_becomes_its_own_argument() { + assert_eq!( + split_custom_args("-XX:+UseG1GC -Xms2G"), + vec!["-XX:+UseG1GC", "-Xms2G"] + ); + } + + #[test] + fn quotes_hold_a_value_with_spaces_together() { + assert_eq!( + split_custom_args(r#"-Dname="My Server" -Xss1M"#), + vec!["-Dname=My Server", "-Xss1M"] + ); + } + + #[test] + fn an_empty_quoted_value_survives() { + assert_eq!(split_custom_args(r#"-Dempty="""#), vec!["-Dempty="]); + } + + fn flags(java_major: u32, java_arch: &str, mem_max: u32) -> Vec { + performance_flags(java_major, java_arch, mem_max, &[]) + } + + fn custom(args: &str) -> Vec { + split_custom_args(args) + } + + #[test] + fn a_java_8_runtime_gets_g1() { + let j8 = flags(8, "amd64", 4096); + assert_eq!(j8.first().unwrap(), "-Xms512M"); + assert!(j8.contains(&"-XX:+UseG1GC".to_string())); + } + + #[test] + fn the_versions_told_to_upgrade_still_get_a_tuned_collector() { + for major in [9, 11, 17, 20] { + let flags = flags(major, "amd64", 4096); + assert!( + flags.contains(&"-XX:+UseG1GC".to_string()), + "java {major} should still be tuned, it is what old packs run on" + ); + assert!( + !flags.iter().any(|f| f.contains("UseZGC")), + "java {major} predates the generational ZGC we would want" + ); + } + } + + #[test] + fn a_32_bit_runtime_never_gets_zgc() { + for arch in ["x86", "arm", "i386", "unknown"] { + let flags = flags(25, arch, 16384); + assert!( + !flags.iter().any(|f| f.contains("UseZGC")), + "{arch} must not select a collector it cannot load" + ); + assert!( + !flags.contains(&"-XX:+UseCompactObjectHeaders".to_string()), + "{arch} has no compact headers to enable" + ); + assert!(flags.contains(&"-XX:+UseG1GC".to_string())); + } + } + + #[test] + fn a_small_heap_gets_g1_rather_than_zgc() { + for mem in [2048, 4096, ZGC_MIN_HEAP_MB - 1] { + let flags = flags(21, "amd64", mem); + assert!( + !flags.iter().any(|f| f.contains("UseZGC")), + "{mem}M is not enough heap for ZGC to stay ahead of a modded client" + ); + assert!(flags.contains(&"-XX:+UseG1GC".to_string())); + } + } + + #[test] + fn a_large_heap_still_gets_zgc() { + for mem in [ZGC_MIN_HEAP_MB, 12288, 16384] { + let flags = flags(21, "amd64", mem); + assert!(flags.contains(&"-XX:+UseZGC".to_string())); + assert!( + !flags.contains(&"-XX:+UseG1GC".to_string()), + "only one collector may be selected" + ); + } + } + + #[test] + fn the_collector_is_selected_but_not_sized() { + for mem in [2048, 4096, 8192, 16384] { + let flags = flags(17, "amd64", mem); + assert_eq!( + flags, + vec![ + "-Xms512M".to_string(), + "-XX:+UseG1GC".to_string(), + "-XX:+ParallelRefProcEnabled".to_string(), + ], + "{mem}M should get no hand-picked sizing" + ); + } + } + + #[test] + fn a_collector_in_the_custom_arguments_replaces_ours() { + for arg in [ + "-XX:+UseG1GC", + "-XX:+UseZGC", + "-XX:+UseSerialGC", + "-XX:+UseShenandoahGC", + "-XX:-UseG1GC", + ] { + let flags = performance_flags(21, "amd64", 16384, &custom(arg)); + assert!( + !flags.iter().any(|f| is_collector_flag(f)), + "{arg} should leave the collector entirely to the user" + ); + assert!( + !flags.iter().any(|f| f.starts_with("-XX:G1")), + "{arg} should not be tuned for a collector we did not select" + ); + assert_eq!( + flags, + vec!["-Xms512M"], + "{arg} should leave only the collector-independent flags" + ); + } + } + + #[test] + fn unrelated_custom_arguments_leave_the_collector_alone() { + let flags = performance_flags(21, "amd64", 4096, &custom("-Dfoo=bar -Xss1M")); + assert!(flags.contains(&"-XX:+UseG1GC".to_string())); + } + + #[test] + fn zgc_is_asked_for_a_young_generation_only_before_23() { + assert!(flags(21, "amd64", 16384).contains(&"-XX:+ZGenerational".to_string())); + assert!(flags(22, "aarch64", 16384).contains(&"-XX:+ZGenerational".to_string())); + assert!(!flags(23, "amd64", 16384).contains(&"-XX:+ZGenerational".to_string())); + assert!(!flags(25, "amd64", 16384).contains(&"-XX:+ZGenerational".to_string())); + } + + #[test] + fn compact_headers_arrive_on_24_whichever_collector_is_chosen() { + for major in [21, 23] { + assert!( + !flags(major, "amd64", 16384).contains(&"-XX:+UseCompactObjectHeaders".to_string()), + "java {major} predates compact object headers" + ); + } + + for mem in [4096, 16384] { + let j24 = flags(24, "amd64", mem); + let unlock = j24 + .iter() + .position(|f| f == "-XX:+UnlockExperimentalVMOptions") + .expect("24 needs the unlock flag"); + let compact = j24 + .iter() + .position(|f| f == "-XX:+UseCompactObjectHeaders") + .expect("24 supports compact headers"); + assert!( + unlock < compact, + "the unlock flag must precede the option it unlocks" + ); + } + + let j25_zgc = flags(25, "amd64", 16384); + assert!(j25_zgc.contains(&"-XX:+UseCompactObjectHeaders".to_string())); + assert!(!j25_zgc.contains(&"-XX:+UnlockExperimentalVMOptions".to_string())); + + assert!(flags(25, "amd64", 4096).contains(&"-XX:+UseCompactObjectHeaders".to_string())); + } + + #[test] + fn every_experimental_option_sits_after_the_unlock() { + const EXPERIMENTAL: [&str; 1] = ["-XX:+UseCompactObjectHeaders"]; + + for major in [8, 17, 21, 24, 25] { + for mem in [4096, 16384] { + let flags = flags(major, "amd64", mem); + let Some(unlock) = flags + .iter() + .position(|f| f == "-XX:+UnlockExperimentalVMOptions") + else { + assert!( + !flags + .iter() + .any(|f| EXPERIMENTAL.iter().any(|e| f.starts_with(e)) + && !(major >= 25 && f == "-XX:+UseCompactObjectHeaders")), + "java {major} at {mem}M emits an experimental option with no unlock" + ); + continue; + }; + + for (i, flag) in flags.iter().enumerate() { + if EXPERIMENTAL.iter().any(|e| flag.starts_with(e)) { + assert!( + unlock < i, + "java {major} at {mem}M puts {flag} before the unlock" + ); + } + } + } + } + } + + #[test] + fn string_deduplication_is_never_added() { + for major in [7, 8, 17, 21, 24, 25] { + assert!( + !flags(major, "amd64", 4096).contains(&"-XX:+UseStringDeduplication".to_string()), + "java {major} should not be told to deduplicate strings" + ); + } + } + + #[test] + fn a_modern_runtime_gets_the_full_set() { + assert_eq!( + flags(25, "aarch64", 16384), + vec!["-Xms512M", "-XX:+UseZGC", "-XX:+UseCompactObjectHeaders"] + ); + } + + #[test] + fn every_tier_starts_small_and_grows_into_the_memory_setting() { + for major in [8, 17, 21, 25] { + assert_eq!( + flags(major, "amd64", 8192).first().unwrap(), + "-Xms512M", + "java {major} should not commit the ceiling up front" + ); + } + } + + #[test] + fn a_tiny_profile_never_starts_above_its_ceiling() { + assert_eq!( + flags(21, "amd64", 256), + vec!["-Xms256M", "-XX:+UseG1GC", "-XX:+ParallelRefProcEnabled",], + "a start size above the maximum is refused by the JVM outright" + ); + } + + #[test] + fn a_java_7_runtime_is_left_untouched() { + assert_eq!(flags(7, "amd64", 4096), vec!["-Xms512M"]); + } +} diff --git a/packages/oneclient_mc/src/download.rs b/packages/oneclient_mc/src/download.rs new file mode 100644 index 00000000..f88bf8ac --- /dev/null +++ b/packages/oneclient_mc/src/download.rs @@ -0,0 +1,57 @@ +use oneclient_events::{EventBus, GroupedProgressSession, TaskCategory}; +use oneclient_net::RequestClient; + +use crate::error::McResult; + +/// The progress child is created before the request goes out otherwise small +/// files dominated by header wait never appear in the UI +#[allow(clippy::too_many_arguments)] +#[tracing::instrument(skip(requester, events, progress, label, expected_sha1), fields(%url), level = "debug")] +pub async fn download_to_path( + requester: &RequestClient, + events: &EventBus, + progress: &GroupedProgressSession, + label: impl Into, + category: TaskCategory, + expected_size: u64, + url: &str, + dest: &std::path::Path, + expected_sha1: Option<&str>, +) -> McResult<()> { + let child = progress.child(label, expected_size, category); + + // Every hash in a Minecraft manifest is SHA-1 so the algorithm is implied + let expected = expected_sha1.map(polyio::Checksum::sha1); + + oneclient_net::download_verified( + requester, + events, + url, + dest, + expected.as_ref(), + expected_size, + Some(oneclient_net::ResponseNotifyOptions::grouped(child)), + ) + .await?; + + Ok(()) +} + +/// Downloads into memory instead of to disk for payloads that are parsed +/// rather than stored +#[tracing::instrument(skip(requester, events, progress, label), fields(%url), level = "debug")] +#[allow(clippy::too_many_arguments)] +pub async fn fetch_bytes_verified( + requester: &RequestClient, + events: &EventBus, + progress: &GroupedProgressSession, + label: impl Into, + category: TaskCategory, + expected_size: u64, + url: &str, + expected_sha1: &str, +) -> McResult> { + let child = progress.child(label, expected_size, category); + + Ok(oneclient_net::fetch_verified(requester, events, url, expected_sha1, Some(child)).await?) +} diff --git a/packages/oneclient_mc/src/error.rs b/packages/oneclient_mc/src/error.rs new file mode 100644 index 00000000..4ef220c4 --- /dev/null +++ b/packages/oneclient_mc/src/error.rs @@ -0,0 +1,83 @@ +use oneclient_common::domain::GameLoader; +use thiserror::Error; + +pub type McResult = Result; + +#[derive(Debug, Error)] +pub enum McError { + #[error("no client download exists for version {0}")] + NoClientDownload(String), + + #[error("failed to resolve library artifact path for {0}")] + LibraryPath(String), + + #[error("invalid game version {0}")] + InvalidVersion(String), + + #[error("failed to fetch metadata")] + FetchError, + + #[error("loader {0} does not use a modded manifest")] + NotModdedManifest(GameLoader), + + #[error("loader {0} does not use a vanilla manifest")] + NotVanillaManifest(GameLoader), + + #[error("no matching loader found")] + NoMatchingLoader, + + #[error("requested loader version '{requested}' was not found")] + RequestedLoaderVersionNotFound { requested: String }, + + #[error("no matching version found")] + NoMatchingVersion, + + #[error("cancelled: {failed} file(s) could not be downloaded")] + IncompleteInstallCancelled { failed: usize }, + + /// A Mojang response was well-formed JSON but not what the API documents + #[error("minecraft: {0}")] + Minecraft(String), + + #[error("failed to parse metadata: {0}")] + ParseError(#[from] serde_json::Error), + + #[error(transparent)] + Request(#[from] oneclient_net::RequestError), + + #[error(transparent)] + Io(#[from] polyio::IOError), + + #[error(transparent)] + StdIo(#[from] std::io::Error), + + #[error(transparent)] + Paths(#[from] oneclient_common::PathsError), + + #[error("Invalid URL: {0}")] + Url(#[from] url::ParseError), +} + +impl McError { + /// Environment fault rather than a launcher bug drives Sentry policy upstream + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + Self::Request(source) => source.is_transient(), + Self::Io(polyio::IOError::IOError(source)) + | Self::Io(polyio::IOError::PathIOError { source, .. }) + | Self::StdIo(source) => { + use std::io::ErrorKind; + matches!( + source.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) + } + _ => false, + } + } +} diff --git a/packages/oneclient_mc/src/install.rs b/packages/oneclient_mc/src/install.rs new file mode 100644 index 00000000..19dec936 --- /dev/null +++ b/packages/oneclient_mc/src/install.rs @@ -0,0 +1,1300 @@ +use std::path::{Path, PathBuf}; + +use futures_util::{StreamExt, stream}; +use interfrost::api::minecraft::{ + Asset, AssetsIndex, DownloadType, Library, Os, Version, VersionInfo, +}; +use interfrost::api::modded::LoaderVersion; +use reqwest::Method; + +use crate::download::{download_to_path, fetch_bytes_verified}; +use crate::rules::validate_rules; + +use crate::McCtx; +use crate::error::{McError, McResult}; +use crate::manifest::MetadataStore; +use oneclient_common::domain::GameLoader; +use oneclient_common::os_ext::OsExt; +use oneclient_common::paths; +use oneclient_events::{Choice, GroupedProgressSession, Prompt, TaskCategory, TaskPhase}; + +/// Asset objects are tiny (median ~10 KiB) and latency-bound so throughput +/// scales with how many are in flight not with bandwidth +const ASSET_DOWNLOAD_CONCURRENCY: usize = 32; +/// Libraries are larger and fewer less fan-out is needed to saturate the link +const LIBRARY_DOWNLOAD_CONCURRENCY: usize = 16; + +/// Computed up-front so the pre-download size estimate and the progress-bar +/// denominators come from the same numbers +#[derive(Debug, Default)] +pub struct DownloadPlan { + pub assets: Vec<(String, Asset)>, + pub asset_bytes: u64, + pub libraries: Vec, + pub library_bytes: u64, + pub client: bool, + pub client_bytes: u64, +} + +impl DownloadPlan { + #[must_use] + pub fn total_bytes(&self) -> u64 { + self.asset_bytes + self.library_bytes + self.client_bytes + } +} + +fn asset_object_path(dir: &Path, name: &str, hash: &str, legacy: bool) -> PathBuf { + if legacy { + dir.join(name.replace('/', std::path::MAIN_SEPARATOR_STR)) + } else { + dir.join(&hash[0..2]).join(hash) + } +} + +/// Size not hash catches interrupted-download truncation without reading +/// ~5000 assets per launch +/// `expected` of 0 means the manifest gave no size +fn matches_expected_size(path: &Path, expected: u64) -> bool { + match std::fs::metadata(path) { + Ok(meta) => expected == 0 || meta.len() == expected, + Err(_) => false, + } +} + +/// 0 when the entry carries no artifact metadata (maven-coordinate libraries +/// from loader manifests) +fn library_artifact_size(lib: &Library) -> u64 { + lib.downloads + .as_ref() + .and_then(|downloads| downloads.artifact.as_ref()) + .map_or(0, |artifact| u64::from(artifact.size)) +} + +fn native_download_size(lib: &Library, java_arch: &str) -> u64 { + let Some((os_key, classifiers)) = lib.natives.as_ref().and_then(|natives| { + Some(( + natives.get(&Os::native_arch(java_arch))?, + lib.downloads.as_ref()?.classifiers.as_ref()?, + )) + }) else { + return 0; + }; + + let parsed = os_key.replace("${arch}", oneclient_common::constants::ARCH_WIDTH); + classifiers + .get(&parsed) + .map_or(0, |native| u64::from(native.size)) +} + +/// Stats thousands of files so it runs on the blocking pool and keeps the +/// download fan-outs from stat-ing inside a concurrency slot +#[tracing::instrument(skip_all, level = "debug")] +pub async fn plan_downloads( + version: &VersionInfo, + assets_index: AssetsIndex, + java_arch: &str, + minecraft_updated: bool, + force: bool, +) -> McResult { + let legacy = version.assets == "legacy"; + let asset_dir = if legacy { + paths::legacy_assets_dir()? + } else { + paths::assets_object_dir()? + }; + let lib_dir = paths::libraries_dir()?; + + let client = version.downloads.get(&DownloadType::Client).map(|client| { + ( + paths::versions_dir() + .map(|dir| dir.join(&version.id).join(format!("{}.jar", version.id))), + u64::from(client.size), + ) + }); + + let libraries = version.libraries.clone(); + let java_arch = java_arch.to_string(); + + tokio::task::spawn_blocking(move || { + let mut plan = DownloadPlan::default(); + + for (name, asset) in assets_index.objects { + let path = asset_object_path(&asset_dir, &name, &asset.hash, legacy); + if !force && matches_expected_size(&path, u64::from(asset.size)) { + continue; + } + plan.asset_bytes += u64::from(asset.size); + plan.assets.push((name, asset)); + } + + for lib in libraries { + if let Some(rules) = &lib.rules + && !validate_rules(rules, &java_arch, minecraft_updated) + { + continue; + } + if !lib.downloadable { + continue; + } + + // A library whose coordinates don't resolve is kept in the plan so the + // download path still reports it as a failure rather than skipping it + let Ok(artifact_path) = interfrost::utils::get_path_from_artifact(&lib.name) else { + plan.libraries.push(lib); + continue; + }; + + if !force + && matches_expected_size(&lib_dir.join(&artifact_path), library_artifact_size(&lib)) + { + continue; + } + + plan.library_bytes += library_artifact_size(&lib); + plan.library_bytes += native_download_size(&lib, &java_arch); + plan.libraries.push(lib); + } + + if let Some((path, size)) = client { + let present = path + .map(|path| matches_expected_size(&path, size)) + .unwrap_or(false); + if force || !present { + plan.client = true; + plan.client_bytes = size; + } + } + + plan + }) + .await + .map_err(std::io::Error::other) + .map_err(Into::into) +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct VerifyReport { + pub checked: usize, + /// Mismatched the manifest and were removed + pub corrupt: usize, + pub missing: usize, +} + +impl VerifyReport { + #[must_use] + pub fn needs_repair(&self) -> bool { + self.corrupt > 0 || self.missing > 0 + } +} + +/// Expensive reads every asset and library so it belongs behind an explicit +/// user action not the launch path +/// Nothing is re-downloaded here corrupt files are deleted so the next prepare +/// refetches them +#[tracing::instrument(skip(progress, version, assets_index), fields(version_id = %version.id), level = "debug")] +pub async fn verify_game_files( + progress: &GroupedProgressSession, + version: &VersionInfo, + assets_index: AssetsIndex, + java_arch: &str, + minecraft_updated: bool, +) -> McResult { + let legacy = version.assets == "legacy"; + let asset_dir = if legacy { + paths::legacy_assets_dir()? + } else { + paths::assets_object_dir()? + }; + let lib_dir = paths::libraries_dir()?; + + let mut targets: Vec<(PathBuf, String)> = Vec::new(); + + for (name, asset) in assets_index.objects { + let path = asset_object_path(&asset_dir, &name, &asset.hash, legacy); + targets.push((path, asset.hash)); + } + + for lib in &version.libraries { + if let Some(rules) = &lib.rules + && !validate_rules(rules, java_arch, minecraft_updated) + { + continue; + } + if !lib.downloadable { + continue; + } + let Ok(artifact_path) = interfrost::utils::get_path_from_artifact(&lib.name) else { + continue; + }; + // Only libraries whose manifest entry carries a hash can be checked + // maven-coordinate entries from loader manifests publish none + if let Some(artifact) = lib + .downloads + .as_ref() + .and_then(|downloads| downloads.artifact.as_ref()) + && !artifact.sha1.is_empty() + { + targets.push((lib_dir.join(&artifact_path), artifact.sha1.clone())); + } + } + + if let Some(client) = version.downloads.get(&DownloadType::Client) { + let path = paths::versions_dir()? + .join(&version.id) + .join(format!("{}.jar", version.id)); + targets.push((path, client.sha1.clone())); + } + + let total = targets.len() as u64; + let child = progress.child("Verifying game files", total.max(1), TaskCategory::Assets); + child.set_phase(TaskPhase::Verifying); + + let started = std::time::Instant::now(); + let child_for_progress = child.clone(); + + // One blocking task for the whole sweep a spawn_blocking per small asset + // would cost more than the hashing itself + let report = tokio::task::spawn_blocking(move || { + let mut report = VerifyReport::default(); + + for (index, (path, expected)) in targets.iter().enumerate() { + if !path.is_file() { + report.missing += 1; + continue; + } + + match polyio::sha1_file_sync(path) { + Ok(actual) + if polyio::normalize_hash(&actual) == polyio::normalize_hash(expected) => + { + report.checked += 1; + } + Ok(actual) => { + tracing::warn!( + path = %path.display(), + %actual, + %expected, + "corrupt game file; removing so it is re-downloaded" + ); + report.checked += 1; + report.corrupt += 1; + if let Err(err) = std::fs::remove_file(path) { + tracing::error!(path = %path.display(), "could not remove: {err}"); + } + } + Err(err) => { + // Unreadable is as good as corrupt removing it lets the + // download path replace it + tracing::warn!(path = %path.display(), "could not hash: {err}"); + report.corrupt += 1; + let _ = std::fs::remove_file(path); + } + } + + // The child's sampling is per-call so throttle here or this emits + // thousands of events + if index % 64 == 0 { + child_for_progress.set_progress(index as u64, Some(total.max(1))); + } + } + + report + }) + .await + .map_err(std::io::Error::other)?; + + child.finish(); + + tracing::info!( + checked = report.checked, + corrupt = report.corrupt, + missing = report.missing, + elapsed_ms = started.elapsed().as_millis() as u64, + "verified game files" + ); + + Ok(report) +} + +#[tracing::instrument(skip_all)] +pub async fn download_minecraft( + ctx: &McCtx, + progress: &GroupedProgressSession, + version: &VersionInfo, + java_arch: &str, + minecraft_updated: bool, + force: bool, +) -> McResult<()> { + let started = std::time::Instant::now(); + let asset_index = download_assets_index(ctx, progress, version, force).await?; + let plan = plan_downloads(version, asset_index, java_arch, minecraft_updated, force).await?; + + tracing::info!( + version = %version.id, + assets = plan.assets.len(), + libraries = plan.libraries.len(), + bytes = plan.total_bytes(), + "planned minecraft download" + ); + + // Reserve each category's real total before any child appears so the bars + // start at the right denominator instead of growing towards it + progress.expect( + TaskCategory::Assets, + plan.assets.len() as u64, + plan.asset_bytes, + ); + progress.expect( + TaskCategory::Libraries, + plan.libraries.len() as u64, + plan.library_bytes, + ); + progress.expect( + TaskCategory::Client, + u64::from(plan.client), + plan.client_bytes, + ); + + let DownloadPlan { + assets, libraries, .. + } = plan; + + let (failed_assets, _client, failed_libraries) = tokio::try_join!( + download_assets(ctx, progress, version.assets == "legacy", assets,), + download_client(ctx, progress, version, force), + download_libraries(ctx, progress, version.id.clone(), libraries, java_arch,), + )?; + + confirm_incomplete_install(ctx, failed_assets, failed_libraries).await?; + + tracing::info!( + version = %version.id, + elapsed_ms = started.elapsed().as_millis() as u64, + "minecraft download complete" + ); + + Ok(()) +} + +enum IncompleteInstall { + LaunchAnyway, +} + +/// Download failures are counted not propagated so one unreachable file does +/// not abandon the other four thousand +/// They already survived every retry but it stays the user's call refusing to +/// launch would strand offline players +#[tracing::instrument(skip(ctx), level = "debug")] +pub async fn confirm_incomplete_install( + ctx: &McCtx, + failed_assets: usize, + failed_libraries: usize, +) -> McResult<()> { + let failed = failed_assets + failed_libraries; + if failed == 0 { + return Ok(()); + } + + tracing::warn!( + failed_assets, + failed_libraries, + "some files could not be downloaded; asking the user how to proceed" + ); + + let body = match (failed_assets, failed_libraries) { + (0, libraries) => format!( + "{libraries} librar{} could not be downloaded after several attempts. \ + The game will most likely fail to start without {}.", + if libraries == 1 { "y" } else { "ies" }, + if libraries == 1 { "it" } else { "them" }, + ), + (assets, 0) => format!( + "{assets} game asset{} could not be downloaded after several attempts. \ + Sounds or textures may be missing.", + if assets == 1 { "" } else { "s" }, + ), + (assets, libraries) => format!( + "{assets} asset(s) and {libraries} librar{} could not be downloaded after \ + several attempts. The game may fail to start or be missing content.", + if libraries == 1 { "y" } else { "ies" }, + ), + }; + + let answer = ctx + .events + .ask( + Prompt::new("Some files could not be downloaded", body) + .option( + Choice::new("launch", "Launch anyway"), + IncompleteInstall::LaunchAnyway, + ) + .dismiss("Cancel"), + ) + .await; + + match answer { + Ok(Some(chosen)) => match chosen.value { + IncompleteInstall::LaunchAnyway => { + tracing::warn!(failed, "user chose to launch with files missing"); + Ok(()) + } + }, + // Dismissed or undeliverable nobody agreed to launch a broken install + // and a headless caller must not silently opt in + Ok(None) => Err(McError::IncompleteInstallCancelled { failed }), + Err(err) => { + tracing::warn!("could not ask about the incomplete install: {err}"); + Err(McError::IncompleteInstallCancelled { failed }) + } + } +} + +#[tracing::instrument(skip(ctx, progress), level = "debug")] +pub async fn download_version_info( + ctx: &McCtx, + progress: Option<&GroupedProgressSession>, + version: &Version, + loader: Option<&LoaderVersion>, + force: bool, +) -> McResult { + let version_id = loader + .map(|it| format!("{}-{}", version.id, it.id)) + .unwrap_or_else(|| version.id.clone()); + + let path = paths::versions_dir()? + .join(&version_id) + .join(format!("{version_id}.json")); + + let result = if path.exists() && !force { + let data = polyio::read(&path).await?; + serde_json::from_slice(&data)? + } else { + tracing::debug!( + version_id = %version_id, + "downloading Minecraft version metadata" + ); + + let version_url = version.url.parse().map_err(McError::Url)?; + let requester = ctx.net.clone(); + let mut info: VersionInfo = match progress { + Some(progress) => { + progress + .run_child( + format!("Version metadata ({version_id})"), + 1, + TaskCategory::Metadata, + |child| { + let requester = requester.clone(); + async move { + child.set_progress(0, Some(1)); + let result = requester + .send_json(Method::GET, version_url, None, &[]) + .await + .map_err(McError::from)?; + child.set_progress(1, Some(1)); + Ok::(result) + } + }, + ) + .await? + } + None => requester + .send_json(Method::GET, version_url, None, &[]) + .await + .map_err(McError::from)?, + }; + + if let Some(loader) = loader { + let loader_url = loader.url.parse().map_err(McError::Url)?; + let requester = ctx.net.clone(); + let partial: interfrost::api::modded::PartialVersionInfo = match progress { + Some(progress) => { + progress + .run_child( + format!("Loader metadata ({version_id})"), + 1, + TaskCategory::Metadata, + |child| { + let requester = requester.clone(); + async move { + child.set_progress(0, Some(1)); + let result = requester + .send_json(Method::GET, loader_url, None, &[]) + .await + .map_err(McError::from)?; + child.set_progress(1, Some(1)); + Ok::( + result, + ) + } + }, + ) + .await? + } + None => requester + .send_json(Method::GET, loader_url, None, &[]) + .await + .map_err(McError::from)?, + }; + + info = interfrost::api::modded::merge_partial_version(partial, info); + + for lib in &mut info.libraries { + lib.name = lib.name.replace("${interpulse.gameVersion}", &version.id); + } + } + + info.id.clone_from(&version_id); + + if let Some(parent) = path.parent() { + polyio::create_dir_all(parent).await?; + } + + polyio::write(&path, &serde_json::to_vec(&info)?).await?; + + info + }; + + Ok(result) +} + +#[tracing::instrument(skip_all, level = "debug")] +pub async fn download_assets_index( + ctx: &McCtx, + progress: &GroupedProgressSession, + version: &VersionInfo, + force: bool, +) -> McResult { + let path = paths::assets_index_dir()?.join(format!("{}.json", version.asset_index.id)); + + if path.exists() && !force { + if let Ok(data) = polyio::read_json::(&path).await { + return Ok(data); + } + tracing::warn!("cached assets index is invalid, redownloading"); + } + + download_to_path( + &ctx.net, + &ctx.events, + progress, + format!("Assets index ({})", version.asset_index.id), + TaskCategory::Assets, + u64::from(version.asset_index.size), + &version.asset_index.url, + &path, + Some(version.asset_index.sha1.as_str()), + ) + .await?; + + polyio::read_json(&path).await.map_err(Into::into) +} + +#[tracing::instrument(skip_all, level = "debug")] +pub async fn download_assets( + ctx: &McCtx, + progress: &GroupedProgressSession, + legacy: bool, + assets: Vec<(String, Asset)>, +) -> McResult { + if assets.is_empty() { + return Ok(0); + } + + let dir = if legacy { + paths::legacy_assets_dir()? + } else { + paths::assets_object_dir()? + }; + + polyio::create_dir_all(&dir).await?; + + // Objects are sharded into 256 fixed subdirectories creating them once + // here saves a blocking-pool round trip on every one of the ~5000 files + if !legacy { + let dir = dir.clone(); + tokio::task::spawn_blocking(move || { + for subhash in 0u16..256 { + let _ = std::fs::create_dir_all(dir.join(format!("{subhash:02x}"))); + } + }) + .await + .map_err(std::io::Error::other)?; + } + + let started = std::time::Instant::now(); + let count = assets.len(); + let requester = ctx.net.clone(); + let events = ctx.events.clone(); + let progress = progress.clone(); + let requests = stream::iter(assets.into_iter().map(|(name, asset)| { + let dir = dir.clone(); + let requester = requester.clone(); + let events = events.clone(); + let progress = progress.clone(); + + async move { + let hash = &asset.hash; + let subhash = &hash[0..2]; + let path = asset_object_path(&dir, &name, hash, legacy); + + let url = format!("https://resources.download.minecraft.net/{subhash}/{hash}"); + download_to_path( + &requester, + &events, + &progress, + format!("Asset {name}"), + TaskCategory::Assets, + u64::from(asset.size), + &url, + &path, + Some(hash), + ) + .await + } + })) + .buffer_unordered(ASSET_DOWNLOAD_CONCURRENCY) + .collect::>(); + + let mut failed = 0; + for res in requests.await { + if let Err(err) = res { + tracing::error!("failed to download asset: {err:?}"); + failed += 1; + } + } + + tracing::info!( + count, + failed, + elapsed_ms = started.elapsed().as_millis() as u64, + "assets ready" + ); + + Ok(failed) +} + +#[tracing::instrument(skip(ctx, progress, version), fields(version_id = %version.id, force), level = "debug")] +pub async fn download_client( + ctx: &McCtx, + progress: &GroupedProgressSession, + version: &VersionInfo, + force: bool, +) -> McResult { + let client = version + .downloads + .get(&DownloadType::Client) + .ok_or_else(|| McError::NoClientDownload(version.id.clone()))?; + + let path = paths::versions_dir()? + .join(&version.id) + .join(format!("{}.jar", version.id)); + + // Size not hash the jar is only published by rename after its hash + // matched so a wrong-but-right-length jar is unreachable + let expected_size = u64::from(client.size); + if !force + && let Ok(meta) = polyio::stat(&path).await + && (expected_size == 0 || meta.len() == expected_size) + { + return Ok(path); + } + + if path.exists() { + tracing::warn!("client jar is the wrong size, redownloading"); + } + + download_to_path( + &ctx.net, + &ctx.events, + progress, + format!("Client {}", version.id), + TaskCategory::Client, + u64::from(client.size), + &client.url, + &path, + Some(&client.sha1), + ) + .await?; + Ok(path) +} + +#[tracing::instrument(skip(version_info), level = "debug")] +pub fn libraries_missing( + version_info: &VersionInfo, + java_arch: &str, + minecraft_updated: bool, +) -> McResult { + let lib_dir = paths::libraries_dir()?; + for lib in &version_info.libraries { + if let Some(rules) = &lib.rules + && !validate_rules(rules, java_arch, minecraft_updated) + { + continue; + } + if !lib.include_in_classpath { + continue; + } + let Ok(rel) = interfrost::utils::get_path_from_artifact(&lib.name) else { + continue; + }; + if !matches_expected_size(&lib_dir.join(&rel), library_artifact_size(lib)) { + tracing::warn!(library = %lib.name, "missing or truncated classpath library; will repair"); + return Ok(true); + } + } + Ok(false) +} + +/// Takes its paths rather than resolving them so it can be tested without the +/// process-global launcher directory +fn assets_tree_missing(index: &Path, objects: &Path) -> bool { + if !index.is_file() { + tracing::warn!(path = %index.display(), "assets index is missing; will repair"); + return true; + } + + if !objects.is_dir() { + tracing::warn!(path = %objects.display(), "assets directory is missing; will repair"); + return true; + } + + false +} + +/// A `Ready` cluster skips preparation so nothing else re-checks its files +/// this turns a deleted assets tree into an automatic repair +/// Coarse checks only per-object verification belongs to `download_minecraft`'s plan +#[tracing::instrument(skip(version_info), level = "debug")] +pub fn game_files_missing( + version_info: &VersionInfo, + java_arch: &str, + minecraft_updated: bool, +) -> McResult { + let index = paths::assets_index_dir()?.join(format!("{}.json", version_info.asset_index.id)); + let objects = if version_info.assets == "legacy" { + paths::legacy_assets_dir()? + } else { + paths::assets_object_dir()? + }; + + if assets_tree_missing(&index, &objects) { + return Ok(true); + } + + if let Some(client) = version_info.downloads.get(&DownloadType::Client) { + let jar = paths::versions_dir()? + .join(&version_info.id) + .join(format!("{}.jar", version_info.id)); + if !matches_expected_size(&jar, u64::from(client.size)) { + tracing::warn!(path = %jar.display(), "client jar is missing or truncated; will repair"); + return Ok(true); + } + } + + libraries_missing(version_info, java_arch, minecraft_updated) +} + +fn lib_short(name: &str) -> String { + let parts: Vec<&str> = name.split(':').collect(); + match parts.as_slice() { + [_group, artifact, version, ..] => format!("{artifact} {version}"), + _ => name.to_string(), + } +} + +#[tracing::instrument(skip(ctx, progress, libraries), level = "debug")] +pub async fn download_libraries( + ctx: &McCtx, + progress: &GroupedProgressSession, + version: String, + libraries: Vec, + java_arch: &str, +) -> McResult { + if libraries.is_empty() { + return Ok(0); + } + + let lib_dir = paths::libraries_dir()?; + let natives_dest = paths::natives_dir()?.join(&version); + let java_arch = java_arch.to_string(); + + polyio::create_dir_all(&lib_dir).await?; + polyio::create_dir_all(&natives_dest).await?; + + let started = std::time::Instant::now(); + let count = libraries.len(); + let requests = stream::iter(libraries.into_iter().map(|lib| { + let lib_dir = lib_dir.clone(); + let natives_dest = natives_dest.clone(); + let java_arch = java_arch.clone(); + let requester = ctx.net.clone(); + let events = ctx.events.clone(); + let progress = progress.clone(); + + async move { + // Rules and the on-disk check already ran in `plan_downloads` + let artifact_path = interfrost::utils::get_path_from_artifact(&lib.name) + .map_err(|_| McError::LibraryPath(lib.name.clone()))?; + let path = lib_dir.join(&artifact_path); + + tokio::try_join!( + async { + if let Some(interfrost::api::minecraft::LibraryDownloads { + artifact: Some(ref artifact), + .. + }) = lib.downloads + && !artifact.url.is_empty() + { + download_to_path( + &requester, + &events, + &progress, + format!("Library {}", lib_short(&lib.name)), + TaskCategory::Libraries, + u64::from(artifact.size), + &artifact.url, + &path, + Some(&artifact.sha1), + ) + .await?; + return Ok::<_, McError>(()); + } + + let url = [ + lib.url + .as_deref() + .unwrap_or("https://libraries.minecraft.net/"), + &artifact_path, + ] + .concat(); + download_to_path( + &requester, + &events, + &progress, + format!("Library {}", lib_short(&lib.name)), + TaskCategory::Libraries, + 0, + &url, + &path, + None, + ) + .await?; + Ok(()) + }, + async { + if let Some((os_key, classifiers)) = lib.natives.as_ref().and_then(|natives| { + Some(( + natives.get(&Os::native_arch(&java_arch))?, + lib.downloads.as_ref()?.classifiers.as_ref()?, + )) + }) { + let parsed = + os_key.replace("${arch}", oneclient_common::constants::ARCH_WIDTH); + if let Some(native) = classifiers.get(&parsed) { + let data = fetch_bytes_verified( + &requester, + &events, + &progress, + format!("Natives {}", lib_short(&lib.name)), + TaskCategory::Natives, + u64::from(native.size), + &native.url, + &native.sha1, + ) + .await?; + + let extract = progress.child( + format!("Natives {}", lib_short(&lib.name)), + 1, + TaskCategory::Natives, + ); + extract.set_phase(oneclient_events::TaskPhase::Extracting); + polyio::unzip_bytes_filtered( + data, + Some(|name: &str| !name.starts_with("META-INF")), + &natives_dest, + ) + .await?; + extract.finish(); + } + } + + Ok(()) + } + )?; + + Ok::<(), McError>(()) + } + })) + .buffer_unordered(LIBRARY_DOWNLOAD_CONCURRENCY) + .collect::>(); + + let mut failed = 0; + for res in requests.await { + if let Err(err) = res { + tracing::error!("failed to download library: {err:?}"); + failed += 1; + } + } + + tracing::info!( + count, + failed, + elapsed_ms = started.elapsed().as_millis() as u64, + "libraries ready" + ); + + Ok(failed) +} + +#[tracing::instrument(skip(metadata, ctx), level = "debug")] +pub async fn get_loader_versions( + metadata: &mut MetadataStore, + ctx: &McCtx, + mc_version: &str, + loader: GameLoader, +) -> McResult> { + if loader == GameLoader::Vanilla { + return Ok(Vec::new()); + } + + let manifest = metadata.get_modded_or_fetch(ctx, loader).await?; + for entry in &manifest.game_versions { + let id = entry + .id + .replace("${interpulse.gameVersion}", mc_version) + .replace(interfrost::api::modded::DUMMY_REPLACE_STRING, mc_version); + if id == mc_version { + return Ok(entry.loaders.iter().map(|l| l.id.clone()).collect()); + } + } + Ok(Vec::new()) +} + +#[tracing::instrument(skip(metadata, ctx), level = "debug")] +pub async fn get_loader_version( + metadata: &mut MetadataStore, + ctx: &McCtx, + mc_version: &str, + loader: GameLoader, + loader_version: Option<&str>, +) -> McResult> { + if loader == GameLoader::Vanilla { + return Ok(None); + } + + let resolve_from_manifest = |manifest: &interfrost::api::modded::Manifest| { + let mut saw_matching_game_version = false; + + for entry in &manifest.game_versions { + if entry + .id + .replace("${interpulse.gameVersion}", mc_version) + .replace(interfrost::api::modded::DUMMY_REPLACE_STRING, mc_version) + != mc_version + { + continue; + } + + saw_matching_game_version = true; + + if let Some(requested) = loader_version { + if let Some(found) = entry + .loaders + .iter() + .find(|loader_entry| loader_entry.id == requested) + { + return (saw_matching_game_version, Some(found.clone())); + } + continue; + } + + if let Some(found) = entry + .loaders + .iter() + .find(|l| l.stable) + .or_else(|| entry.loaders.first()) + { + return (saw_matching_game_version, Some(found.clone())); + } + } + + (saw_matching_game_version, None) + }; + + let mut manifest = metadata.get_modded_or_fetch(ctx, loader).await?; + let (mut saw_matching, mut resolved) = resolve_from_manifest(manifest); + if resolved.is_some() { + return Ok(resolved); + } + + if !saw_matching || loader_version.is_some() { + metadata.fetch_all(ctx).await; + manifest = metadata.get_modded(loader)?; + (saw_matching, resolved) = resolve_from_manifest(manifest); + if resolved.is_some() { + return Ok(resolved); + } + } + + if let Some(requested) = loader_version { + if !saw_matching { + return Err(McError::NoMatchingVersion); + } + return Err(McError::RequestedLoaderVersionNotFound { + requested: requested.to_string(), + }); + } + + if saw_matching { + Err(McError::NoMatchingLoader) + } else { + Err(McError::NoMatchingVersion) + } +} + +#[tracing::instrument(skip(metadata, ctx), level = "debug")] +pub async fn resolve_minecraft_version( + metadata: &mut MetadataStore, + ctx: &McCtx, + mc_version: &str, +) -> McResult<(Version, usize, bool)> { + let mut manifest = metadata.get_vanilla_or_fetch(ctx).await?; + let mut version_index = manifest.versions.iter().position(|it| it.id == mc_version); + + if version_index.is_none() { + metadata.fetch_all(ctx).await; + manifest = metadata.get_vanilla()?; + version_index = manifest.versions.iter().position(|it| it.id == mc_version); + } + + let version_index = version_index.ok_or(McError::NoMatchingVersion)?; + let versions = &manifest.versions; + + Ok(( + versions[version_index].clone(), + version_index, + is_version_updated(version_index, versions), + )) +} + +#[tracing::instrument(skip(metadata, ctx), level = "debug")] +pub async fn get_game_versions( + metadata: &mut MetadataStore, + ctx: &McCtx, +) -> McResult> { + let manifest = metadata.get_vanilla_or_fetch(ctx).await?; + Ok(manifest.versions.clone()) +} + +#[tracing::instrument(skip(metadata, ctx), level = "debug")] +pub async fn get_loaders_for_version( + metadata: &mut MetadataStore, + ctx: &McCtx, + mc_version: &str, +) -> McResult> { + metadata.get_loaders_for_version(ctx, mc_version).await +} + +#[must_use] +pub fn is_version_updated(version_index: usize, versions: &[Version]) -> bool { + version_index <= versions.iter().position(|x| x.id == "22w16a").unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("oneclient-install-{tag}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn install_assets(dir: &Path) -> (PathBuf, PathBuf) { + let indexes = dir.join("assets").join("indexes"); + let objects = dir.join("assets").join("objects"); + std::fs::create_dir_all(&indexes).unwrap(); + std::fs::create_dir_all(&objects).unwrap(); + + let index = indexes.join("17.json"); + std::fs::write(&index, b"{}").unwrap(); + + (index, objects) + } + + #[test] + fn a_complete_assets_tree_needs_no_repair() { + let dir = scratch("assets-ok"); + let (index, objects) = install_assets(&dir); + + assert!(!assets_tree_missing(&index, &objects)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_deleted_assets_directory_asks_for_a_repair() { + let dir = scratch("assets-deleted"); + let (index, objects) = install_assets(&dir); + std::fs::remove_dir_all(dir.join("assets")).unwrap(); + + assert!(assets_tree_missing(&index, &objects)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_deleted_assets_index_asks_for_a_repair() { + let dir = scratch("assets-no-index"); + let (index, objects) = install_assets(&dir); + std::fs::remove_file(&index).unwrap(); + + assert!(assets_tree_missing(&index, &objects)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn an_index_that_is_a_directory_is_not_mistaken_for_a_file() { + // The launcher has produced this shape before so the check must be + // `is_file` not `exists` + let dir = scratch("assets-index-dir"); + let (index, objects) = install_assets(&dir); + std::fs::remove_file(&index).unwrap(); + std::fs::create_dir(&index).unwrap(); + + assert!(assets_tree_missing(&index, &objects)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn size_check_accepts_a_complete_file() { + let dir = scratch("size-ok"); + let file = dir.join("object.bin"); + std::fs::write(&file, b"0123456789").unwrap(); + + assert!(matches_expected_size(&file, 10)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn size_check_rejects_a_truncated_file() { + let dir = scratch("size-short"); + let file = dir.join("object.bin"); + std::fs::write(&file, b"0123").unwrap(); + + assert!(!matches_expected_size(&file, 10)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn size_check_rejects_a_missing_file() { + let dir = scratch("size-missing"); + + assert!(!matches_expected_size(&dir.join("nope.bin"), 10)); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + /// The bus is drained by the test so `ask` has somebody to talk to no + /// request is ever sent through the client + fn test_ctx() -> ( + McCtx, + tokio::sync::mpsc::UnboundedReceiver, + ) { + let (events, rx) = oneclient_events::EventBus::channel(); + let net = oneclient_net::RequestClient::new(oneclient_net::NetConfig::default()) + .expect("a client"); + (McCtx::new(net, events), rx) + } + + #[tokio::test] + async fn a_complete_install_asks_nothing() { + let (ctx, mut rx) = test_ctx(); + + confirm_incomplete_install(&ctx, 0, 0) + .await + .expect("nothing failed, so nothing to confirm"); + + assert!( + rx.try_recv().is_err(), + "a clean install must not interrupt the user" + ); + } + + #[tokio::test] + async fn launching_anyway_is_allowed() { + let (ctx, mut rx) = test_ctx(); + + let asking = tokio::spawn(async move { confirm_incomplete_install(&ctx, 3, 0).await }); + + let Some(oneclient_events::Event::Notification(oneclient_events::Notification::Prompt( + request, + ))) = rx.recv().await + else { + panic!("expected a prompt"); + }; + assert!(request.body.contains("3 game assets"), "{}", request.body); + request + .reply + .send(Some(oneclient_events::Answer::new("launch"))) + .unwrap(); + + asking.await.unwrap().expect("the user opted in"); + } + + #[tokio::test] + async fn dismissing_the_prompt_cancels_the_launch() { + let (ctx, mut rx) = test_ctx(); + + let asking = tokio::spawn(async move { confirm_incomplete_install(&ctx, 0, 2).await }); + + let Some(oneclient_events::Event::Notification(oneclient_events::Notification::Prompt( + request, + ))) = rx.recv().await + else { + panic!("expected a prompt"); + }; + assert!(request.body.contains("fail to start"), "{}", request.body); + request.reply.send(None).unwrap(); + + let err = asking.await.unwrap().expect_err("dismissal must cancel"); + assert!( + matches!(err, McError::IncompleteInstallCancelled { failed: 2 }), + "{err:?}" + ); + } + + #[tokio::test] + async fn a_bus_with_nobody_listening_cancels_rather_than_launching() { + let (ctx, rx) = test_ctx(); + drop(rx); + + let err = confirm_incomplete_install(&ctx, 1, 1) + .await + .expect_err("an unanswerable prompt must not launch the game"); + assert!( + matches!(err, McError::IncompleteInstallCancelled { failed: 2 }), + "{err:?}" + ); + } + + #[test] + fn size_check_falls_back_to_presence_without_a_manifest_size() { + let dir = scratch("size-unknown"); + let file = dir.join("object.bin"); + std::fs::write(&file, b"anything").unwrap(); + + // Maven-coordinate libraries carry no artifact metadata + assert!(matches_expected_size(&file, 0)); + assert!(!matches_expected_size(&dir.join("nope.bin"), 0)); + + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/packages/oneclient_mc/src/lib.rs b/packages/oneclient_mc/src/lib.rs new file mode 100644 index 00000000..25ac2f1e --- /dev/null +++ b/packages/oneclient_mc/src/lib.rs @@ -0,0 +1,37 @@ +//! No database here this crate works against paths so the download planner is +//! testable on its own + +use oneclient_events::EventBus; +use oneclient_net::RequestClient; + +#[derive(Clone)] +pub struct McCtx { + pub net: RequestClient, + pub events: EventBus, +} + +impl McCtx { + #[must_use] + pub fn new(net: RequestClient, events: EventBus) -> Self { + Self { net, events } + } +} + +mod arguments; +mod download; +mod error; +mod install; +mod manifest; +mod profile; +mod rules; + +pub use arguments::*; +pub use download::{download_to_path, fetch_bytes_verified}; +pub use error::{McError, McResult}; +pub use install::*; +pub use manifest::MetadataStore; +pub use profile::{ + MojangCape, MojangFullPlayerProfile, MojangPlayerProfile, MojangSkin, PlayerProfileView, + SkinVariant, fetch_logged_in_profile, fetch_player_profile, fetch_player_profile_view, +}; +pub use rules::validate_rules; diff --git a/packages/oneclient_mc/src/manifest.rs b/packages/oneclient_mc/src/manifest.rs new file mode 100644 index 00000000..20a7d453 --- /dev/null +++ b/packages/oneclient_mc/src/manifest.rs @@ -0,0 +1,264 @@ +use std::collections::HashMap; +use std::str::FromStr; + +use interfrost::api::minecraft::VersionManifest as VanillaManifest; +use interfrost::api::modded::Manifest as ModdedManifest; +use reqwest::Method; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use crate::McCtx; +use crate::error::{McError, McResult}; +use oneclient_common::domain::GameLoader; +use oneclient_common::paths; + +#[derive(Debug, Default)] +pub struct MetadataStore { + initialized: bool, + inner: MetadataInner, + version_loader_cache: HashMap>, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct MetadataInner { + minecraft: Option, + forge: Option, + neo: Option, + fabric: Option, + quilt: Option, + legacyfabric: Option, +} + +impl MetadataStore { + #[must_use] + pub const fn initialized(&self) -> bool { + self.initialized + } + + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn get_vanilla_or_fetch(&mut self, ctx: &McCtx) -> McResult<&VanillaManifest> { + if !self.initialized() { + self.initialize(ctx).await?; + } + + self.get_vanilla() + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn get_modded_or_fetch( + &mut self, + ctx: &McCtx, + loader: GameLoader, + ) -> McResult<&ModdedManifest> { + if !loader.is_modded() { + return Err(McError::NotModdedManifest(loader)); + } + + if !self.initialized() { + self.initialize(ctx).await?; + } + + self.get_modded(loader) + } + + pub fn get_vanilla(&self) -> McResult<&VanillaManifest> { + self.inner + .minecraft + .as_ref() + .ok_or_else(|| McError::FetchError) + } + + pub fn get_modded(&self, loader: GameLoader) -> McResult<&ModdedManifest> { + if !loader.is_modded() { + return Err(McError::NotModdedManifest(loader)); + } + + match loader { + GameLoader::Forge => self.inner.forge.as_ref(), + GameLoader::NeoForge => self.inner.neo.as_ref(), + GameLoader::Fabric => self.inner.fabric.as_ref(), + GameLoader::Quilt => self.inner.quilt.as_ref(), + GameLoader::LegacyFabric => self.inner.legacyfabric.as_ref(), + GameLoader::Vanilla => None, + } + .ok_or_else(|| McError::FetchError) + } + + #[tracing::instrument(skip_all)] + pub async fn initialize(&mut self, ctx: &McCtx) -> McResult<()> { + let path = paths::caches_dir()?.join("metadata.json"); + let mut save_file = false; + let mut metadata = Self::default(); + + if let Ok(bytes) = polyio::read(&path).await { + if let Ok(inner) = serde_json::from_slice::(&bytes) { + metadata.inner = inner; + + if metadata.refetch_errored(ctx).await > 0 { + save_file = true; + } + } else { + metadata.fetch_all(ctx).await; + save_file = true; + } + } else { + metadata.fetch_all(ctx).await; + save_file = true; + } + + if save_file { + if let Some(parent) = path.parent() { + polyio::create_dir_all(parent).await?; + } + polyio::write(&path, &serde_json::to_vec(&metadata.inner)?).await?; + } + + *self = metadata; + self.initialized = true; + + Ok(()) + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn refetch_errored(&mut self, ctx: &McCtx) -> u8 { + let mut changed: u8 = 0; + + if self.inner.minecraft.is_none() + && let Ok(data) = fetch_vanilla_manifest(ctx).await + { + self.inner.minecraft = Some(data); + changed += 1; + } + + macro_rules! check_modded { + ($var:ident) => { + if self.inner.$var.is_none() { + if let Ok(loader) = GameLoader::from_str(stringify!($var)) { + match fetch_modded_manifest(ctx, loader).await { + Ok(data) => { + self.inner.$var = Some(data); + changed += 1; + } + Err(err) => { + tracing::error!("failed to fetch manifest for {}: {}", loader, err); + } + } + } + } + }; + } + + check_modded!(forge); + check_modded!(neo); + check_modded!(fabric); + check_modded!(quilt); + + changed + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn fetch_all(&mut self, ctx: &McCtx) { + let (minecraft, forge, neo, fabric, quilt) = tokio::join!( + fetch_vanilla_manifest(ctx), + fetch_modded_manifest(ctx, GameLoader::Forge), + fetch_modded_manifest(ctx, GameLoader::NeoForge), + fetch_modded_manifest(ctx, GameLoader::Fabric), + fetch_modded_manifest(ctx, GameLoader::Quilt), + ); + + self.inner.minecraft = minecraft.ok(); + self.inner.forge = forge.ok(); + self.inner.neo = neo.ok(); + self.inner.fabric = fabric.ok(); + self.inner.quilt = quilt.ok(); + } + + #[tracing::instrument(level = "debug", skip(self, ctx))] + pub async fn get_loaders_for_version( + &mut self, + ctx: &McCtx, + mc_version: &str, + ) -> McResult> { + if !self.initialized() { + self.initialize(ctx).await?; + } + + if let Some(hit) = self.version_loader_cache.get(mc_version) { + return Ok(hit.clone()); + } + + let mut loaders = Vec::new(); + for loader in GameLoader::modded_loaders() { + let manifest = match self.get_modded(*loader) { + Ok(manifest) => manifest, + Err(McError::NotModdedManifest(_)) => continue, + Err(e) => return Err(e), + }; + + let found = manifest.game_versions.iter().any(|entry| { + entry + .id + .replace("${interpulse.gameVersion}", mc_version) + .replace(interfrost::api::modded::DUMMY_REPLACE_STRING, mc_version) + == mc_version + }); + + if found { + loaders.push(*loader); + } + } + + self.version_loader_cache + .insert(mc_version.to_owned(), loaders.clone()); + + Ok(loaders) + } +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn fetch_vanilla_manifest(ctx: &McCtx) -> McResult { + match fetch_manifest::(ctx, GameLoader::Vanilla).await { + Ok(manifest) => Ok(manifest), + Err(err) => { + tracing::warn!( + "failed to fetch vanilla manifest from metadata mirror: {err}; falling back to Mojang" + ); + + { + let url = interfrost::api::minecraft::VERSION_MANIFEST_URL + .parse() + .map_err(McError::Url)?; + ctx.net + .send_json(Method::GET, url, None, &[]) + .await + .map_err(McError::from) + } + } + } +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn fetch_modded_manifest(ctx: &McCtx, loader: GameLoader) -> McResult { + fetch_manifest(ctx, loader).await +} + +#[tracing::instrument(level = "debug", skip_all)] +async fn fetch_manifest(ctx: &McCtx, loader: GameLoader) -> McResult { + let url = format!( + "{}/{}/v{}/manifest.json", + ctx.net.config().metadata_api_url, + loader.get_format_name(), + loader.get_format_version() + ); + + let parsed = url.parse().map_err(McError::Url)?; + ctx.net + .send_json(Method::GET, parsed, None, &[]) + .await + .map_err(McError::from) +} diff --git a/packages/oneclient_mc/src/profile.rs b/packages/oneclient_mc/src/profile.rs new file mode 100644 index 00000000..4e69b320 --- /dev/null +++ b/packages/oneclient_mc/src/profile.rs @@ -0,0 +1,429 @@ +use base64::Engine; +use chrono::{DateTime, TimeDelta, Utc}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +use crate::error::{McError, McResult}; +use oneclient_net::RequestClient; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MojangPlayerProfile { + pub uuid: String, + pub username: String, + pub is_slim: bool, + pub skin_url: Option, + pub cape_url: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct PlayerProfileView { + pub uuid: String, + pub username: String, + pub is_slim: bool, + pub skin_url: Option, + pub cape_url: Option, + pub skins: Vec, + pub capes: Vec, +} + +impl PlayerProfileView { + pub fn placeholder() -> Self { + Self { + uuid: "00000000-0000-0000-0000-000000000000".into(), + username: "Player".into(), + is_slim: false, + skin_url: None, + cape_url: None, + skins: Vec::new(), + capes: Vec::new(), + } + } +} + +impl From for PlayerProfileView { + fn from(profile: MojangPlayerProfile) -> Self { + Self { + uuid: profile.uuid, + username: profile.username, + is_slim: profile.is_slim, + skin_url: profile.skin_url, + cape_url: profile.cape_url, + skins: Vec::new(), + capes: Vec::new(), + } + } +} + +impl From for PlayerProfileView { + fn from(profile: MojangFullPlayerProfile) -> Self { + let active_skin = profile + .skins + .iter() + .find(|skin| skin.state.eq_ignore_ascii_case("ACTIVE")); + let active_cape = profile + .capes + .iter() + .find(|cape| cape.state.eq_ignore_ascii_case("ACTIVE")); + + Self { + uuid: profile.id, + username: profile.username, + is_slim: active_skin.is_some_and(|skin| skin.variant == SkinVariant::Slim), + skin_url: active_skin.map(|skin| skin.url.clone()), + cape_url: active_cape.map(|cape| cape.url.clone()), + skins: profile.skins, + capes: profile.capes, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SkinVariant { + #[serde(alias = "CLASSIC")] + Classic, + #[serde(alias = "SLIM")] + Slim, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MojangFullPlayerProfile { + pub id: String, + #[serde(alias = "name")] + pub username: String, + pub skins: Vec, + pub capes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MojangSkin { + pub id: String, + pub state: String, + pub url: String, + pub variant: SkinVariant, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MojangCape { + pub id: String, + pub state: String, + pub url: String, + pub alias: String, +} + +fn normalize_texture_url(url: &str) -> String { + url.replace( + "http://textures.minecraft.net", + "https://textures.minecraft.net", + ) +} + +fn normalize_uuid(uuid: &str) -> &str { + uuid.trim() + .trim_matches(|c: char| c == '-' || c.is_whitespace()) +} + +fn auth_headers(access_token: &str) -> [(&'static str, String); 1] { + [("Authorization", format!("Bearer {access_token}"))] +} + +#[tracing::instrument(level = "debug", skip(client))] +pub async fn fetch_player_profile( + client: &RequestClient, + uuid: &str, +) -> McResult { + #[derive(Deserialize)] + struct Properties { + name: String, + value: String, + } + + #[derive(Deserialize)] + struct Response { + id: String, + name: String, + properties: Vec, + } + + #[derive(Default, Deserialize)] + #[serde(default)] + struct TextureMetadata { + model: Option, + } + + #[derive(Deserialize)] + struct Texture { + url: String, + #[serde(default)] + metadata: TextureMetadata, + } + + #[derive(Deserialize)] + #[serde(rename_all = "UPPERCASE")] + struct Textures { + skin: Option, + cape: Option, + } + + #[derive(Deserialize)] + struct DecodedProperties { + textures: Textures, + } + + let uuid = normalize_uuid(uuid); + let url = format!("https://sessionserver.mojang.com/session/minecraft/profile/{uuid}") + .parse() + .map_err(McError::Url)?; + + let response = client + .send_as::(reqwest::Request::new(Method::GET, url)) + .await?; + + let texture_property = response + .properties + .iter() + .find(|p| p.name == "textures") + .ok_or_else(|| McError::Minecraft("no textures in player profile".into()))?; + + let decoded = base64::prelude::BASE64_STANDARD + .decode(texture_property.value.as_bytes()) + .map_err(|_| McError::Minecraft("failed to decode profile texture property".into()))?; + + let decoded = serde_json::from_slice::(&decoded) + .map_err(McError::ParseError)? + .textures; + + let is_slim = decoded + .skin + .as_ref() + .is_some_and(|s| s.metadata.model.as_deref() == Some("slim")); + + Ok(MojangPlayerProfile { + uuid: response.id, + username: response.name, + is_slim, + skin_url: decoded.skin.as_ref().map(|s| normalize_texture_url(&s.url)), + cape_url: decoded.cape.as_ref().map(|c| normalize_texture_url(&c.url)), + }) +} + +#[tracing::instrument(level = "debug", skip(client, access_token))] +pub async fn fetch_logged_in_profile( + client: &RequestClient, + access_token: &str, +) -> McResult { + let url = "https://api.minecraftservices.com/minecraft/profile" + .parse() + .map_err(McError::Url)?; + + let headers = auth_headers(access_token); + let header_refs: Vec<(&str, &str)> = headers + .iter() + .map(|(name, value)| (*name, value.as_str())) + .collect(); + + let mut profile = client + .send_json::(Method::GET, url, None, &header_refs) + .await?; + + for skin in &mut profile.skins { + skin.url = normalize_texture_url(&skin.url); + } + for cape in &mut profile.capes { + cape.url = normalize_texture_url(&cape.url); + } + + Ok(profile) +} + +/// Served without revalidating so cold start doesn't wait on a Mojang round +/// trip before the first avatar paints +const PROFILE_CACHE_TTL_HOURS: i64 = 6; + +#[derive(Debug, Serialize, Deserialize)] +struct CachedProfile { + fetched_at: DateTime, + view: PlayerProfileView, +} + +impl CachedProfile { + fn is_fresh(&self) -> bool { + Utc::now() - self.fetched_at < TimeDelta::hours(PROFILE_CACHE_TTL_HOURS) + } +} + +async fn fetch_profile_view( + client: &RequestClient, + uuid: &str, + access_token: Option<&str>, +) -> McResult { + if let Some(token) = access_token.filter(|t| !t.is_empty()) { + let full = fetch_logged_in_profile(client, token).await?; + return Ok(PlayerProfileView::from(full)); + } + let summary = fetch_player_profile(client, uuid).await?; + Ok(PlayerProfileView::from(summary)) +} + +#[tracing::instrument(level = "debug", skip(client, access_token))] +pub async fn fetch_player_profile_view( + client: &RequestClient, + uuid: &str, + access_token: Option<&str>, +) -> McResult { + let authenticated = access_token.is_some_and(|t| !t.is_empty()); + + if !authenticated && let Some(cached) = read_cached_profile(uuid).await { + if cached.is_fresh() { + return Ok(cached.view); + } + + return match fetch_profile_view(client, uuid, access_token).await { + Ok(view) => { + write_cached_profile(uuid, &view).await; + Ok(view) + } + Err(err) => { + tracing::warn!("serving stale cached profile for {uuid}: {err}"); + Ok(cached.view) + } + }; + } + + match fetch_profile_view(client, uuid, access_token).await { + Ok(view) => { + if !authenticated { + write_cached_profile(uuid, &view).await; + } + Ok(view) + } + Err(err) => match read_cached_profile(uuid).await { + Some(cached) => { + tracing::warn!("using cached player profile for {uuid}: {err}"); + Ok(cached.view) + } + None => Err(err), + }, + } +} + +fn profile_cache_path(uuid: &str) -> Option { + let key = polyio::sha1_bytes(normalize_uuid(uuid).as_bytes()); + Some( + oneclient_common::paths::profiles_cache_dir() + .ok()? + .join(format!("{key}.json")), + ) +} + +async fn write_cached_profile(uuid: &str, view: &PlayerProfileView) { + let Some(path) = profile_cache_path(uuid) else { + return; + }; + let entry = CachedProfile { + fetched_at: Utc::now(), + view: view.clone(), + }; + let Ok(bytes) = serde_json::to_vec(&entry) else { + return; + }; + if let Some(parent) = path.parent() { + let _ = polyio::create_dir_all(parent).await; + } + if let Err(err) = polyio::write(&path, &bytes).await { + tracing::warn!("failed to cache player profile for {uuid}: {err}"); + } +} + +async fn read_cached_profile(uuid: &str) -> Option { + let path = profile_cache_path(uuid)?; + let bytes = polyio::read(&path).await.ok()?; + + if let Ok(entry) = serde_json::from_slice::(&bytes) { + return Some(entry); + } + + let view = serde_json::from_slice::(&bytes).ok()?; + Some(CachedProfile { + fetched_at: DateTime::UNIX_EPOCH, + view, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_launcher_dir() { + let dir = + std::env::temp_dir().join(format!("oneclient-profile-test-{}", uuid::Uuid::new_v4())); + oneclient_common::paths::set_launcher_dir(dir); + } + + fn sample_view(username: &str) -> PlayerProfileView { + PlayerProfileView { + uuid: "069a79f4-44e9-4726-a5be-fca90e38aaf5".into(), + username: username.into(), + is_slim: true, + skin_url: Some("https://textures.minecraft.net/texture/abc".into()), + cape_url: None, + skins: Vec::new(), + capes: Vec::new(), + } + } + + #[tokio::test] + async fn fresh_cache_entry_is_served_without_network() { + temp_launcher_dir(); + let uuid = uuid::Uuid::new_v4().to_string(); + let view = sample_view("Cached"); + write_cached_profile(&uuid, &view).await; + + let client = RequestClient::new(oneclient_net::NetConfig::default()).unwrap(); + let resolved = fetch_player_profile_view(&client, &uuid, None) + .await + .expect("fresh cache entry should resolve offline"); + + assert_eq!(resolved, view); + } + + #[tokio::test] + async fn entry_past_ttl_is_not_fresh() { + let stale = CachedProfile { + fetched_at: Utc::now() - TimeDelta::hours(PROFILE_CACHE_TTL_HOURS + 1), + view: sample_view("Stale"), + }; + assert!(!stale.is_fresh()); + + let recent = CachedProfile { + fetched_at: Utc::now() - TimeDelta::hours(PROFILE_CACHE_TTL_HOURS - 1), + view: sample_view("Recent"), + }; + assert!(recent.is_fresh()); + } + + #[tokio::test] + async fn legacy_cache_entry_loads_and_revalidates() { + temp_launcher_dir(); + let uuid = uuid::Uuid::new_v4().to_string(); + let view = sample_view("Legacy"); + + let path = profile_cache_path(&uuid).unwrap(); + polyio::create_dir_all(path.parent().unwrap()) + .await + .unwrap(); + polyio::write(&path, &serde_json::to_vec(&view).unwrap()) + .await + .unwrap(); + + let cached = read_cached_profile(&uuid) + .await + .expect("legacy entry should still parse"); + + assert_eq!(cached.view, view); + assert!( + !cached.is_fresh(), + "legacy entry should be due for revalidation" + ); + } +} diff --git a/packages/oneclient_mc/src/rules.rs b/packages/oneclient_mc/src/rules.rs new file mode 100644 index 00000000..323200c5 --- /dev/null +++ b/packages/oneclient_mc/src/rules.rs @@ -0,0 +1,69 @@ +use interfrost::api::minecraft::{Os, OsRule, Rule, RuleAction}; + +use oneclient_common::os_ext::OsExt; + +#[must_use] +pub fn validate_rules(rules: &[Rule], java_arch: &str, updated: bool) -> bool { + let mut rule = rules + .iter() + .map(|r| validate_rule(r, java_arch, updated)) + .collect::>>(); + + if rules + .iter() + .all(|r| matches!(r.action, RuleAction::Disallow)) + { + rule.push(Some(true)); + } + + !(rule.iter().any(|r| r == &Some(false)) || rule.iter().all(Option::is_none)) +} + +#[must_use] +pub fn validate_rule(rule: &Rule, java_arch: &str, updated: bool) -> Option { + let result = match rule { + Rule { os: Some(os), .. } => validate_os_rule(os, java_arch, updated), + Rule { + features: Some(features), + .. + } => { + !features.is_demo_user.unwrap_or(true) + || features.has_custom_resolution.unwrap_or(false) + || !features.has_quick_plays_support.unwrap_or(true) + || !features.is_quick_play_multiplayer.unwrap_or(true) + || !features.is_quick_play_realms.unwrap_or(true) + || !features.is_quick_play_singleplayer.unwrap_or(true) + } + _ => return Some(true), + }; + + match rule.action { + RuleAction::Allow => Some(result), + RuleAction::Disallow => { + if result { + Some(false) + } else { + None + } + } + } +} + +#[must_use] +pub fn validate_os_rule(rule: &OsRule, java_arch: &str, updated: bool) -> bool { + let mut rule_match = true; + + if let Some(ref arch) = rule.arch { + rule_match &= !matches!(arch.as_str(), "x86" | "arm"); + } + + if let Some(name) = &rule.name { + if updated && (name != &Os::LinuxArm64 || name != &Os::LinuxArm32) { + rule_match &= Os::native() == name.get_os() || &Os::native_arch(java_arch) == name; + } else { + rule_match &= &Os::native_arch(java_arch) == name; + } + } + + rule_match +} diff --git a/packages/oneclient_net/Cargo.toml b/packages/oneclient_net/Cargo.toml new file mode 100644 index 00000000..50dd5502 --- /dev/null +++ b/packages/oneclient_net/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "oneclient_net" +description = "OneClient HTTP transport: retries, throttling, ETag caching and verified downloads" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_common.workspace = true +oneclient_events.workspace = true +polyio.workspace = true + +reqwest.workspace = true +tokio.workspace = true +futures-lite.workspace = true +futures-util.workspace = true +async-trait.workspace = true +bytes.workspace = true + +# Config read on every outbound request, written only on a settings save. +arc-swap.workspace = true + +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true diff --git a/packages/oneclient_net/src/cache.rs b/packages/oneclient_net/src/cache.rs new file mode 100644 index 00000000..964744fe --- /dev/null +++ b/packages/oneclient_net/src/cache.rs @@ -0,0 +1,138 @@ +use std::path::{Path, PathBuf}; + +use reqwest::{Method, StatusCode, header}; + +use crate::error::RequestError; +use crate::service::RequestClient; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EtagPolicy { + CommitNow, + /// Hands the ETag back on [`Fetched::etag`] + /// Use when work downstream of the body can still fail so an interrupted run + /// retries instead of getting 304 + Defer, +} + +#[derive(Debug, Clone)] +pub struct Fetched { + pub bytes: Vec, + /// `false` when the server answered 304 or the request failed and the + /// cached copy was served instead + pub changed: bool, + /// Present only for a fresh body under [`EtagPolicy::Defer`] + pub etag: Option, +} + +impl Fetched { + pub fn json(&self) -> Result { + serde_json::from_slice(&self.bytes).map_err(|source| RequestError::DeserializeError { + source, + type_name: std::any::type_name::().to_string(), + url: String::new(), + status: 200, + snippet: crate::error::body_snippet(&self.bytes), + }) + } + + /// Lossy a bad byte becomes a replacement character rather than an error + #[must_use] + pub fn text(&self) -> String { + String::from_utf8_lossy(&self.bytes).into_owned() + } +} + +fn etag_path(cache_path: &Path) -> PathBuf { + let mut path = cache_path.as_os_str().to_os_string(); + path.push(".etag"); + PathBuf::from(path) +} + +async fn read_sidecar_etag(cache_path: &Path) -> Option { + polyio::read(etag_path(cache_path)) + .await + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .filter(|etag| !etag.is_empty()) +} + +/// Callers using [`EtagPolicy::Defer`] call this once the work depending on the +/// body has succeeded +pub async fn commit_etag(cache_path: &Path, etag: &str) { + if let Err(err) = polyio::write_atomic(etag_path(cache_path), etag.as_bytes()).await { + tracing::warn!("failed to write etag sidecar: {err}"); + } +} + +/// Returns `Ok(None)` only when the request failed *and* there is no cached +/// copy +#[tracing::instrument(level = "debug", skip(client), fields(%url))] +pub async fn fetch_cached( + client: &RequestClient, + url: &str, + cache_path: &Path, + policy: EtagPolicy, +) -> Result, RequestError> { + let stored_etag = read_sidecar_etag(cache_path).await; + let mut request = reqwest::Request::new(Method::GET, url.parse()?); + + if let Some(etag) = &stored_etag + && let Ok(value) = header::HeaderValue::from_str(etag) + { + request.headers_mut().insert(header::IF_NONE_MATCH, value); + } + + let cached = || async { + match polyio::read(cache_path).await { + Ok(bytes) => Ok(Some(Fetched { + bytes, + changed: false, + etag: None, + })), + Err(err) => { + tracing::debug!("no usable cache at {}: {err}", cache_path.display()); + Ok(None) + } + } + }; + + match client.send(request).await { + Ok(res) if res.status() == StatusCode::NOT_MODIFIED => { + tracing::debug!("cache hit (304)"); + cached().await + } + Ok(res) if res.status().is_success() => { + let server_etag = res + .headers() + .get(header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + + let bytes = res.bytes().await?.to_vec(); + polyio::write_atomic(cache_path, &bytes).await?; + + let etag = match (&server_etag, policy) { + (Some(etag), EtagPolicy::CommitNow) => { + commit_etag(cache_path, etag).await; + None + } + (_, EtagPolicy::Defer) => server_etag, + (None, EtagPolicy::CommitNow) => None, + }; + + Ok(Some(Fetched { + bytes, + changed: true, + etag, + })) + } + Ok(res) => { + tracing::warn!(status = %res.status(), "unexpected response; using cache if present"); + cached().await + } + Err(err) => { + tracing::debug!("fetch failed, using cache if present: {err}"); + cached().await + } + } +} diff --git a/packages/oneclient_net/src/config.rs b/packages/oneclient_net/src/config.rs new file mode 100644 index 00000000..e2d9d7a4 --- /dev/null +++ b/packages/oneclient_net/src/config.rs @@ -0,0 +1,105 @@ +use oneclient_common::constants; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NetConfig { + pub curseforge_api_key: String, + pub modrinth_api_key: Option, + pub metadata_api_url: String, + /// Never has a trailing slash + pub meta_url_base: String, +} + +impl Default for NetConfig { + fn default() -> Self { + Self { + curseforge_api_key: constants::CURSEFORGE_API_KEY.to_string(), + modrinth_api_key: None, + metadata_api_url: constants::METADATA_API_URL.to_string(), + meta_url_base: constants::META_URL_BASE.to_string(), + } + } +} + +impl NetConfig { + /// Blank is treated as absent so clearing a field in the settings UI + /// restores the default rather than sending an empty key + #[must_use] + pub fn with_overrides( + mut self, + curseforge_api_key: Option<&str>, + modrinth_api_key: Option<&str>, + metadata_api_url: Option<&str>, + meta_url_base: Option<&str>, + ) -> Self { + if let Some(value) = non_empty(curseforge_api_key) { + self.curseforge_api_key = value; + } + self.modrinth_api_key = non_empty(modrinth_api_key); + if let Some(value) = non_empty(metadata_api_url) { + self.metadata_api_url = value; + } + if let Some(value) = non_empty(meta_url_base) { + self.meta_url_base = value; + } + self.meta_url_base = self.meta_url_base.trim_end_matches('/').to_string(); + self + } + + #[must_use] + pub fn modrinth_headers(&self) -> Vec<(String, String)> { + match &self.modrinth_api_key { + Some(token) => vec![("Authorization".to_string(), token.clone())], + None => Vec::new(), + } + } +} + +fn non_empty(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blank_overrides_fall_back_to_defaults() { + let config = NetConfig::default().with_overrides(Some(" "), Some(""), None, None); + + assert_eq!(config.curseforge_api_key, constants::CURSEFORGE_API_KEY); + assert_eq!(config.modrinth_api_key, None); + assert_eq!(config.metadata_api_url, constants::METADATA_API_URL); + } + + #[test] + fn meta_url_base_never_keeps_a_trailing_slash() { + let config = NetConfig::default().with_overrides( + None, + None, + None, + Some("https://example.test/base/"), + ); + + assert_eq!(config.meta_url_base, "https://example.test/base"); + } + + #[test] + fn overrides_replace_defaults() { + let config = NetConfig::default().with_overrides( + Some("key"), + Some("token"), + Some("https://a"), + None, + ); + + assert_eq!(config.curseforge_api_key, "key"); + assert_eq!( + config.modrinth_headers(), + vec![("Authorization".to_string(), "token".to_string())] + ); + assert_eq!(config.metadata_api_url, "https://a"); + } +} diff --git a/packages/oneclient_net/src/download.rs b/packages/oneclient_net/src/download.rs new file mode 100644 index 00000000..25b7b831 --- /dev/null +++ b/packages/oneclient_net/src/download.rs @@ -0,0 +1,235 @@ +use std::path::Path; + +use oneclient_events::{EventBus, GroupedProgressChild, TaskPhase}; +use polyio::{Checksum, ChecksumStream, normalize_hash}; +use reqwest::Method; + +use crate::error::RequestError; +use crate::response::{ResponseExt, ResponseNotifyOptions, ResponseOptions}; +use crate::service::RequestClient; + +/// [`RequestClient::send`] only retries failures before the response headers +/// land this covers failures mid-body which would otherwise silently leave an +/// install incomplete +const MAX_DOWNLOAD_ATTEMPTS: u32 = 3; + +/// Only errors meaning the bytes arrived wrong are retried a 404 bad URL or +/// full disk fails identically every time +fn worth_retrying(err: &RequestError) -> bool { + matches!( + err, + RequestError::ReqwestError(_) + | RequestError::HashMismatch { .. } + | RequestError::IncompleteBody { .. } + ) +} + +fn retry_delay(attempt: u32) -> std::time::Duration { + std::time::Duration::from_millis(300 * u64::from(attempt)) +} + +/// Returns `false` when the file is missing or unreadable +pub async fn matches_on_disk(path: &Path, expected_sha1: &str) -> bool { + if !path.is_file() { + return false; + } + + match polyio::sha1_file(path).await { + Ok(actual) => normalize_hash(&actual) == normalize_hash(expected_sha1), + Err(err) => { + tracing::debug!("could not hash {}: {err}", path.display()); + false + } + } +} + +/// `dest` is only created once the hash matches so a failed attempt leaves +/// nothing behind for a later `exists()` check to trip over +#[tracing::instrument(level = "debug", skip(client, events, notify), fields(%url))] +pub async fn download_verified( + client: &RequestClient, + events: &EventBus, + url: &str, + dest: &Path, + expected: Option<&Checksum>, + expected_size: u64, + notify: Option, +) -> Result<(), RequestError> { + // Callers fanning out over thousands of files pre-create the tree so this + // stat avoids a blocking-pool round trip per file + if let Some(parent) = dest.parent() + && !parent.is_dir() + { + polyio::create_dir_all(parent).await?; + } + + // Needs a fixed id up front or each retry opens a new UI entry + let notify = notify.map(ResponseNotifyOptions::pinned); + + let mut attempt = 1; + loop { + match download_attempt( + client, + events, + url, + dest, + expected, + expected_size, + notify.as_ref(), + ) + .await + { + Ok(()) => break, + Err(err) if attempt < MAX_DOWNLOAD_ATTEMPTS && worth_retrying(&err) => { + tracing::warn!( + attempt, + %url, + "download failed, retrying: {err}" + ); + tokio::time::sleep(retry_delay(attempt)).await; + attempt += 1; + } + Err(err) => return Err(err), + } + } + + if let Some(child) = notify.as_ref().and_then(ResponseNotifyOptions::child) { + child.finish(); + } + + Ok(()) +} + +/// The progress child is shared across attempts and reports absolute byte +/// counts so a restarted attempt rewinds its bar rather than double-counting +#[allow(clippy::too_many_arguments)] +async fn download_attempt( + client: &RequestClient, + events: &EventBus, + url: &str, + dest: &Path, + expected: Option<&Checksum>, + expected_size: u64, + notify: Option<&ResponseNotifyOptions>, +) -> Result<(), RequestError> { + let child = notify.and_then(ResponseNotifyOptions::child); + if let Some(child) = child { + child.set_phase(TaskPhase::Downloading); + } + + let request = reqwest::Request::new(Method::GET, url.parse()?); + let response = client.send(request).await?; + + // Without this an error body is written to disk under the requested file's name + let status = response.status(); + if !status.is_success() { + let bytes = response.bytes().await?; + return Err(RequestError::HttpStatus { + status: status.as_u16(), + url: url.to_string(), + snippet: crate::error::body_snippet(&bytes), + }); + } + + // Prefer the manifest size Content-Length is absent for compressed or chunked bodies + let declared = if expected_size > 0 { + Some(expected_size) + } else { + response.content_length() + }; + let total = declared.unwrap_or(0).max(1); + + let options = ResponseOptions { + notify: notify.cloned(), + }; + let stream = response.stream(options, events).await?; + + let mut hasher = expected.map(|expected| ChecksumStream::new(expected.algorithm)); + let mut received = 0u64; + { + let stream = futures_lite::StreamExt::map(stream, |item| { + if let Ok(chunk) = &item { + received += chunk.len() as u64; + if let Some(hasher) = hasher.as_mut() { + hasher.update(chunk); + } + } + item + }); + let stream = std::pin::pin!(stream); + // Writes to a scratch sibling and renames so `dest` is never truncated + polyio::write_stream(dest, stream, Some(total)).await?; + } + + if let (Some(expected), Some(hasher)) = (expected, hasher) { + if let Some(child) = child { + child.set_phase(TaskPhase::Verifying); + } + + let actual = hasher.finish(); + if !expected.matches(&actual) { + // Remove it rather than leave it for the next `exists()` check + let _ = polyio::remove_file(dest).await; + return Err(RequestError::HashMismatch { + source_desc: dest.display().to_string(), + expected: format!("{} {}", expected.algorithm.name(), expected.hex), + actual, + }); + } + } else if let Some(declared) = declared + && received != declared + { + // With no hash the byte count is the only evidence the transfer finished + let _ = polyio::remove_file(dest).await; + return Err(RequestError::IncompleteBody { + source_desc: dest.display().to_string(), + expected: declared, + actual: received, + }); + } + + Ok(()) +} + +#[tracing::instrument(level = "debug", skip(client, events, progress), fields(%url))] +pub async fn fetch_verified( + client: &RequestClient, + events: &EventBus, + url: &str, + expected_sha1: &str, + progress: Option, +) -> Result, RequestError> { + let request = reqwest::Request::new(Method::GET, url.parse()?); + let response = client.send(request).await?; + + let options = ResponseOptions { + notify: progress.clone().map(ResponseNotifyOptions::grouped), + }; + let stream = response.stream(options, events).await?; + + use futures_util::StreamExt; + let mut stream = std::pin::pin!(stream); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + bytes.extend_from_slice(&chunk?); + } + + if let Some(child) = &progress { + child.set_phase(TaskPhase::Verifying); + } + + let actual = polyio::sha1_bytes(&bytes); + if normalize_hash(&actual) != normalize_hash(expected_sha1) { + return Err(RequestError::HashMismatch { + source_desc: url.to_string(), + expected: expected_sha1.to_string(), + actual, + }); + } + + if let Some(child) = progress { + child.finish(); + } + + Ok(bytes) +} diff --git a/packages/oneclient_net/src/error.rs b/packages/oneclient_net/src/error.rs new file mode 100644 index 00000000..de7427e9 --- /dev/null +++ b/packages/oneclient_net/src/error.rs @@ -0,0 +1,245 @@ +/// `reqwest::Error`'s own `Display` stops at "error sending request for url" +/// the fact that names the failure lives further down the `source()` chain +#[must_use] +pub fn error_chain(err: &dyn std::error::Error) -> String { + let mut rendered = err.to_string(); + let mut cursor = err.source(); + + while let Some(cause) = cursor { + let text = cause.to_string(); + // hyper and reqwest repeat the same sentence across several layers + if !rendered.ends_with(&text) { + rendered.push_str(": "); + rendered.push_str(&text); + } + cursor = cause.source(); + } + + rendered +} + +#[derive(Debug, thiserror::Error)] +pub enum RequestError { + #[error("{}", error_chain(.0))] + ReqwestError(#[from] reqwest::Error), + + #[error("IO Error: {0}")] + IOError(#[from] polyio::IOError), + + #[error( + "Failed to parse {type_name} from {url} (HTTP {status}): {source}; body starts: {snippet}" + )] + DeserializeError { + #[source] + source: serde_json::Error, + type_name: String, + url: String, + status: u16, + snippet: String, + }, + + #[error("HTTP {status} from {url}: {snippet}")] + HttpStatus { + status: u16, + url: String, + snippet: String, + }, + + #[error("Failed to serialize request body: {0}")] + SerializeError(#[source] serde_json::Error), + + #[error("{source_desc} has SHA-1 {actual}, expected {expected}")] + HashMismatch { + source_desc: String, + expected: String, + actual: String, + }, + + /// Only reachable when no hash was supplied otherwise it fails as a mismatch + #[error("{source_desc} ended after {actual} of {expected} bytes")] + IncompleteBody { + source_desc: String, + expected: u64, + actual: u64, + }, + + #[error("Invalid URL: {0}")] + UrlParseError(#[from] url::ParseError), + + #[error("Invalid HTTP header name: {0}")] + InvalidHeaderName(#[from] reqwest::header::InvalidHeaderName), + + #[error("Invalid HTTP header value: {0}")] + InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkFailure { + Certificate, + Handshake, + Dns, + Generic, +} + +impl NetworkFailure { + #[must_use] + pub fn user_message(self) -> &'static str { + match self { + Self::Certificate => { + "Something on this network or machine is intercepting secure connections, so OneClient cannot verify it is really talking to the servers it needs. This is usually antivirus HTTPS scanning, a school or workplace filter, or a VPN. Try another network, turn off HTTPS/SSL scanning in your antivirus, or ask your network administrator to allow OneClient." + } + Self::Handshake => { + "OneClient reached the server but the two could not agree on a secure connection. A proxy, filter, or antivirus in between is usually using outdated encryption settings. Try another network or turn off HTTPS/SSL scanning in your antivirus." + } + Self::Dns => { + "OneClient could not look up the address of the server. Check your internet connection, disable any VPN or proxy, and try a different DNS server such as 1.1.1.1." + } + Self::Generic => { + "OneClient could not reach the server. Check your internet connection, and any firewall, proxy, or VPN that could be blocking it." + } + } + } + + /// Retrying or waiting cannot clear these the user has to change something + #[must_use] + pub fn is_tls_interception(self) -> bool { + matches!(self, Self::Certificate | Self::Handshake) + } +} + +#[must_use] +pub fn classify_network_failure(err: &reqwest::Error) -> NetworkFailure { + let chain = error_chain(err).to_ascii_lowercase(); + + // Certificate before handshake a rejected certificate also aborts the + // handshake and the certificate wording is the more specific of the two + const CERTIFICATE: &[&str] = &[ + "invalid peer certificate", + "unknownissuer", + "certificate", + "certexpired", + "notvalidforname", + ]; + const HANDSHAKE: &[&str] = &[ + "handshake", + "received fatal alert", + "peer misbehaved", + "no cipher suites in common", + "protocol version", + "unsupported protocol", + ]; + const DNS: &[&str] = &[ + "dns error", + "failed to lookup address", + "no record found", + "nodename nor servname", + "name or service not known", + "no such host", + ]; + + let matches = |markers: &[&str]| markers.iter().any(|marker| chain.contains(marker)); + + if matches(CERTIFICATE) { + NetworkFailure::Certificate + } else if matches(HANDSHAKE) { + NetworkFailure::Handshake + } else if matches(DNS) { + NetworkFailure::Dns + } else { + NetworkFailure::Generic + } +} + +impl RequestError { + /// Whether this is the user's environment rather than a launcher bug offline + /// connection refused timed out disk full intercepted TLS + #[must_use] + pub fn is_transient(&self) -> bool { + match self { + Self::ReqwestError(source) => { + source.is_timeout() + || source.is_connect() + || classify_network_failure(source).is_tls_interception() + } + Self::IOError(source) => is_transient_io(source), + _ => false, + } + } + + #[must_use] + pub fn network_failure(&self) -> Option { + let Self::ReqwestError(source) = self else { + return None; + }; + + let failure = classify_network_failure(source); + + if failure != NetworkFailure::Generic || source.is_connect() || source.is_timeout() { + Some(failure) + } else { + None + } + } + + #[must_use] + pub fn user_message(&self) -> Option<&'static str> { + self.network_failure().map(NetworkFailure::user_message) + } +} + +fn is_transient_io(error: &polyio::IOError) -> bool { + let source = match error { + polyio::IOError::IOError(source) | polyio::IOError::PathIOError { source, .. } => source, + _ => return false, + }; + + use std::io::ErrorKind; + + // `ErrorKind::StorageFull` is unstable so match raw OS codes platform-gated + // because 112 is the unrelated EHOSTDOWN on Linux + if let Some(code) = source.raw_os_error() { + #[cfg(unix)] + if code == 28 { + // ENOSPC + return true; + } + #[cfg(windows)] + if code == 112 || code == 39 { + // ERROR_DISK_FULL / ERROR_HANDLE_DISK_FULL + return true; + } + let _ = code; + } + + matches!( + source.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NotConnected + | ErrorKind::TimedOut + ) +} + +pub(crate) fn body_snippet(bytes: &[u8]) -> String { + const MAX: usize = 240; + if bytes.is_empty() { + return "".to_string(); + } + let text = String::from_utf8_lossy(bytes); + let collapsed = text.split_whitespace().collect::>().join(" "); + if collapsed.is_empty() { + return "".to_string(); + } + if collapsed.len() > MAX { + let end = collapsed + .char_indices() + .map(|(i, _)| i) + .take_while(|i| *i <= MAX) + .last() + .unwrap_or(0); + format!("{}...", &collapsed[..end]) + } else { + collapsed + } +} diff --git a/packages/oneclient_net/src/lib.rs b/packages/oneclient_net/src/lib.rs new file mode 100644 index 00000000..c9ae2b12 --- /dev/null +++ b/packages/oneclient_net/src/lib.rs @@ -0,0 +1,17 @@ +mod cache; +mod config; +mod download; +mod error; +mod request; +mod response; +mod service; + +pub mod status; + +pub use cache::{EtagPolicy, Fetched, commit_etag, fetch_cached}; +pub use config::NetConfig; +pub use download::{download_verified, fetch_verified, matches_on_disk}; +pub use error::{NetworkFailure, RequestError, classify_network_failure, error_chain}; +pub use request::*; +pub use response::*; +pub use service::RequestClient; diff --git a/packages/oneclient_net/src/request.rs b/packages/oneclient_net/src/request.rs new file mode 100644 index 00000000..2fdcb0ab --- /dev/null +++ b/packages/oneclient_net/src/request.rs @@ -0,0 +1,41 @@ +#[derive(Debug)] +pub struct HttpRequest { + pub request: reqwest::Request, + pub options: RequestOptions, +} + +impl From for HttpRequest { + fn from(value: reqwest::Request) -> Self { + Self { + request: value, + options: RequestOptions::default(), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RequestOptions { + pub use_semaphore: bool, + pub max_retries: usize, +} + +impl Default for RequestOptions { + fn default() -> Self { + Self { + use_semaphore: true, + max_retries: 1, + } + } +} + +impl RequestOptions { + pub fn use_semaphore(mut self, should_use: bool) -> Self { + self.use_semaphore = should_use; + self + } + + pub fn with_retries(mut self, retries: usize) -> Self { + self.max_retries = retries; + self + } +} diff --git a/packages/oneclient_net/src/response.rs b/packages/oneclient_net/src/response.rs new file mode 100644 index 00000000..f2e4e69d --- /dev/null +++ b/packages/oneclient_net/src/response.rs @@ -0,0 +1,143 @@ +use reqwest::Response; +use uuid::Uuid; + +use crate::error::RequestError; +use oneclient_events::{EventBus, GroupedProgressChild}; + +const PROGRESS_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50); + +#[derive(Clone, Default)] +pub struct ResponseOptions { + pub notify: Option, +} + +#[derive(Clone)] +pub struct ResponseNotifyOptions { + child: Option, + standalone_label: Option, + standalone_id: Option, + done_label: Option, +} + +impl ResponseNotifyOptions { + pub fn grouped(child: GroupedProgressChild) -> Self { + Self { + child: Some(child), + standalone_label: None, + standalone_id: None, + done_label: None, + } + } + + pub fn standalone(label: impl Into) -> Self { + Self { + child: None, + standalone_label: Some(label.into()), + standalone_id: None, + done_label: None, + } + } + + pub fn with_id(mut self, id: Uuid) -> Self { + self.standalone_id = Some(id); + self + } + + pub fn done_label(mut self, label: impl Into) -> Self { + self.done_label = Some(label.into()); + self + } + + #[must_use] + pub fn child(&self) -> Option<&GroupedProgressChild> { + self.child.as_ref() + } + + /// Pins the standalone notification id so a retried request keeps updating + /// one notification instead of opening a fresh UI entry per attempt + #[must_use] + pub fn pinned(mut self) -> Self { + if self.standalone_label.is_some() && self.standalone_id.is_none() { + self.standalone_id = Some(Uuid::new_v4()); + } + self + } +} + +#[async_trait::async_trait] +pub trait ResponseExt { + async fn stream( + self, + options: ResponseOptions, + events: &EventBus, + ) -> Result>, RequestError>; +} + +#[async_trait::async_trait] +impl ResponseExt for Response { + #[tracing::instrument(level = "debug", skip_all)] + async fn stream( + self, + options: ResponseOptions, + events: &EventBus, + ) -> Result>, RequestError> + { + let total = self.content_length().unwrap_or(0).max(1); + let mut current = 0u64; + + let grouped_child = options.notify.as_ref().and_then(|n| n.child.clone()); + let standalone_label = options + .notify + .as_ref() + .and_then(|n| n.standalone_label.clone()); + let done_label = options.notify.as_ref().and_then(|n| n.done_label.clone()); + + let standalone_id = standalone_label.as_ref().map(|_| { + options + .notify + .as_ref() + .and_then(|n| n.standalone_id) + .unwrap_or_else(Uuid::new_v4) + }); + + if let Some(ref child) = grouped_child { + child.set_progress(0, Some(total)); + } else if let (Some(id), Some(label)) = (&standalone_id, &standalone_label) { + events.progress(*id, label, 0, total); + } + + let events = events.clone(); + // One event per chunk would bury the UI in tens of thousands of updates + // so sample the last one always emits so the bar lands on complete + let mut last_emit: Option = None; + let stream = futures_lite::StreamExt::map(self.bytes_stream(), move |item| match item { + Ok(chunk) => { + current += chunk.len() as u64; + + let now = std::time::Instant::now(); + let due = current >= total + || last_emit.is_none_or(|last| now.duration_since(last) >= PROGRESS_INTERVAL); + + if due { + last_emit = Some(now); + if let Some(ref child) = grouped_child { + child.set_progress(current, Some(total)); + } else if let (Some(id), Some(label)) = (&standalone_id, &standalone_label) { + let done = current >= total; + let label = if done { + done_label.as_deref().unwrap_or(label) + } else { + label + }; + events.progress(*id, label, current, total); + } + } + + Ok(chunk) + } + Err(err) => Err(RequestError::from(err)), + }); + + Ok(stream) + } +} diff --git a/packages/oneclient_net/src/service.rs b/packages/oneclient_net/src/service.rs new file mode 100644 index 00000000..ef6fc145 --- /dev/null +++ b/packages/oneclient_net/src/service.rs @@ -0,0 +1,354 @@ +use std::sync::Arc; + +use arc_swap::ArcSwap; +use oneclient_events::EventBus; +use reqwest::{ClientBuilder, Response}; +use serde::de::DeserializeOwned; +use tokio::sync::Semaphore; + +use crate::config::NetConfig; +use crate::error::{RequestError, body_snippet}; +use crate::request::HttpRequest; +use crate::response::{ResponseExt, ResponseOptions}; + +const MAX_THROTTLE_RETRIES: u32 = 6; + +/// Backstop against runaway fan-out not the download throttle +/// Must stay above the sum of per-phase caller concurrency or it becomes the +/// bottleneck +const MAX_INFLIGHT_REQUESTS: usize = 64; + +fn retry_after(response: &Response) -> Option { + let raw = response + .headers() + .get(reqwest::header::RETRY_AFTER)? + .to_str() + .ok()? + .trim() + .to_string(); + let secs: u64 = raw.parse().ok()?; + Some(std::time::Duration::from_secs(secs.min(60))) +} + +fn backoff_delay(attempt: u32) -> std::time::Duration { + let base = 500u64.saturating_mul(1 << attempt.min(6)); + let capped = base.min(30_000); + let jitter = (std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.subsec_millis() as u64) + .unwrap_or(0)) + % 250; + std::time::Duration::from_millis(capped + jitter) +} + +fn is_curseforge_host(url: &reqwest::Url) -> bool { + if url.scheme() != "https" { + return false; + } + + let Some(host) = url.host_str() else { + return false; + }; + let host = host.trim_end_matches('.').to_ascii_lowercase(); + + matches!(host.as_str(), "curseforge.com" | "forgecdn.net") + || host.ends_with(".curseforge.com") + || host.ends_with(".forgecdn.net") +} + +fn apply_curseforge_auth( + request: &mut reqwest::Request, + api_key: &str, +) -> Result<(), RequestError> { + const API_KEY_HEADER: reqwest::header::HeaderName = + reqwest::header::HeaderName::from_static("x-api-key"); + + if !is_curseforge_host(request.url()) { + return Ok(()); + } + + let headers = request.headers_mut(); + if headers.contains_key(&API_KEY_HEADER) { + return Ok(()); + } + + let mut value = reqwest::header::HeaderValue::try_from(api_key)?; + value.set_sensitive(true); + headers.insert(API_KEY_HEADER, value); + + Ok(()) +} + +#[derive(Clone)] +pub struct RequestClient { + client: reqwest::Client, + semaphore: Arc, + /// Shared across every clone so a settings save is visible to in-flight + /// handles + /// `ArcSwap` rather than a lock which would serialise requests + config: Arc>, +} + +impl RequestClient { + pub fn http(&self) -> &reqwest::Client { + &self.client + } + + #[must_use] + pub fn config(&self) -> arc_swap::Guard> { + self.config.load() + } + + pub fn set_config(&self, config: NetConfig) { + self.config.store(Arc::new(config)); + } + + pub fn new(config: NetConfig) -> Result { + let mut builder = ClientBuilder::new() + .connect_timeout(std::time::Duration::from_secs(10)) + .read_timeout(std::time::Duration::from_secs(30)) + .timeout(std::time::Duration::from_mins(10)) + .tls_backend_rustls() + .user_agent(format!( + "OneClient {} ({})", + env!("CARGO_PKG_VERSION"), + env!("CARGO_PKG_HOMEPAGE") + )); + + builder = builder.no_hickory_dns(); + + let client = builder.build()?; + + Ok(Self { + client, + semaphore: Arc::new(Semaphore::new(MAX_INFLIGHT_REQUESTS)), + config: Arc::new(ArcSwap::from_pointee(config)), + }) + } +} + +impl RequestClient { + #[tracing::instrument(level = "debug", skip_all)] + pub async fn send(&self, request: impl Into) -> Result { + let mut request: HttpRequest = request.into(); + let mut retries = 0; + let mut throttle_retries = 0u32; + + apply_curseforge_auth(&mut request.request, &self.config.load().curseforge_api_key)?; + + let cloned_backup = request.request.try_clone(); + let cloneable = cloned_backup.is_some(); + + let max_retries = if cloneable { + request.options.max_retries + } else { + 0 + }; + + let mut active_request = request.request; + + tracing::debug!( + method = %active_request.method(), + url = %active_request.url(), + "dispatching http request" + ); + + let res = loop { + let permit = if request.options.use_semaphore { + self.semaphore.acquire().await.ok() + } else { + None + }; + + match self.client.execute(active_request).await { + Ok(response) => { + let status = response.status(); + let retryable = status.as_u16() == 429 || status.is_server_error(); + + if retryable && cloneable && throttle_retries < MAX_THROTTLE_RETRIES { + throttle_retries += 1; + let delay = retry_after(&response) + .unwrap_or_else(|| backoff_delay(throttle_retries)); + tracing::warn!( + status = status.as_u16(), + attempt = throttle_retries, + delay_ms = delay.as_millis() as u64, + url = %response.url(), + "rate limited / transient error; backing off" + ); + drop(permit); + tokio::time::sleep(delay).await; + active_request = cloned_backup.as_ref().unwrap().try_clone().unwrap(); + continue; + } + + break response; + } + Err(err) => { + if retries < max_retries { + retries += 1; + + let current_backup = cloned_backup.as_ref().unwrap(); + + active_request = current_backup.try_clone().unwrap(); + + tokio::time::sleep(std::time::Duration::from_millis(500 * retries as u64)) + .await; + + continue; + } + + crate::status::note_request_result(false); + return Err(RequestError::ReqwestError(err)); + } + } + }; + + crate::status::note_request_result(true); + Ok(res) + } + + #[tracing::instrument(level = "debug", skip(self, request, options, events), fields(dest = %dest.as_ref().display()))] + pub async fn download_file( + &self, + request: impl Into, + dest: impl AsRef + Send, + options: ResponseOptions, + events: &EventBus, + ) -> Result<(), RequestError> { + let res = self.send(request).await?; + + // Without this an error body is written to disk under the requested file's name + let status = res.status(); + if !status.is_success() { + let url = res.url().to_string(); + let bytes = res.bytes().await?; + return Err(RequestError::HttpStatus { + status: status.as_u16(), + url, + snippet: body_snippet(&bytes), + }); + } + + let size_hint = res.content_length(); + let http_stream = res.stream(options, events).await?; + let http_stream = std::pin::pin!(http_stream); + + polyio::write_stream(dest, http_stream, size_hint).await?; + + Ok(()) + } + + #[tracing::instrument(level = "debug", skip_all)] + pub async fn send_as( + &self, + request: impl Into, + ) -> Result { + let res = self.send(request).await?; + let status = res.status(); + let url = res.url().to_string(); + let bytes = res.bytes().await?; + + if !status.is_success() { + return Err(RequestError::HttpStatus { + status: status.as_u16(), + url, + snippet: body_snippet(&bytes), + }); + } + + serde_json::from_slice(&bytes).map_err(|err| RequestError::DeserializeError { + source: err, + type_name: std::any::type_name::().to_string(), + url, + status: status.as_u16(), + snippet: body_snippet(&bytes), + }) + } + + #[tracing::instrument(level = "debug", skip(self, body, extra_headers), fields(method = %method, %url))] + pub async fn send_json( + &self, + method: reqwest::Method, + url: reqwest::Url, + body: Option, + extra_headers: &[(&str, &str)], + ) -> Result { + let mut request = reqwest::Request::new(method, url); + for (name, value) in extra_headers { + request.headers_mut().insert( + reqwest::header::HeaderName::try_from(*name)?, + reqwest::header::HeaderValue::try_from(*value)?, + ); + } + + if let Some(body) = body { + let bytes = serde_json::to_vec(&body).map_err(RequestError::SerializeError)?; + request.headers_mut().insert( + reqwest::header::CONTENT_TYPE, + reqwest::header::HeaderValue::from_static("application/json"), + ); + *request.body_mut() = Some(bytes.into()); + } + + self.send_as(request).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(url: &str) -> reqwest::Request { + reqwest::Request::new(reqwest::Method::GET, url.parse().unwrap()) + } + + fn api_key_of(url: &str) -> Option { + let mut req = request(url); + apply_curseforge_auth(&mut req, oneclient_common::constants::CURSEFORGE_API_KEY).unwrap(); + req.headers() + .get("x-api-key") + .map(|v| v.to_str().unwrap().to_string()) + } + + #[test] + fn attaches_key_to_curseforge_api_and_cdn() { + for url in [ + "https://api.curseforge.com/v1/mods/search", + "https://media.forgecdn.net/avatars/1/2/icon.png", + "https://edge.forgecdn.net/files/1/2/mod.jar", + "https://www.curseforge.com/minecraft", + "https://forgecdn.net/thing", + ] { + assert_eq!( + api_key_of(url).as_deref(), + Some(oneclient_common::constants::CURSEFORGE_API_KEY), + "expected key on {url}" + ); + } + } + + #[test] + fn ignores_unrelated_and_insecure_hosts() { + for url in [ + "https://api.modrinth.com/v2/search", + "https://cdn.modrinth.com/data/x/y.jar", + "https://notcurseforge.com/x", + "https://evil-forgecdn.net.attacker.com/x", + "http://media.forgecdn.net/insecure.png", + ] { + assert_eq!(api_key_of(url), None, "unexpected key on {url}"); + } + } + + #[test] + fn does_not_override_explicit_key() { + let mut req = request("https://api.curseforge.com/v1/mods"); + req.headers_mut().insert( + "x-api-key", + reqwest::header::HeaderValue::from_static("mine"), + ); + apply_curseforge_auth(&mut req, oneclient_common::constants::CURSEFORGE_API_KEY).unwrap(); + assert_eq!(req.headers()["x-api-key"], "mine"); + } +} diff --git a/packages/oneclient_net/src/status.rs b/packages/oneclient_net/src/status.rs new file mode 100644 index 00000000..e1c8006d --- /dev/null +++ b/packages/oneclient_net/src/status.rs @@ -0,0 +1,159 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{OnceLock, RwLock}; +use std::time::Duration; + +use tokio::sync::{Notify, watch}; + +use crate::service::RequestClient; + +const CONNECTIVITY_URL: &str = "https://cp.cloudflare.com/generate_204"; +const MC_AUTH_URL: &str = "https://api.minecraftservices.com/"; +const POLYFROST_STATUS_URL: &str = "https://status.polyfrost.org/index.json"; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(8); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ServiceStatus { + pub online: bool, + pub mc_auth_up: bool, + pub polyfrost_up: bool, +} + +impl Default for ServiceStatus { + fn default() -> Self { + Self { + online: true, + mc_auth_up: true, + polyfrost_up: true, + } + } +} + +struct Connectivity { + tx: watch::Sender, + rx: watch::Receiver, + notify: Notify, + started: AtomicBool, + /// While set probe results are never published so the forced status + /// survives a recheck + forced: RwLock>, +} + +fn connectivity() -> &'static Connectivity { + static CONNECTIVITY: OnceLock = OnceLock::new(); + CONNECTIVITY.get_or_init(|| { + let (tx, rx) = watch::channel(ServiceStatus::default()); + Connectivity { + tx, + rx, + notify: Notify::new(), + started: AtomicBool::new(false), + forced: RwLock::new(None), + } + }) +} + +/// Pins the reported status to `status` or releases the pin with `None` and +/// re-probes +pub fn force(status: Option) { + let conn = connectivity(); + *conn.forced.write().unwrap_or_else(|e| e.into_inner()) = status; + + match status { + Some(status) => { + let _ = conn.tx.send(status); + } + None => request_recheck(), + } +} + +#[must_use] +pub fn forced() -> Option { + *connectivity() + .forced + .read() + .unwrap_or_else(|e| e.into_inner()) +} + +pub fn subscribe() -> watch::Receiver { + connectivity().rx.clone() +} + +pub fn current() -> ServiceStatus { + *connectivity().rx.borrow() +} + +pub fn request_recheck() { + connectivity().notify.notify_one(); +} + +pub fn note_request_result(success: bool) { + let online = connectivity().rx.borrow().online; + if !success || !online { + request_recheck(); + } +} + +/// Idempotent later calls are ignored so the client handed in on the first +/// call is used for the process lifetime +pub fn start(client: RequestClient) { + let conn = connectivity(); + if conn.started.swap(true, Ordering::SeqCst) { + return; + } + + tokio::spawn(async move { + loop { + let probed = check_service_status(&client).await; + if forced().is_none() { + let _ = conn.tx.send(probed); + } + conn.notify.notified().await; + } + }); +} + +async fn reachable(client: &reqwest::Client, url: &str) -> bool { + client.get(url).timeout(PROBE_TIMEOUT).send().await.is_ok() +} + +pub async fn check_service_status(requester: &RequestClient) -> ServiceStatus { + let client = requester.http(); + + if !reachable(client, CONNECTIVITY_URL).await { + return ServiceStatus { + online: false, + mc_auth_up: false, + polyfrost_up: false, + }; + } + + let mc_auth_up = reachable(client, MC_AUTH_URL).await; + + let polyfrost_up = match client + .get(POLYFROST_STATUS_URL) + .timeout(PROBE_TIMEOUT) + .send() + .await + { + Ok(resp) => resp + .json::() + .await + .ok() + .and_then(|json| { + json.get("data")? + .get("attributes")? + .get("aggregate_state")? + .as_str() + .map(|s| s == "operational") + }) + .unwrap_or(true), + Err(_) => false, + }; + + ServiceStatus { + online: true, + mc_auth_up, + polyfrost_up, + } +} diff --git a/packages/oneclient_polyplus/Cargo.toml b/packages/oneclient_polyplus/Cargo.toml new file mode 100644 index 00000000..b62e0684 --- /dev/null +++ b/packages/oneclient_polyplus/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "oneclient_polyplus" +description = "Poly+ playtime reporting" +version.workspace = true +license.workspace = true +edition.workspace = true +repository.workspace = true +documentation.workspace = true +readme.workspace = true +homepage.workspace = true +authors.workspace = true + +[lib] +path = "src/lib.rs" + +[dependencies] +oneclient_auth.workspace = true +oneclient_common.workspace = true +polyio.workspace = true + +reqwest.workspace = true +reqwest-websocket.workspace = true +tokio.workspace = true +futures-util.workspace = true + +bytes.workspace = true +rand.workspace = true +serde.workspace = true +serde_json.workspace = true +sha1.workspace = true +thiserror.workspace = true +tracing.workspace = true +url.workspace = true +uuid.workspace = true + +[dev-dependencies] +async-tungstenite = { version = "=0.32.1", features = ["tokio-runtime"] } +tokio = { workspace = true } diff --git a/packages/oneclient_polyplus/src/lib.rs b/packages/oneclient_polyplus/src/lib.rs new file mode 100644 index 00000000..f696d6ea --- /dev/null +++ b/packages/oneclient_polyplus/src/lib.rs @@ -0,0 +1,353 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use bytes::Bytes; +use futures_util::{SinkExt, TryStreamExt}; +use rand::Rng; +use reqwest_websocket::{Message, Upgrade}; +use uuid::Uuid; + +use oneclient_auth::MinecraftAccount; +use oneclient_common::constants; + +const MOJANG_JOIN_URL: &str = "https://sessionserver.mojang.com/session/minecraft/join"; + +const PING_INTERVAL: Duration = Duration::from_secs(30); + +const RECONNECT_DELAY: Duration = Duration::from_secs(15); +const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(300); + +static STARTED: AtomicBool = AtomicBool::new(false); + +#[derive(Debug, thiserror::Error)] +pub enum PlusError { + #[error(transparent)] + Auth(#[from] oneclient_auth::AuthError), + + #[error("Mojang rejected the Poly+ session join (HTTP {status}): {body}")] + JoinRejected { status: u16, body: String }, + + #[error("Poly+ rejected the login for {username} (HTTP {status}): {body}")] + LoginRejected { + username: String, + status: u16, + body: String, + }, + + #[error(transparent)] + Request(#[from] reqwest::Error), + + #[error(transparent)] + Websocket(#[from] reqwest_websocket::Error), + + #[error(transparent)] + Url(#[from] url::ParseError), +} + +#[derive(serde::Deserialize)] +struct LoginResponse { + token: String, +} + +enum Outcome { + Connected, + NoAccount, + Failed, +} + +enum Event { + Ping, + Message(Option), +} + +/// Idempotent the first caller's handle is used for the process lifetime +pub fn start(auth: Arc) { + if STARTED.swap(true, Ordering::SeqCst) { + return; + } + + tokio::spawn(async move { + let client = match build_client() { + Ok(client) => client, + Err(err) => { + tracing::error!("[plus] could not build the playtime client: {err}"); + return; + } + }; + + let mut delay = RECONNECT_DELAY; + + loop { + delay = match session(&auth, &client).await { + Outcome::Connected | Outcome::NoAccount => RECONNECT_DELAY, + Outcome::Failed => (delay * 2).min(MAX_RECONNECT_DELAY), + }; + + tokio::time::sleep(delay).await; + } + }); +} + +fn build_client() -> Result { + let builder = reqwest::Client::builder() + .tcp_keepalive(Some(Duration::from_secs(15))) + .connect_timeout(Duration::from_secs(10)) + // reqwest-websocket only supports Http <1.1 + .http1_only() + .tls_backend_rustls() + .user_agent(format!( + "OneClient {} ({})", + env!("CARGO_PKG_VERSION"), + env!("CARGO_PKG_HOMEPAGE") + )); + + builder.no_hickory_dns().build() +} + +fn base_url() -> &'static str { + constants::PLUS_BACKEND_URL.trim_end_matches('/') +} + +async fn session(auth: &oneclient_auth::AuthService, client: &reqwest::Client) -> Outcome { + let account = match current_account(auth).await { + Ok(Some(account)) => account, + Ok(None) => return Outcome::NoAccount, + Err(err) => { + tracing::warn!("[plus] no account to track playtime for: {err}"); + return Outcome::Failed; + } + }; + + let id = account.id; + let mut connected = false; + + match run_session(auth, client, &account, &mut connected).await { + Ok(()) => tracing::info!("[plus] playtime session for {id} ended"), + Err(err) => tracing::warn!("[plus] playtime session for {id} failed: {err}"), + } + + if connected { + Outcome::Connected + } else { + Outcome::Failed + } +} + +async fn current_account( + auth: &oneclient_auth::AuthService, +) -> Result, PlusError> { + let Some(account) = auth.default_account_for_launch().await? else { + return Ok(None); + }; + + Ok(account.is_microsoft().then_some(account)) +} + +async fn run_session( + auth: &oneclient_auth::AuthService, + client: &reqwest::Client, + account: &MinecraftAccount, + connected: &mut bool, +) -> Result<(), PlusError> { + let token = login(client, account).await?; + + let mut websocket = client + .get(format!("{}/websocket", base_url())) + .bearer_auth(&token) + .upgrade() + .send() + .await? + .into_websocket() + .await?; + + *connected = true; + tracing::info!("[plus] playtime websocket connected for {}", account.id); + + let account_id = account.id; + pump(&mut websocket, PING_INTERVAL, account_id, || { + account_changed(auth, account_id) + }) + .await +} + +/// Returns when `should_stop` reports the session invalid or the server hangs up +async fn pump( + websocket: &mut reqwest_websocket::WebSocket, + ping_interval: Duration, + account_id: Uuid, + should_stop: F, +) -> Result<(), PlusError> +where + F: Fn() -> Fut, + Fut: std::future::Future, +{ + let mut ping = tokio::time::interval(ping_interval); + ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + ping.tick().await; + + loop { + let event = tokio::select! { + _ = ping.tick() => Event::Ping, + message = websocket.try_next() => Event::Message(message?), + }; + + match event { + Event::Ping => { + if should_stop().await { + tracing::info!( + "[plus] default account changed, ending playtime session for {account_id}" + ); + break; + } + + websocket.send(Message::Ping(Bytes::new())).await?; + } + Event::Message(Some(Message::Close { code, reason })) => { + tracing::info!("[plus] playtime websocket closed by the server: {code:?} {reason}"); + break; + } + Event::Message(Some(_)) => {} + Event::Message(None) => break, + } + } + + Ok(()) +} + +async fn login(client: &reqwest::Client, account: &MinecraftAccount) -> Result { + let server_id = generate_server_id(); + + let join = client + .post(MOJANG_JOIN_URL) + .header("Accept", "application/json") + .json(&serde_json::json!({ + "accessToken": account.access_token, + "selectedProfile": account.id.simple().to_string(), + "serverId": server_id, + })) + .send() + .await?; + + if !join.status().is_success() { + return Err(PlusError::JoinRejected { + status: join.status().as_u16(), + body: join.text().await.unwrap_or_default(), + }); + } + + let mut url = reqwest::Url::parse(&format!("{}/account/login", base_url()))?; + url.query_pairs_mut() + .append_pair("username", &account.username) + .append_pair("server_id", &server_id); + + let login = client + .post(url) + .header("Accept", "application/json") + .send() + .await?; + + if !login.status().is_success() { + return Err(PlusError::LoginRejected { + username: account.username.clone(), + status: login.status().as_u16(), + body: login.text().await.unwrap_or_default(), + }); + } + + Ok(login.json::().await?.token) +} + +async fn account_changed(auth: &oneclient_auth::AuthService, id: Uuid) -> bool { + match auth.default_account().await { + Ok(Some(account)) => account.id != id, + Ok(None) => true, + Err(_) => false, + } +} + +fn generate_server_id() -> String { + let mut bytes = [0u8; 20]; + rand::rng().fill_bytes(&mut bytes); + polyio::to_hex(&bytes) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + + use async_tungstenite::tokio::accept_async; + use async_tungstenite::tungstenite::Message as ServerMessage; + use futures_util::StreamExt; + use tokio::net::TcpListener; + + use super::*; + + async fn ping_counting_server(close_after_pings: usize) -> (String, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let port = listener.local_addr().expect("addr").port(); + let pings = Arc::new(AtomicUsize::new(0)); + + tokio::spawn({ + let pings = Arc::clone(&pings); + async move { + let (stream, _) = listener.accept().await.expect("accept"); + let mut server = accept_async(stream).await.expect("handshake"); + + while let Some(Ok(message)) = server.next().await { + if let ServerMessage::Ping(_) = message + && pings.fetch_add(1, Ordering::SeqCst) + 1 >= close_after_pings + { + let _ = server.close(None).await; + break; + } + } + } + }); + + (format!("ws://127.0.0.1:{port}/websocket"), pings) + } + + async fn connect(url: &str) -> reqwest_websocket::WebSocket { + build_client() + .expect("client") + .get(url) + .upgrade() + .send() + .await + .expect("upgrade") + .into_websocket() + .await + .expect("websocket") + } + + #[tokio::test] + async fn pings_on_every_interval_and_stops_when_the_server_closes() { + let (url, pings) = ping_counting_server(3).await; + let mut websocket = connect(&url).await; + + let pumped = tokio::time::timeout( + Duration::from_secs(10), + pump( + &mut websocket, + Duration::from_millis(50), + Uuid::nil(), + || async { false }, + ), + ) + .await + .expect("pump should return once the server closes the socket"); + + pumped.expect("pump should treat a server close as a clean end"); + assert_eq!(pings.load(Ordering::SeqCst), 3); + } + + #[test] + fn server_ids_are_random_20_byte_hex() { + let id = generate_server_id(); + assert_eq!(id.len(), 40); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + assert_ne!(id, generate_server_id()); + } +} diff --git a/packages/polyio/.gitignore b/packages/polyio/.gitignore new file mode 100644 index 00000000..c41cc9e3 --- /dev/null +++ b/packages/polyio/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/packages/polyio/Cargo.toml b/packages/polyio/Cargo.toml new file mode 100644 index 00000000..3c8961c0 --- /dev/null +++ b/packages/polyio/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "polyio" +description = "PolyIO-rs" +version = { workspace = true } +license = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } +documentation = { workspace = true } +readme = { workspace = true } +homepage = { workspace = true } +authors = { workspace = true } + +[lib] +path = "src/lib.rs" + +[features] +default = [] +# Test-only helpers (`polyio::testing`). Enable as a dev-dependency. +testing = [] + +[dependencies] +tracing.workspace = true +thiserror.workspace = true + +tokio.workspace = true +tokio-util.workspace = true +futures-util.workspace = true +futures-lite.workspace = true + +async-tempfile.workspace = true +async-compression.workspace = true +async-stream.workspace = true +async_zip.workspace = true +astral-tokio-tar.workspace = true +sanitize-filename.workspace = true + +serde.workspace = true +serde_json.workspace = true +dunce.workspace = true +bytes.workspace = true + +# content hashing +digest.workspace = true +sha1.workspace = true +sha2.workspace = true + +[target.'cfg(windows)'.dependencies] +junction.workspace = true + diff --git a/packages/polyio/src/archive.rs b/packages/polyio/src/archive.rs new file mode 100644 index 00000000..82f9ec0a --- /dev/null +++ b/packages/polyio/src/archive.rs @@ -0,0 +1,291 @@ +use std::sync::Arc; + +use async_stream::try_stream; +use async_zip::StoredZipEntry; +use async_zip::base::read::WithoutEntry; +use futures_util::{Stream, TryStreamExt, pin_mut}; +use tokio_util::compat::TokioAsyncWriteCompatExt; + +use crate::{IOError, PolyIOResult}; + +#[tracing::instrument(skip(data, f), level = "debug")] +pub async fn read_zip_entries_bytes(data: Vec, mut f: F) -> PolyIOResult<()> +where + F: AsyncFnMut( + usize, + &StoredZipEntry, + &mut async_zip::base::read::ZipEntryReader< + '_, + futures_lite::io::Cursor<&[u8]>, + WithoutEntry, + >, + ) -> PolyIOResult<()>, +{ + let reader = async_zip::base::read::mem::ZipFileReader::new(data).await?; + + let entries = reader.file().entries(); + for index in 0..entries.len() { + let entry = entries.get(index).expect("expected more zip entries"); + let mut entry_reader = reader + .reader_without_entry(index) + .await + .unwrap_or_else(|_| panic!("expected zip entry at index '{index}'")); + + f(index, entry, &mut entry_reader).await?; + } + + Ok(()) +} + +#[tracing::instrument(skip(data), level = "debug")] +pub fn stream_zip_entries_bytes( + data: Vec, +) -> impl Stream< + Item = Result< + ( + usize, + StoredZipEntry, + Arc, + ), + IOError, + >, +> { + try_stream! { + let reader = Arc::new(async_zip::base::read::mem::ZipFileReader::new(data).await?); + let entries = reader.file().entries(); + + for index in 0..entries.len() { + let entry = entries.get(index).expect("expected more zip entries"); + + yield (index, entry.clone(), reader.clone()); + } + } +} + +#[tracing::instrument( + level = "debug", + skip(data, dest_path), + fields( + dest_path = %dest_path.as_ref().display() + ) +)] +pub async fn unzip_bytes( + data: Vec, + dest_path: impl AsRef, +) -> PolyIOResult<()> { + unzip_bytes_filtered(data, None:: bool>, dest_path).await +} + +#[tracing::instrument( + level = "debug", + skip(data, filter_entries, dest_path), + fields( + dest_path = %dest_path.as_ref().display() + ) +)] +pub async fn unzip_bytes_filtered( + data: Vec, + filter_entries: Option bool + Send + Sync>, + dest_path: impl AsRef, +) -> PolyIOResult<()> { + let stream = stream_zip_entries_bytes(data); + pin_mut!(stream); + + while let Some((index, entry, reader)) = stream.try_next().await? { + let file_name = entry.filename().as_str()?; + + if let Some(filter) = &filter_entries + && !filter(file_name) + { + continue; + } + + let path = dest_path.as_ref().join(crate::sanitize_path(file_name)); + + let is_dir = entry.dir()?; + + if is_dir { + crate::create_dir_all(path).await?; + } else { + if let Some(parent) = path.parent() { + crate::create_dir_all(parent).await?; + } + + let entry_reader = reader.reader_without_entry(index).await?; + + let file = tokio::fs::File::create(&path).await?; + let writer = tokio::io::BufWriter::new(file); + + futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?; + } + } + + Ok(()) +} + +#[tracing::instrument( + level = "debug", + skip(zip_path, dest_path), + fields( + zip_path = %zip_path.as_ref().display(), + dest_path = %dest_path.as_ref().display() + ) +)] +pub async fn extract_zip( + zip_path: impl AsRef, + dest_path: impl AsRef, +) -> PolyIOResult<()> { + extract_zip_filtered( + zip_path, + dest_path, + None:: bool>, + None:: String>, + ) + .await +} + +#[tracing::instrument( + level = "debug", + skip(zip_path, dest_path, filter_entries, modify_entry_name), + fields( + zip_path = %zip_path.as_ref().display(), + dest_path = %dest_path.as_ref().display() + ) +)] +pub async fn extract_zip_filtered( + zip_path: impl AsRef, + dest_path: impl AsRef, + filter_entries: Option bool + Send + Sync>, + modify_entry_name: Option String>, +) -> PolyIOResult<()> { + let zip_path = zip_path.as_ref(); + let dest_path = dest_path.as_ref(); + + let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path).await?; + let entries = reader.file().entries(); + + for index in 0..entries.len() { + let entry = entries.get(index).expect("expected more zip entries"); + + if let Some(filter) = &filter_entries + && !filter(entry) + { + continue; + } + + let old_name = entry.filename().as_str()?; + let name: String = modify_entry_name + .as_ref() + .map_or_else(|| old_name.to_string(), |modify| modify(old_name)); + + let name = crate::sanitize_path(name); + + let path = dest_path.join(name); + let is_dir = entry.dir()?; + + if is_dir { + crate::create_dir_all(&path).await?; + } else { + if let Some(parent) = path.parent() { + crate::create_dir_all(parent).await?; + } + + let file = tokio::fs::File::create(&path).await?; + let writer = tokio::io::BufWriter::new(file); + let entry_reader = reader.reader_without_entry(index).await?; + + futures_lite::io::copy(entry_reader, &mut writer.compat_write()).await?; + } + } + + Ok(()) +} + +/// Each matching entry is read fully into memory so this is for small files +/// not large archives +#[tracing::instrument( + level = "debug", + skip(zip_path, filter), + fields(zip_path = %zip_path.as_ref().display()) +)] +pub async fn read_zip_file_entries( + zip_path: impl AsRef, + filter: impl Fn(&str) -> bool, +) -> PolyIOResult)>> { + let zip_path = zip_path.as_ref(); + let reader = async_zip::tokio::read::fs::ZipFileReader::new(zip_path).await?; + let entries = reader.file().entries(); + + let mut out = Vec::new(); + for index in 0..entries.len() { + let entry = entries.get(index).expect("expected more zip entries"); + let Ok(name) = entry.filename().as_str() else { + continue; + }; + let name = name.to_string(); + + if entry.dir().unwrap_or(false) || !filter(&name) { + continue; + } + + let mut entry_reader = reader.reader_without_entry(index).await?; + let mut data = Vec::new(); + futures_lite::AsyncReadExt::read_to_end(&mut entry_reader, &mut data).await?; + out.push((name, data)); + } + + Ok(out) +} + +/// Returns a zip file entry's bytes without reading the entire file into memory +#[tracing::instrument(level = "debug", skip(reader))] +pub async fn try_read_zip_entry_bytes(reader: R, file_name: &str) -> PolyIOResult> +where + R: tokio::io::AsyncRead + tokio::io::AsyncBufRead + tokio::io::AsyncSeek + Unpin, +{ + use tokio_util::compat::TokioAsyncReadCompatExt; + let compat = reader.compat(); + + let mut zip_reader = async_zip::base::read::seek::ZipFileReader::new(compat).await?; + + let index = zip_reader + .file() + .entries() + .iter() + .position(|entry| entry.filename().as_str().is_ok_and(|n| n == file_name)) + .ok_or_else(|| IOError::FileNotFoundInZip { + file_name: file_name.to_string(), + })?; + + let mut entry_reader = zip_reader.reader_without_entry(index).await?; + + let mut data: Vec = Vec::new(); + + futures_lite::AsyncReadExt::read_to_end(&mut entry_reader, &mut data).await?; + + Ok(data) +} + +#[tracing::instrument( + level = "debug", + skip(archive, dest), + fields( + archive = %archive.as_ref().display(), + dest = %dest.as_ref().display(), + ) +)] +pub async fn extract_tar_gz( + archive: impl AsRef, + dest: impl AsRef, +) -> PolyIOResult<()> { + crate::create_dir_all(&dest).await?; + + let file = tokio::fs::File::open(archive).await?; + let buf_reader = tokio::io::BufReader::new(file); + let gzip_decoder = async_compression::tokio::bufread::GzipDecoder::new(buf_reader); + + let mut tar_archive = tokio_tar::Archive::new(gzip_decoder); + tar_archive.unpack(dest).await?; + + Ok(()) +} diff --git a/packages/polyio/src/error.rs b/packages/polyio/src/error.rs new file mode 100644 index 00000000..f77f1d5d --- /dev/null +++ b/packages/polyio/src/error.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; + +pub type PolyIOResult = Result; + +#[derive(Debug, thiserror::Error)] +pub enum IOError { + #[error("Invalid absolute path '{path}'")] + InvalidAbsolutePath { path: PathBuf }, + + #[error("Couldn't find file '{file_name}' in zip")] + FileNotFoundInZip { file_name: String }, + + #[error("An error occurred whilst accessing path '{path}': {source}")] + PathIOError { + #[source] + source: std::io::Error, + path: String, + }, + + #[error(transparent)] + IOError(#[from] std::io::Error), + + #[error("Failed to parse JSON for file '{file}': {source}")] + JsonFileParseError { + #[source] + source: serde_json::Error, + file: PathBuf, + }, + + #[error("Failed to convert to JSON for file '{file}': {source}")] + JsonFileWrite { + #[source] + source: serde_json::Error, + file: PathBuf, + }, + + #[error(transparent)] + AsyncZipError(#[from] async_zip::error::ZipError), + + #[error("Temporary file error: {0}")] + TempFileError(#[from] async_tempfile::Error), +} diff --git a/packages/polyio/src/file.rs b/packages/polyio/src/file.rs new file mode 100644 index 00000000..bd7c7672 --- /dev/null +++ b/packages/polyio/src/file.rs @@ -0,0 +1,879 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::{ + fs::Metadata, + path::{Path, PathBuf}, +}; + +use async_tempfile::{TempDir, TempFile}; +use serde::{Serialize, de::DeserializeOwned}; + +use crate::{IOError, PolyIOResult}; + +/// HTTP chunks already arrive at ~16 KiB +const MIN_WRITE_BUFFER: usize = 16 * 1024; +const MAX_WRITE_BUFFER: usize = 256 * 1024; +/// Used when the response has no length +const DEFAULT_WRITE_BUFFER: usize = 64 * 1024; + +/// Sizing the buffer to the file is a memory-footprint choice not a throughput +/// one a fixed 256 KiB costs ~8 MiB across 32 concurrent small-asset downloads +fn write_buffer_size(size_hint: Option) -> usize { + match size_hint { + Some(0) | None => DEFAULT_WRITE_BUFFER, + Some(size) => (size.min(MAX_WRITE_BUFFER as u64) as usize).max(MIN_WRITE_BUFFER), + } +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn read_dir(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref(); + + tokio::fs::read_dir(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn create_dir(path: impl AsRef) -> PolyIOResult<()> { + let path = path.as_ref(); + if path.exists() { + return Ok(()); + } + + tokio::fs::create_dir(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn create_dir_all(path: impl AsRef) -> PolyIOResult<()> { + let path = path.as_ref(); + tokio::fs::create_dir_all(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn remove_dir_all(path: impl AsRef) -> PolyIOResult<()> { + let path = path.as_ref(); + + tokio::fs::remove_dir_all(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +/// Fails if the directory is non-empty making "remove if empty" atomic with no +/// read-then-delete window +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn remove_dir(path: impl AsRef) -> PolyIOResult<()> { + let path = path.as_ref(); + + tokio::fs::remove_dir(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn try_exists(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref(); + + tokio::fs::try_exists(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn read_gz_to_string(path: impl AsRef) -> PolyIOResult { + let mut f = tokio::fs::File::open(path).await?; + let mut buf = vec![]; + tokio::io::AsyncReadExt::read_to_end(&mut f, &mut buf).await?; + + let mut decoder = async_compression::tokio::bufread::GzipDecoder::new(buf.as_slice()); + let mut dst = String::new(); + tokio::io::AsyncReadExt::read_to_string(&mut decoder, &mut dst).await?; + + Ok(dst) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn read_to_string(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref(); + + tokio::fs::read_to_string(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn read(path: impl AsRef) -> PolyIOResult> { + let path = path.as_ref(); + + tokio::fs::read(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn read_json(path: impl AsRef) -> PolyIOResult { + serde_json::from_slice(&read(&path).await?).map_err(|err| IOError::JsonFileParseError { + source: err, + file: path.as_ref().to_path_buf(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path, data), + fields(path = %path.as_ref().display()) +)] +pub async fn write(path: impl AsRef, data: impl AsRef<[u8]>) -> PolyIOResult<()> { + let path = path.as_ref(); + + tokio::fs::write(path, data) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path, f), + fields(path = %path.as_ref().display()) +)] +pub async fn write_buf(path: impl AsRef, f: F) -> Result<(), E> +where + E: From, + F: for<'a> FnOnce(&'a mut tokio::io::BufWriter) -> Fut, + Fut: std::future::Future>, +{ + let path = path.as_ref(); + let file = tokio::fs::File::create(path).await.map_err(IOError::from)?; + let mut writer = tokio::io::BufWriter::new(file); + + let write_result = f(&mut writer).await; + + let flush_result = tokio::io::AsyncWriteExt::flush(&mut writer) + .await + .map_err(IOError::from); + + write_result?; + flush_result?; + + Ok(()) +} + +/// Streams into a scratch sibling renamed over `path` on success so `path` +/// never holds a truncated file and an existing good file survives a failure +/// +/// Unlike [`write_atomic`] this deliberately does not fsync too expensive +/// across thousands of asset downloads and nothing here must survive power loss +#[tracing::instrument( + level = "debug", + skip(path, stream), + fields(path = %path.as_ref().display()) +)] +pub async fn write_stream( + path: impl AsRef, + mut stream: S, + size_hint: Option, +) -> Result<(), E> +where + S: futures_lite::Stream> + Unpin + Send, + E: From, +{ + let path = path.as_ref(); + let tmp = temp_sibling(path); + + let write = async { + let file = tokio::fs::File::create(&tmp).await.map_err(IOError::from)?; + let mut writer = tokio::io::BufWriter::with_capacity(write_buffer_size(size_hint), file); + + while let Some(chunk_result) = futures_lite::StreamExt::next(&mut stream).await { + let chunk = chunk_result?; + tokio::io::AsyncWriteExt::write_all(&mut writer, &chunk) + .await + .map_err(IOError::from)?; + } + + tokio::io::AsyncWriteExt::flush(&mut writer) + .await + .map_err(IOError::from)?; + + Ok::<_, E>(()) + } + .await; + + if let Err(err) = write { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(err); + } + + if let Err(err) = rename(&tmp, path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(E::from(err)); + } + + Ok(()) +} + +#[tracing::instrument( + level = "debug", + skip(path, data), + fields(path = %path.as_ref().display()) +)] +pub async fn write_json( + path: impl AsRef, + data: T, +) -> PolyIOResult<()> { + write( + &path, + serde_json::to_vec(&data).map_err(|err| IOError::JsonFileParseError { + source: err, + file: path.as_ref().to_path_buf(), + })?, + ) + .await +} + +/// Keeps two concurrent atomic writes to the same path off the same scratch file +static ATOMIC_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Sibling rather than temp dir `rename` across mount points fails with +/// `EXDEV` and `/tmp` is often a separate tmpfs on Linux +fn temp_sibling(path: &Path) -> PathBuf { + let n = ATOMIC_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed); + let stem = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "tmp".to_string()); + + let name = format!(".{stem}.{}.{n}.tmp", std::process::id()); + match path.parent() { + Some(parent) => parent.join(name), + None => PathBuf::from(name), + } +} + +/// Readers see either the old contents or the complete new ones +/// The fsync before the rename stops a crash leaving a correctly-named zero-length file +#[tracing::instrument( + level = "debug", + skip(path, data), + fields(path = %path.as_ref().display()) +)] +pub async fn write_atomic(path: impl AsRef, data: impl AsRef<[u8]>) -> PolyIOResult<()> { + let path = path.as_ref(); + + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + create_dir_all(parent).await?; + } + + let tmp = temp_sibling(path); + let ctx = |e: std::io::Error| IOError::PathIOError { + source: e, + path: tmp.to_string_lossy().to_string(), + }; + + let write = async { + let mut file = tokio::fs::File::create(&tmp).await.map_err(ctx)?; + tokio::io::AsyncWriteExt::write_all(&mut file, data.as_ref()) + .await + .map_err(ctx)?; + file.sync_all().await.map_err(ctx)?; + Ok::<_, IOError>(()) + } + .await; + + if let Err(err) = write { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(err); + } + + if let Err(err) = rename(&tmp, path).await { + let _ = tokio::fs::remove_file(&tmp).await; + return Err(err); + } + + Ok(()) +} + +#[tracing::instrument( + level = "debug", + skip(path, data), + fields(path = %path.as_ref().display()) +)] +pub async fn write_json_atomic(path: impl AsRef, data: T) -> PolyIOResult<()> { + let bytes = serde_json::to_vec(&data).map_err(|err| IOError::JsonFileWrite { + source: err, + file: path.as_ref().to_path_buf(), + })?; + + write_atomic(path, bytes).await +} + +/// Traversal guard for externally supplied paths +/// `Ok(None)` when the path resolves outside every root +/// `Err` only when `path` cannot be canonicalised +/// Roots that cannot be canonicalised (e.g. not yet created) are skipped +#[tracing::instrument( + level = "debug", + skip(path, roots), + fields(path = %path.as_ref().display()) +)] +pub fn ensure_under>( + path: impl AsRef, + roots: impl IntoIterator, +) -> PolyIOResult> { + // Not `std::fs::canonicalize` its Windows `\\?\` UNC output would never + // `starts_with` the plain-path roots so everything would look like an escape + let canon = crate::canonicalize(path)?; + + for root in roots { + if let Ok(root) = crate::canonicalize(root) + && canon.starts_with(&root) + { + return Ok(Some(canon)); + } + } + + Ok(None) +} + +/// `exclude_top` applies only at the top level a nested directory of the same +/// name is still copied +/// Symlinks are followed and copied as their contents +#[tracing::instrument(level = "debug", skip(exclude_top))] +pub async fn copy_dir(src: &Path, dst: &Path, exclude_top: &[&str]) -> PolyIOResult<()> { + let mut stack: Vec<(PathBuf, PathBuf, bool)> = + vec![(src.to_path_buf(), dst.to_path_buf(), true)]; + + while let Some((cur_src, cur_dst, is_top)) = stack.pop() { + let mut entries = read_dir(&cur_src).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + + if is_top + && let Some(name_str) = name.to_str() + && exclude_top.iter().any(|e| e.eq_ignore_ascii_case(name_str)) + { + continue; + } + + let child_src = entry.path(); + let child_dst = cur_dst.join(&name); + let file_type = entry.file_type().await?; + + if file_type.is_dir() { + create_dir_all(&child_dst).await?; + + stack.push((child_src, child_dst, false)); + } else if file_type.is_file() { + if let Some(parent) = child_dst.parent() { + create_dir_all(parent).await?; + } + + copy(&child_src, &child_dst).await?; + } + } + } + + Ok(()) +} + +pub async fn dir_has_content(dir: &Path) -> bool { + if !dir.is_dir() { + return false; + } + + match read_dir(dir).await { + Ok(mut entries) => matches!(entries.next_entry().await, Ok(Some(_))), + Err(_) => false, + } +} + +/// Replaces `to` if it already exists +#[tracing::instrument( + level = "debug", + skip(from, to), + fields( + from = %from.as_ref().display(), + to = %to.as_ref().display() + ) +)] +pub async fn rename( + from: impl AsRef, + to: impl AsRef, +) -> PolyIOResult<()> { + let from = from.as_ref(); + let to = to.as_ref(); + + tokio::fs::rename(from, to) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: from.to_string_lossy().to_string(), + }) +} + +/// Also copies permission bits and overwrites `to` +#[tracing::instrument( + level = "debug", + skip(from, to), + fields( + from = %from.as_ref().display(), + to = %to.as_ref().display() + ) +)] +pub async fn copy( + from: impl AsRef, + to: impl AsRef, +) -> PolyIOResult { + let from = from.as_ref(); + let to = to.as_ref(); + + tokio::fs::copy(from, to) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: from.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn remove_file(path: impl AsRef) -> PolyIOResult<()> { + let path = path.as_ref(); + tokio::fs::remove_file(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +/// Unlike [`stat`] returns metadata about the link itself not its target +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn symlink_metadata(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref(); + tokio::fs::symlink_metadata(path) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +/// Symlink on Unix hard link on Windows so both paths must be on one volume +#[tracing::instrument( + level = "debug", + skip(original, link), + fields( + original = %original.as_ref().display(), + link = %link.as_ref().display() + ) +)] +pub async fn symlink_file( + original: impl AsRef, + link: impl AsRef, +) -> PolyIOResult<()> { + let original = original.as_ref(); + let link = link.as_ref(); + + #[cfg(windows)] + let res = tokio::fs::hard_link(original, link).await; + #[cfg(not(windows))] + let res = tokio::fs::symlink(original, link).await; + + res.map_err(|e| IOError::PathIOError { + source: e, + path: link.to_string_lossy().to_string(), + }) +} + +/// Windows gets a junction which needs no elevated privilege unlike a real +/// directory symlink +/// Remove with [`remove_symlink_dir`] +#[tracing::instrument( + level = "debug", + skip(original, link), + fields( + original = %original.as_ref().display(), + link = %link.as_ref().display() + ) +)] +pub async fn symlink_dir( + original: impl AsRef, + link: impl AsRef, +) -> PolyIOResult<()> { + let original = original.as_ref(); + let link = link.as_ref(); + + #[cfg(windows)] + { + let path = link.to_string_lossy().to_string(); + let original = original.to_path_buf(); + let link = link.to_path_buf(); + return tokio::task::spawn_blocking(move || junction::create(&original, &link)) + .await + .map_err(std::io::Error::other)? + .map_err(|e| IOError::PathIOError { source: e, path }); + } + + #[cfg(not(windows))] + tokio::fs::symlink(original, link) + .await + .map_err(|e| IOError::PathIOError { + source: e, + path: link.to_string_lossy().to_string(), + }) +} + +/// A Windows junction must be removed with `remove_dir` not `remove_file` +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn remove_symlink_dir(path: impl AsRef) -> PolyIOResult<()> { + let path = path.as_ref(); + + #[cfg(windows)] + let res = tokio::fs::remove_dir(path).await; + + #[cfg(not(windows))] + let res = tokio::fs::remove_file(path).await; + + res.map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} + +#[tracing::instrument(level = "debug")] +pub async fn tempdir() -> PolyIOResult { + Ok(TempDir::new().await?) +} + +#[tracing::instrument(level = "debug")] +pub async fn tempfile() -> PolyIOResult { + Ok(TempFile::new().await?) +} + +/// Sanitises every component and normalises separators to `/` +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub fn sanitize_path(path: impl AsRef) -> PathBuf { + path.as_ref() + .to_string_lossy() + .replace('\\', "/") + .split('/') + .map(sanitize_filename::sanitize) + .collect() +} + +#[tracing::instrument( + level = "debug", + skip(path), + fields(path = %path.as_ref().display()) +)] +pub async fn stat(path: impl AsRef) -> PolyIOResult { + tokio::fs::metadata(path).await.map_err(IOError::from) +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn write_buffer_fits_the_file() { + assert_eq!(write_buffer_size(Some(10_217)), MIN_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(1)), MIN_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(64 * 1024)), 64 * 1024); + assert_eq!(write_buffer_size(Some(25_000_000)), MAX_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(u64::MAX)), MAX_WRITE_BUFFER); + } + + #[test] + fn write_buffer_falls_back_without_a_length() { + assert_eq!(write_buffer_size(None), DEFAULT_WRITE_BUFFER); + assert_eq!(write_buffer_size(Some(0)), DEFAULT_WRITE_BUFFER); + } + + fn scratch(tag: &str) -> PathBuf { + static N: AtomicU64 = AtomicU64::new(0); + let dir = std::env::temp_dir().join(format!( + "polyio-{tag}-{}-{}", + std::process::id(), + N.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[tokio::test] + async fn write_atomic_creates_missing_parents() { + let dir = scratch("atomic-parents"); + let target = dir.join("a").join("b").join("settings.json"); + + write_atomic(&target, b"{}").await.unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"{}"); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn write_atomic_replaces_and_leaves_no_scratch_files() { + let dir = scratch("atomic-replace"); + let target = dir.join("settings.json"); + + write_atomic(&target, b"old").await.unwrap(); + write_atomic(&target, b"new-and-longer").await.unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"new-and-longer"); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n != "settings.json") + .collect(); + assert!( + leftovers.is_empty(), + "left scratch files behind: {leftovers:?}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + fn failing_stream( + chunks: Vec<&'static [u8]>, + ) -> impl futures_lite::Stream> + Unpin + Send { + let items = chunks + .into_iter() + .map(|c| Ok(bytes::Bytes::from_static(c))) + .chain(std::iter::once(Err(IOError::IOError( + std::io::Error::from(std::io::ErrorKind::ConnectionReset), + )))); + + Box::pin(futures_lite::stream::iter(items)) + } + + #[tokio::test] + async fn write_stream_publishes_only_a_complete_file() { + let dir = scratch("stream-ok"); + let target = dir.join("object.bin"); + + let chunks = vec![ + Ok(bytes::Bytes::from_static(b"hello ")), + Ok(bytes::Bytes::from_static(b"world")), + ]; + let stream = Box::pin(futures_lite::stream::iter(chunks)); + + write_stream::<_, IOError>(&target, stream, Some(11)) + .await + .unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"hello world"); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .filter(|n| n != "object.bin") + .collect(); + assert!( + leftovers.is_empty(), + "left scratch files behind: {leftovers:?}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn write_stream_leaves_nothing_when_the_stream_dies() { + let dir = scratch("stream-drop"); + let target = dir.join("object.bin"); + + write_stream::<_, IOError>(&target, failing_stream(vec![b"partial"]), Some(64)) + .await + .expect_err("a dropped stream must fail"); + + assert!(!target.exists(), "left a truncated file at the destination"); + + let leftovers: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + assert!( + leftovers.is_empty(), + "left scratch files behind: {leftovers:?}" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn write_stream_keeps_the_old_file_when_a_rewrite_fails() { + let dir = scratch("stream-keep"); + let target = dir.join("object.bin"); + std::fs::write(&target, b"known-good").unwrap(); + + write_stream::<_, IOError>(&target, failing_stream(vec![b"junk"]), Some(64)) + .await + .expect_err("a dropped stream must fail"); + + assert_eq!(std::fs::read(&target).unwrap(), b"known-good"); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_accepts_a_path_inside_a_root() { + let dir = scratch("under-inside"); + let nested = dir.join("clusters").join("logs"); + std::fs::create_dir_all(&nested).unwrap(); + let file = nested.join("latest.log"); + std::fs::write(&file, b"").unwrap(); + + let resolved = ensure_under(&file, [&dir]).unwrap(); + assert!(resolved.is_some()); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_rejects_a_traversal_out_of_every_root() { + let dir = scratch("under-escape"); + let root = dir.join("clusters"); + let outside = dir.join("secrets"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let file = outside.join("auth.json"); + std::fs::write(&file, b"").unwrap(); + + let sneaky = root.join("..").join("secrets").join("auth.json"); + assert_eq!(ensure_under(&sneaky, [&root]).unwrap(), None); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn ensure_under_skips_roots_that_do_not_exist() { + let dir = scratch("under-missing-root"); + let file = dir.join("a.log"); + std::fs::write(&file, b"").unwrap(); + + let missing = dir.join("not-created-yet"); + let resolved = ensure_under(&file, [&missing, &dir]).unwrap(); + assert!( + resolved.is_some(), + "a missing root must not shadow a real one" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[tokio::test] + async fn copy_dir_excludes_only_at_the_top_level() { + let dir = scratch("copy-dir"); + let src = dir.join("src"); + let dst = dir.join("dst"); + std::fs::create_dir_all(src.join("mods")).unwrap(); + std::fs::create_dir_all(src.join("keep").join("mods")).unwrap(); + std::fs::write(src.join("mods").join("top.jar"), b"top").unwrap(); + std::fs::write(src.join("keep").join("mods").join("nested.jar"), b"nested").unwrap(); + std::fs::write(src.join("options.txt"), b"opts").unwrap(); + + copy_dir(&src, &dst, &["mods"]).await.unwrap(); + + assert!( + !dst.join("mods").exists(), + "top-level `mods` should be excluded" + ); + assert!(dst.join("options.txt").exists()); + assert!( + dst.join("keep").join("mods").join("nested.jar").exists(), + "exclusion must not apply below the top level" + ); + + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/packages/polyio/src/hash.rs b/packages/polyio/src/hash.rs new file mode 100644 index 00000000..e596d554 --- /dev/null +++ b/packages/polyio/src/hash.rs @@ -0,0 +1,252 @@ +use std::fmt::Write; +use std::io::Read; +use std::path::Path; + +use digest::Digest; + +use crate::{IOError, PolyIOResult}; + +/// Upper bound only smaller files get a buffer sized to fit +const MAX_HASH_BUFFER: usize = 256 * 1024; + +pub struct Sha1Stream(sha1::Sha1); + +impl Sha1Stream { + #[must_use] + pub fn new() -> Self { + Self(sha1::Sha1::new()) + } + + pub fn update(&mut self, data: &[u8]) { + Digest::update(&mut self.0, data); + } + + #[must_use] + pub fn finish(self) -> String { + to_hex(&self.0.finalize()) + } +} + +impl Default for Sha1Stream { + fn default() -> Self { + Self::new() + } +} + +/// Runs on the blocking pool one dispatch for the whole file keeping the +/// digest off the async workers driving concurrent downloads +pub async fn sha1_file(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref().to_path_buf(); + tokio::task::spawn_blocking(move || sha1_file_sync(&path)) + .await + .map_err(std::io::Error::other)? +} + +pub fn sha1_file_sync(path: &Path) -> PolyIOResult { + let mut file = std::fs::File::open(path).map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; + let mut hasher = Sha1Stream::new(); + + let size = file + .metadata() + .map(|meta| meta.len()) + .unwrap_or(MAX_HASH_BUFFER as u64); + let capacity = (size.max(1).min(MAX_HASH_BUFFER as u64)) as usize; + let mut buffer = vec![0u8; capacity]; + + loop { + let n = file.read(&mut buffer).map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + })?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + + Ok(hasher.finish()) +} + +#[must_use] +pub fn sha1_bytes(data: &[u8]) -> String { + let mut hasher = Sha1Stream::new(); + hasher.update(data); + hasher.finish() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ChecksumAlgorithm { + Sha1, + Sha256, +} + +impl ChecksumAlgorithm { + /// Hex length which is how a bare manifest string with no algorithm field + /// gets identified + #[must_use] + pub const fn hex_len(self) -> usize { + match self { + Self::Sha1 => 40, + Self::Sha256 => 64, + } + } + + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::Sha1 => "SHA-1", + Self::Sha256 => "SHA-256", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Checksum { + pub algorithm: ChecksumAlgorithm, + /// Always normalized (trimmed lowercase) + pub hex: String, +} + +impl Checksum { + #[must_use] + pub fn new(algorithm: ChecksumAlgorithm, hex: impl AsRef) -> Self { + Self { + algorithm, + hex: normalize_hash(hex.as_ref()), + } + } + + #[must_use] + pub fn sha1(hex: impl AsRef) -> Self { + Self::new(ChecksumAlgorithm::Sha1, hex) + } + + #[must_use] + pub fn sha256(hex: impl AsRef) -> Self { + Self::new(ChecksumAlgorithm::Sha256, hex) + } + + /// Whether the hash is the right shape for its algorithm + /// Callers drop malformed checksums and download unverified + #[must_use] + pub fn is_well_formed(&self) -> bool { + self.hex.len() == self.algorithm.hex_len() + && self.hex.bytes().all(|b| b.is_ascii_hexdigit()) + } + + #[must_use] + pub fn matches(&self, actual: &str) -> bool { + self.hex == normalize_hash(actual) + } +} + +pub enum ChecksumStream { + Sha1(sha1::Sha1), + Sha256(sha2::Sha256), +} + +impl ChecksumStream { + #[must_use] + pub fn new(algorithm: ChecksumAlgorithm) -> Self { + match algorithm { + ChecksumAlgorithm::Sha1 => Self::Sha1(sha1::Sha1::new()), + ChecksumAlgorithm::Sha256 => Self::Sha256(sha2::Sha256::new()), + } + } + + pub fn update(&mut self, data: &[u8]) { + match self { + Self::Sha1(hasher) => Digest::update(hasher, data), + Self::Sha256(hasher) => Digest::update(hasher, data), + } + } + + #[must_use] + pub fn finish(self) -> String { + match self { + Self::Sha1(hasher) => to_hex(&hasher.finalize()), + Self::Sha256(hasher) => to_hex(&hasher.finalize()), + } + } +} + +#[must_use] +pub fn normalize_hash(hash: &str) -> String { + hash.trim().to_ascii_lowercase() +} + +#[must_use] +pub fn to_hex(data: &[u8]) -> String { + data.iter().fold(String::new(), |mut out, b| { + let _ = write!(out, "{b:02x}"); + out + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha1_bytes_matches_known_vector() { + assert_eq!( + sha1_bytes(b"abc"), + "a9993e364706816aba3e25717850c26c9cd0d89d" + ); + assert_eq!(sha1_bytes(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709"); + } + + #[test] + fn stream_and_oneshot_agree() { + let mut stream = Sha1Stream::new(); + stream.update(b"ab"); + stream.update(b"c"); + assert_eq!(stream.finish(), sha1_bytes(b"abc")); + } + + #[test] + fn normalize_hash_trims_and_lowercases() { + assert_eq!(normalize_hash(" A9B2 "), "a9b2"); + } + + #[test] + fn checksum_streams_match_their_one_shot_digests() { + let mut sha1 = ChecksumStream::new(ChecksumAlgorithm::Sha1); + sha1.update(b"ab"); + sha1.update(b"c"); + assert_eq!(sha1.finish(), "a9993e364706816aba3e25717850c26c9cd0d89d"); + + let mut sha256 = ChecksumStream::new(ChecksumAlgorithm::Sha256); + sha256.update(b"ab"); + sha256.update(b"c"); + assert_eq!( + sha256.finish(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn checksum_comparison_ignores_case_and_padding() { + let expected = Checksum::sha1(" A9993E364706816ABA3E25717850C26C9CD0D89D "); + assert!(expected.matches("a9993e364706816aba3e25717850c26c9cd0d89d")); + assert!(expected.matches("A9993E364706816ABA3E25717850C26C9CD0D89D")); + assert!(!expected.matches("da39a3ee5e6b4b0d3255bfef95601890afd80709")); + } + + #[test] + fn a_hash_of_the_wrong_shape_is_not_well_formed() { + assert!(!Checksum::sha256("").is_well_formed()); + assert!(!Checksum::sha256("n/a").is_well_formed()); + assert!(!Checksum::sha256("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed()); + assert!(!Checksum::sha1("zzz3e364706816aba3e25717850c26c9cd0d89d!").is_well_formed()); + + assert!(Checksum::sha1("a9993e364706816aba3e25717850c26c9cd0d89d").is_well_formed()); + assert!( + Checksum::sha256("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + .is_well_formed() + ); + } +} diff --git a/packages/polyio/src/lib.rs b/packages/polyio/src/lib.rs new file mode 100644 index 00000000..00b33596 --- /dev/null +++ b/packages/polyio/src/lib.rs @@ -0,0 +1,15 @@ +mod archive; +mod error; +mod file; +mod hash; +mod system; + +#[cfg(feature = "testing")] +pub mod testing; + +pub use archive::*; +pub use error::IOError; +pub(crate) use error::PolyIOResult; +pub use file::*; +pub use hash::*; +pub use system::*; diff --git a/packages/polyio/src/system.rs b/packages/polyio/src/system.rs new file mode 100644 index 00000000..61fc8fbd --- /dev/null +++ b/packages/polyio/src/system.rs @@ -0,0 +1,17 @@ +use std::path::PathBuf; + +use crate::{IOError, PolyIOResult}; + +pub fn env_path(name: &str) -> Option { + std::env::var_os(name).map(PathBuf::from) +} + +/// Like [`std::fs::canonicalize`] but on Windows emits the most compatible +/// path form instead of UNC +pub fn canonicalize(path: impl AsRef) -> PolyIOResult { + let path = path.as_ref(); + dunce::canonicalize(path).map_err(|e| IOError::PathIOError { + source: e, + path: path.to_string_lossy().to_string(), + }) +} diff --git a/packages/polyio/src/testing.rs b/packages/polyio/src/testing.rs new file mode 100644 index 00000000..ab7aee91 --- /dev/null +++ b/packages/polyio/src/testing.rs @@ -0,0 +1,44 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// A uniquely-named directory under the system temp dir removed when dropped +#[derive(Debug)] +pub struct ScratchDir(PathBuf); + +impl ScratchDir { + /// `tag` only makes a leaked directory identifiable if the destructor is + /// skipped + #[must_use] + pub fn new(tag: &str) -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + + let path = std::env::temp_dir().join(format!("oneclient-{tag}-{}-{n}", std::process::id())); + std::fs::create_dir_all(&path) + .unwrap_or_else(|e| panic!("failed to create scratch dir {}: {e}", path.display())); + + Self(path) + } + + #[must_use] + pub fn path(&self) -> &Path { + &self.0 + } + + #[must_use] + pub fn join(&self, name: impl AsRef) -> PathBuf { + self.0.join(name) + } +} + +impl AsRef for ScratchDir { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl Drop for ScratchDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +}