Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08b26f9d5d | ||
|
|
99ea06e22a | ||
|
|
49cecf837b | ||
|
|
2877919639 | ||
|
|
76447019c0 | ||
|
|
3e7fd80824 | ||
|
|
6699b4cb33 | ||
|
|
3ff0ff238a | ||
|
|
0d3f007dd4 | ||
|
|
9702dae19d | ||
|
|
f6a697780b |
44
.github/workflows/cli-build.yml
vendored
44
.github/workflows/cli-build.yml
vendored
@@ -1,44 +0,0 @@
|
||||
name: CLI Build + Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./theseus_cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Rust setup
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: './src-tauri -> target'
|
||||
|
||||
- uses: actions-rs/cargo@v1
|
||||
name: Build program
|
||||
with:
|
||||
command: build
|
||||
args: --bin theseus_cli
|
||||
|
||||
- name: Run Lint
|
||||
uses: actions-rs/clippy-check@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: --bin theseus_cli
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -100,6 +100,7 @@ fabric.properties
|
||||
# that are supposed to be shared within teams.
|
||||
|
||||
.idea/*
|
||||
.vscode/*
|
||||
|
||||
!.idea/codeStyles
|
||||
!.idea/runConfigurations
|
||||
|
||||
12
.vscode/extensions.json
vendored
12
.vscode/extensions.json
vendored
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"tauri-apps.tauri-vscode",
|
||||
"vunguyentuan.vscode-css-variables",
|
||||
"stylelint.vscode-stylelint",
|
||||
"eamodio.gitlens",
|
||||
"esbenp.prettier-vscode",
|
||||
"pivaszbs.svelte-autoimport",
|
||||
"svelte.svelte-vscode",
|
||||
"ardenivanov.svelte-intellisense"
|
||||
]
|
||||
}
|
||||
160
.vscode/launch.json
vendored
160
.vscode/launch.json
vendored
@@ -1,160 +0,0 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug unit tests in library 'theseus'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"test",
|
||||
"--no-run",
|
||||
"--lib",
|
||||
"--package=theseus"
|
||||
],
|
||||
"filter": {
|
||||
"name": "theseus",
|
||||
"kind": "lib"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug executable 'theseus_cli'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"build",
|
||||
"--bin=theseus_cli",
|
||||
"--package=theseus_cli"
|
||||
],
|
||||
"filter": {
|
||||
"name": "theseus_cli",
|
||||
"kind": "bin"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug unit tests in executable 'theseus_cli'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"test",
|
||||
"--no-run",
|
||||
"--bin=theseus_cli",
|
||||
"--package=theseus_cli"
|
||||
],
|
||||
"filter": {
|
||||
"name": "theseus_cli",
|
||||
"kind": "bin"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug executable 'theseus_playground'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"build",
|
||||
"--bin=theseus_playground",
|
||||
"--package=theseus_playground"
|
||||
],
|
||||
"filter": {
|
||||
"name": "theseus_playground",
|
||||
"kind": "bin"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug unit tests in executable 'theseus_playground'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"test",
|
||||
"--no-run",
|
||||
"--bin=theseus_playground",
|
||||
"--package=theseus_playground"
|
||||
],
|
||||
"filter": {
|
||||
"name": "theseus_playground",
|
||||
"kind": "bin"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug executable 'theseus_gui'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"build",
|
||||
"--bin=theseus_gui",
|
||||
"--package=theseus_gui"
|
||||
],
|
||||
"filter": {
|
||||
"name": "theseus_gui",
|
||||
"kind": "bin"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug unit tests in executable 'theseus_gui'",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"test",
|
||||
"--no-run",
|
||||
"--bin=theseus_gui",
|
||||
"--package=theseus_gui"
|
||||
],
|
||||
"filter": {
|
||||
"name": "theseus_gui",
|
||||
"kind": "bin"
|
||||
}
|
||||
},
|
||||
"args": [],
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Tauri Development Debug",
|
||||
"cargo": {
|
||||
"args": [
|
||||
"build",
|
||||
"--manifest-path=./theseus_gui/src-tauri/Cargo.toml",
|
||||
"--no-default-features"
|
||||
]
|
||||
},
|
||||
"preLaunchTask": "ui:dev"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Tauri Production Debug",
|
||||
"cargo": {
|
||||
"args": ["build", "--release", "--manifest-path=.theseus_gui/src-tauri/Cargo.toml"]
|
||||
},
|
||||
"preLaunchTask": "ui:build"
|
||||
}
|
||||
]
|
||||
}
|
||||
60
.vscode/settings.json
vendored
60
.vscode/settings.json
vendored
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"cssVariables.lookupFiles": [
|
||||
"**/*.postcss",
|
||||
"**/node_modules/omorphia/**/*.postcss"
|
||||
],
|
||||
"cssVariables.blacklistFolders": [
|
||||
"**/.git",
|
||||
"**/.svn",
|
||||
"**/.hg",
|
||||
"**/CVS",
|
||||
"**/.DS_Store",
|
||||
"**/.git",
|
||||
"**/bower_components",
|
||||
"**/tmp",
|
||||
"**/dist",
|
||||
"**/tests"
|
||||
],
|
||||
"gitlens.showWelcomeOnInstall": false,
|
||||
"gitlens.showWhatsNewAfterUpgrades": false,
|
||||
"gitlens.plusFeatures.enabled": false,
|
||||
"gitlens.currentLine.enabled": false,
|
||||
"gitlens.currentLine.pullRequests.enabled": false,
|
||||
"gitlens.currentLine.scrollable": true,
|
||||
"gitlens.codeLens.enabled": false,
|
||||
"gitlens.hovers.enabled": false,
|
||||
"CSSNavigation.activeCSSFileExtensions": [
|
||||
"css",
|
||||
"postcss"
|
||||
],
|
||||
"CSSNavigation.activeHTMLFileExtensions": [
|
||||
"html",
|
||||
"svelte",
|
||||
"js",
|
||||
"ts"
|
||||
],
|
||||
"CSSNavigation.excludeGlobPatterns": [
|
||||
"**/bower_components/**",
|
||||
"**/vendor/**",
|
||||
"**/coverage/**"
|
||||
],
|
||||
"CSSNavigation.alwaysIncludeGlobPatterns": [
|
||||
"./theseus_gui/node_modules/omorphia/**/*.postcss"
|
||||
],
|
||||
"html-css-class-completion.HTMLLanguages": [
|
||||
"html",
|
||||
"svelte"
|
||||
],
|
||||
"html-css-class-completion.includeGlobPattern": "**/*.{postcss,svelte}",
|
||||
"html-css-class-completion.CSSLanguages": [
|
||||
"postcss",
|
||||
],
|
||||
"svelte.enable-ts-plugin": true,
|
||||
"svelte.ask-to-enable-ts-plugin": false,
|
||||
"svelte.plugin.css.diagnostics.enable": false,
|
||||
"svelte.plugin.svelte.diagnostics.enable": false,
|
||||
"rust-analyzer.linkedProjects": [
|
||||
"./theseus/Cargo.toml"
|
||||
],
|
||||
"rust-analyzer.showUnlinkedFileNotification": false,
|
||||
}
|
||||
32
.vscode/tasks.json
vendored
32
.vscode/tasks.json
vendored
@@ -1,32 +0,0 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
// for the documentation about the tasks.json format
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "ui:dev",
|
||||
"type": "shell",
|
||||
// `dev` keeps running in the background
|
||||
// ideally you should also configure a `problemMatcher`
|
||||
// see https://code.visualstudio.com/docs/editor/tasks#_can-a-background-task-be-used-as-a-prelaunchtask-in-launchjson
|
||||
"isBackground": true,
|
||||
// change this to your `beforeDevCommand`:
|
||||
"command": "yarn",
|
||||
"args": ["dev"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/theseus_gui"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "ui:build",
|
||||
"type": "shell",
|
||||
// change this to your `beforeBuildCommand`:
|
||||
"command": "yarn",
|
||||
"args": ["build"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/theseus_gui"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
3149
Cargo.lock
generated
3149
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@
|
||||
|
||||
members = [
|
||||
"theseus",
|
||||
"theseus_cli",
|
||||
"theseus_playground",
|
||||
"theseus_gui/src-tauri",
|
||||
"theseus_macros"
|
||||
|
||||
11
theseus.iml
11
theseus.iml
@@ -1,11 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/theseus/library" isTestSource="false" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus"
|
||||
version = "0.6.3"
|
||||
version = "0.7.0"
|
||||
authors = ["Jai A <jaiagr+gpg@pm.me>"]
|
||||
edition = "2018"
|
||||
|
||||
@@ -13,14 +13,14 @@ bytes = "1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_ini = "0.2.0"
|
||||
toml = "0.7.3"
|
||||
sha1 = { version = "0.6.1", features = ["std"]}
|
||||
sha2 = "0.9.9"
|
||||
toml = "0.8.12"
|
||||
sha1_smol = { version = "1.0.0", features = ["std"] }
|
||||
sha2 = "0.10.8"
|
||||
url = "2.2"
|
||||
uuid = { version = "1.1", features = ["serde", "v4"] }
|
||||
zip = "0.6.5"
|
||||
async_zip = { version = "0.0.13", features = ["full"] }
|
||||
flate2 = "1.0.27"
|
||||
async_zip = { version = "0.0.17", features = ["full"] }
|
||||
flate2 = "1.0.28"
|
||||
tempfile = "3.5.0"
|
||||
urlencoding = "2.1.3"
|
||||
|
||||
@@ -31,28 +31,28 @@ dirs = "5.0.1"
|
||||
|
||||
regex = "1.5"
|
||||
sys-info = "0.9.0"
|
||||
sysinfo = "0.29.9"
|
||||
sysinfo = "0.30.8"
|
||||
thiserror = "1.0"
|
||||
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = {version = "0.2", features = ["chrono"]}
|
||||
tracing-error = "0.1.0"
|
||||
tracing-appender = "0.1"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["chrono", "env-filter"] }
|
||||
tracing-error = "0.2.0"
|
||||
tracing-appender = "0.2.3"
|
||||
|
||||
paste = { version = "1.0"}
|
||||
paste = { version = "1.0" }
|
||||
|
||||
tauri = { version = "1.2", optional = true}
|
||||
tauri = { version = "1.6.1", optional = true }
|
||||
indicatif = { version = "0.17.3", optional = true }
|
||||
|
||||
async-tungstenite = { version = "0.22.1", features = ["tokio-runtime", "tokio-native-tls"] }
|
||||
async-tungstenite = { version = "0.25.1", features = ["tokio-runtime", "tokio-native-tls"] }
|
||||
futures = "0.3"
|
||||
reqwest = { version = "0.11", features = ["json", "stream"] }
|
||||
reqwest = { version = "0.12.3", features = ["json", "stream"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-stream = { version = "0.1", features = ["fs"] }
|
||||
async-recursion = "1.0.4"
|
||||
|
||||
notify = { version = "5.1.0", default-features = false }
|
||||
notify-debouncer-mini = { version = "0.2.1", default-features = false }
|
||||
notify = { version = "6.1.1", default-features = false }
|
||||
notify-debouncer-mini = { version = "0.4.1", default-features = false }
|
||||
|
||||
lazy_static = "1.4.0"
|
||||
dunce = "1.0.3"
|
||||
@@ -61,8 +61,13 @@ whoami = "1.4.0"
|
||||
|
||||
discord-rich-presence = "0.2.3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.50.0"
|
||||
p256 = { version = "0.13.2", features = ["ecdsa"] }
|
||||
rand = "0.8"
|
||||
byteorder = "1.5.0"
|
||||
base64 = "0.22.0"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.52.0"
|
||||
|
||||
[features]
|
||||
tauri = ["dep:tauri"]
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
//! Authentication flow interface
|
||||
use crate::{
|
||||
hydra::{self, init::DeviceLoginSuccess},
|
||||
launcher::auth as inner,
|
||||
State,
|
||||
};
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::state::AuthTask;
|
||||
pub use inner::Credentials;
|
||||
|
||||
/// Authenticate a user with Hydra - part 1
|
||||
/// This begins the authentication flow quasi-synchronously, returning a URL
|
||||
/// This can be used in conjunction with 'authenticate_await_complete_flow'
|
||||
/// to call authenticate and call the flow from the frontend.
|
||||
/// Visit the URL in a browser, then call and await 'authenticate_await_complete_flow'.
|
||||
pub async fn authenticate_begin_flow() -> crate::Result<DeviceLoginSuccess> {
|
||||
let url = AuthTask::begin_auth().await?;
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
/// Authenticate a user with Hydra - part 2
|
||||
/// This completes the authentication flow quasi-synchronously, returning the credentials
|
||||
/// This can be used in conjunction with 'authenticate_begin_flow'
|
||||
/// to call authenticate and call the flow from the frontend.
|
||||
pub async fn authenticate_await_complete_flow() -> crate::Result<Credentials> {
|
||||
let credentials = AuthTask::await_auth_completion().await?;
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
/// Cancels the active authentication flow
|
||||
pub async fn cancel_flow() -> crate::Result<()> {
|
||||
AuthTask::cancel().await
|
||||
}
|
||||
|
||||
/// Refresh some credentials using Hydra, if needed
|
||||
/// This is the primary desired way to get credentials, as it will also refresh them.
|
||||
#[tracing::instrument]
|
||||
#[theseus_macros::debug_pin]
|
||||
pub async fn refresh(user: uuid::Uuid) -> crate::Result<Credentials> {
|
||||
let state = State::get().await?;
|
||||
let mut users = state.users.write().await;
|
||||
|
||||
let mut credentials = users.get(user).ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(
|
||||
"You are not logged in with a Minecraft account!".to_string(),
|
||||
)
|
||||
.as_error()
|
||||
})?;
|
||||
|
||||
let offline = *state.offline.read().await;
|
||||
|
||||
if !offline {
|
||||
let fetch_semaphore: &crate::util::fetch::FetchSemaphore =
|
||||
&state.fetch_semaphore;
|
||||
if Utc::now() > credentials.expires
|
||||
&& inner::refresh_credentials(&mut credentials, fetch_semaphore)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
users.remove(credentials.id).await?;
|
||||
|
||||
return Err(crate::ErrorKind::OtherError(
|
||||
"Please re-authenticate with your Minecraft account!"
|
||||
.to_string(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
|
||||
// Update player info from bearer token
|
||||
let player_info =
|
||||
hydra::stages::player_info::fetch_info(&credentials.access_token)
|
||||
.await
|
||||
.map_err(|_err| {
|
||||
crate::ErrorKind::HydraError(
|
||||
"No Minecraft account for your profile. Please try again or contact support in our Discord for help!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
credentials.username = player_info.name;
|
||||
users.insert(&credentials).await?;
|
||||
}
|
||||
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
/// Remove a user account from the database
|
||||
#[tracing::instrument]
|
||||
pub async fn remove_user(user: uuid::Uuid) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let mut users = state.users.write().await;
|
||||
|
||||
if state.settings.read().await.default_user == Some(user) {
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.default_user = users.0.values().next().map(|it| it.id);
|
||||
}
|
||||
|
||||
users.remove(user).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a user exists in Theseus
|
||||
#[tracing::instrument]
|
||||
pub async fn has_user(user: uuid::Uuid) -> crate::Result<bool> {
|
||||
let state = State::get().await?;
|
||||
let users = state.users.read().await;
|
||||
|
||||
Ok(users.contains(user))
|
||||
}
|
||||
|
||||
/// Get a copy of the list of all user credentials
|
||||
#[tracing::instrument]
|
||||
pub async fn users() -> crate::Result<Vec<Credentials>> {
|
||||
let state = State::get().await?;
|
||||
let users = state.users.read().await;
|
||||
Ok(users.0.values().cloned().collect())
|
||||
}
|
||||
|
||||
/// Get a specific user by user ID
|
||||
/// Prefer to use 'refresh' instead of this function
|
||||
#[tracing::instrument]
|
||||
pub async fn get_user(user: uuid::Uuid) -> crate::Result<Credentials> {
|
||||
let state = State::get().await?;
|
||||
let users = state.users.read().await;
|
||||
let user = users.get(user).ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Tried to get nonexistent user with ID {user}"
|
||||
))
|
||||
.as_error()
|
||||
})?;
|
||||
Ok(user)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//! Main authentication flow for Hydra
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::prelude::Credentials;
|
||||
|
||||
use super::stages::{
|
||||
bearer_token, player_info, poll_response, xbl_signin, xsts_token,
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OauthFailure {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
pub struct SuccessfulLogin {
|
||||
pub name: String,
|
||||
pub icon: String,
|
||||
pub token: String,
|
||||
pub refresh_token: String,
|
||||
pub expires_after: i64,
|
||||
}
|
||||
|
||||
pub async fn wait_finish(device_code: String) -> crate::Result<Credentials> {
|
||||
// Loop, polling for response from Microsoft
|
||||
let oauth = poll_response::poll_response(device_code).await?;
|
||||
|
||||
// Get xbl token from oauth token
|
||||
let xbl_token = xbl_signin::login_xbl(&oauth.access_token).await?;
|
||||
|
||||
// Get xsts token from xbl token
|
||||
let xsts_response = xsts_token::fetch_token(&xbl_token.token).await?;
|
||||
|
||||
match xsts_response {
|
||||
xsts_token::XSTSResponse::Unauthorized(err) => {
|
||||
Err(crate::ErrorKind::HydraError(format!(
|
||||
"Error getting XBox Live token: {}",
|
||||
err
|
||||
))
|
||||
.as_error())
|
||||
}
|
||||
xsts_token::XSTSResponse::Success { token: xsts_token } => {
|
||||
// Get xsts bearer token from xsts token
|
||||
let (bearer_token, expires_in) =
|
||||
bearer_token::fetch_bearer(&xsts_token, &xbl_token.uhs)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Error getting bearer token: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
|
||||
// Get player info from bearer token
|
||||
let player_info = player_info::fetch_info(&bearer_token).await.map_err(|_err| {
|
||||
crate::ErrorKind::HydraError("No Minecraft account for profile. Make sure you own the game and have set a username through the official Minecraft launcher."
|
||||
.to_string())
|
||||
})?;
|
||||
|
||||
// Create credentials
|
||||
let credentials = Credentials::new(
|
||||
uuid::Uuid::parse_str(&player_info.id)?, // get uuid from player_info.id which is a String
|
||||
player_info.name,
|
||||
bearer_token,
|
||||
oauth.refresh_token,
|
||||
chrono::Utc::now() + chrono::Duration::seconds(expires_in),
|
||||
);
|
||||
|
||||
// Put credentials into state
|
||||
let state = crate::State::get().await?;
|
||||
{
|
||||
let mut users = state.users.write().await;
|
||||
users.insert(&credentials).await?;
|
||||
}
|
||||
|
||||
if state.settings.read().await.default_user.is_none() {
|
||||
let mut settings = state.settings.write().await;
|
||||
settings.default_user = Some(credentials.id);
|
||||
}
|
||||
|
||||
Ok(credentials)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! Login route for Hydra, redirects to the Microsoft login page before going to the redirect route
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{hydra::MicrosoftError, util::fetch::REQWEST_CLIENT};
|
||||
|
||||
use super::{stages::auth_retry, MICROSOFT_CLIENT_ID};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct DeviceLoginSuccess {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub async fn init() -> crate::Result<DeviceLoginSuccess> {
|
||||
// Get the initial URL
|
||||
// Get device code
|
||||
// Define the parameters
|
||||
|
||||
// urlencoding::encode("XboxLive.signin offline_access"));
|
||||
let resp = auth_retry(|| REQWEST_CLIENT.get("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode")
|
||||
.header("Content-Length", "0")
|
||||
.query(&[
|
||||
("client_id", MICROSOFT_CLIENT_ID),
|
||||
(
|
||||
"scope",
|
||||
"XboxLive.signin XboxLive.offline_access profile openid email",
|
||||
),
|
||||
])
|
||||
.send()
|
||||
).await?;
|
||||
|
||||
match resp.status() {
|
||||
reqwest::StatusCode::OK => Ok(resp.json().await?),
|
||||
_ => {
|
||||
let microsoft_error = resp.json::<MicrosoftError>().await?;
|
||||
Err(crate::ErrorKind::HydraError(format!(
|
||||
"Error from Microsoft: {:?}",
|
||||
microsoft_error.error_description
|
||||
))
|
||||
.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
pub mod complete;
|
||||
pub mod init;
|
||||
pub mod refresh;
|
||||
pub(crate) mod stages;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
const MICROSOFT_CLIENT_ID: &str = "c4502edb-87c6-40cb-b595-64a280cf8906";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MicrosoftError {
|
||||
pub error: String,
|
||||
pub error_description: String,
|
||||
pub error_codes: Vec<u64>,
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
hydra::{MicrosoftError, MICROSOFT_CLIENT_ID},
|
||||
util::fetch::REQWEST_CLIENT,
|
||||
};
|
||||
|
||||
use super::stages::auth_retry;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OauthSuccess {
|
||||
pub token_type: String,
|
||||
pub scope: String,
|
||||
pub expires_in: i64,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
pub async fn refresh(refresh_token: String) -> crate::Result<OauthSuccess> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "refresh_token");
|
||||
params.insert("client_id", MICROSOFT_CLIENT_ID);
|
||||
params.insert("refresh_token", &refresh_token);
|
||||
params.insert(
|
||||
"redirect_uri",
|
||||
"https://login.microsoftonline.com/common/oauth2/nativeclient",
|
||||
);
|
||||
|
||||
// Poll the URL in a loop until we are successful.
|
||||
// On an authorization_pending response, wait 5 seconds and try again.
|
||||
let resp =
|
||||
auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
|
||||
match resp.status() {
|
||||
StatusCode::OK => {
|
||||
let oauth = resp.json::<OauthSuccess>().await.map_err(|err| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Could not decipher successful response: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
Ok(oauth)
|
||||
}
|
||||
_ => {
|
||||
let failure =
|
||||
resp.json::<MicrosoftError>().await.map_err(|err| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Could not decipher failure response: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
Err(crate::ErrorKind::HydraError(format!(
|
||||
"Error refreshing token: {}",
|
||||
failure.error
|
||||
))
|
||||
.as_error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
const MCSERVICES_AUTH_URL: &str =
|
||||
"https://api.minecraftservices.com/authentication/login_with_xbox";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BearerTokenResponse {
|
||||
access_token: String,
|
||||
expires_in: i64,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn fetch_bearer(
|
||||
token: &str,
|
||||
uhs: &str,
|
||||
) -> crate::Result<(String, i64)> {
|
||||
let body = auth_retry(|| {
|
||||
let client = reqwest::Client::new();
|
||||
client
|
||||
.post(MCSERVICES_AUTH_URL)
|
||||
.header("Accept", "application/json")
|
||||
.json(&json!({
|
||||
"identityToken": format!("XBL3.0 x={};{}", uhs, token),
|
||||
}))
|
||||
.send()
|
||||
})
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
serde_json::from_str::<BearerTokenResponse>(&body)
|
||||
.map(|x| (x.access_token, x.expires_in))
|
||||
.map_err(|_| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Response didn't contain valid bearer token. body: {body}"
|
||||
))
|
||||
.into()
|
||||
})
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
//! MSA authentication stages
|
||||
|
||||
use futures::Future;
|
||||
use reqwest::Response;
|
||||
|
||||
const RETRY_COUNT: usize = 9; // Does command 3 times
|
||||
const RETRY_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
pub mod bearer_token;
|
||||
pub mod player_info;
|
||||
pub mod poll_response;
|
||||
pub mod xbl_signin;
|
||||
pub mod xsts_token;
|
||||
|
||||
#[tracing::instrument(skip(reqwest_request))]
|
||||
pub async fn auth_retry<F>(
|
||||
reqwest_request: impl Fn() -> F,
|
||||
) -> crate::Result<reqwest::Response>
|
||||
where
|
||||
F: Future<Output = Result<Response, reqwest::Error>>,
|
||||
{
|
||||
let mut resp = reqwest_request().await?;
|
||||
for i in 0..RETRY_COUNT {
|
||||
if resp.status().is_success() {
|
||||
break;
|
||||
}
|
||||
tracing::debug!(
|
||||
"Request failed with status code {}, retrying...",
|
||||
resp.status()
|
||||
);
|
||||
if i < RETRY_COUNT - 1 {
|
||||
tokio::time::sleep(RETRY_WAIT).await;
|
||||
}
|
||||
resp = reqwest_request().await?;
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//! Fetch player info for display
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::util::fetch::REQWEST_CLIENT;
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
const PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PlayerInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Default for PlayerInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: "606e2ff0ed7748429d6ce1d3321c7838".to_string(),
|
||||
name: String::from("Unknown"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn fetch_info(token: &str) -> crate::Result<PlayerInfo> {
|
||||
auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.get("https://api.minecraftservices.com/entitlements/mcstore")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.get(PROFILE_URL)
|
||||
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let resp = response.error_for_status()?.json().await?;
|
||||
|
||||
Ok(resp)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
hydra::{MicrosoftError, MICROSOFT_CLIENT_ID},
|
||||
util::fetch::REQWEST_CLIENT,
|
||||
};
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OauthSuccess {
|
||||
pub token_type: String,
|
||||
pub scope: String,
|
||||
pub expires_in: i64,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn poll_response(device_code: String) -> crate::Result<OauthSuccess> {
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
||||
params.insert("client_id", MICROSOFT_CLIENT_ID);
|
||||
params.insert("device_code", &device_code);
|
||||
params.insert(
|
||||
"scope",
|
||||
"XboxLive.signin XboxLive.offline_access profile openid email",
|
||||
);
|
||||
|
||||
// Poll the URL in a loop until we are successful.
|
||||
// On an authorization_pending response, wait 5 seconds and try again.
|
||||
loop {
|
||||
let resp = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post(
|
||||
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
|
||||
)
|
||||
.form(¶ms)
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
|
||||
match resp.status() {
|
||||
StatusCode::OK => {
|
||||
let oauth =
|
||||
resp.json::<OauthSuccess>().await.map_err(|err| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Could not decipher successful response: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
return Ok(oauth);
|
||||
}
|
||||
_ => {
|
||||
let failure =
|
||||
resp.json::<MicrosoftError>().await.map_err(|err| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Could not decipher failure response: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
match failure.error.as_str() {
|
||||
"authorization_pending" => {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2))
|
||||
.await;
|
||||
}
|
||||
"authorization_declined" => {
|
||||
return Err(crate::ErrorKind::HydraError(
|
||||
"Authorization declined".to_string(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
"expired_token" => {
|
||||
return Err(crate::ErrorKind::HydraError(
|
||||
"Device code expired".to_string(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
"bad_verification_code" => {
|
||||
return Err(crate::ErrorKind::HydraError(
|
||||
"Invalid device code".to_string(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
_ => {
|
||||
return Err(crate::ErrorKind::HydraError(format!(
|
||||
"Unknown error: {}",
|
||||
failure.error
|
||||
))
|
||||
.as_error());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::util::fetch::REQWEST_CLIENT;
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
const XBL_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
|
||||
|
||||
// Deserialization
|
||||
pub struct XBLLogin {
|
||||
pub token: String,
|
||||
pub uhs: String,
|
||||
}
|
||||
|
||||
// Impl
|
||||
#[tracing::instrument]
|
||||
pub async fn login_xbl(token: &str) -> crate::Result<XBLLogin> {
|
||||
let response = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post(XBL_AUTH_URL)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.json(&json!({
|
||||
"Properties": {
|
||||
"AuthMethod": "RPS",
|
||||
"SiteName": "user.auth.xboxlive.com",
|
||||
"RpsTicket": format!("d={token}")
|
||||
},
|
||||
"RelyingParty": "http://auth.xboxlive.com",
|
||||
"TokenType": "JWT"
|
||||
}))
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
let body = response.text().await?;
|
||||
|
||||
let json = serde_json::from_str::<serde_json::Value>(&body)?;
|
||||
let token = Some(&json)
|
||||
.and_then(|it| it.get("Token")?.as_str().map(String::from))
|
||||
.ok_or(crate::ErrorKind::HydraError(
|
||||
"XBL response didn't contain valid token".to_string(),
|
||||
))?;
|
||||
let uhs = Some(&json)
|
||||
.and_then(|it| {
|
||||
it.get("DisplayClaims")?
|
||||
.get("xui")?
|
||||
.get(0)?
|
||||
.get("uhs")?
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
})
|
||||
.ok_or(
|
||||
crate::ErrorKind::HydraError(
|
||||
"XBL response didn't contain valid user hash".to_string(),
|
||||
)
|
||||
.as_error(),
|
||||
)?;
|
||||
|
||||
Ok(XBLLogin { token, uhs })
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::util::fetch::REQWEST_CLIENT;
|
||||
|
||||
use super::auth_retry;
|
||||
|
||||
const XSTS_AUTH_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
|
||||
|
||||
pub enum XSTSResponse {
|
||||
Unauthorized(String),
|
||||
Success { token: String },
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn fetch_token(token: &str) -> crate::Result<XSTSResponse> {
|
||||
let resp = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post(XSTS_AUTH_URL)
|
||||
.header(reqwest::header::ACCEPT, "application/json")
|
||||
.json(&json!({
|
||||
"Properties": {
|
||||
"SandboxId": "RETAIL",
|
||||
"UserTokens": [
|
||||
token
|
||||
]
|
||||
},
|
||||
"RelyingParty": "rp://api.minecraftservices.com/",
|
||||
"TokenType": "JWT"
|
||||
}))
|
||||
.send()
|
||||
})
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
|
||||
let body = resp.text().await?;
|
||||
let json = serde_json::from_str::<serde_json::Value>(&body)?;
|
||||
|
||||
if status.is_success() {
|
||||
Ok(json
|
||||
.get("Token")
|
||||
.and_then(|x| x.as_str().map(String::from))
|
||||
.map(|it| XSTSResponse::Success { token: it })
|
||||
.unwrap_or(XSTSResponse::Unauthorized(
|
||||
"XSTS response didn't contain valid token!".to_string(),
|
||||
)))
|
||||
} else {
|
||||
Ok(XSTSResponse::Unauthorized(
|
||||
#[allow(clippy::unreadable_literal)]
|
||||
match json.get("XErr").and_then(|x| x.as_i64()) {
|
||||
Some(2148916238) => {
|
||||
String::from("This Microsoft account is underage and is not linked to a family.")
|
||||
},
|
||||
Some(2148916235) => {
|
||||
String::from("XBOX Live/Minecraft is not available in your country.")
|
||||
},
|
||||
Some(2148916233) => String::from("This account does not have a valid XBOX Live profile. Please buy Minecraft and try again!"),
|
||||
Some(2148916236) | Some(2148916237) => String::from("This account needs adult verification on Xbox page."),
|
||||
_ => String::from("Unknown error code"),
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -10,64 +10,32 @@ use crate::util::fetch::{fetch_advanced, fetch_json};
|
||||
use crate::util::io;
|
||||
use crate::util::jre::extract_java_majorminor_version;
|
||||
use crate::{
|
||||
state::JavaGlobals,
|
||||
util::jre::{self, JavaVersion},
|
||||
LoadingBarType, State,
|
||||
};
|
||||
|
||||
pub const JAVA_8_KEY: &str = "JAVA_8";
|
||||
pub const JAVA_17_KEY: &str = "JAVA_17";
|
||||
pub const JAVA_18PLUS_KEY: &str = "JAVA_18PLUS";
|
||||
|
||||
// Autodetect JavaSettings default
|
||||
// Using the supplied JavaVersions, autodetects the default JavaSettings
|
||||
// Make a guess for what the default Java global settings should be
|
||||
// Since the JRE paths are passed in as args, this handles the logic for selection. Currently this just pops the last one found
|
||||
// TODO: When tauri compiler issue is fixed, this can be be improved (ie: getting JREs in-function)
|
||||
pub async fn autodetect_java_globals(
|
||||
mut java_8: Vec<JavaVersion>,
|
||||
mut java_17: Vec<JavaVersion>,
|
||||
mut java_18plus: Vec<JavaVersion>,
|
||||
) -> crate::Result<JavaGlobals> {
|
||||
// Simply select last one found for initial guess
|
||||
let mut java_globals = JavaGlobals::new();
|
||||
if let Some(jre) = java_8.pop() {
|
||||
java_globals.insert(JAVA_8_KEY.to_string(), jre);
|
||||
}
|
||||
if let Some(jre) = java_17.pop() {
|
||||
java_globals.insert(JAVA_17_KEY.to_string(), jre);
|
||||
}
|
||||
if let Some(jre) = java_18plus.pop() {
|
||||
java_globals.insert(JAVA_18PLUS_KEY.to_string(), jre);
|
||||
}
|
||||
|
||||
Ok(java_globals)
|
||||
}
|
||||
|
||||
// Searches for jres on the system given a java version (ex: 1.8, 1.17, 1.18)
|
||||
// Allow higher allows for versions higher than the given version to be returned ('at least')
|
||||
pub async fn find_filtered_jres(
|
||||
version: &str,
|
||||
jres: Vec<JavaVersion>,
|
||||
allow_higher: bool,
|
||||
java_version: Option<u32>,
|
||||
) -> crate::Result<Vec<JavaVersion>> {
|
||||
let version = extract_java_majorminor_version(version)?;
|
||||
let jres = jre::get_all_jre().await?;
|
||||
|
||||
// Filter out JREs that are not 1.17 or higher
|
||||
Ok(jres
|
||||
.into_iter()
|
||||
.filter(|jre| {
|
||||
let jre_version = extract_java_majorminor_version(&jre.version);
|
||||
if let Ok(jre_version) = jre_version {
|
||||
if allow_higher {
|
||||
jre_version >= version
|
||||
Ok(if let Some(java_version) = java_version {
|
||||
jres.into_iter()
|
||||
.filter(|jre| {
|
||||
let jre_version = extract_java_majorminor_version(&jre.version);
|
||||
if let Ok(jre_version) = jre_version {
|
||||
jre_version.1 == java_version
|
||||
} else {
|
||||
jre_version == version
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
jres
|
||||
})
|
||||
}
|
||||
|
||||
#[theseus_macros::debug_pin]
|
||||
@@ -176,17 +144,6 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
// Get all JREs that exist on the system
|
||||
pub async fn get_all_jre() -> crate::Result<Vec<JavaVersion>> {
|
||||
Ok(jre::get_all_jre().await?)
|
||||
}
|
||||
|
||||
pub async fn validate_globals() -> crate::Result<bool> {
|
||||
let state = State::get().await?;
|
||||
let settings = state.settings.read().await;
|
||||
Ok(settings.java_globals.is_all_valid().await)
|
||||
}
|
||||
|
||||
// Validates JRE at a given at a given path
|
||||
pub async fn check_jre(path: PathBuf) -> crate::Result<Option<JavaVersion>> {
|
||||
Ok(jre::check_java_at_filepath(&path).await)
|
||||
@@ -196,14 +153,13 @@ pub async fn check_jre(path: PathBuf) -> crate::Result<Option<JavaVersion>> {
|
||||
pub async fn test_jre(
|
||||
path: PathBuf,
|
||||
major_version: u32,
|
||||
minor_version: u32,
|
||||
) -> crate::Result<bool> {
|
||||
let jre = match jre::check_java_at_filepath(&path).await {
|
||||
Some(jre) => jre,
|
||||
None => return Ok(false),
|
||||
};
|
||||
let (major, minor) = extract_java_majorminor_version(&jre.version)?;
|
||||
Ok(major == major_version && minor == minor_version)
|
||||
let (major, _) = extract_java_majorminor_version(&jre.version)?;
|
||||
Ok(major == major_version)
|
||||
}
|
||||
|
||||
// Gets maximum memory in KiB.
|
||||
|
||||
@@ -140,8 +140,14 @@ pub async fn get_output_by_filename(
|
||||
let logs_folder = DirectoryInfo::profile_logs_dir(profile_subpath).await?;
|
||||
let path = logs_folder.join(file_name);
|
||||
|
||||
let credentials: Vec<Credentials> =
|
||||
state.users.read().await.clone().0.into_values().collect();
|
||||
let credentials: Vec<Credentials> = state
|
||||
.users
|
||||
.read()
|
||||
.await
|
||||
.users
|
||||
.clone()
|
||||
.into_values()
|
||||
.collect();
|
||||
|
||||
// Load .gz file into String
|
||||
if let Some(ext) = path.extension() {
|
||||
@@ -241,14 +247,6 @@ pub async fn get_latest_log_cursor(
|
||||
get_generic_live_log_cursor(profile_path, "latest.log", cursor).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_std_log_cursor(
|
||||
profile_path: ProfilePathId,
|
||||
cursor: u64, // 0 to start at beginning of file
|
||||
) -> crate::Result<LatestLogCursor> {
|
||||
get_generic_live_log_cursor(profile_path, "latest_stdout.log", cursor).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_generic_live_log_cursor(
|
||||
profile_path: ProfilePathId,
|
||||
@@ -304,8 +302,14 @@ pub async fn get_generic_live_log_cursor(
|
||||
let output = String::from_utf8_lossy(&buffer).to_string(); // Convert to String
|
||||
let cursor = cursor + bytes_read as u64; // Update cursor
|
||||
|
||||
let credentials: Vec<Credentials> =
|
||||
state.users.read().await.clone().0.into_values().collect();
|
||||
let credentials: Vec<Credentials> = state
|
||||
.users
|
||||
.read()
|
||||
.await
|
||||
.users
|
||||
.clone()
|
||||
.into_values()
|
||||
.collect();
|
||||
let output = CensoredString::censor(output, &credentials);
|
||||
Ok(LatestLogCursor {
|
||||
cursor,
|
||||
|
||||
76
theseus/src/api/minecraft_auth.rs
Normal file
76
theseus/src/api/minecraft_auth.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! Authentication flow interface
|
||||
use crate::state::{Credentials, MinecraftLoginFlow};
|
||||
use crate::State;
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn begin_login() -> crate::Result<MinecraftLoginFlow> {
|
||||
let state = State::get().await?;
|
||||
let mut users = state.users.write().await;
|
||||
|
||||
users.login_begin().await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn finish_login(
|
||||
code: &str,
|
||||
flow: MinecraftLoginFlow,
|
||||
) -> crate::Result<Credentials> {
|
||||
let state = State::get().await?;
|
||||
let mut users = state.users.write().await;
|
||||
|
||||
users.login_finish(code, flow).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
|
||||
let state = State::get().await?;
|
||||
let users = state.users.read().await;
|
||||
Ok(users.default_user)
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn set_default_user(user: uuid::Uuid) -> crate::Result<()> {
|
||||
let user = get_user(user).await?;
|
||||
let state = State::get().await?;
|
||||
let mut users = state.users.write().await;
|
||||
users.default_user = Some(user.id);
|
||||
users.save().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a user account from the database
|
||||
#[tracing::instrument]
|
||||
pub async fn remove_user(user: uuid::Uuid) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let mut users = state.users.write().await;
|
||||
users.remove(user).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a copy of the list of all user credentials
|
||||
#[tracing::instrument]
|
||||
pub async fn users() -> crate::Result<Vec<Credentials>> {
|
||||
let state = State::get().await?;
|
||||
let users = state.users.read().await;
|
||||
Ok(users.users.values().cloned().collect())
|
||||
}
|
||||
|
||||
/// Get a specific user by user ID
|
||||
/// Prefer to use 'refresh' instead of this function
|
||||
#[tracing::instrument]
|
||||
pub async fn get_user(user: uuid::Uuid) -> crate::Result<Credentials> {
|
||||
let state = State::get().await?;
|
||||
let users = state.users.read().await;
|
||||
let user = users
|
||||
.users
|
||||
.get(&user)
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(format!(
|
||||
"Tried to get nonexistent user with ID {user}"
|
||||
))
|
||||
.as_error()
|
||||
})?
|
||||
.clone();
|
||||
Ok(user)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
//! API for interacting with Theseus
|
||||
pub mod auth;
|
||||
pub mod handler;
|
||||
pub mod hydra;
|
||||
pub mod jre;
|
||||
pub mod logs;
|
||||
pub mod metadata;
|
||||
pub mod minecraft_auth;
|
||||
pub mod mr_auth;
|
||||
pub mod pack;
|
||||
pub mod process;
|
||||
@@ -15,19 +14,19 @@ pub mod tags;
|
||||
|
||||
pub mod data {
|
||||
pub use crate::state::{
|
||||
DirectoryInfo, Hooks, JavaSettings, LinkedData, MemorySettings,
|
||||
ModLoader, ModrinthCredentials, ModrinthCredentialsResult,
|
||||
ModrinthProject, ModrinthTeamMember, ModrinthUser, ModrinthVersion,
|
||||
ProfileMetadata, ProjectMetadata, Settings, Theme, WindowSize,
|
||||
Credentials, DirectoryInfo, Hooks, JavaSettings, LinkedData,
|
||||
MemorySettings, ModLoader, ModrinthCredentials,
|
||||
ModrinthCredentialsResult, ModrinthProject, ModrinthTeamMember,
|
||||
ModrinthUser, ModrinthVersion, ProfileMetadata, ProjectMetadata,
|
||||
Settings, Theme, WindowSize,
|
||||
};
|
||||
}
|
||||
|
||||
pub mod prelude {
|
||||
pub use crate::{
|
||||
auth::{self, Credentials},
|
||||
data::*,
|
||||
event::CommandPayload,
|
||||
jre, metadata, pack, process,
|
||||
jre, metadata, minecraft_auth, pack, process,
|
||||
profile::{self, create, Profile},
|
||||
settings,
|
||||
state::JavaGlobals,
|
||||
|
||||
@@ -301,7 +301,7 @@ pub async fn copy_dotminecraft(
|
||||
#[theseus_macros::debug_pin]
|
||||
#[async_recursion::async_recursion]
|
||||
#[tracing::instrument]
|
||||
async fn get_all_subfiles(src: &Path) -> crate::Result<Vec<PathBuf>> {
|
||||
pub async fn get_all_subfiles(src: &Path) -> crate::Result<Vec<PathBuf>> {
|
||||
if !src.is_dir() {
|
||||
return Ok(vec![src.to_path_buf()]);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::state::{ProfileInstallStage, Profiles, SideType};
|
||||
use crate::util::fetch::{fetch_json, fetch_mirrors, write};
|
||||
use crate::util::io;
|
||||
use crate::{profile, State};
|
||||
use async_zip::tokio::read::seek::ZipFileReader;
|
||||
use async_zip::base::read::seek::ZipFileReader;
|
||||
use reqwest::Method;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -93,29 +93,21 @@ pub async fn install_zipped_mrpack_files(
|
||||
let reader: Cursor<&bytes::Bytes> = Cursor::new(&file);
|
||||
|
||||
// Create zip reader around file
|
||||
let mut zip_reader = ZipFileReader::new(reader).await.map_err(|_| {
|
||||
crate::Error::from(crate::ErrorKind::InputError(
|
||||
"Failed to read input modpack zip".to_string(),
|
||||
))
|
||||
})?;
|
||||
let mut zip_reader =
|
||||
ZipFileReader::with_tokio(reader).await.map_err(|_| {
|
||||
crate::Error::from(crate::ErrorKind::InputError(
|
||||
"Failed to read input modpack zip".to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
// Extract index of modrinth.index.json
|
||||
let zip_index_option = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|f| f.entry().filename() == "modrinth.index.json");
|
||||
let zip_index_option = zip_reader.file().entries().iter().position(|f| {
|
||||
f.filename().as_str().unwrap_or_default() == "modrinth.index.json"
|
||||
});
|
||||
if let Some(zip_index) = zip_index_option {
|
||||
let mut manifest = String::new();
|
||||
let entry = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.get(zip_index)
|
||||
.unwrap()
|
||||
.entry()
|
||||
.clone();
|
||||
let mut reader = zip_reader.entry(zip_index).await?;
|
||||
reader.read_to_string_checked(&mut manifest, &entry).await?;
|
||||
let mut reader = zip_reader.reader_with_entry(zip_index).await?;
|
||||
reader.read_to_string_checked(&mut manifest).await?;
|
||||
|
||||
let pack: PackFormat = serde_json::from_str(&manifest)?;
|
||||
|
||||
@@ -217,34 +209,31 @@ pub async fn install_zipped_mrpack_files(
|
||||
let mut total_len = 0;
|
||||
|
||||
for index in 0..zip_reader.file().entries().len() {
|
||||
let file = zip_reader.file().entries().get(index).unwrap().entry();
|
||||
let file = zip_reader.file().entries().get(index).unwrap();
|
||||
let filename = file.filename().as_str().unwrap_or_default();
|
||||
|
||||
if (file.filename().starts_with("overrides")
|
||||
|| file.filename().starts_with("client_overrides"))
|
||||
&& !file.filename().ends_with('/')
|
||||
if (filename.starts_with("overrides")
|
||||
|| filename.starts_with("client-overrides"))
|
||||
&& !filename.ends_with('/')
|
||||
{
|
||||
total_len += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for index in 0..zip_reader.file().entries().len() {
|
||||
let file = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.get(index)
|
||||
.unwrap()
|
||||
.entry()
|
||||
.clone();
|
||||
let file = zip_reader.file().entries().get(index).unwrap();
|
||||
|
||||
let file_path = PathBuf::from(file.filename());
|
||||
if (file.filename().starts_with("overrides")
|
||||
|| file.filename().starts_with("client_overrides"))
|
||||
&& !file.filename().ends_with('/')
|
||||
let filename = file.filename().as_str().unwrap_or_default();
|
||||
|
||||
let file_path = PathBuf::from(filename);
|
||||
if (filename.starts_with("overrides")
|
||||
|| filename.starts_with("client-overrides"))
|
||||
&& !filename.ends_with('/')
|
||||
{
|
||||
// Reads the file into the 'content' variable
|
||||
let mut content = Vec::new();
|
||||
let mut reader = zip_reader.entry(index).await?;
|
||||
reader.read_to_end_checked(&mut content, &file).await?;
|
||||
let mut reader = zip_reader.reader_with_entry(index).await?;
|
||||
reader.read_to_end_checked(&mut content).await?;
|
||||
|
||||
let mut new_path = PathBuf::new();
|
||||
let components = file_path.components().skip(1);
|
||||
@@ -310,29 +299,22 @@ pub async fn remove_all_related_files(
|
||||
let reader: Cursor<&bytes::Bytes> = Cursor::new(&mrpack_file);
|
||||
|
||||
// Create zip reader around file
|
||||
let mut zip_reader = ZipFileReader::new(reader).await.map_err(|_| {
|
||||
crate::Error::from(crate::ErrorKind::InputError(
|
||||
"Failed to read input modpack zip".to_string(),
|
||||
))
|
||||
})?;
|
||||
let mut zip_reader =
|
||||
ZipFileReader::with_tokio(reader).await.map_err(|_| {
|
||||
crate::Error::from(crate::ErrorKind::InputError(
|
||||
"Failed to read input modpack zip".to_string(),
|
||||
))
|
||||
})?;
|
||||
|
||||
// Extract index of modrinth.index.json
|
||||
let zip_index_option = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|f| f.entry().filename() == "modrinth.index.json");
|
||||
let zip_index_option = zip_reader.file().entries().iter().position(|f| {
|
||||
f.filename().as_str().unwrap_or_default() == "modrinth.index.json"
|
||||
});
|
||||
if let Some(zip_index) = zip_index_option {
|
||||
let mut manifest = String::new();
|
||||
let entry = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.get(zip_index)
|
||||
.unwrap()
|
||||
.entry()
|
||||
.clone();
|
||||
let mut reader = zip_reader.entry(zip_index).await?;
|
||||
reader.read_to_string_checked(&mut manifest, &entry).await?;
|
||||
|
||||
let mut reader = zip_reader.reader_with_entry(zip_index).await?;
|
||||
reader.read_to_string_checked(&mut manifest).await?;
|
||||
|
||||
let pack: PackFormat = serde_json::from_str(&manifest)?;
|
||||
|
||||
@@ -415,18 +397,14 @@ pub async fn remove_all_related_files(
|
||||
|
||||
// Iterate over each 'overrides' file and remove it
|
||||
for index in 0..zip_reader.file().entries().len() {
|
||||
let file = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.get(index)
|
||||
.unwrap()
|
||||
.entry()
|
||||
.clone();
|
||||
let file = zip_reader.file().entries().get(index).unwrap();
|
||||
|
||||
let file_path = PathBuf::from(file.filename());
|
||||
if (file.filename().starts_with("overrides")
|
||||
|| file.filename().starts_with("client_overrides"))
|
||||
&& !file.filename().ends_with('/')
|
||||
let filename = file.filename().as_str().unwrap_or_default();
|
||||
|
||||
let file_path = PathBuf::from(filename);
|
||||
if (filename.starts_with("overrides")
|
||||
|| filename.starts_with("client-overrides"))
|
||||
&& !filename.ends_with('/')
|
||||
{
|
||||
let mut new_path = PathBuf::new();
|
||||
let components = file_path.components().skip(1);
|
||||
|
||||
@@ -8,12 +8,13 @@ use crate::pack::install_from::{
|
||||
EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
|
||||
};
|
||||
use crate::prelude::{JavaVersion, ProfilePathId, ProjectPathId};
|
||||
use crate::state::{InnerProjectPathUnix, ProjectMetadata, SideType};
|
||||
use crate::state::{
|
||||
Credentials, InnerProjectPathUnix, ProjectMetadata, SideType,
|
||||
};
|
||||
|
||||
use crate::util::fetch;
|
||||
use crate::util::io::{self, IOError};
|
||||
use crate::{
|
||||
auth::{self, refresh},
|
||||
event::{emit::emit_profile, ProfilePayloadType},
|
||||
state::MinecraftChild,
|
||||
};
|
||||
@@ -608,7 +609,7 @@ pub async fn export_mrpack(
|
||||
let mut file = File::create(&export_path)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &export_path))?;
|
||||
let mut writer = ZipFileWriter::new(&mut file);
|
||||
let mut writer = ZipFileWriter::with_tokio(&mut file);
|
||||
|
||||
// Create mrpack json configuration file
|
||||
let version_id = version_id.unwrap_or("1.0.0".to_string());
|
||||
@@ -660,7 +661,7 @@ pub async fn export_mrpack(
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &path))?;
|
||||
let builder = ZipEntryBuilder::new(
|
||||
format!("overrides/{relative_path}"),
|
||||
format!("overrides/{relative_path}").into(),
|
||||
Compression::Deflate,
|
||||
);
|
||||
writer.write_entry_whole(builder, &data).await?;
|
||||
@@ -670,7 +671,7 @@ pub async fn export_mrpack(
|
||||
// Add modrinth json to the zip
|
||||
let data = serde_json::to_vec_pretty(&packfile)?;
|
||||
let builder = ZipEntryBuilder::new(
|
||||
"modrinth.index.json".to_string(),
|
||||
"modrinth.index.json".to_string().into(),
|
||||
Compression::Deflate,
|
||||
);
|
||||
writer.write_entry_whole(builder, &data).await?;
|
||||
@@ -745,20 +746,16 @@ pub async fn run(
|
||||
let state = State::get().await?;
|
||||
|
||||
// Get default account and refresh credentials (preferred way to log in)
|
||||
let default_account = state.settings.read().await.default_user;
|
||||
let credentials = if let Some(default_account) = default_account {
|
||||
refresh(default_account).await?
|
||||
} else {
|
||||
// If no default account, try to use a logged in account
|
||||
let users = auth::users().await?;
|
||||
let last_account = users.first();
|
||||
if let Some(last_account) = last_account {
|
||||
refresh(last_account.id).await?
|
||||
} else {
|
||||
return Err(crate::ErrorKind::NoCredentialsError.as_error());
|
||||
}
|
||||
let default_account = {
|
||||
let mut write = state.users.write().await;
|
||||
|
||||
write
|
||||
.get_default_credential()
|
||||
.await?
|
||||
.ok_or_else(|| crate::ErrorKind::NoCredentialsError.as_error())?
|
||||
};
|
||||
run_credentials(path, &credentials).await
|
||||
|
||||
run_credentials(path, &default_account).await
|
||||
}
|
||||
|
||||
/// Run Minecraft using a profile, and credentials for authentication
|
||||
@@ -767,7 +764,7 @@ pub async fn run(
|
||||
#[theseus_macros::debug_pin]
|
||||
pub async fn run_credentials(
|
||||
path: &ProfilePathId,
|
||||
credentials: &auth::Credentials,
|
||||
credentials: &Credentials,
|
||||
) -> crate::Result<Arc<RwLock<MinecraftChild>>> {
|
||||
let state = State::get().await?;
|
||||
let settings = state.settings.read().await;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Theseus profile management interface
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
|
||||
use io::IOError;
|
||||
@@ -10,7 +10,7 @@ use crate::{
|
||||
event::emit::{emit_loading, init_loading},
|
||||
prelude::DirectoryInfo,
|
||||
state::{self, Profiles},
|
||||
util::io,
|
||||
util::{fetch, io},
|
||||
};
|
||||
pub use crate::{
|
||||
state::{
|
||||
@@ -77,6 +77,7 @@ pub async fn set(settings: Settings) -> crate::Result<()> {
|
||||
/// Sets the new config dir, the location of all Theseus data except for the settings.json and caches
|
||||
/// Takes control of the entire state and blocks until completion
|
||||
pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
|
||||
tracing::trace!("Changing config dir to: {}", new_config_dir.display());
|
||||
if !new_config_dir.is_dir() {
|
||||
return Err(crate::ErrorKind::FSError(format!(
|
||||
"New config dir is not a folder: {}",
|
||||
@@ -85,6 +86,14 @@ pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
|
||||
.as_error());
|
||||
}
|
||||
|
||||
if !is_dir_writeable(new_config_dir.clone()).await? {
|
||||
return Err(crate::ErrorKind::FSError(format!(
|
||||
"New config dir is not writeable: {}",
|
||||
new_config_dir.display()
|
||||
))
|
||||
.as_error());
|
||||
}
|
||||
|
||||
let loading_bar = init_loading(
|
||||
crate::LoadingBarType::ConfigChange {
|
||||
new_path: new_config_dir.clone(),
|
||||
@@ -100,6 +109,54 @@ pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
|
||||
let old_config_dir =
|
||||
state_write.directories.config_dir.read().await.clone();
|
||||
|
||||
// Reset file watcher
|
||||
tracing::trace!("Reset file watcher");
|
||||
let file_watcher = state::init_watcher().await?;
|
||||
state_write.file_watcher = RwLock::new(file_watcher);
|
||||
|
||||
// Getting files to be moved
|
||||
let mut config_entries = io::read_dir(&old_config_dir).await?;
|
||||
let across_drives = is_different_drive(&old_config_dir, &new_config_dir);
|
||||
let mut entries = vec![];
|
||||
let mut deletable_entries = vec![];
|
||||
while let Some(entry) = config_entries
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &old_config_dir))?
|
||||
{
|
||||
let entry_path = entry.path();
|
||||
if let Some(file_name) = entry_path.file_name() {
|
||||
// We are only moving the profiles and metadata folders
|
||||
if file_name == state::PROFILES_FOLDER_NAME
|
||||
|| file_name == state::METADATA_FOLDER_NAME
|
||||
{
|
||||
if across_drives {
|
||||
entries.extend(
|
||||
crate::pack::import::get_all_subfiles(&entry_path)
|
||||
.await?,
|
||||
);
|
||||
deletable_entries.push(entry_path.clone());
|
||||
} else {
|
||||
entries.push(entry_path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::trace!("Moving files");
|
||||
let semaphore = &state_write.io_semaphore;
|
||||
let num_entries = entries.len() as f64;
|
||||
for entry_path in entries {
|
||||
let relative_path = entry_path.strip_prefix(&old_config_dir)?;
|
||||
let new_path = new_config_dir.join(relative_path);
|
||||
if across_drives {
|
||||
fetch::copy(&entry_path, &new_path, semaphore).await?;
|
||||
} else {
|
||||
io::rename(entry_path.clone(), new_path.clone()).await?;
|
||||
}
|
||||
emit_loading(&loading_bar, 80.0 * (1.0 / num_entries), None).await?;
|
||||
}
|
||||
|
||||
tracing::trace!("Setting configuration setting");
|
||||
// Set load config dir setting
|
||||
let settings = {
|
||||
@@ -132,41 +189,20 @@ pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
|
||||
tracing::trace!("Reinitializing directory");
|
||||
// Set new state information
|
||||
state_write.directories = DirectoryInfo::init(&settings)?;
|
||||
let total_entries = std::fs::read_dir(&old_config_dir)
|
||||
.map_err(|e| IOError::with_path(e, &old_config_dir))?
|
||||
.count() as f64;
|
||||
|
||||
// Move all files over from state_write.directories.config_dir to new_config_dir
|
||||
tracing::trace!("Renaming folder structure");
|
||||
let mut i = 0.0;
|
||||
let mut entries = io::read_dir(&old_config_dir).await?;
|
||||
while let Some(entry) = entries
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &old_config_dir))?
|
||||
{
|
||||
let entry_path = entry.path();
|
||||
if let Some(file_name) = entry_path.file_name() {
|
||||
// Ignore settings.json
|
||||
if file_name == state::SETTINGS_FILE_NAME {
|
||||
continue;
|
||||
}
|
||||
// Ignore caches folder
|
||||
if file_name == state::CACHES_FOLDER_NAME {
|
||||
continue;
|
||||
}
|
||||
// Ignore modrinth_logs folder
|
||||
if file_name == state::LAUNCHER_LOGS_FOLDER_NAME {
|
||||
continue;
|
||||
}
|
||||
|
||||
let new_path = new_config_dir.join(file_name);
|
||||
io::rename(entry_path, new_path).await?;
|
||||
|
||||
i += 1.0;
|
||||
emit_loading(&loading_bar, 90.0 * (i / total_entries), None)
|
||||
.await?;
|
||||
}
|
||||
// Delete entries that were from a different drive
|
||||
let deletable_entries_len = deletable_entries.len();
|
||||
if deletable_entries_len > 0 {
|
||||
tracing::trace!("Deleting old files");
|
||||
}
|
||||
for entry in deletable_entries {
|
||||
io::remove_dir_all(entry).await?;
|
||||
emit_loading(
|
||||
&loading_bar,
|
||||
10.0 * (1.0 / deletable_entries_len as f64),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Reset file watcher
|
||||
@@ -181,15 +217,20 @@ pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
|
||||
|
||||
emit_loading(&loading_bar, 10.0, None).await?;
|
||||
|
||||
// TODO: need to be able to safely error out of this function, reverting the changes
|
||||
tracing::info!(
|
||||
"Successfully switched config folder to: {}",
|
||||
new_config_dir.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Function to check if two paths are on different drives/roots
|
||||
fn is_different_drive(path1: &Path, path2: &Path) -> bool {
|
||||
let root1 = path1.components().next();
|
||||
let root2 = path2.components().next();
|
||||
root1 != root2
|
||||
}
|
||||
|
||||
pub async fn is_dir_writeable(new_config_dir: PathBuf) -> crate::Result<bool> {
|
||||
let temp_path = new_config_dir.join(".tmp");
|
||||
match fs::write(temp_path.clone(), "test").await {
|
||||
|
||||
@@ -25,11 +25,10 @@ pub enum ErrorKind {
|
||||
#[error("Metadata error: {0}")]
|
||||
MetadataError(#[from] daedalus::Error),
|
||||
|
||||
#[error("Minecraft authentication Hydra error: {0}")]
|
||||
HydraError(String),
|
||||
|
||||
#[error("Minecraft authentication task error: {0}")]
|
||||
AuthTaskError(#[from] crate::state::AuthTaskError),
|
||||
#[error("Minecraft authentication error: {0}")]
|
||||
MinecraftAuthenticationError(
|
||||
#[from] crate::state::MinecraftAuthenticationError,
|
||||
),
|
||||
|
||||
#[error("I/O error: {0}")]
|
||||
IOError(#[from] util::io::IOError),
|
||||
|
||||
@@ -31,7 +31,7 @@ impl EventState {
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map(Arc::clone)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "tauri"))]
|
||||
@@ -43,7 +43,7 @@ impl EventState {
|
||||
}))
|
||||
})
|
||||
.await
|
||||
.map(Arc::clone)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[cfg(feature = "tauri")]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Minecraft CLI argument logic
|
||||
use super::auth::Credentials;
|
||||
use crate::launcher::parse_rules;
|
||||
use crate::state::Credentials;
|
||||
use crate::{
|
||||
state::{MemorySettings, WindowSize},
|
||||
util::{io::IOError, platform::classpath_separator},
|
||||
@@ -148,7 +148,6 @@ pub fn get_jvm_arguments(
|
||||
parsed_arguments.push(arg);
|
||||
}
|
||||
}
|
||||
parsed_arguments.push("-Dorg.lwjgl.util.Debug=true".to_string());
|
||||
|
||||
Ok(parsed_arguments)
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
//! Authentication flow based on Hydra
|
||||
|
||||
use crate::hydra;
|
||||
use crate::util::fetch::FetchSemaphore;
|
||||
|
||||
use chrono::{prelude::*, Duration};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::api::hydra::stages::{bearer_token, xbl_signin, xsts_token};
|
||||
|
||||
// Login information
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Credentials {
|
||||
pub id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub expires: DateTime<Utc>,
|
||||
_ctor_scope: std::marker::PhantomData<()>,
|
||||
}
|
||||
|
||||
impl Credentials {
|
||||
pub fn new(
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
expires: DateTime<Utc>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
username,
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires,
|
||||
_ctor_scope: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expires < Utc::now()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_credentials(
|
||||
credentials: &mut Credentials,
|
||||
_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<()> {
|
||||
let oauth =
|
||||
hydra::refresh::refresh(credentials.refresh_token.clone()).await?;
|
||||
|
||||
let xbl_token = xbl_signin::login_xbl(&oauth.access_token).await?;
|
||||
|
||||
// Get xsts token from xbl token
|
||||
let xsts_response = xsts_token::fetch_token(&xbl_token.token).await?;
|
||||
|
||||
match xsts_response {
|
||||
xsts_token::XSTSResponse::Unauthorized(err) => {
|
||||
return Err(crate::ErrorKind::HydraError(format!(
|
||||
"Error getting XBox Live token: {}",
|
||||
err
|
||||
))
|
||||
.as_error())
|
||||
}
|
||||
xsts_token::XSTSResponse::Success { token: xsts_token } => {
|
||||
let (bearer_token, expires_in) =
|
||||
bearer_token::fetch_bearer(&xsts_token, &xbl_token.uhs)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
crate::ErrorKind::HydraError(format!(
|
||||
"Error getting bearer token: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
|
||||
credentials.access_token = bearer_token;
|
||||
credentials.refresh_token = oauth.refresh_token;
|
||||
credentials.expires = Utc::now() + Duration::seconds(expires_in);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
//! Logic for launching Minecraft
|
||||
use crate::event::emit::{emit_loading, init_or_edit_loading};
|
||||
use crate::event::{LoadingBarId, LoadingBarType};
|
||||
use crate::jre::{self, JAVA_17_KEY, JAVA_18PLUS_KEY, JAVA_8_KEY};
|
||||
use crate::launcher::io::IOError;
|
||||
use crate::prelude::JavaVersion;
|
||||
use crate::state::ProfileInstallStage;
|
||||
use crate::state::{Credentials, ProfileInstallStage};
|
||||
use crate::util::io;
|
||||
use crate::{
|
||||
process,
|
||||
@@ -16,13 +15,12 @@ use daedalus as d;
|
||||
use daedalus::minecraft::{RuleAction, VersionInfo};
|
||||
use st::Profile;
|
||||
use std::collections::HashMap;
|
||||
use std::{process::Stdio, sync::Arc};
|
||||
use std::sync::Arc;
|
||||
use tokio::process::Command;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod args;
|
||||
|
||||
pub mod auth;
|
||||
pub mod download;
|
||||
|
||||
// All nones -> disallowed
|
||||
@@ -119,24 +117,17 @@ pub async fn get_java_version_from_profile(
|
||||
if let Some(java) = profile.java.clone().and_then(|x| x.override_version) {
|
||||
Ok(Some(java))
|
||||
} else {
|
||||
let optimal_keys = match version_info
|
||||
let key = version_info
|
||||
.java_version
|
||||
.as_ref()
|
||||
.map(|it| it.major_version)
|
||||
.unwrap_or(8)
|
||||
{
|
||||
0..=15 => vec![JAVA_8_KEY, JAVA_17_KEY, JAVA_18PLUS_KEY],
|
||||
16..=17 => vec![JAVA_17_KEY, JAVA_18PLUS_KEY],
|
||||
_ => vec![JAVA_18PLUS_KEY],
|
||||
};
|
||||
.unwrap_or(8);
|
||||
|
||||
let state = State::get().await?;
|
||||
let settings = state.settings.read().await;
|
||||
|
||||
for key in optimal_keys {
|
||||
if let Some(java) = settings.java_globals.get(&key.to_string()) {
|
||||
return Ok(Some(java.clone()));
|
||||
}
|
||||
if let Some(java) = settings.java_globals.get(&format!("JAVA_{key}")) {
|
||||
return Ok(Some(java.clone()));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
@@ -216,24 +207,43 @@ pub async fn install_minecraft(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let java_version = get_java_version_from_profile(profile, &version_info)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::OtherError(
|
||||
"Missing correct java installation".to_string(),
|
||||
)
|
||||
})?;
|
||||
// TODO: check if java exists, if not install it add to install step
|
||||
|
||||
let key = version_info
|
||||
.java_version
|
||||
.as_ref()
|
||||
.map(|it| it.major_version)
|
||||
.unwrap_or(8);
|
||||
let (java_version, set_java) = if let Some(java_version) =
|
||||
get_java_version_from_profile(profile, &version_info).await?
|
||||
{
|
||||
(std::path::PathBuf::from(java_version.path), false)
|
||||
} else {
|
||||
let path = crate::api::jre::auto_install_java(key).await?;
|
||||
|
||||
(path, true)
|
||||
};
|
||||
|
||||
// Test jre version
|
||||
let java_version = jre::check_jre(java_version.path.clone().into())
|
||||
let java_version = crate::api::jre::check_jre(java_version.clone())
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
"Java path invalid or non-functional: {}",
|
||||
java_version.path
|
||||
"Java path invalid or non-functional: {:?}",
|
||||
java_version
|
||||
))
|
||||
})?;
|
||||
|
||||
if set_java {
|
||||
{
|
||||
let mut settings = state.settings.write().await;
|
||||
settings
|
||||
.java_globals
|
||||
.insert(format!("JAVA_{key}"), java_version.clone());
|
||||
}
|
||||
State::sync().await?;
|
||||
}
|
||||
|
||||
// Download minecraft (5-90)
|
||||
download::download_minecraft(
|
||||
&state,
|
||||
@@ -368,7 +378,7 @@ pub async fn launch_minecraft(
|
||||
wrapper: &Option<String>,
|
||||
memory: &st::MemorySettings,
|
||||
resolution: &st::WindowSize,
|
||||
credentials: &auth::Credentials,
|
||||
credentials: &Credentials,
|
||||
post_exit_hook: Option<String>,
|
||||
profile: &Profile,
|
||||
) -> crate::Result<Arc<tokio::sync::RwLock<MinecraftChild>>> {
|
||||
@@ -435,14 +445,15 @@ pub async fn launch_minecraft(
|
||||
})?;
|
||||
|
||||
// Test jre version
|
||||
let java_version = jre::check_jre(java_version.path.clone().into())
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
"Java path invalid or non-functional: {}",
|
||||
java_version.path
|
||||
))
|
||||
})?;
|
||||
let java_version =
|
||||
crate::api::jre::check_jre(java_version.path.clone().into())
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
"Java path invalid or non-functional: {}",
|
||||
java_version.path
|
||||
))
|
||||
})?;
|
||||
|
||||
let client_path = state
|
||||
.directories
|
||||
@@ -511,9 +522,7 @@ pub async fn launch_minecraft(
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.current_dir(instance_path.clone())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
.current_dir(instance_path.clone());
|
||||
|
||||
// CARGO-set DYLD_LIBRARY_PATH breaks Minecraft on macOS during testing on playground
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -65,7 +65,7 @@ pub fn start_logger() -> Option<WorkerGuard> {
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false) // disable ANSI escape codes
|
||||
.with_timer(ChronoLocal::rfc3339()),
|
||||
.with_timer(ChronoLocal::rfc_3339()),
|
||||
)
|
||||
.with(filter)
|
||||
.with(tracing_error::ErrorLayer::default());
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
use crate::{
|
||||
hydra::{self, init::DeviceLoginSuccess},
|
||||
launcher::auth::Credentials,
|
||||
};
|
||||
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
// Authentication task
|
||||
// A wrapper over the authentication task that allows it to be called from the frontend
|
||||
// without caching the task handle in the frontend
|
||||
|
||||
pub struct AuthTask(
|
||||
#[allow(clippy::type_complexity)]
|
||||
Option<JoinHandle<crate::Result<Credentials>>>,
|
||||
);
|
||||
|
||||
impl AuthTask {
|
||||
pub fn new() -> AuthTask {
|
||||
AuthTask(None)
|
||||
}
|
||||
|
||||
pub async fn begin_auth() -> crate::Result<DeviceLoginSuccess> {
|
||||
let state = crate::State::get().await?;
|
||||
// Init task, get url
|
||||
let login = hydra::init::init().await?;
|
||||
|
||||
// Await completion
|
||||
let task = tokio::spawn(hydra::complete::wait_finish(
|
||||
login.device_code.clone(),
|
||||
));
|
||||
|
||||
// Flow is going, store in state and return
|
||||
let mut write = state.auth_flow.write().await;
|
||||
write.0 = Some(task);
|
||||
|
||||
Ok(login)
|
||||
}
|
||||
|
||||
pub async fn await_auth_completion() -> crate::Result<Credentials> {
|
||||
// Gets the task handle from the state, replacing with None
|
||||
let task = {
|
||||
let state = crate::State::get().await?;
|
||||
let mut write = state.auth_flow.write().await;
|
||||
|
||||
write.0.take()
|
||||
};
|
||||
|
||||
// Waits for the task to complete, and returns the credentials
|
||||
let credentials = task
|
||||
.ok_or(AuthTaskError::TaskMissing)?
|
||||
.await
|
||||
.map_err(AuthTaskError::from)??;
|
||||
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
pub async fn cancel() -> crate::Result<()> {
|
||||
// Gets the task handle from the state, replacing with None
|
||||
let task = {
|
||||
let state = crate::State::get().await?;
|
||||
let mut write = state.auth_flow.write().await;
|
||||
|
||||
write.0.take()
|
||||
};
|
||||
if let Some(task) = task {
|
||||
// Cancels the task
|
||||
task.abort();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AuthTask {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum AuthTaskError {
|
||||
#[error("Authentication task was aborted or missing")]
|
||||
TaskMissing,
|
||||
#[error("Join handle error")]
|
||||
JoinHandleError(#[from] tokio::task::JoinError),
|
||||
}
|
||||
@@ -1,28 +1,17 @@
|
||||
use super::DirectoryInfo;
|
||||
use super::{Profile, ProfilePathId};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use sysinfo::PidExt;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::process::Child;
|
||||
use tokio::process::ChildStderr;
|
||||
use tokio::process::ChildStdout;
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::error;
|
||||
|
||||
use crate::event::emit::emit_process;
|
||||
use crate::event::ProcessPayloadType;
|
||||
use crate::util::fetch::read_json;
|
||||
use crate::util::io::IOError;
|
||||
use crate::{profile, ErrorKind};
|
||||
use sysinfo::{ProcessExt, SystemExt};
|
||||
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
@@ -126,7 +115,16 @@ impl ChildType {
|
||||
})?;
|
||||
let start_time = process.start_time();
|
||||
let name = process.name().to_string();
|
||||
let exe = process.exe().to_string_lossy().to_string();
|
||||
|
||||
let Some(path) = process.exe() else {
|
||||
return Err(ErrorKind::LauncherError(format!(
|
||||
"Cached process {} has no accessable path",
|
||||
pid
|
||||
))
|
||||
.into());
|
||||
};
|
||||
|
||||
let exe = path.to_string_lossy().to_string();
|
||||
|
||||
let cached_process = ProcessCache {
|
||||
pid,
|
||||
@@ -201,7 +199,6 @@ impl ChildType {
|
||||
pub struct MinecraftChild {
|
||||
pub uuid: Uuid,
|
||||
pub profile_relative_path: ProfilePathId,
|
||||
pub output: Option<SharedOutput>,
|
||||
pub manager: Option<JoinHandle<crate::Result<i32>>>, // None when future has completed and been handled
|
||||
pub current_child: Arc<RwLock<ChildType>>,
|
||||
pub last_updated_playtime: DateTime<Utc>, // The last time we updated the playtime for the associated profile
|
||||
@@ -281,44 +278,9 @@ impl Children {
|
||||
censor_strings: HashMap<String, String>,
|
||||
) -> crate::Result<Arc<RwLock<MinecraftChild>>> {
|
||||
// Takes the first element of the commands vector and spawns it
|
||||
let mut child = mc_command.spawn().map_err(IOError::from)?;
|
||||
let mc_proc = mc_command.spawn().map_err(IOError::from)?;
|
||||
|
||||
// Create std watcher threads for stdout and stderr
|
||||
let log_path = DirectoryInfo::profile_logs_dir(&profile_relative_path)
|
||||
.await?
|
||||
.join("latest_stdout.log");
|
||||
let shared_output =
|
||||
SharedOutput::build(&log_path, censor_strings).await?;
|
||||
if let Some(child_stdout) = child.stdout.take() {
|
||||
let stdout_clone = shared_output.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = stdout_clone.read_stdout(child_stdout).await {
|
||||
error!("Stdout process died with error: {}", e);
|
||||
let _ = stdout_clone
|
||||
.push_line(format!(
|
||||
"Stdout process died with error: {}",
|
||||
e
|
||||
))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(child_stderr) = child.stderr.take() {
|
||||
let stderr_clone = shared_output.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = stderr_clone.read_stderr(child_stderr).await {
|
||||
error!("Stderr process died with error: {}", e);
|
||||
let _ = stderr_clone
|
||||
.push_line(format!(
|
||||
"Stderr process died with error: {}",
|
||||
e
|
||||
))
|
||||
.await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let child = ChildType::TokioChild(child);
|
||||
let child = ChildType::TokioChild(mc_proc);
|
||||
|
||||
// Slots child into manager
|
||||
let pid = child.id().ok_or_else(|| {
|
||||
@@ -358,7 +320,6 @@ impl Children {
|
||||
let mchild = MinecraftChild {
|
||||
uuid,
|
||||
profile_relative_path,
|
||||
output: Some(shared_output),
|
||||
current_child,
|
||||
manager,
|
||||
last_updated_playtime,
|
||||
@@ -403,8 +364,16 @@ impl Children {
|
||||
if cached_process.name != process.name() {
|
||||
return Err(ErrorKind::LauncherError(format!("Cached process {} has different name than actual process {}", cached_process.pid, process.name())).into());
|
||||
}
|
||||
if cached_process.exe != process.exe().to_string_lossy() {
|
||||
return Err(ErrorKind::LauncherError(format!("Cached process {} has different exe than actual process {}", cached_process.pid, process.exe().to_string_lossy())).into());
|
||||
if let Some(path) = process.exe() {
|
||||
if cached_process.exe != path.to_string_lossy() {
|
||||
return Err(ErrorKind::LauncherError(format!("Cached process {} has different exe than actual process {}", cached_process.pid, path.to_string_lossy())).into());
|
||||
}
|
||||
} else {
|
||||
return Err(ErrorKind::LauncherError(format!(
|
||||
"Cached process {} has no accessable path",
|
||||
cached_process.pid
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,7 +418,6 @@ impl Children {
|
||||
let mchild = MinecraftChild {
|
||||
uuid: cached_process.uuid,
|
||||
profile_relative_path: cached_process.profile_relative_path,
|
||||
output: None, // No output for cached/rescued processes
|
||||
current_child,
|
||||
manager,
|
||||
last_updated_playtime,
|
||||
@@ -758,117 +726,3 @@ impl Default for Children {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// SharedOutput, a wrapper around a String that can be read from and written to concurrently
|
||||
// Designed to be used with ChildStdout and ChildStderr in a tokio thread to have a simple String storage for the output of a child process
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SharedOutput {
|
||||
log_file: Arc<RwLock<File>>,
|
||||
censor_strings: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl SharedOutput {
|
||||
#[tracing::instrument(skip(censor_strings))]
|
||||
async fn build(
|
||||
log_file_path: &Path,
|
||||
censor_strings: HashMap<String, String>,
|
||||
) -> crate::Result<Self> {
|
||||
// create log_file_path parent if it doesn't exist
|
||||
let parent_folder = log_file_path.parent().ok_or_else(|| {
|
||||
crate::ErrorKind::LauncherError(format!(
|
||||
"Could not get parent folder of {:?}",
|
||||
log_file_path
|
||||
))
|
||||
})?;
|
||||
tokio::fs::create_dir_all(parent_folder)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, parent_folder))?;
|
||||
|
||||
Ok(SharedOutput {
|
||||
log_file: Arc::new(RwLock::new(
|
||||
File::create(log_file_path)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, log_file_path))?,
|
||||
)),
|
||||
censor_strings,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_stdout(
|
||||
&self,
|
||||
child_stdout: ChildStdout,
|
||||
) -> crate::Result<()> {
|
||||
let mut buf_reader = BufReader::new(child_stdout);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
while buf_reader
|
||||
.read_until(b'\n', &mut buf)
|
||||
.await
|
||||
.map_err(IOError::from)?
|
||||
> 0
|
||||
{
|
||||
let line = String::from_utf8_lossy(&buf).into_owned();
|
||||
let val_line = self.censor_log(line.clone());
|
||||
{
|
||||
let mut log_file = self.log_file.write().await;
|
||||
log_file
|
||||
.write_all(val_line.as_bytes())
|
||||
.await
|
||||
.map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_stderr(
|
||||
&self,
|
||||
child_stderr: ChildStderr,
|
||||
) -> crate::Result<()> {
|
||||
let mut buf_reader = BufReader::new(child_stderr);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// TODO: these can be asbtracted into noe function
|
||||
while buf_reader
|
||||
.read_until(b'\n', &mut buf)
|
||||
.await
|
||||
.map_err(IOError::from)?
|
||||
> 0
|
||||
{
|
||||
let line = String::from_utf8_lossy(&buf).into_owned();
|
||||
let val_line = self.censor_log(line.clone());
|
||||
{
|
||||
let mut log_file = self.log_file.write().await;
|
||||
log_file
|
||||
.write_all(val_line.as_bytes())
|
||||
.await
|
||||
.map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
buf.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn push_line(&self, line: String) -> crate::Result<()> {
|
||||
let val_line = self.censor_log(line.clone());
|
||||
{
|
||||
let mut log_file = self.log_file.write().await;
|
||||
log_file
|
||||
.write_all(val_line.as_bytes())
|
||||
.await
|
||||
.map_err(IOError::from)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn censor_log(&self, mut val: String) -> String {
|
||||
for (find, replace) in &self.censor_strings {
|
||||
val = val.replace(find, replace);
|
||||
}
|
||||
|
||||
val
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ use super::{ProfilePathId, Settings};
|
||||
pub const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
pub const CACHES_FOLDER_NAME: &str = "caches";
|
||||
pub const LAUNCHER_LOGS_FOLDER_NAME: &str = "launcher_logs";
|
||||
pub const PROFILES_FOLDER_NAME: &str = "profiles";
|
||||
pub const METADATA_FOLDER_NAME: &str = "meta";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DirectoryInfo {
|
||||
@@ -75,7 +77,7 @@ impl DirectoryInfo {
|
||||
/// Get the Minecraft instance metadata directory
|
||||
#[inline]
|
||||
pub async fn metadata_dir(&self) -> PathBuf {
|
||||
self.config_dir.read().await.join("meta")
|
||||
self.config_dir.read().await.join(METADATA_FOLDER_NAME)
|
||||
}
|
||||
|
||||
/// Get the Minecraft java versions metadata directory
|
||||
@@ -153,7 +155,7 @@ impl DirectoryInfo {
|
||||
/// Get the profiles directory for created profiles
|
||||
#[inline]
|
||||
pub async fn profiles_dir(&self) -> PathBuf {
|
||||
self.config_dir.read().await.join("profiles")
|
||||
self.config_dir.read().await.join(PROFILES_FOLDER_NAME)
|
||||
}
|
||||
|
||||
/// Gets the logs dir for a given profile
|
||||
|
||||
878
theseus/src/state/minecraft_auth.rs
Normal file
878
theseus/src/state/minecraft_auth.rs
Normal file
@@ -0,0 +1,878 @@
|
||||
use crate::data::DirectoryInfo;
|
||||
use crate::util::fetch::{read_json, write, IoSemaphore, REQWEST_CLIENT};
|
||||
use crate::State;
|
||||
use base64::prelude::{BASE64_STANDARD, BASE64_URL_SAFE_NO_PAD};
|
||||
use base64::Engine;
|
||||
use byteorder::BigEndian;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use p256::ecdsa::signature::Signer;
|
||||
use p256::ecdsa::{Signature, SigningKey, VerifyingKey};
|
||||
use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding};
|
||||
use rand::rngs::OsRng;
|
||||
use rand::Rng;
|
||||
use reqwest::header::HeaderMap;
|
||||
use reqwest::Response;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum MinecraftAuthStep {
|
||||
GetDeviceToken,
|
||||
SisuAuthenicate,
|
||||
GetOAuthToken,
|
||||
RefreshOAuthToken,
|
||||
SisuAuthorize,
|
||||
XstsAuthorize,
|
||||
MinecraftToken,
|
||||
MinecraftEntitlements,
|
||||
MinecraftProfile,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum MinecraftAuthenticationError {
|
||||
#[error("Failed to serialize private key to PEM: {0}")]
|
||||
PEMSerialize(#[from] p256::pkcs8::Error),
|
||||
#[error("Failed to serialize body to JSON during step {step:?}: {source}")]
|
||||
SerializeBody {
|
||||
step: MinecraftAuthStep,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error(
|
||||
"Failed to deserialize response to JSON during step {step:?}: {source}"
|
||||
)]
|
||||
DeserializeResponse {
|
||||
step: MinecraftAuthStep,
|
||||
raw: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("Request failed during step {step:?}: {source}")]
|
||||
Request {
|
||||
step: MinecraftAuthStep,
|
||||
#[source]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
#[error("Error creating signed request buffer {step:?}: {source}")]
|
||||
ConstructingSignedRequest {
|
||||
step: MinecraftAuthStep,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("Error reading user hash")]
|
||||
NoUserHash,
|
||||
}
|
||||
|
||||
const AUTH_JSON: &str = "minecraft_auth.json";
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct SaveDeviceToken {
|
||||
pub id: String,
|
||||
pub private_key: String,
|
||||
pub x: String,
|
||||
pub y: String,
|
||||
pub token: DeviceToken,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct MinecraftLoginFlow {
|
||||
pub challenge: String,
|
||||
pub session_id: String,
|
||||
pub redirect_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct MinecraftAuthStore {
|
||||
pub users: HashMap<Uuid, Credentials>,
|
||||
pub token: Option<SaveDeviceToken>,
|
||||
pub default_user: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl MinecraftAuthStore {
|
||||
#[tracing::instrument]
|
||||
pub async fn init(
|
||||
dirs: &DirectoryInfo,
|
||||
io_semaphore: &IoSemaphore,
|
||||
) -> crate::Result<Self> {
|
||||
let auth_path = dirs.caches_meta_dir().await.join(AUTH_JSON);
|
||||
let store = read_json(&auth_path, io_semaphore).await.ok();
|
||||
|
||||
if let Some(store) = store {
|
||||
Ok(store)
|
||||
} else {
|
||||
Ok(Self {
|
||||
users: HashMap::new(),
|
||||
token: None,
|
||||
default_user: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn save(&self) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let auth_path =
|
||||
state.directories.caches_meta_dir().await.join(AUTH_JSON);
|
||||
|
||||
write(&auth_path, &serde_json::to_vec(&self)?, &state.io_semaphore)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
async fn refresh_and_get_device_token(
|
||||
&mut self,
|
||||
) -> crate::Result<(DeviceTokenKey, DeviceToken)> {
|
||||
macro_rules! generate_key {
|
||||
($self:ident, $generate_key:expr, $device_token:expr, $SaveDeviceToken:path) => {{
|
||||
let key = generate_key()?;
|
||||
let token = device_token(&key).await?;
|
||||
|
||||
self.token = Some(SaveDeviceToken {
|
||||
id: key.id.clone(),
|
||||
private_key: key
|
||||
.key
|
||||
.to_pkcs8_pem(LineEnding::default())
|
||||
.map_err(|err| {
|
||||
MinecraftAuthenticationError::PEMSerialize(err)
|
||||
})?
|
||||
.to_string(),
|
||||
x: key.x.clone(),
|
||||
y: key.y.clone(),
|
||||
token: token.clone(),
|
||||
});
|
||||
self.save().await?;
|
||||
|
||||
(key, token)
|
||||
}};
|
||||
}
|
||||
|
||||
let (key, token) = if let Some(ref token) = self.token {
|
||||
if token.token.not_after > Utc::now() {
|
||||
if let Ok(private_key) =
|
||||
SigningKey::from_pkcs8_pem(&token.private_key)
|
||||
{
|
||||
(
|
||||
DeviceTokenKey {
|
||||
id: token.id.clone(),
|
||||
key: private_key,
|
||||
x: token.x.clone(),
|
||||
y: token.y.clone(),
|
||||
},
|
||||
token.token.clone(),
|
||||
)
|
||||
} else {
|
||||
generate_key!(
|
||||
self,
|
||||
generate_key,
|
||||
device_token,
|
||||
SaveDeviceToken
|
||||
)
|
||||
}
|
||||
} else {
|
||||
generate_key!(self, generate_key, device_token, SaveDeviceToken)
|
||||
}
|
||||
} else {
|
||||
generate_key!(self, generate_key, device_token, SaveDeviceToken)
|
||||
};
|
||||
|
||||
Ok((key, token))
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn login_begin(&mut self) -> crate::Result<MinecraftLoginFlow> {
|
||||
let (key, token) = self.refresh_and_get_device_token().await?;
|
||||
|
||||
let challenge = generate_oauth_challenge();
|
||||
let (session_id, redirect_uri) =
|
||||
sisu_authenticate(&token.token, &challenge, &key).await?;
|
||||
|
||||
Ok(MinecraftLoginFlow {
|
||||
challenge,
|
||||
session_id,
|
||||
redirect_uri: redirect_uri.msa_oauth_redirect,
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn login_finish(
|
||||
&mut self,
|
||||
code: &str,
|
||||
flow: MinecraftLoginFlow,
|
||||
) -> crate::Result<Credentials> {
|
||||
let (key, token) = self.refresh_and_get_device_token().await?;
|
||||
|
||||
let oauth_token = oauth_token(code, &flow.challenge).await?;
|
||||
let sisu_authorize = sisu_authorize(
|
||||
Some(&flow.session_id),
|
||||
&oauth_token.access_token,
|
||||
&token.token,
|
||||
&key,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let xbox_token =
|
||||
xsts_authorize(sisu_authorize, &token.token, &key).await?;
|
||||
let minecraft_token = minecraft_token(xbox_token).await?;
|
||||
|
||||
minecraft_entitlements(&minecraft_token.access_token).await?;
|
||||
|
||||
let profile = minecraft_profile(&minecraft_token.access_token).await?;
|
||||
|
||||
let profile_id = profile.id.unwrap_or_default();
|
||||
|
||||
let credentials = Credentials {
|
||||
id: profile_id,
|
||||
username: profile.name,
|
||||
access_token: minecraft_token.access_token,
|
||||
refresh_token: oauth_token.refresh_token,
|
||||
expires: Utc::now()
|
||||
+ Duration::seconds(oauth_token.expires_in as i64),
|
||||
};
|
||||
|
||||
self.users.insert(profile_id, credentials.clone());
|
||||
|
||||
if self.default_user.is_none() {
|
||||
self.default_user = Some(profile_id);
|
||||
}
|
||||
|
||||
self.save().await?;
|
||||
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn get_default_credential(
|
||||
&mut self,
|
||||
) -> crate::Result<Option<Credentials>> {
|
||||
let credentials = if let Some(default_user) = self.default_user {
|
||||
if let Some(creds) = self.users.get(&default_user) {
|
||||
Some(creds)
|
||||
} else {
|
||||
self.users.values().next()
|
||||
}
|
||||
} else {
|
||||
self.users.values().next()
|
||||
};
|
||||
|
||||
if let Some(creds) = credentials {
|
||||
if self.default_user != Some(creds.id) {
|
||||
self.default_user = Some(creds.id);
|
||||
self.save().await?;
|
||||
}
|
||||
|
||||
if creds.expires < Utc::now() {
|
||||
let cred_id = creds.id;
|
||||
let profile_name = creds.username.clone();
|
||||
|
||||
let oauth_token = oauth_refresh(&creds.refresh_token).await?;
|
||||
let (key, token) = self.refresh_and_get_device_token().await?;
|
||||
|
||||
let sisu_authorize = sisu_authorize(
|
||||
None,
|
||||
&oauth_token.access_token,
|
||||
&token.token,
|
||||
&key,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let xbox_token =
|
||||
xsts_authorize(sisu_authorize, &token.token, &key).await?;
|
||||
|
||||
let minecraft_token = minecraft_token(xbox_token).await?;
|
||||
|
||||
let val = Credentials {
|
||||
id: cred_id,
|
||||
username: profile_name,
|
||||
access_token: minecraft_token.access_token,
|
||||
refresh_token: oauth_token.refresh_token,
|
||||
expires: Utc::now()
|
||||
+ Duration::seconds(oauth_token.expires_in as i64),
|
||||
};
|
||||
|
||||
self.users.insert(val.id, val.clone());
|
||||
self.save().await?;
|
||||
|
||||
Ok(Some(val))
|
||||
} else {
|
||||
Ok(Some(creds.clone()))
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn remove(
|
||||
&mut self,
|
||||
id: Uuid,
|
||||
) -> crate::Result<Option<Credentials>> {
|
||||
let val = self.users.remove(&id);
|
||||
self.save().await?;
|
||||
Ok(val)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Credentials {
|
||||
pub id: Uuid,
|
||||
pub username: String,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub expires: DateTime<Utc>,
|
||||
}
|
||||
|
||||
const MICROSOFT_CLIENT_ID: &str = "00000000402b5328";
|
||||
const REDIRECT_URL: &str = "https://login.live.com/oauth20_desktop.srf";
|
||||
const REQUESTED_SCOPES: &str = "service::user.auth.xboxlive.com::MBI_SSL";
|
||||
|
||||
// flow steps
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub struct DeviceToken {
|
||||
pub issue_instant: DateTime<Utc>,
|
||||
pub not_after: DateTime<Utc>,
|
||||
pub token: String,
|
||||
pub display_claims: HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(key))]
|
||||
pub async fn device_token(
|
||||
key: &DeviceTokenKey,
|
||||
) -> Result<DeviceToken, MinecraftAuthenticationError> {
|
||||
Ok(send_signed_request(
|
||||
None,
|
||||
"https://device.auth.xboxlive.com/device/authenticate",
|
||||
"/device/authenticate",
|
||||
json!({
|
||||
"Properties": {
|
||||
"AuthMethod": "ProofOfPossession",
|
||||
"Id": format!("{{{}}}", key.id),
|
||||
"DeviceType": "Win32",
|
||||
"Version": "10.16.0",
|
||||
"ProofKey": {
|
||||
"kty": "EC",
|
||||
"x": key.x,
|
||||
"y": key.y,
|
||||
"crv": "P-256",
|
||||
"alg": "ES256",
|
||||
"use": "sig"
|
||||
}
|
||||
},
|
||||
"RelyingParty": "http://auth.xboxlive.com",
|
||||
"TokenType": "JWT"
|
||||
|
||||
}),
|
||||
key,
|
||||
MinecraftAuthStep::GetDeviceToken,
|
||||
)
|
||||
.await?
|
||||
.1)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct RedirectUri {
|
||||
pub msa_oauth_redirect: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(key))]
|
||||
async fn sisu_authenticate(
|
||||
token: &str,
|
||||
challenge: &str,
|
||||
key: &DeviceTokenKey,
|
||||
) -> Result<(String, RedirectUri), MinecraftAuthenticationError> {
|
||||
let (headers, res) = send_signed_request(
|
||||
None,
|
||||
"https://sisu.xboxlive.com/authenticate",
|
||||
"/authenticate",
|
||||
json!({
|
||||
"AppId": MICROSOFT_CLIENT_ID,
|
||||
"DeviceToken": token,
|
||||
"Offers": [
|
||||
REQUESTED_SCOPES
|
||||
],
|
||||
"Query": {
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "plain",
|
||||
"state": "",
|
||||
"prompt": "select_account"
|
||||
},
|
||||
"RedirectUri": REDIRECT_URL,
|
||||
"Sandbox": "RETAIL",
|
||||
"TokenType": "code",
|
||||
}),
|
||||
key,
|
||||
MinecraftAuthStep::SisuAuthenicate,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let session_id = headers
|
||||
.get("X-SessionId")
|
||||
.and_then(|x| x.to_str().ok())
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
Ok((session_id, res))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OAuthToken {
|
||||
// pub token_type: String,
|
||||
pub expires_in: u64,
|
||||
// pub scope: String,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
// pub user_id: String,
|
||||
// pub foci: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
async fn oauth_token(
|
||||
code: &str,
|
||||
challenge: &str,
|
||||
) -> Result<OAuthToken, MinecraftAuthenticationError> {
|
||||
let mut query = HashMap::new();
|
||||
query.insert("client_id", "00000000402b5328");
|
||||
query.insert("code", code);
|
||||
query.insert("code_verifier", challenge);
|
||||
query.insert("grant_type", "authorization_code");
|
||||
query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf");
|
||||
query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL");
|
||||
|
||||
let res = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post("https://login.live.com/oauth20_token.srf")
|
||||
.header("Accept", "application/json")
|
||||
.form(&query)
|
||||
.send()
|
||||
})
|
||||
.await
|
||||
.map_err(|source| MinecraftAuthenticationError::Request {
|
||||
source,
|
||||
step: MinecraftAuthStep::GetOAuthToken,
|
||||
})?;
|
||||
|
||||
let text = res.text().await.map_err(|source| {
|
||||
MinecraftAuthenticationError::Request {
|
||||
source,
|
||||
step: MinecraftAuthStep::GetOAuthToken,
|
||||
}
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&text).map_err(|source| {
|
||||
MinecraftAuthenticationError::DeserializeResponse {
|
||||
source,
|
||||
raw: text,
|
||||
step: MinecraftAuthStep::GetOAuthToken,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
async fn oauth_refresh(
|
||||
refresh_token: &str,
|
||||
) -> Result<OAuthToken, MinecraftAuthenticationError> {
|
||||
let mut query = HashMap::new();
|
||||
query.insert("client_id", "00000000402b5328");
|
||||
query.insert("refresh_token", refresh_token);
|
||||
query.insert("grant_type", "refresh_token");
|
||||
query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf");
|
||||
query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL");
|
||||
|
||||
let res = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post("https://login.live.com/oauth20_token.srf")
|
||||
.header("Accept", "application/json")
|
||||
.form(&query)
|
||||
.send()
|
||||
})
|
||||
.await
|
||||
.map_err(|source| MinecraftAuthenticationError::Request {
|
||||
source,
|
||||
step: MinecraftAuthStep::RefreshOAuthToken,
|
||||
})?;
|
||||
|
||||
let text = res.text().await.map_err(|source| {
|
||||
MinecraftAuthenticationError::Request {
|
||||
source,
|
||||
step: MinecraftAuthStep::RefreshOAuthToken,
|
||||
}
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&text).map_err(|source| {
|
||||
MinecraftAuthenticationError::DeserializeResponse {
|
||||
source,
|
||||
raw: text,
|
||||
step: MinecraftAuthStep::RefreshOAuthToken,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct SisuAuthorize {
|
||||
// pub authorization_token: DeviceToken,
|
||||
// pub device_token: String,
|
||||
// pub sandbox: String,
|
||||
pub title_token: DeviceToken,
|
||||
pub user_token: DeviceToken,
|
||||
// pub web_page: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(key))]
|
||||
async fn sisu_authorize(
|
||||
session_id: Option<&str>,
|
||||
access_token: &str,
|
||||
device_token: &str,
|
||||
key: &DeviceTokenKey,
|
||||
) -> Result<SisuAuthorize, MinecraftAuthenticationError> {
|
||||
Ok(send_signed_request(
|
||||
None,
|
||||
"https://sisu.xboxlive.com/authorize",
|
||||
"/authorize",
|
||||
json!({
|
||||
"AccessToken": format!("t={access_token}"),
|
||||
"AppId": "00000000402b5328",
|
||||
"DeviceToken": device_token,
|
||||
"ProofKey": {
|
||||
"kty": "EC",
|
||||
"x": key.x,
|
||||
"y": key.y,
|
||||
"crv": "P-256",
|
||||
"alg": "ES256",
|
||||
"use": "sig"
|
||||
},
|
||||
"Sandbox": "RETAIL",
|
||||
"SessionId": session_id,
|
||||
"SiteName": "user.auth.xboxlive.com",
|
||||
}),
|
||||
key,
|
||||
MinecraftAuthStep::SisuAuthorize,
|
||||
)
|
||||
.await?
|
||||
.1)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(key))]
|
||||
async fn xsts_authorize(
|
||||
authorize: SisuAuthorize,
|
||||
device_token: &str,
|
||||
key: &DeviceTokenKey,
|
||||
) -> Result<DeviceToken, MinecraftAuthenticationError> {
|
||||
Ok(send_signed_request(
|
||||
None,
|
||||
"https://xsts.auth.xboxlive.com/xsts/authorize",
|
||||
"/xsts/authorize",
|
||||
json!({
|
||||
"RelyingParty": "rp://api.minecraftservices.com/",
|
||||
"TokenType": "JWT",
|
||||
"Properties": {
|
||||
"SandboxId": "RETAIL",
|
||||
"UserTokens": [authorize.user_token.token],
|
||||
"DeviceToken": device_token,
|
||||
"TitleToken": authorize.title_token.token,
|
||||
},
|
||||
}),
|
||||
key,
|
||||
MinecraftAuthStep::XstsAuthorize,
|
||||
)
|
||||
.await?
|
||||
.1)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MinecraftToken {
|
||||
// pub username: String,
|
||||
pub access_token: String,
|
||||
// pub token_type: String,
|
||||
// pub expires_in: u64,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
async fn minecraft_token(
|
||||
token: DeviceToken,
|
||||
) -> Result<MinecraftToken, MinecraftAuthenticationError> {
|
||||
let uhs = token
|
||||
.display_claims
|
||||
.get("xui")
|
||||
.and_then(|x| x.get(0))
|
||||
.and_then(|x| x.get("uhs"))
|
||||
.and_then(|x| x.as_str().map(String::from))
|
||||
.ok_or_else(|| MinecraftAuthenticationError::NoUserHash)?;
|
||||
|
||||
let token = token.token;
|
||||
|
||||
let res = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.post("https://api.minecraftservices.com/launcher/login")
|
||||
.header("Accept", "application/json")
|
||||
.json(&json!({
|
||||
"platform": "PC_LAUNCHER",
|
||||
"xtoken": format!("XBL3.0 x={uhs};{token}"),
|
||||
}))
|
||||
.send()
|
||||
})
|
||||
.await
|
||||
.map_err(|source| MinecraftAuthenticationError::Request {
|
||||
source,
|
||||
step: MinecraftAuthStep::MinecraftToken,
|
||||
})?;
|
||||
|
||||
let text = res.text().await.map_err(|source| {
|
||||
MinecraftAuthenticationError::Request {
|
||||
source,
|
||||
step: MinecraftAuthStep::MinecraftToken,
|
||||
}
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&text).map_err(|source| {
|
||||
MinecraftAuthenticationError::DeserializeResponse {
|
||||
source,
|
||||
raw: text,
|
||||
step: MinecraftAuthStep::MinecraftToken,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct MinecraftProfile {
|
||||
pub id: Option<Uuid>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
async fn minecraft_profile(
|
||||
token: &str,
|
||||
) -> Result<MinecraftProfile, MinecraftAuthenticationError> {
|
||||
let res = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.get("https://api.minecraftservices.com/minecraft/profile")
|
||||
.header("Accept", "application/json")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
})
|
||||
.await
|
||||
.map_err(|source| MinecraftAuthenticationError::Request {
|
||||
source,
|
||||
step: MinecraftAuthStep::MinecraftProfile,
|
||||
})?;
|
||||
|
||||
let text = res.text().await.map_err(|source| {
|
||||
MinecraftAuthenticationError::Request {
|
||||
source,
|
||||
step: MinecraftAuthStep::MinecraftProfile,
|
||||
}
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&text).map_err(|source| {
|
||||
MinecraftAuthenticationError::DeserializeResponse {
|
||||
source,
|
||||
raw: text,
|
||||
step: MinecraftAuthStep::MinecraftProfile,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct MinecraftEntitlements {}
|
||||
|
||||
#[tracing::instrument]
|
||||
async fn minecraft_entitlements(
|
||||
token: &str,
|
||||
) -> Result<MinecraftEntitlements, MinecraftAuthenticationError> {
|
||||
let res = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
.get(format!("https://api.minecraftservices.com/entitlements/license?requestId={}", Uuid::new_v4()))
|
||||
.header("Accept", "application/json")
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
})
|
||||
.await.map_err(|source| MinecraftAuthenticationError::Request { source, step: MinecraftAuthStep::MinecraftEntitlements })?;
|
||||
|
||||
let text = res.text().await.map_err(|source| {
|
||||
MinecraftAuthenticationError::Request {
|
||||
source,
|
||||
step: MinecraftAuthStep::MinecraftEntitlements,
|
||||
}
|
||||
})?;
|
||||
|
||||
serde_json::from_str(&text).map_err(|source| {
|
||||
MinecraftAuthenticationError::DeserializeResponse {
|
||||
source,
|
||||
raw: text,
|
||||
step: MinecraftAuthStep::MinecraftEntitlements,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// auth utils
|
||||
#[tracing::instrument(skip(reqwest_request))]
|
||||
async fn auth_retry<F>(
|
||||
reqwest_request: impl Fn() -> F,
|
||||
) -> Result<reqwest::Response, reqwest::Error>
|
||||
where
|
||||
F: Future<Output = Result<Response, reqwest::Error>>,
|
||||
{
|
||||
const RETRY_COUNT: usize = 9; // Does command 9 times
|
||||
const RETRY_WAIT: std::time::Duration =
|
||||
std::time::Duration::from_millis(250);
|
||||
|
||||
let mut resp = reqwest_request().await?;
|
||||
for i in 0..RETRY_COUNT {
|
||||
if resp.status().is_success() {
|
||||
break;
|
||||
}
|
||||
tracing::debug!(
|
||||
"Request failed with status code {}, retrying...",
|
||||
resp.status()
|
||||
);
|
||||
if i < RETRY_COUNT - 1 {
|
||||
tokio::time::sleep(RETRY_WAIT).await;
|
||||
}
|
||||
resp = reqwest_request().await?;
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
pub struct DeviceTokenKey {
|
||||
pub id: String,
|
||||
pub key: SigningKey,
|
||||
pub x: String,
|
||||
pub y: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
fn generate_key() -> Result<DeviceTokenKey, MinecraftAuthenticationError> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
|
||||
let signing_key = SigningKey::random(&mut OsRng);
|
||||
let public_key = VerifyingKey::from(&signing_key);
|
||||
|
||||
let encoded_point = public_key.to_encoded_point(false);
|
||||
|
||||
Ok(DeviceTokenKey {
|
||||
id,
|
||||
key: signing_key,
|
||||
x: BASE64_URL_SAFE_NO_PAD.encode(encoded_point.x().unwrap()),
|
||||
y: BASE64_URL_SAFE_NO_PAD.encode(encoded_point.y().unwrap()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(key))]
|
||||
async fn send_signed_request<T: DeserializeOwned>(
|
||||
authorization: Option<&str>,
|
||||
url: &str,
|
||||
url_path: &str,
|
||||
raw_body: serde_json::Value,
|
||||
key: &DeviceTokenKey,
|
||||
step: MinecraftAuthStep,
|
||||
) -> Result<(HeaderMap, T), MinecraftAuthenticationError> {
|
||||
let auth = authorization.map_or(Vec::new(), |v| v.as_bytes().to_vec());
|
||||
|
||||
let body = serde_json::to_vec(&raw_body).map_err(|source| {
|
||||
MinecraftAuthenticationError::SerializeBody { source, step }
|
||||
})?;
|
||||
let time: u128 =
|
||||
{ ((Utc::now().timestamp() as u128) + 11644473600) * 10000000 };
|
||||
|
||||
use byteorder::WriteBytesExt;
|
||||
let mut buffer = Vec::new();
|
||||
buffer.write_u32::<BigEndian>(1).map_err(|source| {
|
||||
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
|
||||
})?;
|
||||
buffer.write_u8(0).map_err(|source| {
|
||||
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
|
||||
})?;
|
||||
buffer
|
||||
.write_u64::<BigEndian>(time as u64)
|
||||
.map_err(|source| {
|
||||
MinecraftAuthenticationError::ConstructingSignedRequest {
|
||||
source,
|
||||
step,
|
||||
}
|
||||
})?;
|
||||
buffer.write_u8(0).map_err(|source| {
|
||||
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
|
||||
})?;
|
||||
buffer.extend_from_slice("POST".as_bytes());
|
||||
buffer.write_u8(0).map_err(|source| {
|
||||
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
|
||||
})?;
|
||||
buffer.extend_from_slice(url_path.as_bytes());
|
||||
buffer.write_u8(0).map_err(|source| {
|
||||
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
|
||||
})?;
|
||||
buffer.extend_from_slice(&auth);
|
||||
buffer.write_u8(0).map_err(|source| {
|
||||
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
|
||||
})?;
|
||||
buffer.extend_from_slice(&body);
|
||||
buffer.write_u8(0).map_err(|source| {
|
||||
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
|
||||
})?;
|
||||
|
||||
let ecdsa_sig: Signature = key.key.sign(&buffer);
|
||||
|
||||
let mut sig_buffer = Vec::new();
|
||||
sig_buffer.write_i32::<BigEndian>(1).map_err(|source| {
|
||||
MinecraftAuthenticationError::ConstructingSignedRequest { source, step }
|
||||
})?;
|
||||
sig_buffer
|
||||
.write_u64::<BigEndian>(time as u64)
|
||||
.map_err(|source| {
|
||||
MinecraftAuthenticationError::ConstructingSignedRequest {
|
||||
source,
|
||||
step,
|
||||
}
|
||||
})?;
|
||||
sig_buffer.extend_from_slice(&ecdsa_sig.r().to_bytes());
|
||||
sig_buffer.extend_from_slice(&ecdsa_sig.s().to_bytes());
|
||||
|
||||
let signature = BASE64_STANDARD.encode(&sig_buffer);
|
||||
|
||||
let res = auth_retry(|| {
|
||||
let mut request = REQWEST_CLIENT
|
||||
.post(url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.header("x-xbl-contract-version", "1")
|
||||
.header("signature", &signature);
|
||||
|
||||
if let Some(auth) = authorization {
|
||||
request = request.header("Authorization", auth);
|
||||
}
|
||||
|
||||
request.body(body.clone()).send()
|
||||
})
|
||||
.await
|
||||
.map_err(|source| MinecraftAuthenticationError::Request { source, step })?;
|
||||
|
||||
let headers = res.headers().clone();
|
||||
let res = res.text().await.map_err(|source| {
|
||||
MinecraftAuthenticationError::Request { source, step }
|
||||
})?;
|
||||
|
||||
let body = serde_json::from_str(&res).map_err(|source| {
|
||||
MinecraftAuthenticationError::DeserializeResponse {
|
||||
source,
|
||||
raw: res,
|
||||
step,
|
||||
}
|
||||
})?;
|
||||
Ok((headers, body))
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
fn generate_oauth_challenge() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
|
||||
let bytes: Vec<u8> = (0..32).map(|_| rng.gen()).collect();
|
||||
bytes.iter().map(|byte| format!("{:02x}", byte)).collect()
|
||||
}
|
||||
@@ -5,7 +5,6 @@ use std::path::PathBuf;
|
||||
use crate::event::LoadingBarType;
|
||||
use crate::loading_join;
|
||||
|
||||
use crate::state::users::Users;
|
||||
use crate::util::fetch::{self, FetchSemaphore, IoSemaphore};
|
||||
use notify::RecommendedWatcher;
|
||||
use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer};
|
||||
@@ -32,14 +31,9 @@ pub use self::settings::*;
|
||||
mod projects;
|
||||
pub use self::projects::*;
|
||||
|
||||
mod users;
|
||||
|
||||
mod children;
|
||||
pub use self::children::*;
|
||||
|
||||
mod auth_task;
|
||||
pub use self::auth_task::*;
|
||||
|
||||
mod tags;
|
||||
pub use self::tags::*;
|
||||
|
||||
@@ -52,6 +46,9 @@ pub use self::safe_processes::*;
|
||||
mod discord;
|
||||
pub use self::discord::*;
|
||||
|
||||
mod minecraft_auth;
|
||||
pub use self::minecraft_auth::*;
|
||||
|
||||
mod mr_auth;
|
||||
pub use self::mr_auth::*;
|
||||
|
||||
@@ -87,9 +84,7 @@ pub struct State {
|
||||
/// Launcher processes that should be safely exited on shutdown
|
||||
pub(crate) safety_processes: RwLock<SafeProcesses>,
|
||||
/// Launcher user account info
|
||||
pub(crate) users: RwLock<Users>,
|
||||
/// Authentication flow
|
||||
pub auth_flow: RwLock<AuthTask>,
|
||||
pub(crate) users: RwLock<MinecraftAuthStore>,
|
||||
/// Modrinth Credentials Store
|
||||
pub credentials: RwLock<CredentialsStore>,
|
||||
/// Modrinth auth flow
|
||||
@@ -172,7 +167,7 @@ impl State {
|
||||
&fetch_semaphore,
|
||||
&CredentialsStore(None),
|
||||
);
|
||||
let users_fut = Users::init(&directories, &io_semaphore);
|
||||
let users_fut = MinecraftAuthStore::init(&directories, &io_semaphore);
|
||||
let creds_fut = CredentialsStore::init(&directories, &io_semaphore);
|
||||
// Launcher data
|
||||
let (metadata, profiles, tags, users, creds) = loading_join! {
|
||||
@@ -184,7 +179,6 @@ impl State {
|
||||
creds_fut,
|
||||
}?;
|
||||
|
||||
let auth_flow = AuthTask::new();
|
||||
let safety_processes = SafeProcesses::new();
|
||||
|
||||
let discord_rpc = DiscordGuard::init(is_offline).await?;
|
||||
@@ -217,7 +211,6 @@ impl State {
|
||||
profiles: RwLock::new(profiles),
|
||||
users: RwLock::new(users),
|
||||
children: RwLock::new(children),
|
||||
auth_flow: RwLock::new(auth_flow),
|
||||
credentials: RwLock::new(creds),
|
||||
tags: RwLock::new(tags),
|
||||
discord_rpc,
|
||||
@@ -251,11 +244,9 @@ impl State {
|
||||
let res2 = Tags::update();
|
||||
let res3 = Metadata::update();
|
||||
let res4 = Profiles::update_projects();
|
||||
let res5 = Settings::update_java();
|
||||
let res6 = CredentialsStore::update_creds();
|
||||
let res7 = Settings::update_default_user();
|
||||
|
||||
let _ = join!(res1, res2, res3, res4, res5, res6, res7);
|
||||
let _ = join!(res1, res2, res3, res4, res6);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -348,7 +339,6 @@ pub async fn init_watcher() -> crate::Result<Debouncer<RecommendedWatcher>> {
|
||||
|
||||
let file_watcher = new_debouncer(
|
||||
Duration::from_secs_f32(2.0),
|
||||
None,
|
||||
move |res: DebounceEventResult| {
|
||||
futures::executor::block_on(async {
|
||||
tx.send(res).await.unwrap();
|
||||
@@ -411,9 +401,7 @@ pub async fn init_watcher() -> crate::Result<Debouncer<RecommendedWatcher>> {
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(errors) => errors.iter().for_each(|err| {
|
||||
tracing::warn!("Unable to watch file: {err}")
|
||||
}),
|
||||
Err(error) => tracing::warn!("Unable to watch file: {error}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -351,6 +351,7 @@ pub async fn refresh_credentials(
|
||||
}
|
||||
}
|
||||
|
||||
credentials_store.save().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -228,38 +228,29 @@ async fn read_icon_from_file(
|
||||
let zip_file_reader = ZipFileReader::new(path).await;
|
||||
if let Ok(zip_file_reader) = zip_file_reader {
|
||||
// Get index of icon file and open it
|
||||
let zip_index_option = zip_file_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|f| f.entry().filename() == icon_path);
|
||||
if let Some(index) = zip_index_option {
|
||||
let entry = zip_file_reader
|
||||
.file()
|
||||
.entries()
|
||||
.get(index)
|
||||
.unwrap()
|
||||
.entry();
|
||||
let mut bytes = Vec::new();
|
||||
if zip_file_reader
|
||||
.entry(zip_index_option.unwrap())
|
||||
.await?
|
||||
.read_to_end_checked(&mut bytes, entry)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let bytes = bytes::Bytes::from(bytes);
|
||||
let path = write_cached_icon(
|
||||
&icon_path,
|
||||
cache_dir,
|
||||
bytes,
|
||||
io_semaphore,
|
||||
)
|
||||
.await?;
|
||||
let zip_index_option =
|
||||
zip_file_reader.file().entries().iter().position(|f| {
|
||||
f.filename().as_str().unwrap_or_default() == icon_path
|
||||
});
|
||||
let mut bytes = Vec::new();
|
||||
if zip_file_reader
|
||||
.reader_with_entry(zip_index_option.unwrap())
|
||||
.await?
|
||||
.read_to_end_checked(&mut bytes)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
let bytes = bytes::Bytes::from(bytes);
|
||||
let path = write_cached_icon(
|
||||
&icon_path,
|
||||
cache_dir,
|
||||
bytes,
|
||||
io_semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
return Ok(Some(path));
|
||||
}
|
||||
};
|
||||
return Ok(Some(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,13 +455,12 @@ pub async fn infer_data_from_files(
|
||||
};
|
||||
|
||||
// Forge
|
||||
let zip_index_option = zip_file_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|f| f.entry().filename() == "META-INF/mods.toml");
|
||||
let zip_index_option =
|
||||
zip_file_reader.file().entries().iter().position(|f| {
|
||||
f.filename().as_str().unwrap_or_default()
|
||||
== "META-INF/mods.toml"
|
||||
});
|
||||
if let Some(index) = zip_index_option {
|
||||
let file = zip_file_reader.file().entries().get(index).unwrap();
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ForgeModInfo {
|
||||
@@ -489,9 +479,9 @@ pub async fn infer_data_from_files(
|
||||
|
||||
let mut file_str = String::new();
|
||||
if zip_file_reader
|
||||
.entry(index)
|
||||
.reader_with_entry(index)
|
||||
.await?
|
||||
.read_to_string_checked(&mut file_str, file.entry())
|
||||
.read_to_string_checked(&mut file_str)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
@@ -538,13 +528,11 @@ pub async fn infer_data_from_files(
|
||||
}
|
||||
|
||||
// Forge
|
||||
let zip_index_option = zip_file_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|f| f.entry().filename() == "mcmod.info");
|
||||
let zip_index_option =
|
||||
zip_file_reader.file().entries().iter().position(|f| {
|
||||
f.filename().as_str().unwrap_or_default() == "mcmod.info"
|
||||
});
|
||||
if let Some(index) = zip_index_option {
|
||||
let file = zip_file_reader.file().entries().get(index).unwrap();
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ForgeMod {
|
||||
@@ -558,9 +546,9 @@ pub async fn infer_data_from_files(
|
||||
|
||||
let mut file_str = String::new();
|
||||
if zip_file_reader
|
||||
.entry(index)
|
||||
.reader_with_entry(index)
|
||||
.await?
|
||||
.read_to_string_checked(&mut file_str, file.entry())
|
||||
.read_to_string_checked(&mut file_str)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
@@ -599,13 +587,11 @@ pub async fn infer_data_from_files(
|
||||
}
|
||||
|
||||
// Fabric
|
||||
let zip_index_option = zip_file_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|f| f.entry().filename() == "fabric.mod.json");
|
||||
let zip_index_option =
|
||||
zip_file_reader.file().entries().iter().position(|f| {
|
||||
f.filename().as_str().unwrap_or_default() == "fabric.mod.json"
|
||||
});
|
||||
if let Some(index) = zip_index_option {
|
||||
let file = zip_file_reader.file().entries().get(index).unwrap();
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum FabricAuthor {
|
||||
@@ -625,9 +611,9 @@ pub async fn infer_data_from_files(
|
||||
|
||||
let mut file_str = String::new();
|
||||
if zip_file_reader
|
||||
.entry(index)
|
||||
.reader_with_entry(index)
|
||||
.await?
|
||||
.read_to_string_checked(&mut file_str, file.entry())
|
||||
.read_to_string_checked(&mut file_str)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
@@ -669,13 +655,11 @@ pub async fn infer_data_from_files(
|
||||
}
|
||||
|
||||
// Quilt
|
||||
let zip_index_option = zip_file_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|f| f.entry().filename() == "quilt.mod.json");
|
||||
let zip_index_option =
|
||||
zip_file_reader.file().entries().iter().position(|f| {
|
||||
f.filename().as_str().unwrap_or_default() == "quilt.mod.json"
|
||||
});
|
||||
if let Some(index) = zip_index_option {
|
||||
let file = zip_file_reader.file().entries().get(index).unwrap();
|
||||
#[derive(Deserialize)]
|
||||
struct QuiltMetadata {
|
||||
pub name: Option<String>,
|
||||
@@ -692,9 +676,9 @@ pub async fn infer_data_from_files(
|
||||
|
||||
let mut file_str = String::new();
|
||||
if zip_file_reader
|
||||
.entry(index)
|
||||
.reader_with_entry(index)
|
||||
.await?
|
||||
.read_to_string_checked(&mut file_str, file.entry())
|
||||
.read_to_string_checked(&mut file_str)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
@@ -746,13 +730,11 @@ pub async fn infer_data_from_files(
|
||||
}
|
||||
|
||||
// Other
|
||||
let zip_index_option = zip_file_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|f| f.entry().filename() == "pack.mcmeta");
|
||||
let zip_index_option =
|
||||
zip_file_reader.file().entries().iter().position(|f| {
|
||||
f.filename().as_str().unwrap_or_default() == "pack.mcmeta"
|
||||
});
|
||||
if let Some(index) = zip_index_option {
|
||||
let file = zip_file_reader.file().entries().get(index).unwrap();
|
||||
#[derive(Deserialize)]
|
||||
struct Pack {
|
||||
description: Option<String>,
|
||||
@@ -760,9 +742,9 @@ pub async fn infer_data_from_files(
|
||||
|
||||
let mut file_str = String::new();
|
||||
if zip_file_reader
|
||||
.entry(index)
|
||||
.reader_with_entry(index)
|
||||
.await?
|
||||
.read_to_string_checked(&mut file_str, file.entry())
|
||||
.read_to_string_checked(&mut file_str)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
//! Theseus settings file
|
||||
use crate::{
|
||||
jre::{self, autodetect_java_globals, find_filtered_jres},
|
||||
State,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::fs;
|
||||
@@ -24,7 +20,6 @@ pub struct Settings {
|
||||
pub custom_java_args: Vec<String>,
|
||||
pub custom_env_args: Vec<(String, String)>,
|
||||
pub java_globals: JavaGlobals,
|
||||
pub default_user: Option<uuid::Uuid>,
|
||||
pub hooks: Hooks,
|
||||
pub max_concurrent_downloads: usize,
|
||||
pub max_concurrent_writes: usize,
|
||||
@@ -93,7 +88,6 @@ impl Settings {
|
||||
custom_java_args: Vec::new(),
|
||||
custom_env_args: Vec::new(),
|
||||
java_globals: JavaGlobals::new(),
|
||||
default_user: None,
|
||||
hooks: Hooks::default(),
|
||||
max_concurrent_downloads: 10,
|
||||
max_concurrent_writes: 10,
|
||||
@@ -118,66 +112,6 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
#[theseus_macros::debug_pin]
|
||||
pub async fn update_java() {
|
||||
let res = async {
|
||||
let state = State::get().await?;
|
||||
let settings_read = state.settings.write().await;
|
||||
|
||||
if settings_read.java_globals.count() == 0 {
|
||||
drop(settings_read);
|
||||
let jres = jre::get_all_jre().await?;
|
||||
let java_8 =
|
||||
find_filtered_jres("1.8", jres.clone(), false).await?;
|
||||
let java_17 =
|
||||
find_filtered_jres("1.17", jres.clone(), false).await?;
|
||||
let java_18plus =
|
||||
find_filtered_jres("1.18", jres.clone(), true).await?;
|
||||
let java_globals =
|
||||
autodetect_java_globals(java_8, java_17, java_18plus)
|
||||
.await?;
|
||||
state.settings.write().await.java_globals = java_globals;
|
||||
}
|
||||
|
||||
Ok::<(), crate::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!("Unable to update launcher java: {err}")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
#[theseus_macros::debug_pin]
|
||||
pub async fn update_default_user() {
|
||||
let res = async {
|
||||
let state = State::get().await?;
|
||||
let settings_read = state.settings.read().await;
|
||||
|
||||
if settings_read.default_user.is_none() {
|
||||
drop(settings_read);
|
||||
let users = state.users.read().await;
|
||||
let user = users.0.iter().next().map(|(id, _)| *id);
|
||||
state.settings.write().await.default_user = user;
|
||||
}
|
||||
|
||||
Ok::<(), crate::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!("Unable to update default user: {err}")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn sync(&self, to: &Path) -> crate::Result<()> {
|
||||
fs::write(to, serde_json::to_vec(self)?)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
//! User login info
|
||||
use crate::auth::Credentials;
|
||||
use crate::data::DirectoryInfo;
|
||||
use crate::util::fetch::{read_json, write, IoSemaphore};
|
||||
use crate::State;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
const USERS_JSON: &str = "users.json";
|
||||
|
||||
/// The set of users stored in the launcher
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Users(pub(crate) HashMap<Uuid, Credentials>);
|
||||
|
||||
impl Users {
|
||||
pub async fn init(
|
||||
dirs: &DirectoryInfo,
|
||||
io_semaphore: &IoSemaphore,
|
||||
) -> crate::Result<Self> {
|
||||
let users_path = dirs.caches_meta_dir().await.join(USERS_JSON);
|
||||
let users = read_json(&users_path, io_semaphore).await.ok();
|
||||
|
||||
if let Some(users) = users {
|
||||
Ok(Self(users))
|
||||
} else {
|
||||
Ok(Self(HashMap::new()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save(&self) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let users_path =
|
||||
state.directories.caches_meta_dir().await.join(USERS_JSON);
|
||||
write(
|
||||
&users_path,
|
||||
&serde_json::to_vec(&self.0)?,
|
||||
&state.io_semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn insert(
|
||||
&mut self,
|
||||
credentials: &Credentials,
|
||||
) -> crate::Result<&Self> {
|
||||
self.0.insert(credentials.id, credentials.clone());
|
||||
self.save().await?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub fn contains(&self, id: Uuid) -> bool {
|
||||
self.0.contains_key(&id)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub fn get(&self, id: Uuid) -> Option<Credentials> {
|
||||
self.0.get(&id).cloned()
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
pub async fn remove(&mut self, id: Uuid) -> crate::Result<&Self> {
|
||||
self.0.remove(&id);
|
||||
self.save().await?;
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
@@ -337,7 +337,7 @@ pub async fn write_cached_icon(
|
||||
|
||||
async fn sha1_async(bytes: Bytes) -> crate::Result<String> {
|
||||
let hash = tokio::task::spawn_blocking(move || {
|
||||
sha1::Sha1::from(bytes).hexdigest()
|
||||
sha1_smol::Sha1::from(bytes).hexdigest()
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// IO error
|
||||
// A wrapper around the tokio IO functions that adds the path to the error message, instead of the uninformative std::io::Error.
|
||||
|
||||
use std::path::Path;
|
||||
use std::{io::Write, path::Path};
|
||||
|
||||
use tempfile::NamedTempFile;
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum IOError {
|
||||
@@ -113,15 +116,40 @@ pub async fn write(
|
||||
path: impl AsRef<std::path::Path>,
|
||||
data: impl AsRef<[u8]>,
|
||||
) -> Result<(), IOError> {
|
||||
let path = path.as_ref();
|
||||
tokio::fs::write(path, data)
|
||||
.await
|
||||
.map_err(|e| IOError::IOPathError {
|
||||
let path = path.as_ref().to_owned();
|
||||
let data = data.as_ref().to_owned();
|
||||
spawn_blocking(move || {
|
||||
let cloned_path = path.clone();
|
||||
sync_write(data, path).map_err(|e| IOError::IOPathError {
|
||||
source: e,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
path: cloned_path.to_string_lossy().to_string(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::Other, "background task failed")
|
||||
})??;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_write(
|
||||
data: impl AsRef<[u8]>,
|
||||
path: impl AsRef<Path>,
|
||||
) -> Result<(), std::io::Error> {
|
||||
let mut tempfile =
|
||||
NamedTempFile::new_in(path.as_ref().parent().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
"could not get parent directory for temporary file",
|
||||
)
|
||||
})?)?;
|
||||
tempfile.write_all(data.as_ref())?;
|
||||
let tmp_path = tempfile.into_temp_path();
|
||||
let path = path.as_ref();
|
||||
tmp_path.persist(path)?;
|
||||
std::io::Result::Ok(())
|
||||
}
|
||||
// rename
|
||||
pub async fn rename(
|
||||
from: impl AsRef<std::path::Path>,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
[package]
|
||||
name = "theseus_cli"
|
||||
version = "0.6.3"
|
||||
authors = ["Jai A <jaiagr+gpg@pm.me>"]
|
||||
edition = "2018"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
theseus = { path = "../theseus", features = ["cli"] }
|
||||
daedalus = {version = "0.1.15", features = ["bincode"]}
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-stream = { version = "0.1", features = ["fs"] }
|
||||
futures = "0.3"
|
||||
argh = "0.1"
|
||||
paris = { version = "1.5", features = ["macros", "no_logger"] }
|
||||
dialoguer = "0.10"
|
||||
tabled = "0.5"
|
||||
dirs = "4.0"
|
||||
uuid = {version = "1.1", features = ["v4", "serde"]}
|
||||
url = "2.2"
|
||||
|
||||
color-eyre = "0.6"
|
||||
eyre = "0.6"
|
||||
tracing = "0.1"
|
||||
tracing-error = "0.2"
|
||||
tracing-futures = "0.2"
|
||||
tracing-subscriber = {version = "0.3", features = ["env-filter"]}
|
||||
dunce = "1.0.3"
|
||||
|
||||
webbrowser = "0.7"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.11.0"
|
||||
@@ -1,52 +0,0 @@
|
||||
use eyre::Result;
|
||||
use futures::TryFutureExt;
|
||||
use paris::*;
|
||||
use tracing_error::ErrorLayer;
|
||||
use tracing_futures::WithSubscriber;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
|
||||
#[macro_use]
|
||||
mod util;
|
||||
|
||||
mod subcommands;
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
/// The official Modrinth CLI
|
||||
pub struct Args {
|
||||
#[argh(subcommand)]
|
||||
pub subcommand: subcommands::Subcommand,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
fn main() -> Result<()> {
|
||||
let args = argh::from_env::<Args>();
|
||||
|
||||
color_eyre::install()?;
|
||||
let filter = EnvFilter::try_from_default_env()
|
||||
.or_else(|_| EnvFilter::try_new("info"))?;
|
||||
|
||||
let format = fmt::layer()
|
||||
.without_time()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_target(false)
|
||||
.compact();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(format)
|
||||
.with(filter)
|
||||
.with(ErrorLayer::default())
|
||||
.init();
|
||||
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(
|
||||
async move {
|
||||
args.dispatch()
|
||||
.inspect_err(|_| error!("An error has occurred!\n"))
|
||||
.await
|
||||
}
|
||||
.with_current_subscriber(),
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
use eyre::Result;
|
||||
|
||||
mod profile;
|
||||
mod user;
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
#[argh(subcommand)]
|
||||
pub enum Subcommand {
|
||||
Profile(profile::ProfileCommand),
|
||||
User(user::UserCommand),
|
||||
}
|
||||
|
||||
impl crate::Args {
|
||||
pub async fn dispatch(&self) -> Result<()> {
|
||||
dispatch!(self.subcommand, (self) => {
|
||||
Subcommand::Profile,
|
||||
Subcommand::User
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
//! Profile management subcommand
|
||||
use crate::util::{
|
||||
confirm_async, prompt_async, select_async, table, table_path_display,
|
||||
};
|
||||
use daedalus::modded::LoaderVersion;
|
||||
use dunce::canonicalize;
|
||||
use eyre::{ensure, Result};
|
||||
use futures::prelude::*;
|
||||
use paris::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tabled::Tabled;
|
||||
use theseus::prelude::*;
|
||||
use theseus::profile::create::profile_create;
|
||||
use tokio::fs;
|
||||
use tokio_stream::wrappers::ReadDirStream;
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
#[argh(subcommand, name = "profile")]
|
||||
/// manage Minecraft instances
|
||||
pub struct ProfileCommand {
|
||||
#[argh(subcommand)]
|
||||
action: ProfileSubcommand,
|
||||
}
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
#[argh(subcommand)]
|
||||
pub enum ProfileSubcommand {
|
||||
Init(ProfileInit),
|
||||
List(ProfileList),
|
||||
Remove(ProfileRemove),
|
||||
Run(ProfileRun),
|
||||
}
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
#[argh(subcommand, name = "init")]
|
||||
/// create a new profile and manage it with Theseus
|
||||
pub struct ProfileInit {
|
||||
#[argh(positional, default = "std::env::current_dir().unwrap()")]
|
||||
/// the path of the newly created profile
|
||||
path: PathBuf,
|
||||
|
||||
#[argh(option)]
|
||||
/// the name of the profile
|
||||
name: Option<String>,
|
||||
|
||||
#[argh(option)]
|
||||
/// the game version of the profile
|
||||
game_version: Option<String>,
|
||||
|
||||
#[argh(option, from_str_fn(modloader_from_str))]
|
||||
/// the modloader to use
|
||||
modloader: Option<ModLoader>,
|
||||
|
||||
#[argh(option)]
|
||||
/// the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
|
||||
loader_version: Option<String>,
|
||||
}
|
||||
|
||||
impl ProfileInit {
|
||||
pub async fn run(
|
||||
&self,
|
||||
_args: &crate::Args,
|
||||
_largs: &ProfileCommand,
|
||||
) -> Result<()> {
|
||||
// TODO: validate inputs from args early
|
||||
let state = State::get().await?;
|
||||
let metadata = state.metadata.read().await;
|
||||
|
||||
if self.path.exists() {
|
||||
ensure!(
|
||||
self.path.is_dir(),
|
||||
"Attempted to create profile in something other than a folder!"
|
||||
);
|
||||
ensure!(
|
||||
!self.path.join("profile.json").exists(),
|
||||
"Profile already exists! Perhaps you want `profile add` instead?"
|
||||
);
|
||||
if ReadDirStream::new(fs::read_dir(&self.path).await?)
|
||||
.next()
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
warn!("You are trying to create a profile in a non-empty directory. If this is an instance from another launcher, please be sure to properly fill the profile.json fields!");
|
||||
if !confirm_async(
|
||||
String::from("Do you wish to continue"),
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
eyre::bail!("Aborted!");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fs::create_dir_all(&self.path).await?;
|
||||
}
|
||||
info!(
|
||||
"Creating profile at path {}",
|
||||
&canonicalize(&self.path)?.display()
|
||||
);
|
||||
|
||||
// TODO: abstract default prompting
|
||||
let name = match &self.name {
|
||||
Some(name) => name.clone(),
|
||||
None => {
|
||||
let default = self.path.file_name().unwrap().to_string_lossy();
|
||||
|
||||
prompt_async(
|
||||
String::from("Instance name"),
|
||||
Some(default.into_owned()),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let game_version = match &self.game_version {
|
||||
Some(version) => version.clone(),
|
||||
None => {
|
||||
let default = &metadata.minecraft.latest.release;
|
||||
|
||||
prompt_async(
|
||||
String::from("Game version"),
|
||||
Some(default.clone()),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
|
||||
let loader = match &self.modloader {
|
||||
Some(loader) => *loader,
|
||||
None => {
|
||||
let choice = select_async(
|
||||
"Modloader".to_owned(),
|
||||
&["vanilla", "fabric", "forge"],
|
||||
)
|
||||
.await?;
|
||||
|
||||
match choice {
|
||||
0 => ModLoader::Vanilla,
|
||||
1 => ModLoader::Fabric,
|
||||
2 => ModLoader::Forge,
|
||||
_ => eyre::bail!(
|
||||
"Invalid modloader ID: {choice}. This is a bug in the launcher!"
|
||||
),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let loader = if loader != ModLoader::Vanilla {
|
||||
let version = match &self.loader_version {
|
||||
Some(version) => String::from(version),
|
||||
None => prompt_async(
|
||||
String::from(
|
||||
"Modloader version (latest, stable, or a version ID)",
|
||||
),
|
||||
Some(String::from("latest")),
|
||||
)
|
||||
.await?,
|
||||
};
|
||||
|
||||
let filter = |it: &LoaderVersion| match version.as_str() {
|
||||
"latest" => true,
|
||||
"stable" => it.stable,
|
||||
id => it.id == *id,
|
||||
};
|
||||
|
||||
let loader_data = match loader {
|
||||
ModLoader::Forge => &metadata.forge,
|
||||
ModLoader::Fabric => &metadata.fabric,
|
||||
_ => eyre::bail!("Could not get manifest for loader {loader}. This is a bug in the CLI!"),
|
||||
};
|
||||
|
||||
let loaders = &loader_data.game_versions
|
||||
.iter()
|
||||
.find(|it| it.id == game_version)
|
||||
.ok_or_else(|| eyre::eyre!("Modloader {loader} unsupported for Minecraft version {game_version}"))?
|
||||
.loaders;
|
||||
|
||||
let loader_version = loaders
|
||||
.iter()
|
||||
.find(|&x| filter(x))
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
eyre::eyre!("Invalid version {version} for modloader {loader}")
|
||||
})?;
|
||||
|
||||
Some((loader_version, loader))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
profile_create(
|
||||
name,
|
||||
game_version,
|
||||
loader.clone().map(|x| x.1).unwrap_or(ModLoader::Vanilla),
|
||||
loader.map(|x| x.0.id),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
success!(
|
||||
"Successfully created instance, it is now available to use with Theseus!"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
/// list all managed profiles
|
||||
#[argh(subcommand, name = "list")]
|
||||
pub struct ProfileList {}
|
||||
|
||||
#[derive(Tabled)]
|
||||
struct ProfileRow<'a> {
|
||||
name: &'a str,
|
||||
#[field(display_with = "table_path_display")]
|
||||
path: &'a Path,
|
||||
#[header("game version")]
|
||||
game_version: &'a str,
|
||||
loader: &'a ModLoader,
|
||||
#[header("loader version")]
|
||||
loader_version: &'a str,
|
||||
}
|
||||
|
||||
impl<'a> From<&'a Profile> for ProfileRow<'a> {
|
||||
fn from(it: &'a Profile) -> Self {
|
||||
Self {
|
||||
name: &it.metadata.name,
|
||||
path: Path::new(&it.metadata.name),
|
||||
game_version: &it.metadata.game_version,
|
||||
loader: &it.metadata.loader,
|
||||
loader_version: it
|
||||
.metadata
|
||||
.loader_version
|
||||
.as_ref()
|
||||
.map_or("", |it| &it.id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> From<&'a Path> for ProfileRow<'a> {
|
||||
fn from(it: &'a Path) -> Self {
|
||||
Self {
|
||||
name: "?",
|
||||
path: it,
|
||||
game_version: "?",
|
||||
loader: &ModLoader::Vanilla,
|
||||
loader_version: "?",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProfileList {
|
||||
pub async fn run(
|
||||
&self,
|
||||
_args: &crate::Args,
|
||||
_largs: &ProfileCommand,
|
||||
) -> Result<()> {
|
||||
let profiles = profile::list(None).await?;
|
||||
let rows = profiles.values().map(ProfileRow::from);
|
||||
|
||||
let table = table(rows).with(
|
||||
tabled::Modify::new(tabled::Column(1..=1))
|
||||
.with(tabled::MaxWidth::wrapping(40)),
|
||||
);
|
||||
println!("{table}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
/// unmanage a profile
|
||||
#[argh(subcommand, name = "remove")]
|
||||
pub struct ProfileRemove {
|
||||
#[argh(positional, default = "std::env::current_dir().unwrap()")]
|
||||
/// the profile to get rid of
|
||||
profile: PathBuf,
|
||||
}
|
||||
|
||||
impl ProfileRemove {
|
||||
pub async fn run(
|
||||
&self,
|
||||
_args: &crate::Args,
|
||||
_largs: &ProfileCommand,
|
||||
) -> Result<()> {
|
||||
let profile =
|
||||
ProfilePathId::from_fs_path(canonicalize(&self.profile)?).await?;
|
||||
info!("Removing profile {} from Theseus", self.profile.display());
|
||||
|
||||
if confirm_async(String::from("Do you wish to continue"), true).await? {
|
||||
profile::remove(&profile).await?;
|
||||
State::sync().await?;
|
||||
success!("Profile removed!");
|
||||
} else {
|
||||
error!("Aborted!");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
/// run a profile
|
||||
#[argh(subcommand, name = "run")]
|
||||
pub struct ProfileRun {
|
||||
#[argh(positional, default = "std::env::current_dir().unwrap()")]
|
||||
/// the profile to run
|
||||
profile: PathBuf,
|
||||
|
||||
#[argh(option)]
|
||||
/// the user to authenticate with
|
||||
user: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
impl ProfileRun {
|
||||
pub async fn run(
|
||||
&self,
|
||||
_args: &crate::Args,
|
||||
_largs: &ProfileCommand,
|
||||
) -> Result<()> {
|
||||
info!("Starting profile at path {}...", self.profile.display());
|
||||
let path = canonicalize(&self.profile)?;
|
||||
|
||||
let id = future::ready(self.user.ok_or(()))
|
||||
.or_else(|_| async move {
|
||||
let state = State::get().await?;
|
||||
let settings = state.settings.read().await;
|
||||
|
||||
settings.default_user
|
||||
.ok_or(eyre::eyre!(
|
||||
"Could not find any users, please add one using the `user add` command."
|
||||
))
|
||||
})
|
||||
.await?;
|
||||
let credentials = auth::refresh(id).await?;
|
||||
|
||||
let profile_path_id = ProfilePathId::from_fs_path(path).await?;
|
||||
let proc_lock =
|
||||
profile::run_credentials(&profile_path_id, &credentials).await?;
|
||||
let mut proc = proc_lock.write().await;
|
||||
process::wait_for(&mut proc).await?;
|
||||
|
||||
success!("Process exited successfully!");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ProfileCommand {
|
||||
pub async fn run(&self, args: &crate::Args) -> Result<()> {
|
||||
dispatch!(&self.action, (args, self) => {
|
||||
ProfileSubcommand::Init,
|
||||
ProfileSubcommand::List,
|
||||
ProfileSubcommand::Remove,
|
||||
ProfileSubcommand::Run
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn modloader_from_str(it: &str) -> core::result::Result<ModLoader, String> {
|
||||
match it {
|
||||
"vanilla" => Ok(ModLoader::Vanilla),
|
||||
"forge" => Ok(ModLoader::Forge),
|
||||
"fabric" => Ok(ModLoader::Fabric),
|
||||
"quilt" => Ok(ModLoader::Quilt),
|
||||
"neoforge" => Ok(ModLoader::NeoForge),
|
||||
_ => Err(String::from("Invalid modloader: {it}")),
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
//! User management subcommand
|
||||
use crate::util::{confirm_async, table};
|
||||
use eyre::Result;
|
||||
use paris::*;
|
||||
use tabled::Tabled;
|
||||
use theseus::prelude::*;
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
#[argh(subcommand, name = "user")]
|
||||
/// manage Minecraft accounts
|
||||
pub struct UserCommand {
|
||||
#[argh(subcommand)]
|
||||
action: UserSubcommand,
|
||||
}
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
#[argh(subcommand)]
|
||||
pub enum UserSubcommand {
|
||||
Add(UserAdd),
|
||||
List(UserList),
|
||||
Remove(UserRemove),
|
||||
SetDefault(UserDefault),
|
||||
}
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
/// add a new user to Theseus
|
||||
#[argh(subcommand, name = "add")]
|
||||
pub struct UserAdd {
|
||||
#[argh(option)]
|
||||
/// the browser to authenticate using
|
||||
browser: Option<webbrowser::Browser>,
|
||||
}
|
||||
|
||||
impl UserAdd {
|
||||
pub async fn run(
|
||||
&self,
|
||||
_args: &crate::Args,
|
||||
_largs: &UserCommand,
|
||||
) -> Result<()> {
|
||||
info!("Adding new user account to Theseus");
|
||||
info!("A browser window will now open, follow the login flow there.");
|
||||
|
||||
let login = auth::authenticate_begin_flow().await?;
|
||||
let flow = tokio::spawn(auth::authenticate_await_complete_flow());
|
||||
|
||||
info!("Opening browser window at {}", login.verification_uri);
|
||||
info!("Your code is {}", login.user_code);
|
||||
|
||||
match self.browser {
|
||||
Some(browser) => webbrowser::open_browser(
|
||||
browser,
|
||||
login.verification_uri.as_str(),
|
||||
),
|
||||
None => webbrowser::open(login.verification_uri.as_str()),
|
||||
}?;
|
||||
|
||||
let credentials = flow.await??;
|
||||
State::sync().await?;
|
||||
success!("Logged in user {}.", credentials.username);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
/// list all known users
|
||||
#[argh(subcommand, name = "list")]
|
||||
pub struct UserList {}
|
||||
|
||||
#[derive(Tabled)]
|
||||
struct UserRow<'a> {
|
||||
username: &'a str,
|
||||
id: uuid::Uuid,
|
||||
default: bool,
|
||||
}
|
||||
|
||||
impl<'a> UserRow<'a> {
|
||||
pub fn from(
|
||||
credentials: &'a Credentials,
|
||||
default: Option<uuid::Uuid>,
|
||||
) -> Self {
|
||||
Self {
|
||||
username: &credentials.username,
|
||||
id: credentials.id,
|
||||
default: Some(credentials.id) == default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserList {
|
||||
pub async fn run(
|
||||
&self,
|
||||
_args: &crate::Args,
|
||||
_largs: &UserCommand,
|
||||
) -> Result<()> {
|
||||
let state = State::get().await?;
|
||||
let default = state.settings.read().await.default_user;
|
||||
|
||||
let users = auth::users().await?;
|
||||
let rows = users.iter().map(|user| UserRow::from(user, default));
|
||||
|
||||
let table = table(rows);
|
||||
println!("{table}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
/// remove a user
|
||||
#[argh(subcommand, name = "remove")]
|
||||
pub struct UserRemove {
|
||||
/// the user to remove
|
||||
#[argh(positional)]
|
||||
user: uuid::Uuid,
|
||||
}
|
||||
|
||||
impl UserRemove {
|
||||
pub async fn run(
|
||||
&self,
|
||||
_args: &crate::Args,
|
||||
_largs: &UserCommand,
|
||||
) -> Result<()> {
|
||||
info!("Removing user {}", self.user.as_hyphenated());
|
||||
|
||||
if confirm_async(String::from("Do you wish to continue"), true).await? {
|
||||
if !auth::has_user(self.user).await? {
|
||||
warn!("Profile was not managed by Theseus!");
|
||||
} else {
|
||||
auth::remove_user(self.user).await?;
|
||||
State::sync().await?;
|
||||
success!("User removed!");
|
||||
}
|
||||
} else {
|
||||
error!("Aborted!");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(argh::FromArgs, Debug)]
|
||||
/// set the default user
|
||||
#[argh(subcommand, name = "set-default")]
|
||||
pub struct UserDefault {
|
||||
/// the user to set as default
|
||||
#[argh(positional)]
|
||||
user: uuid::Uuid,
|
||||
}
|
||||
|
||||
impl UserDefault {
|
||||
pub async fn run(
|
||||
&self,
|
||||
_args: &crate::Args,
|
||||
_largs: &UserCommand,
|
||||
) -> Result<()> {
|
||||
info!("Setting user {} as default", self.user.as_hyphenated());
|
||||
|
||||
let state = State::get().await?;
|
||||
let mut settings = state.settings.write().await;
|
||||
|
||||
if settings.default_user == Some(self.user) {
|
||||
warn!("User is already the default!");
|
||||
} else {
|
||||
settings.default_user = Some(self.user);
|
||||
success!("User set as default!");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl UserCommand {
|
||||
pub async fn run(&self, args: &crate::Args) -> Result<()> {
|
||||
dispatch!(&self.action, (args, self) => {
|
||||
UserSubcommand::Add,
|
||||
UserSubcommand::List,
|
||||
UserSubcommand::Remove,
|
||||
UserSubcommand::SetDefault
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
use dialoguer::{Confirm, Input, Select};
|
||||
use eyre::Result;
|
||||
use std::{borrow::Cow, path::Path};
|
||||
use tabled::{Table, Tabled};
|
||||
|
||||
// TODO: make primarily async to avoid copies
|
||||
|
||||
// Prompting helpers
|
||||
pub fn prompt(prompt: &str, default: Option<String>) -> Result<String> {
|
||||
let prompt = match default.as_deref() {
|
||||
Some("") => Cow::Owned(format!("{prompt} (optional)")),
|
||||
Some(default) => Cow::Owned(format!("{prompt} (default: {default})")),
|
||||
None => Cow::Borrowed(prompt),
|
||||
};
|
||||
print_prompt(&prompt);
|
||||
|
||||
let mut input = Input::<String>::new();
|
||||
input.with_prompt("").show_default(false);
|
||||
|
||||
if let Some(default) = default {
|
||||
input.default(default);
|
||||
}
|
||||
|
||||
Ok(input.interact_text()?.trim().to_owned())
|
||||
}
|
||||
|
||||
pub async fn prompt_async(
|
||||
text: String,
|
||||
default: Option<String>,
|
||||
) -> Result<String> {
|
||||
tokio::task::spawn_blocking(move || prompt(&text, default)).await?
|
||||
}
|
||||
|
||||
// Selection helpers
|
||||
pub fn select(prompt: &str, choices: &[&str]) -> Result<usize> {
|
||||
print_prompt(prompt);
|
||||
|
||||
let res = Select::new().items(choices).default(0).interact()?;
|
||||
eprintln!("> {}", choices[res]);
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn select_async(
|
||||
prompt: String,
|
||||
choices: &'static [&'static str],
|
||||
) -> Result<usize> {
|
||||
tokio::task::spawn_blocking(move || select(&prompt, choices)).await?
|
||||
}
|
||||
|
||||
// Confirmation helpers
|
||||
pub fn confirm(prompt: &str, default: bool) -> Result<bool> {
|
||||
print_prompt(prompt);
|
||||
Ok(Confirm::new().default(default).interact()?)
|
||||
}
|
||||
|
||||
pub async fn confirm_async(prompt: String, default: bool) -> Result<bool> {
|
||||
tokio::task::spawn_blocking(move || confirm(&prompt, default)).await?
|
||||
}
|
||||
|
||||
// Table helpers
|
||||
pub fn table<T: Tabled>(rows: impl IntoIterator<Item = T>) -> Table {
|
||||
Table::new(rows).with(tabled::Style::psql())
|
||||
}
|
||||
|
||||
pub fn table_path_display(path: &Path) -> String {
|
||||
let mut res = path.display().to_string();
|
||||
|
||||
if let Some(home_dir) = dirs::home_dir() {
|
||||
res = res.replace(&home_dir.display().to_string(), "~");
|
||||
}
|
||||
|
||||
res
|
||||
}
|
||||
|
||||
// Dispatch macros
|
||||
macro_rules! dispatch {
|
||||
($on:expr, $args:tt => {$($option:path),+}) => {
|
||||
match $on {
|
||||
$($option (ref cmd) => dispatch!(@apply cmd => $args)),+
|
||||
}
|
||||
};
|
||||
|
||||
(@apply $cmd:expr => ($($args:expr),*)) => {{
|
||||
use tracing_futures::WithSubscriber;
|
||||
$cmd.run($($args),*).with_current_subscriber().await
|
||||
}};
|
||||
}
|
||||
|
||||
// Internal helpers
|
||||
fn print_prompt(prompt: &str) {
|
||||
println!(
|
||||
"{}",
|
||||
paris::formatter::colorize_string(format!("<yellow>?</> {prompt}:"))
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "theseus_gui",
|
||||
"private": true,
|
||||
"version": "0.6.3",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -13,31 +13,30 @@
|
||||
"fix": "eslint --fix --ext .js,.vue,.ts,.jsx,.tsx,.html,.vue . && prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^1.3.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"floating-vue": "^2.0.0-beta.20",
|
||||
"mixpanel-browser": "^2.47.0",
|
||||
"ofetch": "^1.0.1",
|
||||
"omorphia": "^0.4.38",
|
||||
"pinia": "^2.1.3",
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"@tauri-apps/api": "^1.5.3",
|
||||
"dayjs": "^1.11.10",
|
||||
"floating-vue": "^5.2.2",
|
||||
"mixpanel-browser": "^2.49.0",
|
||||
"ofetch": "^1.3.4",
|
||||
"omorphia": "^0.4.41",
|
||||
"pinia": "^2.1.7",
|
||||
"tauri-plugin-window-state-api": "github:tauri-apps/tauri-plugin-window-state#v1",
|
||||
"vite-svg-loader": "^4.0.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-multiselect": "^3.0.0-beta.2",
|
||||
"vue-router": "4.2.1",
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
"vue": "^3.4.21",
|
||||
"vue-multiselect": "3.0.0-beta.3",
|
||||
"vue-router": "4.3.0",
|
||||
"vue-virtual-scroller": "2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-alias": "^4.0.4",
|
||||
"@tauri-apps/cli": "^1.3.1",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"eslint": "^8.41.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-vue": "^9.14.1",
|
||||
"prettier": "^2.8.8",
|
||||
"sass": "^1.62.1",
|
||||
"vite": "^4.3.9",
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
"@tauri-apps/cli": "^1.5.11",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-vue": "^9.24.0",
|
||||
"prettier": "^3.2.5",
|
||||
"sass": "^1.74.1",
|
||||
"vite": "^5.2.8",
|
||||
"vite-plugin-eslint": "^1.8.1"
|
||||
},
|
||||
"packageManager": "pnpm@8.6.0"
|
||||
|
||||
1004
theseus_gui/pnpm-lock.yaml
generated
1004
theseus_gui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus_gui"
|
||||
version = "0.6.3"
|
||||
version = "0.7.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
@@ -19,10 +19,10 @@ theseus = { path = "../../theseus", features = ["tauri"] }
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
tauri = { version = "1.3", features = ["app-all", "devtools", "dialog", "dialog-confirm", "dialog-open", "macos-private-api", "os-all", "protocol-asset", "shell-open", "updater", "window-close", "window-create", "window-hide", "window-maximize", "window-minimize", "window-set-decorations", "window-show", "window-start-dragging", "window-unmaximize", "window-unminimize"] }
|
||||
tauri = { version = "1.6", features = ["app-all", "devtools", "dialog", "dialog-confirm", "dialog-open", "macos-private-api", "os-all", "protocol-asset", "shell-open", "updater", "window-close", "window-create", "window-hide", "window-maximize", "window-minimize", "window-set-decorations", "window-show", "window-start-dragging", "window-unmaximize", "window-unminimize"] }
|
||||
tauri-plugin-single-instance = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
tauri-plugin-window-state = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
|
||||
tauri-plugin-deep-link = "0.1.1"
|
||||
tauri-plugin-deep-link = "0.1.2"
|
||||
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
thiserror = "1.0"
|
||||
@@ -38,10 +38,10 @@ uuid = { version = "1.1", features = ["serde", "v4"] }
|
||||
os_info = "3.7.0"
|
||||
|
||||
tracing = "0.1.37"
|
||||
tracing-error = "0.1"
|
||||
tracing-error = "0.2.0"
|
||||
|
||||
sentry = "0.30"
|
||||
sentry-rust-minidump = "0.5"
|
||||
sentry = "0.32.2"
|
||||
sentry-rust-minidump = "0.7.0"
|
||||
|
||||
lazy_static = "1"
|
||||
once_cell = "1"
|
||||
@@ -50,7 +50,7 @@ once_cell = "1"
|
||||
window-shadows = "0.2.1"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cocoa = "0.24.1"
|
||||
cocoa = "0.25.0"
|
||||
objc = "0.2.7"
|
||||
|
||||
[features]
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
use crate::api::Result;
|
||||
use chrono::{Duration, Utc};
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use theseus::{hydra::init::DeviceLoginSuccess, prelude::*};
|
||||
use tauri::{Manager, UserAttentionType};
|
||||
use theseus::prelude::*;
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("auth")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
auth_authenticate_begin_flow,
|
||||
auth_authenticate_await_completion,
|
||||
auth_cancel_flow,
|
||||
auth_refresh,
|
||||
auth_get_default_user,
|
||||
auth_set_default_user,
|
||||
auth_remove_user,
|
||||
auth_has_user,
|
||||
auth_users,
|
||||
auth_get_user,
|
||||
])
|
||||
@@ -20,47 +19,82 @@ pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
||||
/// Authenticate a user with Hydra - part 1
|
||||
/// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at)
|
||||
#[tauri::command]
|
||||
pub async fn auth_authenticate_begin_flow() -> Result<DeviceLoginSuccess> {
|
||||
Ok(auth::authenticate_begin_flow().await?)
|
||||
}
|
||||
pub async fn auth_login(app: tauri::AppHandle) -> Result<Option<Credentials>> {
|
||||
let flow = minecraft_auth::begin_login().await?;
|
||||
|
||||
/// Authenticate a user with Hydra - part 2
|
||||
/// This completes the authentication flow quasi-synchronously, returning the sign-in credentials
|
||||
/// (and also adding the credentials to the state)
|
||||
#[tauri::command]
|
||||
pub async fn auth_authenticate_await_completion() -> Result<Credentials> {
|
||||
Ok(auth::authenticate_await_complete_flow().await?)
|
||||
}
|
||||
let start = Utc::now();
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auth_cancel_flow() -> Result<()> {
|
||||
Ok(auth::cancel_flow().await?)
|
||||
}
|
||||
if let Some(window) = app.get_window("signin") {
|
||||
window.close()?;
|
||||
}
|
||||
|
||||
/// Refresh some credentials using Hydra, if needed
|
||||
// invoke('plugin:auth|auth_refresh',user)
|
||||
#[tauri::command]
|
||||
pub async fn auth_refresh(user: uuid::Uuid) -> Result<Credentials> {
|
||||
Ok(auth::refresh(user).await?)
|
||||
}
|
||||
let window = tauri::WindowBuilder::new(
|
||||
&app,
|
||||
"signin",
|
||||
tauri::WindowUrl::External(flow.redirect_uri.parse().map_err(
|
||||
|_| {
|
||||
theseus::ErrorKind::OtherError(
|
||||
"Error parsing auth redirect URL".to_string(),
|
||||
)
|
||||
.as_error()
|
||||
},
|
||||
)?),
|
||||
)
|
||||
.title("Sign into Modrinth")
|
||||
.always_on_top(true)
|
||||
.center()
|
||||
.build()?;
|
||||
|
||||
window.request_user_attention(Some(UserAttentionType::Critical))?;
|
||||
|
||||
while (Utc::now() - start) < Duration::minutes(10) {
|
||||
if window.title().is_err() {
|
||||
// user closed window, cancelling flow
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if window
|
||||
.url()
|
||||
.as_str()
|
||||
.starts_with("https://login.live.com/oauth20_desktop.srf")
|
||||
{
|
||||
if let Some((_, code)) =
|
||||
window.url().query_pairs().find(|x| x.0 == "code")
|
||||
{
|
||||
window.close()?;
|
||||
let val =
|
||||
minecraft_auth::finish_login(&code.clone(), flow).await?;
|
||||
|
||||
return Ok(Some(val));
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
window.close()?;
|
||||
Ok(None)
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn auth_remove_user(user: uuid::Uuid) -> Result<()> {
|
||||
Ok(auth::remove_user(user).await?)
|
||||
Ok(minecraft_auth::remove_user(user).await?)
|
||||
}
|
||||
|
||||
/// Check if a user exists in Theseus
|
||||
// invoke('plugin:auth|auth_has_user',user)
|
||||
#[tauri::command]
|
||||
pub async fn auth_has_user(user: uuid::Uuid) -> Result<bool> {
|
||||
Ok(auth::has_user(user).await?)
|
||||
pub async fn auth_get_default_user() -> Result<Option<uuid::Uuid>> {
|
||||
Ok(minecraft_auth::get_default_user().await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn auth_set_default_user(user: uuid::Uuid) -> Result<()> {
|
||||
Ok(minecraft_auth::set_default_user(user).await?)
|
||||
}
|
||||
|
||||
/// Get a copy of the list of all user credentials
|
||||
// invoke('plugin:auth|auth_users',user)
|
||||
#[tauri::command]
|
||||
pub async fn auth_users() -> Result<Vec<Credentials>> {
|
||||
Ok(auth::users().await?)
|
||||
Ok(minecraft_auth::users().await?)
|
||||
}
|
||||
|
||||
/// Get a user from the UUID
|
||||
@@ -68,5 +102,5 @@ pub async fn auth_users() -> Result<Vec<Credentials>> {
|
||||
// invoke('plugin:auth|auth_users',user)
|
||||
#[tauri::command]
|
||||
pub async fn auth_get_user(user: uuid::Uuid) -> Result<Credentials> {
|
||||
Ok(auth::get_user(user).await?)
|
||||
Ok(minecraft_auth::get_user(user).await?)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,7 @@ use theseus::prelude::*;
|
||||
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("jre")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
jre_get_all_jre,
|
||||
jre_find_filtered_jres,
|
||||
jre_autodetect_java_globals,
|
||||
jre_validate_globals,
|
||||
jre_get_jre,
|
||||
jre_test_jre,
|
||||
jre_auto_install_java,
|
||||
@@ -20,39 +17,12 @@ pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
||||
.build()
|
||||
}
|
||||
|
||||
/// Get all JREs that exist on the system
|
||||
#[tauri::command]
|
||||
pub async fn jre_get_all_jre() -> Result<Vec<JavaVersion>> {
|
||||
Ok(jre::get_all_jre().await?)
|
||||
}
|
||||
|
||||
// Finds the installation of Java 8, if it exists
|
||||
#[tauri::command]
|
||||
pub async fn jre_find_filtered_jres(
|
||||
jres: Vec<JavaVersion>,
|
||||
version: String,
|
||||
allow_higher: bool,
|
||||
version: Option<u32>,
|
||||
) -> Result<Vec<JavaVersion>> {
|
||||
Ok(jre::find_filtered_jres(&version, jres, allow_higher).await?)
|
||||
}
|
||||
|
||||
// Autodetect Java globals, by searching the users computer.
|
||||
// Selects from the given JREs, and returns a new JavaGlobals
|
||||
// Returns a *NEW* JavaGlobals that can be put into Settings
|
||||
#[tauri::command]
|
||||
pub async fn jre_autodetect_java_globals(
|
||||
java_8: Vec<JavaVersion>,
|
||||
java_17: Vec<JavaVersion>,
|
||||
java_18plus: Vec<JavaVersion>,
|
||||
) -> Result<JavaGlobals> {
|
||||
Ok(jre::autodetect_java_globals(java_8, java_17, java_18plus).await?)
|
||||
}
|
||||
|
||||
// Validates java globals, by checking if the paths exist
|
||||
// If false, recommend to direct them to reassign, or to re-guess
|
||||
#[tauri::command]
|
||||
pub async fn jre_validate_globals() -> Result<bool> {
|
||||
Ok(jre::validate_globals().await?)
|
||||
Ok(jre::find_filtered_jres(version).await?)
|
||||
}
|
||||
|
||||
// Validates JRE at a given path
|
||||
@@ -64,12 +34,8 @@ pub async fn jre_get_jre(path: PathBuf) -> Result<Option<JavaVersion>> {
|
||||
|
||||
// Tests JRE of a certain version
|
||||
#[tauri::command]
|
||||
pub async fn jre_test_jre(
|
||||
path: PathBuf,
|
||||
major_version: u32,
|
||||
minor_version: u32,
|
||||
) -> Result<bool> {
|
||||
Ok(jre::test_jre(path, major_version, minor_version).await?)
|
||||
pub async fn jre_test_jre(path: PathBuf, major_version: u32) -> Result<bool> {
|
||||
Ok(jre::test_jre(path, major_version).await?)
|
||||
}
|
||||
|
||||
// Auto installs java for the given java version
|
||||
|
||||
@@ -23,7 +23,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
logs_delete_logs,
|
||||
logs_delete_logs_by_filename,
|
||||
logs_get_latest_log_cursor,
|
||||
logs_get_std_log_cursor,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@@ -91,12 +90,3 @@ pub async fn logs_get_latest_log_cursor(
|
||||
) -> Result<LatestLogCursor> {
|
||||
Ok(logs::get_latest_log_cursor(profile_path, cursor).await?)
|
||||
}
|
||||
|
||||
/// Get live stdout log from a cursor
|
||||
#[tauri::command]
|
||||
pub async fn logs_get_std_log_cursor(
|
||||
profile_path: ProfilePathId,
|
||||
cursor: u64, // 0 to start at beginning of file
|
||||
) -> Result<LatestLogCursor> {
|
||||
Ok(logs::get_std_log_cursor(profile_path, cursor).await?)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ pub enum TheseusSerializableError {
|
||||
#[error("IO error: {0}")]
|
||||
IO(#[from] std::io::Error),
|
||||
|
||||
#[error("Tauri error: {0}")]
|
||||
Tauri(#[from] tauri::Error),
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[error("Callback error: {0}")]
|
||||
Callback(String),
|
||||
@@ -88,9 +91,12 @@ macro_rules! impl_serialize {
|
||||
#[cfg(target_os = "macos")]
|
||||
impl_serialize! {
|
||||
IO,
|
||||
Tauri,
|
||||
Callback
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
impl_serialize! {
|
||||
IO,
|
||||
Tauri,
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ fn main() {
|
||||
initialize_state,
|
||||
is_dev,
|
||||
toggle_decorations,
|
||||
api::auth::auth_login,
|
||||
]);
|
||||
|
||||
builder
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"package": {
|
||||
"productName": "Modrinth App",
|
||||
"version": "0.6.3"
|
||||
"version": "0.7.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { get } from '@/helpers/settings'
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
import ErrorModal from '@/components/ui/ErrorModal.vue'
|
||||
import ModrinthLoadingIndicator from '@/components/modrinth-loading-indicator'
|
||||
import { handleError, useNotifications } from '@/store/notifications.js'
|
||||
import { offline_listener, command_listener, warning_listener } from '@/helpers/events.js'
|
||||
@@ -40,15 +41,14 @@ import { TauriEvent } from '@tauri-apps/api/event'
|
||||
import { await_sync, check_safe_loading_bars_complete } from './helpers/state'
|
||||
import { confirm } from '@tauri-apps/api/dialog'
|
||||
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
|
||||
import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue'
|
||||
import OnboardingScreen from '@/components/ui/tutorial/OnboardingScreen.vue'
|
||||
import { install_from_file } from './helpers/pack'
|
||||
import { useError } from '@/store/error.js'
|
||||
|
||||
const themeStore = useTheming()
|
||||
const urlModal = ref(null)
|
||||
const isLoading = ref(true)
|
||||
|
||||
const videoPlaying = ref(false)
|
||||
const offline = ref(false)
|
||||
const showOnboarding = ref(false)
|
||||
const nativeDecorations = ref(false)
|
||||
@@ -71,7 +71,6 @@ defineExpose({
|
||||
} = await get()
|
||||
// video should play if the user is not on linux, and has not onboarded
|
||||
os.value = await getOS()
|
||||
videoPlaying.value = !fully_onboarded && os.value !== 'Linux'
|
||||
const dev = await isDev()
|
||||
const version = await getVersion()
|
||||
showOnboarding.value = !fully_onboarded
|
||||
@@ -107,7 +106,7 @@ defineExpose({
|
||||
title: 'Warning',
|
||||
text: e.message,
|
||||
type: 'warn',
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
if (showOnboarding.value) {
|
||||
@@ -127,7 +126,7 @@ const confirmClose = async () => {
|
||||
{
|
||||
title: 'Modrinth',
|
||||
type: 'warning',
|
||||
}
|
||||
},
|
||||
)
|
||||
return confirmed
|
||||
}
|
||||
@@ -180,12 +179,19 @@ const isOnBrowse = computed(() => route.path.startsWith('/browse'))
|
||||
const loading = useLoading()
|
||||
|
||||
const notifications = useNotifications()
|
||||
const notificationsWrapper = ref(null)
|
||||
const notificationsWrapper = ref()
|
||||
|
||||
watch(notificationsWrapper, () => {
|
||||
notifications.setNotifs(notificationsWrapper.value)
|
||||
})
|
||||
|
||||
const error = useError()
|
||||
const errorModal = ref()
|
||||
|
||||
watch(errorModal, () => {
|
||||
error.setErrorModal(errorModal.value)
|
||||
})
|
||||
|
||||
document.querySelector('body').addEventListener('click', function (e) {
|
||||
let target = e.target
|
||||
while (target != null) {
|
||||
@@ -245,15 +251,6 @@ command_listener(async (e) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StickyTitleBar v-if="videoPlaying" />
|
||||
<video
|
||||
v-if="videoPlaying"
|
||||
ref="onboardingVideo"
|
||||
class="video"
|
||||
src="@/assets/video.mp4"
|
||||
autoplay
|
||||
@ended="videoPlaying = false"
|
||||
/>
|
||||
<div v-if="failureText" class="failure dark-mode">
|
||||
<div class="appbar-failure dark-mode">
|
||||
<Button v-if="os != 'MacOS'" icon-only @click="TauriWindow.getCurrent().close()">
|
||||
@@ -294,7 +291,7 @@ command_listener(async (e) => {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<SplashScreen v-else-if="!videoPlaying && isLoading" app-loading />
|
||||
<SplashScreen v-else-if="isLoading" app-loading />
|
||||
<OnboardingScreen v-else-if="showOnboarding" :finish="() => (showOnboarding = false)" />
|
||||
<div v-else class="container">
|
||||
<div class="nav-container">
|
||||
@@ -389,6 +386,7 @@ command_listener(async (e) => {
|
||||
</div>
|
||||
<URLConfirmModal ref="urlModal" />
|
||||
<Notifications ref="notificationsWrapper" />
|
||||
<ErrorModal ref="errorModal" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
Binary file not shown.
@@ -45,7 +45,7 @@ const confirmModal = ref(null)
|
||||
async function deleteProfile() {
|
||||
if (currentDeleteInstance.value) {
|
||||
instanceComponents.value = instanceComponents.value.filter(
|
||||
(x) => x.instance.path !== currentDeleteInstance.value
|
||||
(x) => x.instance.path !== currentDeleteInstance.value,
|
||||
)
|
||||
await remove(currentDeleteInstance.value).catch(handleError)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ const handleRightClick = (event, profilePathId) => {
|
||||
color: 'primary',
|
||||
},
|
||||
...baseOptions,
|
||||
]
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useFetch } from '@/helpers/fetch.js'
|
||||
import { install as pack_install } from '@/helpers/pack.js'
|
||||
import { useTheming } from '@/store/state.js'
|
||||
import { mixpanel_track } from '@/helpers/mixpanel'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -50,7 +51,7 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const actualInstances = computed(() =>
|
||||
props.instances.filter((x) => x && x.instances && x.instances[0])
|
||||
props.instances.filter((x) => x && x.instances && x.instances[0]),
|
||||
)
|
||||
|
||||
const modsRow = ref(null)
|
||||
@@ -129,7 +130,7 @@ const handleProjectClick = (event, passedInstance) => {
|
||||
const handleOptionsClick = async (args) => {
|
||||
switch (args.option) {
|
||||
case 'play':
|
||||
await run(args.item.path).catch(handleError)
|
||||
await run(args.item.path).catch(handleSevereError)
|
||||
mixpanel_track('InstanceStart', {
|
||||
loader: args.item.metadata.loader,
|
||||
game_version: args.item.metadata.game_version,
|
||||
@@ -171,7 +172,7 @@ const handleOptionsClick = async (args) => {
|
||||
case 'install': {
|
||||
const versions = await useFetch(
|
||||
`https://api.modrinth.com/v2/project/${args.item.project_id}/version`,
|
||||
'project versions'
|
||||
'project versions',
|
||||
)
|
||||
|
||||
if (args.item.project_type === 'modpack') {
|
||||
@@ -179,7 +180,7 @@ const handleOptionsClick = async (args) => {
|
||||
args.item.project_id,
|
||||
versions[0].id,
|
||||
args.item.title,
|
||||
args.item.icon_url
|
||||
args.item.icon_url,
|
||||
)
|
||||
} else {
|
||||
modInstallModal.value.show(args.item.project_id, versions)
|
||||
@@ -197,7 +198,7 @@ const handleOptionsClick = async (args) => {
|
||||
break
|
||||
case 'copy_link':
|
||||
await navigator.clipboard.writeText(
|
||||
`https://modrinth.com/${args.item.project_type}/${args.item.slug}`
|
||||
`https://modrinth.com/${args.item.project_type}/${args.item.slug}`,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export default defineComponent({
|
||||
zIndex: 6,
|
||||
},
|
||||
},
|
||||
slots
|
||||
slots,
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -56,68 +56,22 @@
|
||||
</Button>
|
||||
</Card>
|
||||
</transition>
|
||||
<Modal ref="loginModal" class="modal" header="Signing in" :noblur="!themeStore.advancedRendering">
|
||||
<div class="modal-body">
|
||||
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
|
||||
<div class="modal-text">
|
||||
<div class="label">Copy this code</div>
|
||||
<div class="code-text">
|
||||
<div class="code">
|
||||
{{ loginCode }}
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="'Copy code'"
|
||||
icon-only
|
||||
large
|
||||
color="raised"
|
||||
@click="() => clipboardWrite(loginCode)"
|
||||
>
|
||||
<ClipboardCopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div>And enter it on Microsoft's website to sign in.</div>
|
||||
<div class="iconified-input">
|
||||
<LogInIcon />
|
||||
<input type="text" :value="loginUrl" readonly />
|
||||
<Button
|
||||
v-tooltip="'Open link'"
|
||||
icon-only
|
||||
color="raised"
|
||||
@click="() => clipboardWrite(loginUrl)"
|
||||
>
|
||||
<GlobeIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
LogInIcon,
|
||||
Modal,
|
||||
GlobeIcon,
|
||||
ClipboardCopyIcon,
|
||||
} from 'omorphia'
|
||||
import { Avatar, Button, Card, PlusIcon, TrashIcon, LogInIcon } from 'omorphia'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||
import {
|
||||
users,
|
||||
remove_user,
|
||||
authenticate_begin_flow,
|
||||
authenticate_await_completion,
|
||||
set_default_user,
|
||||
login as login_flow,
|
||||
get_default_user,
|
||||
} from '@/helpers/auth'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { handleError } from '@/store/state.js'
|
||||
import { useTheming } from '@/store/theme.js'
|
||||
import { mixpanel_track } from '@/helpers/mixpanel'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
defineProps({
|
||||
mode: {
|
||||
@@ -129,16 +83,11 @@ defineProps({
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const loginCode = ref(null)
|
||||
|
||||
const themeStore = useTheming()
|
||||
const settings = ref({})
|
||||
const accounts = ref([])
|
||||
const loginUrl = ref('')
|
||||
const loginModal = ref(null)
|
||||
const accounts = ref({})
|
||||
const defaultUser = ref()
|
||||
|
||||
async function refreshValues() {
|
||||
settings.value = await get().catch(handleError)
|
||||
defaultUser.value = await get_default_user().catch(handleError)
|
||||
accounts.value = await users().catch(handleError)
|
||||
}
|
||||
defineExpose({
|
||||
@@ -147,46 +96,27 @@ defineExpose({
|
||||
await refreshValues()
|
||||
|
||||
const displayAccounts = computed(() =>
|
||||
accounts.value.filter((account) => settings.value.default_user !== account.id)
|
||||
accounts.value.filter((account) => defaultUser.value !== account.id),
|
||||
)
|
||||
|
||||
const selectedAccount = computed(() =>
|
||||
accounts.value.find((account) => account.id === settings.value.default_user)
|
||||
accounts.value.find((account) => account.id === defaultUser.value),
|
||||
)
|
||||
|
||||
async function setAccount(account) {
|
||||
settings.value.default_user = account.id
|
||||
await set(settings.value).catch(handleError)
|
||||
defaultUser.value = account.id
|
||||
await set_default_user(account.id).catch(handleError)
|
||||
emit('change')
|
||||
}
|
||||
|
||||
const clipboardWrite = async (a) => {
|
||||
navigator.clipboard.writeText(a)
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const loginSuccess = await authenticate_begin_flow().catch(handleError)
|
||||
|
||||
loginModal.value.show()
|
||||
loginCode.value = loginSuccess.user_code
|
||||
loginUrl.value = loginSuccess.verification_uri
|
||||
await window.__TAURI_INVOKE__('tauri', {
|
||||
__tauriModule: 'Shell',
|
||||
message: {
|
||||
cmd: 'open',
|
||||
path: loginSuccess.verification_uri,
|
||||
},
|
||||
})
|
||||
|
||||
const loggedIn = await authenticate_await_completion().catch(handleError)
|
||||
loginModal.value.hide()
|
||||
const loggedIn = await login_flow().catch(handleSevereError)
|
||||
|
||||
if (loggedIn) {
|
||||
await setAccount(loggedIn)
|
||||
await refreshValues()
|
||||
}
|
||||
|
||||
loginModal.value.hide()
|
||||
mixpanel_track('AccountLogIn')
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ const breadcrumbs = computed(() => {
|
||||
route.meta.useContext === true
|
||||
? breadcrumbData.context
|
||||
: route.meta.useRootContext === true
|
||||
? breadcrumbData.rootContext
|
||||
: null
|
||||
? breadcrumbData.rootContext
|
||||
: null
|
||||
return additionalContext ? [additionalContext, ...route.meta.breadcrumb] : route.meta.breadcrumb
|
||||
})
|
||||
</script>
|
||||
|
||||
223
theseus_gui/src/components/ui/ErrorModal.vue
Normal file
223
theseus_gui/src/components/ui/ErrorModal.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<script setup>
|
||||
import { Modal, XIcon, IssuesIcon, LogInIcon } from 'omorphia'
|
||||
import { ChatIcon } from '@/assets/icons'
|
||||
import { ref } from 'vue'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import mixpanel from 'mixpanel-browser'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const errorModal = ref()
|
||||
const error = ref()
|
||||
|
||||
const title = ref('An error occurred')
|
||||
const errorType = ref('unknown')
|
||||
const supportLink = ref('https://support.modrinth.com')
|
||||
const metadata = ref({})
|
||||
|
||||
defineExpose({
|
||||
async show(errorVal) {
|
||||
if (errorVal.message && errorVal.message.includes('Minecraft authentication error:')) {
|
||||
title.value = 'Unable to sign in to Minecraft'
|
||||
errorType.value = 'minecraft_auth'
|
||||
supportLink.value =
|
||||
'https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues'
|
||||
|
||||
if (errorVal.message.includes('existing connection was forcibly closed')) {
|
||||
metadata.value.network = true
|
||||
}
|
||||
if (errorVal.message.includes('because the target machine actively refused it')) {
|
||||
metadata.value.hostsFile = true
|
||||
}
|
||||
} else if (errorVal.message && errorVal.message.includes('User is not logged in')) {
|
||||
title.value = 'Sign in to Minecraft'
|
||||
errorType.value = 'minecraft_sign_in'
|
||||
supportLink.value = 'https://support.modrinth.com'
|
||||
} else {
|
||||
title.value = 'An error occurred'
|
||||
errorType.value = 'unknown'
|
||||
supportLink.value = 'https://support.modrinth.com'
|
||||
metadata.value = {}
|
||||
}
|
||||
|
||||
error.value = errorVal
|
||||
errorModal.value.show()
|
||||
},
|
||||
})
|
||||
|
||||
const loadingMinecraft = ref(false)
|
||||
async function loginMinecraft() {
|
||||
try {
|
||||
loadingMinecraft.value = true
|
||||
const loggedIn = await login_flow()
|
||||
|
||||
if (loggedIn) {
|
||||
await set_default_user(loggedIn.id).catch(handleError)
|
||||
}
|
||||
|
||||
await mixpanel.track('AccountLogIn')
|
||||
loadingMinecraft.value = false
|
||||
errorModal.value.hide()
|
||||
} catch (err) {
|
||||
loadingMinecraft.value = false
|
||||
handleSevereError(err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal ref="errorModal" :header="title">
|
||||
<div class="modal-body">
|
||||
<div class="markdown-body">
|
||||
<template v-if="errorType === 'minecraft_auth'">
|
||||
<p>
|
||||
Signing into Microsoft account is a complex task for the launchers, and there are a lot
|
||||
of things can go wrong.
|
||||
</p>
|
||||
<template v-if="metadata.network">
|
||||
<h3>Network issues</h3>
|
||||
<p>
|
||||
It looks like there were issues with the Modrinth App connecting to Microsoft's
|
||||
servers. This is often the result of a poor connection, so we recommend trying again
|
||||
to see if it works. If issues continue to persist, follow the steps in
|
||||
<a
|
||||
href="https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues#h_e71a5f805f"
|
||||
>
|
||||
our support article
|
||||
</a>
|
||||
to troubleshoot.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="metadata.hostsFile">
|
||||
<h3>Network issues</h3>
|
||||
<p>
|
||||
The Modrinth App tried to connect to Microsoft / Xbox / Minecraft services, but the
|
||||
remote server rejected the connection. This may indicate that these services are
|
||||
blocked by the hosts file. Please visit
|
||||
<a
|
||||
href="https://support.modrinth.com/en/articles/9038231-minecraft-sign-in-issues#h_d694a29256"
|
||||
>
|
||||
our support article
|
||||
</a>
|
||||
for steps on how to fix the issue.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h3>Make sure you are signing into the right Microsoft account</h3>
|
||||
<p>
|
||||
More often than not, this error is caused by you signing into an incorrect Microsoft
|
||||
account which isn't linked to Minecraft. Double check and try again!
|
||||
</p>
|
||||
<h3>Try signing in and launching through the official launcher first</h3>
|
||||
<p>
|
||||
If you just bought Minecraft, are coming from the Bedrock Edition world and have never
|
||||
played Java before, or just subscribed to PC Game Pass, you would need to start the
|
||||
game at least once using the
|
||||
<a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>.
|
||||
Once you're done, come back here and sign in!
|
||||
</p>
|
||||
</template>
|
||||
<div class="cta-button">
|
||||
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
|
||||
<LogInIcon /> Sign in to Minecraft
|
||||
</button>
|
||||
</div>
|
||||
<hr />
|
||||
<p>
|
||||
If nothing is working and you need help, visit
|
||||
<a :href="supportLink">our support page</a>
|
||||
and start a chat using the widget in the bottom right and we will be more than happy to
|
||||
assist!
|
||||
</p>
|
||||
<details>
|
||||
<summary>Debug info</summary>
|
||||
{{ error.message ?? error }}
|
||||
</details>
|
||||
</template>
|
||||
<div v-else-if="errorType === 'minecraft_sign_in'">
|
||||
<div class="warning-banner">
|
||||
<div class="warning-banner__title">
|
||||
<IssuesIcon />
|
||||
<span>Installed the app before April 23rd, 2024?</span>
|
||||
</div>
|
||||
<div class="warning-banner__description">
|
||||
Modrinth has updated our sign-in workflow to allow for better stability, security, and
|
||||
performance. You must sign in again so your credentials can be upgraded to this new
|
||||
flow.
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
To play this instance, you must sign in through Microsoft below. If you don't have a
|
||||
Minecraft account, you can purchase the game on the
|
||||
<a href="https://www.minecraft.net/en-us/store/minecraft-java-bedrock-edition-pc"
|
||||
>Minecraft website</a
|
||||
>.
|
||||
</p>
|
||||
<div class="cta-button">
|
||||
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
|
||||
<LogInIcon /> Sign in to Minecraft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
{{ error.message ?? error }}
|
||||
</template>
|
||||
</div>
|
||||
<div class="input-group push-right">
|
||||
<a :href="supportLink" class="btn" @click="errorModal.hide()"><ChatIcon /> Get support</a>
|
||||
<button class="btn" @click="errorModal.hide()"><XIcon /> Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.light-mode {
|
||||
--color-orange-bg: rgba(255, 163, 71, 0.2);
|
||||
}
|
||||
|
||||
.dark-mode,
|
||||
.oled-mode {
|
||||
--color-orange-bg: rgba(224, 131, 37, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cta-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: var(--gap-lg);
|
||||
background-color: var(--color-orange-bg);
|
||||
border: 2px solid var(--color-orange);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.warning-banner__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 700;
|
||||
|
||||
svg {
|
||||
color: var(--color-orange);
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
padding: var(--gap-lg);
|
||||
}
|
||||
</style>
|
||||
@@ -63,7 +63,7 @@ const initFiles = async () => {
|
||||
} else {
|
||||
files.value.push(pathData)
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
folders.value = [...newFolders.entries()].map(([name, value]) => [
|
||||
{
|
||||
@@ -97,7 +97,7 @@ const exportPack = async () => {
|
||||
filesToExport,
|
||||
versionInput.value,
|
||||
exportDescription.value,
|
||||
nameInput.value
|
||||
nameInput.value,
|
||||
).catch((err) => handleError(err))
|
||||
exportModal.value.hide()
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ defineExpose({
|
||||
selectedVersions,
|
||||
extMarkInstalled,
|
||||
projectIdVal,
|
||||
projectTypeVal
|
||||
projectTypeVal,
|
||||
) => {
|
||||
instance.value = instanceVal
|
||||
projectTitle.value = projectTitleVal
|
||||
|
||||
@@ -36,7 +36,7 @@ async function install() {
|
||||
projectId.value,
|
||||
version.value,
|
||||
title.value,
|
||||
icon.value ? icon.value : null
|
||||
icon.value ? icon.value : null,
|
||||
).catch(handleError)
|
||||
mixpanel_track('PackInstall', {
|
||||
id: projectId.value,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { handleError } from '@/store/state.js'
|
||||
import { showProfileInFolder } from '@/helpers/utils.js'
|
||||
import ModInstallModal from '@/components/ui/ModInstallModal.vue'
|
||||
import { mixpanel_track } from '@/helpers/mixpanel'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
@@ -33,7 +34,7 @@ const playing = ref(false)
|
||||
|
||||
const uuid = ref(null)
|
||||
const modLoading = ref(
|
||||
props.instance.install_stage ? props.instance.install_stage !== 'installed' : false
|
||||
props.instance.install_stage ? props.instance.install_stage !== 'installed' : false,
|
||||
)
|
||||
|
||||
watch(
|
||||
@@ -42,7 +43,7 @@ watch(
|
||||
modLoading.value = props.instance.install_stage
|
||||
? props.instance.install_stage !== 'installed'
|
||||
: false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
@@ -72,7 +73,7 @@ const install = async (e) => {
|
||||
modLoading.value = true
|
||||
const versions = await useFetch(
|
||||
`https://api.modrinth.com/v2/project/${props.instance.project_id}/version`,
|
||||
'project versions'
|
||||
'project versions',
|
||||
)
|
||||
|
||||
if (props.instance.project_type === 'modpack') {
|
||||
@@ -89,7 +90,7 @@ const install = async (e) => {
|
||||
props.instance.project_id,
|
||||
versions[0].id,
|
||||
props.instance.title,
|
||||
props.instance.icon_url
|
||||
props.instance.icon_url,
|
||||
).catch(handleError)
|
||||
modLoading.value = false
|
||||
|
||||
@@ -104,14 +105,14 @@ const install = async (e) => {
|
||||
props.instance.project_id,
|
||||
versions[0].id,
|
||||
props.instance.title,
|
||||
props.instance.icon_url
|
||||
props.instance.icon_url,
|
||||
)
|
||||
} else {
|
||||
modInstallModal.value.show(
|
||||
props.instance.project_id,
|
||||
versions,
|
||||
props.instance.title,
|
||||
props.instance.project_type
|
||||
props.instance.project_type,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -121,7 +122,7 @@ const install = async (e) => {
|
||||
const play = async (e, context) => {
|
||||
e?.stopPropagation()
|
||||
modLoading.value = true
|
||||
uuid.value = await run(props.instance.path).catch(handleError)
|
||||
uuid.value = await run(props.instance.path).catch(handleSevereError)
|
||||
modLoading.value = false
|
||||
playing.value = true
|
||||
|
||||
@@ -267,7 +268,10 @@ onUnmounted(() => unlisten())
|
||||
right: calc(var(--gap-md) * 2);
|
||||
bottom: 3.25rem;
|
||||
opacity: 0;
|
||||
transition: 0.2s ease-in-out bottom, 0.2s ease-in-out opacity, 0.1s ease-in-out filter !important;
|
||||
transition:
|
||||
0.2s ease-in-out bottom,
|
||||
0.2s ease-in-out opacity,
|
||||
0.1s ease-in-out filter !important;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-floating);
|
||||
|
||||
|
||||
@@ -177,14 +177,14 @@
|
||||
loading
|
||||
? 'Importing...'
|
||||
: Array.from(profiles.values())
|
||||
.flatMap((e) => e)
|
||||
.some((e) => e.selected)
|
||||
? `Import ${
|
||||
Array.from(profiles.values())
|
||||
.flatMap((e) => e)
|
||||
.filter((e) => e.selected).length
|
||||
} profiles`
|
||||
: 'Select profiles to import'
|
||||
.some((e) => e.selected)
|
||||
? `Import ${
|
||||
Array.from(profiles.values())
|
||||
.flatMap((e) => e)
|
||||
.filter((e) => e.selected).length
|
||||
} profiles`
|
||||
: 'Select profiles to import'
|
||||
}}
|
||||
</Button>
|
||||
<ProgressBar
|
||||
@@ -317,7 +317,7 @@ const [
|
||||
.then((value) =>
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack'))
|
||||
.map((item) => item.name.toLowerCase())
|
||||
.map((item) => item.name.toLowerCase()),
|
||||
)
|
||||
.then(ref)
|
||||
.catch(handleError),
|
||||
@@ -367,7 +367,7 @@ const create_instance = async () => {
|
||||
game_version.value,
|
||||
loader.value,
|
||||
loader.value === 'vanilla' ? null : loader_version_value ?? 'stable',
|
||||
icon.value
|
||||
icon.value,
|
||||
).catch(handleError)
|
||||
|
||||
mixpanel_track('InstanceCreate', {
|
||||
@@ -441,7 +441,7 @@ const profiles = ref(
|
||||
['ATLauncher', []],
|
||||
['Curseforge', []],
|
||||
['PrismLauncher', []],
|
||||
])
|
||||
]),
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -470,7 +470,7 @@ const promises = profileOptions.value.map(async (option) => {
|
||||
profileOptions.value.find((profile) => profile.name === option.name).path = path
|
||||
profiles.value.set(
|
||||
option.name,
|
||||
instances.map((name) => ({ name, selected: false }))
|
||||
instances.map((name) => ({ name, selected: false })),
|
||||
)
|
||||
} catch (error) {
|
||||
// Allow failure silently
|
||||
@@ -489,12 +489,12 @@ const selectLauncherPath = async () => {
|
||||
const reload = async () => {
|
||||
const instances = await get_importable_instances(
|
||||
selectedProfileType.value.name,
|
||||
selectedProfileType.value.path
|
||||
selectedProfileType.value.path,
|
||||
).catch(handleError)
|
||||
if (instances) {
|
||||
profiles.value.set(
|
||||
selectedProfileType.value.name,
|
||||
instances.map((name) => ({ name, selected: false }))
|
||||
instances.map((name) => ({ name, selected: false })),
|
||||
)
|
||||
} else {
|
||||
profiles.value.set(selectedProfileType.value.name, [])
|
||||
|
||||
@@ -37,12 +37,7 @@
|
||||
<script setup>
|
||||
import { Modal, PlusIcon, CheckIcon, Button, XIcon } from 'omorphia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
find_jre_17_jres,
|
||||
find_jre_18plus_jres,
|
||||
find_jre_8_jres,
|
||||
get_all_jre,
|
||||
} from '@/helpers/jre.js'
|
||||
import { find_filtered_jres } from '@/helpers/jre.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { mixpanel_track } from '@/helpers/mixpanel'
|
||||
import { useTheming } from '@/store/theme.js'
|
||||
@@ -55,15 +50,10 @@ const currentSelected = ref({})
|
||||
|
||||
defineExpose({
|
||||
show: async (version, currentSelectedJava) => {
|
||||
if (version <= 8 && !!version) {
|
||||
chosenInstallOptions.value = await find_jre_8_jres().catch(handleError)
|
||||
} else if (version >= 18) {
|
||||
chosenInstallOptions.value = await find_jre_18plus_jres().catch(handleError)
|
||||
} else if (version) {
|
||||
chosenInstallOptions.value = await find_jre_17_jres().catch(handleError)
|
||||
} else {
|
||||
chosenInstallOptions.value = await get_all_jre().catch(handleError)
|
||||
}
|
||||
chosenInstallOptions.value = await find_filtered_jres(version).catch(handleError)
|
||||
|
||||
console.log(chosenInstallOptions.value)
|
||||
console.log(version)
|
||||
|
||||
currentSelected.value = currentSelectedJava
|
||||
if (!currentSelected.value) {
|
||||
|
||||
@@ -61,13 +61,7 @@ import {
|
||||
FolderSearchIcon,
|
||||
DownloadIcon,
|
||||
} from 'omorphia'
|
||||
import {
|
||||
auto_install_java,
|
||||
find_jre_17_jres,
|
||||
find_jre_8_jres,
|
||||
get_jre,
|
||||
test_jre,
|
||||
} from '@/helpers/jre.js'
|
||||
import { auto_install_java, find_filtered_jres, get_jre, test_jre } from '@/helpers/jre.js'
|
||||
import { ref } from 'vue'
|
||||
import { open } from '@tauri-apps/api/dialog'
|
||||
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
|
||||
@@ -82,7 +76,10 @@ const props = defineProps({
|
||||
},
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({
|
||||
path: '',
|
||||
version: '',
|
||||
}),
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
@@ -112,7 +109,7 @@ async function testJava() {
|
||||
testingJavaSuccess.value = await test_jre(
|
||||
props.modelValue ? props.modelValue.path : '',
|
||||
1,
|
||||
props.version
|
||||
props.version,
|
||||
)
|
||||
testingJava.value = false
|
||||
|
||||
@@ -153,16 +150,9 @@ async function autoDetect() {
|
||||
if (!props.compact) {
|
||||
detectJavaModal.value.show(props.version, props.modelValue)
|
||||
} else {
|
||||
if (props.version == 8) {
|
||||
let versions = await find_jre_8_jres().catch(handleError)
|
||||
if (versions.length > 0) {
|
||||
emit('update:modelValue', versions[0])
|
||||
}
|
||||
} else {
|
||||
let versions = await find_jre_17_jres().catch(handleError)
|
||||
if (versions.length > 0) {
|
||||
emit('update:modelValue', versions[0])
|
||||
}
|
||||
let versions = await find_filtered_jres(props.version).catch(handleError)
|
||||
if (versions.length > 0) {
|
||||
emit('update:modelValue', versions[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,7 +160,6 @@ async function autoDetect() {
|
||||
async function reinstallJava() {
|
||||
installingJava.value = true
|
||||
const path = await auto_install_java(props.version).catch(handleError)
|
||||
console.log('java path: ' + path)
|
||||
let result = await get_jre(path)
|
||||
|
||||
console.log('java result ' + result)
|
||||
|
||||
@@ -112,7 +112,8 @@ async function getData() {
|
||||
.flatMap((v) => v.loaders)
|
||||
.some(
|
||||
(value) =>
|
||||
value === profile.metadata.loader || ['minecraft', 'iris', 'optifine'].includes(value)
|
||||
value === profile.metadata.loader ||
|
||||
['minecraft', 'iris', 'optifine'].includes(value),
|
||||
)
|
||||
)
|
||||
})
|
||||
@@ -175,7 +176,7 @@ const createInstance = async () => {
|
||||
versions.value[0].game_versions[0],
|
||||
loader,
|
||||
'latest',
|
||||
icon.value
|
||||
icon.value,
|
||||
).catch(handleError)
|
||||
|
||||
await installMod(id, versions.value[0].id).catch(handleError)
|
||||
@@ -264,10 +265,10 @@ const check_valid = computed(() => {
|
||||
profile.installing
|
||||
? 'Installing...'
|
||||
: profile.installedMod
|
||||
? 'Installed'
|
||||
: profile.metadata.linked_data && profile.metadata.linked_data.locked
|
||||
? 'Paired'
|
||||
: 'Install'
|
||||
? 'Installed'
|
||||
: profile.metadata.linked_data && profile.metadata.linked_data.locked
|
||||
? 'Paired'
|
||||
: 'Install'
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@ const install = async (e) => {
|
||||
installing.value = true
|
||||
const versions = await useFetch(
|
||||
`https://api.modrinth.com/v2/project/${props.project.project_id}/version`,
|
||||
'project versions'
|
||||
'project versions',
|
||||
)
|
||||
|
||||
if (props.project.project_type === 'modpack') {
|
||||
@@ -91,7 +91,7 @@ const install = async (e) => {
|
||||
props.project.project_id,
|
||||
versions[0].id,
|
||||
props.project.title,
|
||||
props.project.icon_url
|
||||
props.project.icon_url,
|
||||
).catch(handleError)
|
||||
installing.value = false
|
||||
} else
|
||||
@@ -99,7 +99,7 @@ const install = async (e) => {
|
||||
props.project.project_id,
|
||||
versions[0].id,
|
||||
props.project.title,
|
||||
props.project.icon_url
|
||||
props.project.icon_url,
|
||||
)
|
||||
} else {
|
||||
props.modInstallModal.show(props.project.project_id, versions)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="action-groups">
|
||||
<a href="https://discord.modrinth.com" class="link">
|
||||
<a href="https://support.modrinth.com" class="link">
|
||||
<ChatIcon />
|
||||
<span> Get support </span>
|
||||
</a>
|
||||
@@ -196,8 +196,19 @@ const refreshInfo = async () => {
|
||||
}
|
||||
|
||||
return x
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
currentLoadingBars.value.sort((a, b) => {
|
||||
if (a.loading_bar_uuid < b.loading_bar_uuid) {
|
||||
return -1
|
||||
}
|
||||
if (a.loading_bar_uuid > b.loading_bar_uuid) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
if (currentLoadingBars.value.length === 0) {
|
||||
showCard.value = false
|
||||
} else if (currentLoadingBarCount < currentLoadingBars.value.length) {
|
||||
|
||||
@@ -136,7 +136,7 @@ async function install() {
|
||||
installing.value = true
|
||||
const versions = await useFetch(
|
||||
`https://api.modrinth.com/v2/project/${props.project.project_id}/version`,
|
||||
'project versions'
|
||||
'project versions',
|
||||
)
|
||||
let queuedVersionData
|
||||
|
||||
@@ -146,7 +146,8 @@ async function install() {
|
||||
queuedVersionData = versions.find(
|
||||
(v) =>
|
||||
v.game_versions.includes(props.instance.metadata.game_version) &&
|
||||
(props.project.project_type !== 'mod' || v.loaders.includes(props.instance.metadata.loader))
|
||||
(props.project.project_type !== 'mod' ||
|
||||
v.loaders.includes(props.instance.metadata.loader)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -162,7 +163,7 @@ async function install() {
|
||||
props.project.project_id,
|
||||
queuedVersionData.id,
|
||||
props.project.title,
|
||||
props.project.icon_url
|
||||
props.project.icon_url,
|
||||
).catch(handleError)
|
||||
|
||||
mixpanel_track('PackInstall', {
|
||||
@@ -176,7 +177,7 @@ async function install() {
|
||||
props.project.project_id,
|
||||
queuedVersionData.id,
|
||||
props.project.title,
|
||||
props.project.icon_url
|
||||
props.project.icon_url,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -188,7 +189,7 @@ async function install() {
|
||||
versions,
|
||||
() => (installed.value = true),
|
||||
props.project.project_id,
|
||||
props.project.project_type
|
||||
props.project.project_type,
|
||||
)
|
||||
installing.value = false
|
||||
return
|
||||
@@ -211,7 +212,7 @@ async function install() {
|
||||
props.project.project_id,
|
||||
versions,
|
||||
props.project.title,
|
||||
props.project.project_type
|
||||
props.project.project_type,
|
||||
)
|
||||
installing.value = false
|
||||
return
|
||||
|
||||
@@ -21,28 +21,28 @@ defineExpose({
|
||||
if (event.event === 'InstallVersion') {
|
||||
version.value = await useFetch(
|
||||
`https://api.modrinth.com/v2/version/${encodeURIComponent(event.id)}`,
|
||||
'version'
|
||||
'version',
|
||||
)
|
||||
project.value = await useFetch(
|
||||
`https://api.modrinth.com/v2/project/${encodeURIComponent(version.value.project_id)}`,
|
||||
'project'
|
||||
'project',
|
||||
)
|
||||
} else {
|
||||
project.value = await useFetch(
|
||||
`https://api.modrinth.com/v2/project/${encodeURIComponent(event.id)}`,
|
||||
'project'
|
||||
'project',
|
||||
)
|
||||
version.value = await useFetch(
|
||||
`https://api.modrinth.com/v2/version/${encodeURIComponent(project.value.versions[0])}`,
|
||||
'version'
|
||||
'version',
|
||||
)
|
||||
}
|
||||
categories.value = (await get_categories().catch(handleError)).filter(
|
||||
(cat) => project.value.categories.includes(cat.name) && cat.project_type === 'mod'
|
||||
(cat) => project.value.categories.includes(cat.name) && cat.project_type === 'mod',
|
||||
)
|
||||
confirmModal.value.show()
|
||||
categories.value = (await get_categories().catch(handleError)).filter(
|
||||
(cat) => project.value.categories.includes(cat.name) && cat.project_type === 'mod'
|
||||
(cat) => project.value.categories.includes(cat.name) && cat.project_type === 'mod',
|
||||
)
|
||||
confirmModal.value.show()
|
||||
},
|
||||
@@ -55,7 +55,7 @@ async function install() {
|
||||
project.value.id,
|
||||
version.value.id,
|
||||
project.value.title,
|
||||
project.value.icon_url
|
||||
project.value.icon_url,
|
||||
).catch(handleError)
|
||||
|
||||
mixpanel.track('PackInstall', {
|
||||
@@ -69,7 +69,7 @@ async function install() {
|
||||
project.value.id,
|
||||
[version.value],
|
||||
project.value.title,
|
||||
project.value.project_type
|
||||
project.value.project_type,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
<template>
|
||||
<div ref="button" class="button-base avatar-button" :class="{ highlighted: showDemo }">
|
||||
<Avatar src="https://launcher-files.modrinth.com/assets/steve_head.png" />
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="showDemo" class="card-section">
|
||||
<Card ref="card" class="fake-account-card expanded highlighted">
|
||||
<div class="selected account">
|
||||
<Avatar size="xs" src="https://launcher-files.modrinth.com/assets/steve_head.png" />
|
||||
<div>
|
||||
<h4>Modrinth</h4>
|
||||
<p>Selected</p>
|
||||
</div>
|
||||
<Button v-tooltip="'Log out'" icon-only color="raised">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<Button>
|
||||
<PlusIcon />
|
||||
Add account
|
||||
</Button>
|
||||
</Card>
|
||||
<slot />
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Avatar, Button, Card, PlusIcon, TrashIcon } from 'omorphia'
|
||||
|
||||
defineProps({
|
||||
showDemo: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.selected {
|
||||
background: var(--color-brand-highlight);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-contrast);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.logged-out {
|
||||
background: var(--color-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.account {
|
||||
width: max-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
h4,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-section {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 5.5rem;
|
||||
z-index: 9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fake-account-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--color-button-bg);
|
||||
width: max-content;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.isolated {
|
||||
position: relative;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.accounts-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.account-group {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.option {
|
||||
width: calc(100% - 2.25rem);
|
||||
background: var(--color-raised-bg);
|
||||
color: var(--color-base);
|
||||
box-shadow: none;
|
||||
|
||||
img {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
--size: 1.5rem !important;
|
||||
}
|
||||
|
||||
.account-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
vertical-align: center;
|
||||
justify-content: space-between;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.avatar-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--color-base);
|
||||
background-color: var(--color-raised-bg);
|
||||
border-radius: var(--radius-md);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
|
||||
&.expanded {
|
||||
border: 1px solid var(--color-button-bg);
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-text {
|
||||
margin: auto 0 auto 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.text {
|
||||
width: 6rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.accounts-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,265 +0,0 @@
|
||||
<template>
|
||||
<div class="action-groups">
|
||||
<Button color="danger" outline @click="exit">
|
||||
<LogOutIcon />
|
||||
Exit tutorial
|
||||
</Button>
|
||||
<Button v-if="showDownload" ref="infoButton" icon-only class="icon-button show-card-icon">
|
||||
<DownloadIcon />
|
||||
</Button>
|
||||
<div v-if="showRunning" class="status highlighted">
|
||||
<span class="circle running" />
|
||||
<div ref="profileButton" class="running-text">Example Modpack</div>
|
||||
<Button v-tooltip="'Stop instance'" icon-only class="icon-button stop">
|
||||
<StopCircleIcon />
|
||||
</Button>
|
||||
<Button v-tooltip="'View logs'" icon-only class="icon-button">
|
||||
<TerminalSquareIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="status">
|
||||
<span class="circle stopped" />
|
||||
<span class="running-text"> No running instances </span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="download">
|
||||
<div v-if="showDownload" class="info-section">
|
||||
<Card ref="card" class="highlighted info-card">
|
||||
<h3 class="info-title">New Modpack</h3>
|
||||
<ProgressBar :progress="50" />
|
||||
<div class="row">50% Downloading modpack</div>
|
||||
</Card>
|
||||
<slot name="download" />
|
||||
</div>
|
||||
</transition>
|
||||
<transition name="running">
|
||||
<div v-if="showRunning" class="info-section">
|
||||
<slot name="running" />
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Button,
|
||||
DownloadIcon,
|
||||
Card,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
LogOutIcon,
|
||||
} from 'omorphia'
|
||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
|
||||
defineProps({
|
||||
showDownload: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showRunning: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
exit: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.action-groups {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-button-bg);
|
||||
padding: var(--gap-sm) var(--gap-lg);
|
||||
}
|
||||
|
||||
.running-text {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-xs);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-ms-user-select: none; /* IE 10 and IE 11 */
|
||||
user-select: none;
|
||||
|
||||
&.clickable:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.circle {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
|
||||
&.running {
|
||||
background-color: var(--color-brand);
|
||||
}
|
||||
|
||||
&.stopped {
|
||||
background-color: var(--color-base);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
box-shadow: none;
|
||||
width: 1.25rem !important;
|
||||
height: 1.25rem !important;
|
||||
|
||||
&.stop {
|
||||
--text-color: var(--color-red) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.info-section {
|
||||
position: absolute;
|
||||
top: 3.5rem;
|
||||
right: 0.75rem;
|
||||
z-index: 9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
width: 20rem;
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-raised);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
overflow: auto;
|
||||
transition: all 0.2s ease-in-out;
|
||||
border: 1px solid var(--color-button-bg);
|
||||
|
||||
&.hidden {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-option {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
:hover {
|
||||
background-color: var(--color-raised-bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: block;
|
||||
|
||||
:deep(svg) {
|
||||
left: 1rem;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.show-card-icon {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.download-enter-active,
|
||||
.download-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.download-enter-from,
|
||||
.download-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
width: 100%;
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: none;
|
||||
|
||||
.text {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
position: absolute;
|
||||
top: 3.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 9;
|
||||
background-color: var(--color-raised-bg);
|
||||
box-shadow: var(--shadow-raised);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
transition: all 0.2s ease-in-out;
|
||||
border: 1px solid var(--color-button-bg);
|
||||
padding: var(--gap-md);
|
||||
|
||||
&.hidden {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,222 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Card, DropdownSelect, SearchIcon, XIcon, Button, Avatar } from 'omorphia'
|
||||
|
||||
const search = ref('')
|
||||
const group = ref('Category')
|
||||
const filters = ref('All profiles')
|
||||
const sortBy = ref('Name')
|
||||
|
||||
defineProps({
|
||||
showFilters: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showInstances: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<Card class="header" :class="{ highlighted: showFilters }">
|
||||
<div class="iconified-input">
|
||||
<SearchIcon />
|
||||
<input v-model="search" type="text" placeholder="Search" class="search-input" />
|
||||
<Button @click="() => (search = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="labeled_button">
|
||||
<span>Sort by</span>
|
||||
<DropdownSelect
|
||||
v-model="sortBy"
|
||||
class="sort-dropdown"
|
||||
name="Sort Dropdown"
|
||||
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
|
||||
placeholder="Select..."
|
||||
/>
|
||||
</div>
|
||||
<div class="labeled_button">
|
||||
<span>Filter by</span>
|
||||
<DropdownSelect
|
||||
v-model="filters"
|
||||
class="filter-dropdown"
|
||||
name="Filter Dropdown"
|
||||
:options="['All profiles', 'Custom instances', 'Downloaded modpacks']"
|
||||
placeholder="Select..."
|
||||
/>
|
||||
</div>
|
||||
<div class="labeled_button">
|
||||
<span>Group by</span>
|
||||
<DropdownSelect
|
||||
v-model="group"
|
||||
class="group-dropdown"
|
||||
name="Group dropdown"
|
||||
:options="['Category', 'Loader', 'Game version', 'None']"
|
||||
placeholder="Select..."
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<div class="row">
|
||||
<section class="instances">
|
||||
<Card
|
||||
v-for="project in 20"
|
||||
:key="project"
|
||||
class="instance-card-item button-base"
|
||||
:class="{ highlighted: project === 1 && showInstance }"
|
||||
>
|
||||
<Avatar
|
||||
size="sm"
|
||||
src="https://launcher-files.modrinth.com/assets/default_profile.png"
|
||||
alt="Mod card"
|
||||
class="mod-image"
|
||||
/>
|
||||
<div class="project-info">
|
||||
<p class="title">Example Profile</p>
|
||||
<p class="description">Forge/Fabric 1.20.1</p>
|
||||
</div>
|
||||
</Card>
|
||||
<slot />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
white-space: nowrap;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
hr {
|
||||
background-color: var(--color-gray);
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: inherit;
|
||||
margin: 1rem 1rem 0 !important;
|
||||
padding: 1rem;
|
||||
width: calc(100% - 2rem);
|
||||
|
||||
.iconified-input {
|
||||
flex-grow: 1;
|
||||
|
||||
input {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.sort-dropdown {
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
.filter-dropdown {
|
||||
width: 15rem;
|
||||
}
|
||||
|
||||
.group-dropdown {
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
.labeled_button {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.instances {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
margin-right: auto;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.instance {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.instance-card-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: var(--gap-md);
|
||||
transition: 0.1s ease-in-out all !important; /* overrides Omorphia defaults */
|
||||
margin-bottom: 0;
|
||||
|
||||
.mod-image {
|
||||
--size: 100%;
|
||||
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
max-width: unset !important;
|
||||
max-height: unset !important;
|
||||
aspect-ratio: 1 / 1 !important;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
|
||||
.title {
|
||||
color: var(--color-contrast);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
line-height: 110%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-base);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-weight: 500;
|
||||
font-size: 0.775rem;
|
||||
line-height: 125%;
|
||||
margin: 0.25rem 0 0;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,376 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
DownloadIcon,
|
||||
ChevronRightIcon,
|
||||
formatNumber,
|
||||
CalendarIcon,
|
||||
HeartIcon,
|
||||
Avatar,
|
||||
Card,
|
||||
} from 'omorphia'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const modsRow = ref(null)
|
||||
const rows = ref(null)
|
||||
const maxInstancesPerRow = ref(0)
|
||||
const maxProjectsPerRow = ref(0)
|
||||
|
||||
const calculateCardsPerRow = () => {
|
||||
// Calculate how many cards fit in one row
|
||||
const containerWidth = rows.value[0].clientWidth
|
||||
// Convert container width from pixels to rem
|
||||
const containerWidthInRem =
|
||||
containerWidth / parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
maxInstancesPerRow.value = Math.floor((containerWidthInRem + 1) / 11)
|
||||
maxProjectsPerRow.value = Math.floor((containerWidthInRem + 1) / 17)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
calculateCardsPerRow()
|
||||
window.addEventListener('resize', calculateCardsPerRow)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', calculateCardsPerRow)
|
||||
})
|
||||
|
||||
defineProps({
|
||||
showInstance: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content">
|
||||
<div
|
||||
v-for="(row, index) in ['Jump back in', 'Popular modpacks', 'Popular mods']"
|
||||
ref="rows"
|
||||
:key="row"
|
||||
class="row"
|
||||
>
|
||||
<div class="header">
|
||||
<p>{{ row }}</p>
|
||||
<ChevronRightIcon />
|
||||
</div>
|
||||
<section v-if="index < 1" ref="modsRow" class="instances">
|
||||
<Card
|
||||
v-for="project in maxInstancesPerRow"
|
||||
:key="project"
|
||||
class="instance-card-item button-base"
|
||||
:class="{ highlighted: showInstance }"
|
||||
>
|
||||
<Avatar
|
||||
size="sm"
|
||||
src="https://launcher-files.modrinth.com/assets/default_profile.png"
|
||||
alt="Mod card"
|
||||
class="mod-image"
|
||||
/>
|
||||
<div class="project-info">
|
||||
<p class="title">Example Profile</p>
|
||||
<p class="description">Forge/Fabric 1.20.1</p>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
<section v-else ref="modsRow" class="projects">
|
||||
<div v-for="project in maxProjectsPerRow" :key="project" class="wrapper">
|
||||
<Card class="project-card button-base" :class="{ highlighted: showInstance }">
|
||||
<div
|
||||
class="banner no-image"
|
||||
:style="{
|
||||
'background-image': `url(https://launcher-files.modrinth.com/assets/maze-bg.png)`,
|
||||
}"
|
||||
>
|
||||
<div class="badges">
|
||||
<div class="badge">
|
||||
<DownloadIcon />
|
||||
{{ formatNumber(69420) }}
|
||||
</div>
|
||||
<div class="badge">
|
||||
<HeartIcon />
|
||||
{{ formatNumber(69) }}
|
||||
</div>
|
||||
<div class="badge">
|
||||
<CalendarIcon />
|
||||
Today
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="badges-wrapper no-image"
|
||||
:style="{
|
||||
background:
|
||||
'linear-gradient(rgba(' +
|
||||
[27, 217, 106, 0.03].join(',') +
|
||||
'), 65%, rgba(' +
|
||||
[27, 217, 106, 0.3].join(',') +
|
||||
'))',
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<Avatar
|
||||
class="icon"
|
||||
size="sm"
|
||||
src="https://launcher-files.modrinth.com/assets/default_profile.png"
|
||||
/>
|
||||
<div class="title">
|
||||
<div class="title-text">Example Project</div>
|
||||
<div class="author">by Modrinth</div>
|
||||
</div>
|
||||
<div class="description">
|
||||
An example project hangin on the Rinth. Very cool project, its probably on Forge and
|
||||
Fabric. Probably has a 401k and a family.
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
min-width: 100%;
|
||||
|
||||
&:nth-child(even) {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
gap: var(--gap-xs);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: bolder;
|
||||
white-space: nowrap;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
.instances {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||
grid-gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.projects {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
grid-gap: 1rem;
|
||||
|
||||
.item {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
width: 2.5rem !important;
|
||||
height: 2.5rem !important;
|
||||
|
||||
svg {
|
||||
width: 2.5rem !important;
|
||||
height: 2.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
.instance {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.instance-card-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: var(--gap-md);
|
||||
transition: 0.1s ease-in-out all !important; /* overrides Omorphia defaults */
|
||||
margin-bottom: 0;
|
||||
|
||||
.mod-image {
|
||||
--size: 100%;
|
||||
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
max-width: unset !important;
|
||||
max-height: unset !important;
|
||||
aspect-ratio: 1 / 1 !important;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
|
||||
.title {
|
||||
color: var(--color-contrast);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
line-height: 110%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--color-base);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-weight: 500;
|
||||
font-size: 0.775rem;
|
||||
line-height: 125%;
|
||||
margin: 0.25rem 0 0;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
|
||||
&:hover {
|
||||
.install:enabled {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.project-card {
|
||||
display: grid;
|
||||
grid-gap: 1rem;
|
||||
grid-template:
|
||||
'. . . .' 0
|
||||
'. icon title .' 3rem
|
||||
'banner banner banner banner' auto
|
||||
'. description description .' 3.5rem
|
||||
'. . . .' 0 / 0 3rem minmax(0, 1fr) 0;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
.icon {
|
||||
grid-area: icon;
|
||||
}
|
||||
|
||||
.title {
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
grid-area: title;
|
||||
white-space: nowrap;
|
||||
|
||||
.title-text {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: var(--font-size-sm);
|
||||
grid-area: author;
|
||||
}
|
||||
|
||||
.banner {
|
||||
grid-area: banner;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
|
||||
.badges-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
mix-blend-mode: hard-light;
|
||||
}
|
||||
|
||||
.badges {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: var(--gap-sm);
|
||||
gap: var(--gap-xs);
|
||||
display: flex;
|
||||
z-index: 1;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
grid-area: description;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
background-color: var(--color-raised-bg);
|
||||
font-size: var(--font-size-xs);
|
||||
padding: var(--gap-xs) var(--gap-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: var(--gap-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,496 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, readonly, ref } from 'vue'
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
CalendarIcon,
|
||||
Card,
|
||||
Categories,
|
||||
Checkbox,
|
||||
ClearIcon,
|
||||
ClientIcon,
|
||||
DownloadIcon,
|
||||
DropdownSelect,
|
||||
EnvironmentIndicator,
|
||||
formatCategory,
|
||||
formatCategoryHeader,
|
||||
formatNumber,
|
||||
HeartIcon,
|
||||
NavRow,
|
||||
Pagination,
|
||||
Promotion,
|
||||
SearchFilter,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
StarIcon,
|
||||
XIcon,
|
||||
} from 'omorphia'
|
||||
import Multiselect from 'vue-multiselect'
|
||||
import { handleError } from '@/store/state'
|
||||
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const query = ref('')
|
||||
const facets = ref([])
|
||||
const orFacets = ref([])
|
||||
const selectedVersions = ref([])
|
||||
const onlyOpenSource = ref(false)
|
||||
const showSnapshots = ref(false)
|
||||
const selectedEnvironments = ref([])
|
||||
const sortTypes = readonly([
|
||||
{ display: 'Relevance', name: 'relevance' },
|
||||
{ display: 'Download count', name: 'downloads' },
|
||||
{ display: 'Follow count', name: 'follows' },
|
||||
{ display: 'Recently published', name: 'newest' },
|
||||
{ display: 'Recently updated', name: 'updated' },
|
||||
])
|
||||
const sortType = ref(sortTypes[0])
|
||||
const maxResults = ref(20)
|
||||
const currentPage = ref(1)
|
||||
const projectType = ref('modpack')
|
||||
|
||||
const searchWrapper = ref(null)
|
||||
|
||||
const sortedCategories = computed(() => {
|
||||
const values = new Map()
|
||||
for (const category of categories.value.filter((cat) => cat.project_type === 'mod')) {
|
||||
if (!values.has(category.header)) {
|
||||
values.set(category.header, [])
|
||||
}
|
||||
values.get(category.header).push(category)
|
||||
}
|
||||
return values
|
||||
})
|
||||
|
||||
const [categories, loaders, availableGameVersions] = await Promise.all([
|
||||
get_categories().catch(handleError).then(ref),
|
||||
get_loaders().catch(handleError).then(ref),
|
||||
get_game_versions().catch(handleError).then(ref),
|
||||
])
|
||||
|
||||
const pageCount = ref(1)
|
||||
|
||||
const selectableProjectTypes = computed(() => {
|
||||
return [
|
||||
{ label: 'Shaders', href: `` },
|
||||
{ label: 'Resource Packs', href: `` },
|
||||
{ label: 'Data Packs', href: `` },
|
||||
{ label: 'Mods', href: '' },
|
||||
{ label: 'Modpacks', href: '' },
|
||||
]
|
||||
})
|
||||
|
||||
defineProps({
|
||||
showSearch: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-container">
|
||||
<aside class="filter-panel">
|
||||
<Card class="search-panel-card" :class="{ highlighted: showSearch }">
|
||||
<Button role="button" disabled> <ClearIcon /> Clear Filters </Button>
|
||||
<div class="loaders">
|
||||
<h2>Loaders</h2>
|
||||
<div
|
||||
v-for="loader in loaders.filter(
|
||||
(l) =>
|
||||
(projectType !== 'mod' && l.supported_project_types?.includes(projectType)) ||
|
||||
(projectType === 'mod' && ['fabric', 'forge', 'quilt'].includes(l.name))
|
||||
)"
|
||||
:key="loader"
|
||||
>
|
||||
<SearchFilter
|
||||
:active-filters="orFacets"
|
||||
:icon="loader.icon"
|
||||
:display-name="formatCategory(loader.name)"
|
||||
:facet-name="`categories:${encodeURIComponent(loader.name)}`"
|
||||
class="filter-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="versions">
|
||||
<h2>Minecraft versions</h2>
|
||||
<Checkbox v-model="showSnapshots" class="filter-checkbox" label="Include snapshots" />
|
||||
<multiselect
|
||||
v-model="selectedVersions"
|
||||
:options="
|
||||
showSnapshots
|
||||
? availableGameVersions.map((x) => x.version)
|
||||
: availableGameVersions
|
||||
.filter((it) => it.version_type === 'release')
|
||||
.map((x) => x.version)
|
||||
"
|
||||
:multiple="true"
|
||||
:searchable="true"
|
||||
:show-no-results="false"
|
||||
:close-on-select="false"
|
||||
:clear-search-on-select="false"
|
||||
:show-labels="false"
|
||||
placeholder="Choose versions..."
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="categoryList in Array.from(sortedCategories)"
|
||||
:key="categoryList[0]"
|
||||
class="categories"
|
||||
>
|
||||
<h2>{{ formatCategoryHeader(categoryList[0]) }}</h2>
|
||||
<div v-for="category in categoryList[1]" :key="category.name">
|
||||
<SearchFilter
|
||||
:active-filters="facets"
|
||||
:icon="category.icon"
|
||||
:display-name="formatCategory(category.name)"
|
||||
:facet-name="`categories:${encodeURIComponent(category.name)}`"
|
||||
class="filter-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="projectType !== 'datapack'" class="environment">
|
||||
<h2>Environments</h2>
|
||||
<SearchFilter
|
||||
:active-filters="selectedEnvironments"
|
||||
display-name="Client"
|
||||
facet-name="client"
|
||||
class="filter-checkbox"
|
||||
>
|
||||
<ClientIcon aria-hidden="true" />
|
||||
</SearchFilter>
|
||||
<SearchFilter
|
||||
:active-filters="selectedEnvironments"
|
||||
display-name="Server"
|
||||
facet-name="server"
|
||||
class="filter-checkbox"
|
||||
>
|
||||
<ServerIcon aria-hidden="true" />
|
||||
</SearchFilter>
|
||||
</div>
|
||||
<div class="open-source">
|
||||
<h2>Open source</h2>
|
||||
<Checkbox v-model="onlyOpenSource" label="Open source only" class="filter-checkbox" />
|
||||
</div>
|
||||
</Card>
|
||||
</aside>
|
||||
<div ref="searchWrapper" class="search">
|
||||
<Promotion class="promotion" :external="false" query-param="?r=launcher" />
|
||||
<Card class="project-type-container">
|
||||
<NavRow :links="selectableProjectTypes" />
|
||||
</Card>
|
||||
<Card class="search-panel-container" :class="{ highlighted: showSearch }">
|
||||
<div class="iconified-input">
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
v-model="query"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
:placeholder="`Search ${projectType}s...`"
|
||||
/>
|
||||
<Button @click="() => (query = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="inline-option">
|
||||
<span>Sort by</span>
|
||||
<DropdownSelect
|
||||
v-model="sortType"
|
||||
name="Sort by"
|
||||
:options="sortTypes"
|
||||
:display-name="(option) => option?.display"
|
||||
/>
|
||||
</div>
|
||||
<div class="inline-option">
|
||||
<span>Show per page</span>
|
||||
<DropdownSelect
|
||||
v-model="maxResults"
|
||||
name="Max results"
|
||||
:options="[5, 10, 15, 20, 50, 100]"
|
||||
:default-value="maxResults"
|
||||
:model-value="maxResults"
|
||||
class="limit-dropdown"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Pagination :page="currentPage" :count="pageCount" class="pagination-before" />
|
||||
<SplashScreen v-if="loading" />
|
||||
<section v-else class="project-list display-mode--list instance-results" role="list">
|
||||
<Card v-for="project in 20" :key="project" class="search-card button-base">
|
||||
<div class="icon">
|
||||
<Avatar
|
||||
src="https://launcher-files.modrinth.com/assets/default_profile.png"
|
||||
size="md"
|
||||
class="search-icon"
|
||||
/>
|
||||
</div>
|
||||
<div class="content-wrapper">
|
||||
<div class="title joined-text">
|
||||
<h2>Example Modpack</h2>
|
||||
<span>by Modrinth</span>
|
||||
</div>
|
||||
<div class="description">
|
||||
A very cool project that does cool project things that you can your friends can do.
|
||||
</div>
|
||||
<div class="tags">
|
||||
<Categories
|
||||
:categories="
|
||||
categories
|
||||
.filter((cat) => cat.project_type === projectType)
|
||||
.slice(project / 2, project / 2 + 3)
|
||||
"
|
||||
:type="modpack"
|
||||
>
|
||||
<EnvironmentIndicator
|
||||
:type-only="true"
|
||||
:client-side="true"
|
||||
:server-side="true"
|
||||
type="modpack"
|
||||
:search="true"
|
||||
/>
|
||||
</Categories>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats button-group">
|
||||
<div v-if="featured" class="badge">
|
||||
<StarIcon />
|
||||
Featured
|
||||
</div>
|
||||
<div class="badge">
|
||||
<DownloadIcon />
|
||||
{{ formatNumber(420) }}
|
||||
</div>
|
||||
<div class="badge">
|
||||
<HeartIcon />
|
||||
{{ formatNumber(69) }}
|
||||
</div>
|
||||
<div class="badge">
|
||||
<CalendarIcon />
|
||||
A minute ago
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
<pagination :page="currentPage" :count="pageCount" class="pagination-after" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
|
||||
<style lang="scss">
|
||||
.small-instance {
|
||||
min-height: unset !important;
|
||||
|
||||
.instance {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
.small-instance_info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-checkbox {
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 1rem;
|
||||
|
||||
svg {
|
||||
display: flex;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
button.checkbox {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style lang="scss" scoped>
|
||||
.project-type-dropdown {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.promotion {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.project-type-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-panel-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0 !important;
|
||||
min-height: min-content !important;
|
||||
}
|
||||
|
||||
.iconified-input {
|
||||
input {
|
||||
max-width: none !important;
|
||||
flex-basis: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.search-panel-container {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
padding: 1rem !important;
|
||||
white-space: nowrap;
|
||||
gap: 1rem;
|
||||
|
||||
.inline-option {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.sort-dropdown {
|
||||
max-width: 12.25rem;
|
||||
}
|
||||
|
||||
.limit-dropdown {
|
||||
width: 5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.iconified-input {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
|
||||
svg {
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
|
||||
.filter-panel {
|
||||
position: fixed;
|
||||
width: 20rem;
|
||||
padding: 1rem 0.5rem 1rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: fit-content;
|
||||
min-height: calc(100vh - 3.25rem);
|
||||
max-height: calc(100vh - 3.25rem);
|
||||
overflow-y: auto;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--color-contrast);
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.16rem;
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
scroll-behavior: smooth;
|
||||
margin: 0 1rem 0.5rem 20.5rem;
|
||||
width: calc(100% - 20.5rem);
|
||||
|
||||
.loading {
|
||||
margin: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-card {
|
||||
margin-bottom: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 6rem auto 7rem;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
|
||||
&:active:not(&:disabled) {
|
||||
scale: 0.98 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.joined-text {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: row;
|
||||
column-gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 0 !important;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.icon {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
align-self: center;
|
||||
height: 6rem;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
grid-column: 2 / 4;
|
||||
flex-direction: column;
|
||||
grid-row: 1;
|
||||
gap: 0.5rem;
|
||||
|
||||
.description {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 2;
|
||||
justify-self: stretch;
|
||||
align-self: start;
|
||||
}
|
||||
</style>
|
||||
@@ -1,270 +0,0 @@
|
||||
<script setup>
|
||||
import { Card, Slider, DropdownSelect, Toggle } from 'omorphia'
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
|
||||
const pageOptions = ['Home', 'Library']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<Card>
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Display</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="theme">
|
||||
<span class="label__title">Color theme</span>
|
||||
<span class="label__description">Change the global launcher color theme.</span>
|
||||
</label>
|
||||
<DropdownSelect
|
||||
id="theme"
|
||||
name="Theme dropdown"
|
||||
:options="['Dark']"
|
||||
:disabled="true"
|
||||
:default-value="'dark'"
|
||||
class="theme-dropdown disable-children"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="collapsed-nav">
|
||||
<span class="label__title">Collapsed navigation mode</span>
|
||||
<span class="label__description"
|
||||
>Change the style of the side navigation bar to a compact version.</span
|
||||
>
|
||||
</label>
|
||||
<Toggle id="collapsed-nav" :checked="false" :disabled="true" />
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="advanced-rendering">
|
||||
<span class="label__title">Advanced rendering</span>
|
||||
<span class="label__description">
|
||||
Enables advanced rendering such as blur effects that may cause performance issues
|
||||
without hardware-accelerated rendering.
|
||||
</span>
|
||||
</label>
|
||||
<Toggle id="advanced-rendering" :checked="true" :disabled="true" />
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="minimize-launcher">
|
||||
<span class="label__title">Minimize launcher</span>
|
||||
<span class="label__description"
|
||||
>Minimize the launcher when a Minecraft process starts.</span
|
||||
>
|
||||
</label>
|
||||
<Toggle id="minimize-launcher" :checked="false" :disabled="true" />
|
||||
</div>
|
||||
<div class="opening-page">
|
||||
<label for="opening-page">
|
||||
<span class="label__title">Default landing page</span>
|
||||
<span class="label__description">Change the page to which the launcher opens on.</span>
|
||||
</label>
|
||||
<DropdownSelect
|
||||
id="opening-page"
|
||||
name="Opening page dropdown"
|
||||
:options="pageOptions"
|
||||
default-value="Home"
|
||||
class="opening-page"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Resource management</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="max-downloads">
|
||||
<span class="label__title">Maximum concurrent downloads</span>
|
||||
<span class="label__description"
|
||||
>The maximum amount of files the launcher can download at the same time. Set this to a
|
||||
lower value if you have a poor internet connection.</span
|
||||
>
|
||||
</label>
|
||||
<Slider id="max-downloads" :min="1" :max="10" :step="1" :disabled="true" />
|
||||
</div>
|
||||
|
||||
<div class="adjacent-input">
|
||||
<label for="max-writes">
|
||||
<span class="label__title">Maximum concurrent writes</span>
|
||||
<span class="label__description"
|
||||
>The maximum amount of files the launcher can write to the disk at once. Set this to a
|
||||
lower value if you are frequently getting I/O errors.</span
|
||||
>
|
||||
</label>
|
||||
<Slider id="max-writes" :min="1" :max="50" :step="1" :disabled="true" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Privacy</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="opt-out-analytics">
|
||||
<span class="label__title">Disable analytics</span>
|
||||
<span class="label__description">
|
||||
Modrinth collects anonymized analytics and usage data to improve our user experience and
|
||||
customize your experience. Opting out will disable this data collection.
|
||||
</span>
|
||||
</label>
|
||||
<Toggle id="opt-out-analytics" :disabled="true" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Java settings</span>
|
||||
</h3>
|
||||
</div>
|
||||
<label for="java-17">
|
||||
<span class="label__title">Java 17 location</span>
|
||||
</label>
|
||||
<JavaSelector id="java-17" :version="17" model-value="" :disabled="true" />
|
||||
<label for="java-8">
|
||||
<span class="label__title">Java 8 location</span>
|
||||
</label>
|
||||
<JavaSelector id="java-8" :version="8" model-value="" :disabled="true" />
|
||||
<hr class="card-divider" />
|
||||
<label for="java-args">
|
||||
<span class="label__title">Java arguments</span>
|
||||
</label>
|
||||
<input
|
||||
id="java-args"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
class="installation-input"
|
||||
placeholder="Enter java arguments..."
|
||||
:disabled="true"
|
||||
/>
|
||||
<label for="env-vars">
|
||||
<span class="label__title">Environmental variables</span>
|
||||
</label>
|
||||
<input
|
||||
id="env-vars"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
class="installation-input"
|
||||
placeholder="Enter environmental variables..."
|
||||
:disabled="true"
|
||||
/>
|
||||
<hr class="card-divider" />
|
||||
<div class="adjacent-input">
|
||||
<label for="max-memory">
|
||||
<span class="label__title">Java memory</span>
|
||||
<span class="label__description">
|
||||
The memory allocated to each instance when it is ran.
|
||||
</span>
|
||||
</label>
|
||||
<Slider id="max-memory" :min="256" :max="10256" :step="1" unit="mb" :disabled="true" />
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Hooks</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="pre-launch">
|
||||
<span class="label__title">Pre launch</span>
|
||||
<span class="label__description"> Ran before the instance is launched. </span>
|
||||
</label>
|
||||
<input
|
||||
id="pre-launch"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
placeholder="Enter pre-launch command..."
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="wrapper">
|
||||
<span class="label__title">Wrapper</span>
|
||||
<span class="label__description"> Wrapper command for launching Minecraft. </span>
|
||||
</label>
|
||||
<input
|
||||
id="wrapper"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
placeholder="Enter wrapper command..."
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="post-exit">
|
||||
<span class="label__title">Post exit</span>
|
||||
<span class="label__description"> Ran after the game closes. </span>
|
||||
</label>
|
||||
<input
|
||||
id="post-exit"
|
||||
autocomplete="off"
|
||||
type="text"
|
||||
placeholder="Enter post-exit command..."
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="label">
|
||||
<h3>
|
||||
<span class="label__title size-card-header">Window size</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="width">
|
||||
<span class="label__title">Width</span>
|
||||
<span class="label__description"> The width of the game window when launched. </span>
|
||||
</label>
|
||||
<input
|
||||
id="width"
|
||||
autocomplete="off"
|
||||
type="number"
|
||||
placeholder="Enter width..."
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="adjacent-input">
|
||||
<label for="height">
|
||||
<span class="label__title">Height</span>
|
||||
<span class="label__description"> The height of the game window when launched. </span>
|
||||
</label>
|
||||
<input
|
||||
id="height"
|
||||
autocomplete="off"
|
||||
type="number"
|
||||
class="input"
|
||||
placeholder="Enter height..."
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.settings-page {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.installation-input {
|
||||
width: 100% !important;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.theme-dropdown {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.card-divider {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.disable-children * {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,293 +0,0 @@
|
||||
<script setup>
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Chips,
|
||||
XIcon,
|
||||
FolderOpenIcon,
|
||||
FolderSearchIcon,
|
||||
UpdatedIcon,
|
||||
} from 'omorphia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
get_default_launcher_path,
|
||||
get_importable_instances,
|
||||
import_instance,
|
||||
} from '@/helpers/import.js'
|
||||
import { open } from '@tauri-apps/api/dialog'
|
||||
import { handleError } from '@/store/state.js'
|
||||
|
||||
const props = defineProps({
|
||||
nextPage: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
prevPage: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const profiles = ref(
|
||||
new Map([
|
||||
['MultiMC', []],
|
||||
['GDLauncher', []],
|
||||
['ATLauncher', []],
|
||||
['Curseforge', []],
|
||||
['PrismLauncher', []],
|
||||
])
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const selectedProfileType = ref('MultiMC')
|
||||
const profileOptions = ref([
|
||||
{ name: 'MultiMC', path: '' },
|
||||
{ name: 'GDLauncher', path: '' },
|
||||
{ name: 'ATLauncher', path: '' },
|
||||
{ name: 'Curseforge', path: '' },
|
||||
{ name: 'PrismLauncher', path: '' },
|
||||
])
|
||||
|
||||
// Attempt to get import profiles on default paths
|
||||
const promises = profileOptions.value.map(async (option) => {
|
||||
const path = await get_default_launcher_path(option.name).catch(handleError)
|
||||
if (!path || path === '') return
|
||||
|
||||
// Try catch to allow failure and simply ignore default path attempt
|
||||
try {
|
||||
const instances = await get_importable_instances(option.name, path)
|
||||
|
||||
if (!instances) return
|
||||
profileOptions.value.find((profile) => profile.name === option.name).path = path
|
||||
profiles.value.set(
|
||||
option.name,
|
||||
instances.map((name) => ({ name, selected: false }))
|
||||
)
|
||||
} catch (error) {
|
||||
// Allow failure silently
|
||||
}
|
||||
})
|
||||
Promise.all(promises)
|
||||
|
||||
const selectLauncherPath = async () => {
|
||||
selectedProfileType.value.path = await open({ multiple: false, directory: true })
|
||||
|
||||
if (selectedProfileType.value.path) {
|
||||
await reload()
|
||||
}
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
const instances = await get_importable_instances(
|
||||
selectedProfileType.value.name,
|
||||
selectedProfileType.value.path
|
||||
).catch(handleError)
|
||||
profiles.value.set(
|
||||
selectedProfileType.value.name,
|
||||
instances.map((name) => ({ name, selected: false }))
|
||||
)
|
||||
}
|
||||
|
||||
const setPath = () => {
|
||||
profileOptions.value.find((profile) => profile.name === selectedProfileType.value.name).path =
|
||||
selectedProfileType.value.path
|
||||
}
|
||||
|
||||
const next = async () => {
|
||||
loading.value = true
|
||||
for (const launcher of Array.from(profiles.value.entries()).map(([launcher, profiles]) => ({
|
||||
launcher,
|
||||
path: profileOptions.value.find((option) => option.name === launcher).path,
|
||||
profiles,
|
||||
}))) {
|
||||
for (const profile of launcher.profiles.filter((profile) => profile.selected)) {
|
||||
await import_instance(launcher.launcher, launcher.path, profile.name)
|
||||
.catch(handleError)
|
||||
.then(() => console.log(`Successfully Imported ${profile.name} from ${launcher.launcher}`))
|
||||
}
|
||||
}
|
||||
loading.value = false
|
||||
props.nextPage()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<h2>Importing external profiles</h2>
|
||||
<Chips
|
||||
v-model="selectedProfileType"
|
||||
:items="profileOptions"
|
||||
:format-label="(profile) => profile?.name"
|
||||
/>
|
||||
<div class="path-selection">
|
||||
<h3>{{ selectedProfileType.name }} path</h3>
|
||||
<div class="path-input">
|
||||
<div class="iconified-input">
|
||||
<FolderOpenIcon />
|
||||
<input
|
||||
v-model="selectedProfileType.path"
|
||||
type="text"
|
||||
placeholder="Path to launcher"
|
||||
@change="setPath"
|
||||
/>
|
||||
<Button @click="() => (selectedLauncherPath = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<Button icon-only @click="selectLauncherPath">
|
||||
<FolderSearchIcon />
|
||||
</Button>
|
||||
<Button icon-only @click="reload">
|
||||
<UpdatedIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table">
|
||||
<div class="table-head table-row">
|
||||
<div class="toggle-all table-cell">
|
||||
<Checkbox
|
||||
class="select-checkbox"
|
||||
:model-value="profiles.get(selectedProfileType.name)?.every((child) => child.selected)"
|
||||
@update:model-value="
|
||||
(newValue) =>
|
||||
profiles
|
||||
.get(selectedProfileType.name)
|
||||
?.forEach((child) => (child.selected = newValue))
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="name-cell table-cell">Profile name</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
profiles.get(selectedProfileType.name) &&
|
||||
profiles.get(selectedProfileType.name).length > 0
|
||||
"
|
||||
class="table-content"
|
||||
>
|
||||
<div
|
||||
v-for="(profile, index) in profiles.get(selectedProfileType.name)"
|
||||
:key="index"
|
||||
class="table-row"
|
||||
>
|
||||
<div class="checkbox-cell table-cell">
|
||||
<Checkbox v-model="profile.selected" class="select-checkbox" />
|
||||
</div>
|
||||
<div class="name-cell table-cell">
|
||||
{{ profile.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="table-content empty">No profiles found</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<Button class="transparent" @click="prevPage"> Back </Button>
|
||||
<Button
|
||||
:disabled="
|
||||
loading ||
|
||||
!Array.from(profiles.values())
|
||||
.flatMap((e) => e)
|
||||
.some((e) => e.selected)
|
||||
"
|
||||
color="primary"
|
||||
@click="next"
|
||||
>
|
||||
{{
|
||||
loading
|
||||
? 'Importing...'
|
||||
: Array.from(profiles.values())
|
||||
.flatMap((e) => e)
|
||||
.some((e) => e.selected)
|
||||
? `Import ${
|
||||
Array.from(profiles.values())
|
||||
.flatMap((e) => e)
|
||||
.filter((e) => e.selected).length
|
||||
} profiles`
|
||||
: 'Select profiles to import'
|
||||
}}
|
||||
</Button>
|
||||
<Button class="transparent" @click="nextPage"> Next </Button>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card {
|
||||
padding: var(--gap-xl);
|
||||
min-height: unset;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.path-selection {
|
||||
padding: var(--gap-xl);
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: var(--gap-md);
|
||||
gap: var(--gap-md);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.path-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.iconified-input {
|
||||
flex-grow: 1;
|
||||
:deep(input) {
|
||||
width: 100%;
|
||||
flex-basis: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
border: 1px solid var(--color-bg);
|
||||
margin-bottom: var(--gap-md);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
grid-template-columns: min-content auto;
|
||||
}
|
||||
|
||||
.table-content {
|
||||
max-height: calc(5 * (18px + 2rem));
|
||||
height: calc(5 * (18px + 2rem));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.select-checkbox {
|
||||
button.checkbox {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-md);
|
||||
|
||||
.transparent {
|
||||
padding: var(--gap-sm) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bolder;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,11 @@
|
||||
<script setup>
|
||||
import { Button, LogInIcon, Modal, ClipboardCopyIcon, GlobeIcon, Card } from 'omorphia'
|
||||
import { authenticate_await_completion, authenticate_begin_flow } from '@/helpers/auth.js'
|
||||
import { Button, LogInIcon, Card } from 'omorphia'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { useTheming } from '@/store/theme.js'
|
||||
import mixpanel from 'mixpanel-browser'
|
||||
import { get, set } from '@/helpers/settings.js'
|
||||
import { ref } from 'vue'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
|
||||
const themeStore = useTheming()
|
||||
const loginUrl = ref(null)
|
||||
const loginModal = ref()
|
||||
const loginCode = ref(null)
|
||||
const finalizedLogin = ref(false)
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
const loading = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
nextPage: {
|
||||
@@ -26,42 +19,21 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
async function login() {
|
||||
const loginSuccess = await authenticate_begin_flow().catch(handleError)
|
||||
loginUrl.value = loginSuccess.verification_uri
|
||||
loginCode.value = loginSuccess.user_code
|
||||
loginModal.value.show()
|
||||
try {
|
||||
loading.value = true
|
||||
const loggedIn = await login_flow()
|
||||
|
||||
await window.__TAURI_INVOKE__('tauri', {
|
||||
__tauriModule: 'Shell',
|
||||
message: {
|
||||
cmd: 'open',
|
||||
path: loginSuccess.verification_uri,
|
||||
},
|
||||
})
|
||||
if (loggedIn) {
|
||||
await set_default_user(loggedIn.id).catch(handleError)
|
||||
}
|
||||
|
||||
const loggedIn = await authenticate_await_completion().catch(handleError)
|
||||
loginModal.value.hide()
|
||||
|
||||
const settings = await get().catch(handleError)
|
||||
settings.default_user = loggedIn.id
|
||||
await set(settings).catch(handleError)
|
||||
finalizedLogin.value = true
|
||||
await mixpanel.track('AccountLogIn')
|
||||
props.nextPage()
|
||||
}
|
||||
|
||||
const openUrl = async () => {
|
||||
await window.__TAURI_INVOKE__('tauri', {
|
||||
__tauriModule: 'Shell',
|
||||
message: {
|
||||
cmd: 'open',
|
||||
path: loginUrl.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const clipboardWrite = async (a) => {
|
||||
navigator.clipboard.writeText(a)
|
||||
await mixpanel.track('AccountLogIn')
|
||||
loading.value = false
|
||||
props.nextPage()
|
||||
} catch (err) {
|
||||
loading.value = false
|
||||
handleSevereError(err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -87,45 +59,15 @@ const clipboardWrite = async (a) => {
|
||||
<div class="action-row">
|
||||
<Button class="transparent" large @click="prevPage"> Back </Button>
|
||||
<div class="sign-in-pair">
|
||||
<Button color="primary" large @click="login">
|
||||
<LogInIcon v-if="!finalizedLogin" />
|
||||
{{ finalizedLogin ? 'Next' : 'Sign in' }}
|
||||
<Button color="primary" large :disabled="loading" @click="login">
|
||||
<LogInIcon />
|
||||
{{ loading ? 'Loading...' : 'Sign in' }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button class="transparent" large @click="nextPage()"> Next </Button>
|
||||
<Button class="transparent" large @click="nextPage()"> Finish</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Modal ref="loginModal" header="Signing in" :noblur="!themeStore.advancedRendering">
|
||||
<div class="modal-body">
|
||||
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
|
||||
<div class="modal-text">
|
||||
<div class="label">Copy this code</div>
|
||||
<div class="code-text">
|
||||
<div class="code">
|
||||
{{ loginCode }}
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="'Copy code'"
|
||||
icon-only
|
||||
large
|
||||
color="raised"
|
||||
@click="() => clipboardWrite(loginCode)"
|
||||
>
|
||||
<ClipboardCopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div>And enter it on Microsoft's website to sign in.</div>
|
||||
<div class="iconified-input">
|
||||
<LogInIcon />
|
||||
<input type="text" :value="loginUrl" readonly />
|
||||
<Button v-tooltip="'Open link'" icon-only color="raised" @click="openUrl">
|
||||
<GlobeIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -188,79 +130,10 @@ const clipboardWrite = async (a) => {
|
||||
}
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
background-color: white !important;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-lg);
|
||||
align-items: center;
|
||||
padding: var(--gap-lg);
|
||||
|
||||
.modal-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
width: 100%;
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-text {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: var(--gap-xs);
|
||||
align-items: center;
|
||||
|
||||
.code {
|
||||
background-color: var(--color-bg);
|
||||
border-radius: var(--radius-md);
|
||||
border: solid 1px var(--color-button-bg);
|
||||
font-family: var(--mono-font);
|
||||
letter-spacing: var(--gap-md);
|
||||
color: var(--color-contrast);
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
padding: var(--gap-sm) 0 var(--gap-sm) var(--gap-md);
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.sticker {
|
||||
width: 100%;
|
||||
max-width: 25rem;
|
||||
height: auto;
|
||||
margin-bottom: var(--gap-lg);
|
||||
}
|
||||
|
||||
.sign-in-pair {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-sm);
|
||||
align-items: center;
|
||||
}
|
||||
.code {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.card {
|
||||
background: var(--color-base);
|
||||
color: var(--color-contrast);
|
||||
padding: 0.5rem 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -75,7 +75,7 @@ async function signIn() {
|
||||
const creds = await login_pass(
|
||||
username.value,
|
||||
password.value,
|
||||
window.turnstile.getResponse()
|
||||
window.turnstile.getResponse(),
|
||||
).catch(handleError)
|
||||
window.turnstile.reset()
|
||||
|
||||
@@ -102,7 +102,7 @@ async function createAccount() {
|
||||
email.value,
|
||||
password.value,
|
||||
window.turnstile.getResponse(),
|
||||
subscribe.value
|
||||
subscribe.value,
|
||||
).catch(handleError)
|
||||
window.turnstile.reset()
|
||||
|
||||
|
||||
@@ -1,40 +1,12 @@
|
||||
<script setup>
|
||||
import {
|
||||
Button,
|
||||
HomeIcon,
|
||||
SearchIcon,
|
||||
LibraryIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
XIcon,
|
||||
Notifications,
|
||||
} from 'omorphia'
|
||||
import { appWindow } from '@tauri-apps/api/window'
|
||||
import { saveWindowState, StateFlags } from 'tauri-plugin-window-state-api'
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
|
||||
import FakeAppBar from '@/components/ui/tutorial/FakeAppBar.vue'
|
||||
import FakeAccountsCard from '@/components/ui/tutorial/FakeAccountsCard.vue'
|
||||
import { MinimizeIcon, MaximizeIcon } from '@/assets/icons'
|
||||
import ModrinthLoadingIndicator from '@/components/modrinth-loading-indicator.js'
|
||||
import FakeSearch from '@/components/ui/tutorial/FakeSearch.vue'
|
||||
import FakeGridDisplay from '@/components/ui/tutorial/FakeGridDisplay.vue'
|
||||
import FakeRowDisplay from '@/components/ui/tutorial/FakeRowDisplay.vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { window } from '@tauri-apps/api'
|
||||
import TutorialTip from '@/components/ui/tutorial/TutorialTip.vue'
|
||||
import FakeSettings from '@/components/ui/tutorial/FakeSettings.vue'
|
||||
import { Button } from 'omorphia'
|
||||
import { ref } from 'vue'
|
||||
import { get, set } from '@/helpers/settings.js'
|
||||
import mixpanel from 'mixpanel-browser'
|
||||
import GalleryImage from '@/components/ui/tutorial/GalleryImage.vue'
|
||||
import LoginCard from '@/components/ui/tutorial/LoginCard.vue'
|
||||
import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue'
|
||||
import { auto_install_java, get_jre } from '@/helpers/jre.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import ImportingCard from '@/components/ui/tutorial/ImportingCard.vue'
|
||||
import ModrinthLoginScreen from '@/components/ui/tutorial/ModrinthLoginScreen.vue'
|
||||
import PreImportScreen from '@/components/ui/tutorial/PreImportScreen.vue'
|
||||
|
||||
const phase = ref(0)
|
||||
const page = ref(1)
|
||||
|
||||
const props = defineProps({
|
||||
@@ -46,15 +18,6 @@ const props = defineProps({
|
||||
|
||||
const flow = ref('')
|
||||
|
||||
const nextPhase = () => {
|
||||
phase.value++
|
||||
mixpanel.track('TutorialPhase', { page: phase.value })
|
||||
}
|
||||
|
||||
const prevPhase = () => {
|
||||
phase.value--
|
||||
}
|
||||
|
||||
const nextPage = (newFlow) => {
|
||||
page.value++
|
||||
mixpanel.track('OnboardingPage', { page: page.value })
|
||||
@@ -64,10 +27,6 @@ const nextPage = (newFlow) => {
|
||||
}
|
||||
}
|
||||
|
||||
const endOnboarding = () => {
|
||||
nextPhase()
|
||||
}
|
||||
|
||||
const prevPage = () => {
|
||||
page.value--
|
||||
}
|
||||
@@ -79,44 +38,21 @@ const finishOnboarding = async () => {
|
||||
await set(settings)
|
||||
props.finish()
|
||||
}
|
||||
|
||||
async function fetchSettings() {
|
||||
const fetchSettings = await get().catch(handleError)
|
||||
if (!fetchSettings.java_globals) {
|
||||
fetchSettings.java_globals = {}
|
||||
}
|
||||
|
||||
if (!fetchSettings.java_globals.JAVA_17) {
|
||||
const path1 = await auto_install_java(17).catch(handleError)
|
||||
fetchSettings.java_globals.JAVA_17 = await get_jre(path1).catch(handleError)
|
||||
}
|
||||
|
||||
if (!fetchSettings.java_globals.JAVA_8) {
|
||||
const path2 = await auto_install_java(8).catch(handleError)
|
||||
fetchSettings.java_globals.JAVA_8 = await get_jre(path2).catch(handleError)
|
||||
}
|
||||
|
||||
await set(fetchSettings).catch(handleError)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="phase === 0" class="onboarding">
|
||||
<div class="onboarding">
|
||||
<StickyTitleBar />
|
||||
<GalleryImage
|
||||
v-if="page === 1"
|
||||
:gallery="[
|
||||
{
|
||||
url: 'https://cdn.discordapp.com/attachments/817413688771608587/1131109353928265809/Screenshot_2023-07-15_at_4.16.18_PM.png',
|
||||
url: 'https://launcher-files.modrinth.com/onboarding/home.png',
|
||||
title: 'Discovery',
|
||||
subtitle: 'See the latest and greatest mods and modpacks to play with from Modrinth',
|
||||
},
|
||||
{
|
||||
url: 'https://cdn.discordapp.com/attachments/817413688771608587/1131109354238640238/Screenshot_2023-07-15_at_4.17.43_PM.png',
|
||||
url: 'https://launcher-files.modrinth.com/onboarding/discover.png',
|
||||
title: 'Profile Management',
|
||||
subtitle:
|
||||
'Play, manage and search through all the amazing profiles downloaded on your computer at any time, even offline!',
|
||||
@@ -126,185 +62,7 @@ onMounted(async () => {
|
||||
>
|
||||
<Button color="primary" @click="nextPage"> Get started </Button>
|
||||
</GalleryImage>
|
||||
<LoginCard v-else-if="page === 2" :next-page="nextPage" :prev-page="prevPage" />
|
||||
<ModrinthLoginScreen
|
||||
v-else-if="page === 3"
|
||||
:modal="false"
|
||||
:next-page="nextPage"
|
||||
:prev-page="prevPage"
|
||||
:flow="flow"
|
||||
/>
|
||||
<PreImportScreen
|
||||
v-else-if="page === 4"
|
||||
:next-page="endOnboarding"
|
||||
:prev-page="prevPage"
|
||||
:import-page="nextPage"
|
||||
/>
|
||||
<ImportingCard v-else-if="page === 5" :next-page="endOnboarding" :prev-page="prevPage" />
|
||||
</div>
|
||||
<div v-else class="container">
|
||||
<StickyTitleBar v-if="phase === 9" />
|
||||
<div v-if="phase < 9" class="nav-container">
|
||||
<div class="nav-section">
|
||||
<FakeAccountsCard :show-demo="phase === 3">
|
||||
<TutorialTip
|
||||
:progress-function="nextPhase"
|
||||
:previous-function="prevPhase"
|
||||
:progress="phase"
|
||||
title="Signing in"
|
||||
description="The Modrinth App uses your Microsoft account to allow you to launch Minecraft. You can sign in with your Microsoft account here, and switch between multiple accounts."
|
||||
/>
|
||||
</FakeAccountsCard>
|
||||
<div class="pages-list">
|
||||
<div class="btn icon-only" :class="{ active: phase < 4 }">
|
||||
<HomeIcon />
|
||||
</div>
|
||||
<div
|
||||
class="btn icon-only"
|
||||
:class="{ active: phase === 4 || phase === 5, highlighted: phase === 4 }"
|
||||
>
|
||||
<SearchIcon />
|
||||
</div>
|
||||
<div
|
||||
class="btn icon-only"
|
||||
:class="{
|
||||
active: phase === 6 || phase === 7,
|
||||
highlighted: phase === 6,
|
||||
}"
|
||||
>
|
||||
<LibraryIcon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings pages-list">
|
||||
<Button class="sleek-primary" icon-only>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
<Button icon-only :class="{ active: phase === 8, highlighted: phase === 8 }">
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="phase < 9" class="view">
|
||||
<div data-tauri-drag-region class="appbar">
|
||||
<section class="navigation-controls">
|
||||
<Breadcrumbs data-tauri-drag-region />
|
||||
</section>
|
||||
<section class="mod-stats">
|
||||
<FakeAppBar
|
||||
:show-running="phase === 7"
|
||||
:show-download="phase === 5"
|
||||
:exit="finishOnboarding"
|
||||
>
|
||||
<template #running>
|
||||
<TutorialTip
|
||||
:progress-function="nextPhase"
|
||||
:previous-function="prevPhase"
|
||||
:progress="phase"
|
||||
title="Playing modpacks"
|
||||
description="When you launch a modpack, you can manage it directly in the title bar here. You can stop the modpack, view the logs, and see all currently running packs."
|
||||
/>
|
||||
</template>
|
||||
<template #download>
|
||||
<TutorialTip
|
||||
:progress-function="nextPhase"
|
||||
:previous-function="prevPhase"
|
||||
:progress="phase"
|
||||
title="Installing modpacks"
|
||||
description="When you download a modpack, Modrinth App will automatically install it for you. You can view the progress of the installation here."
|
||||
/>
|
||||
</template>
|
||||
</FakeAppBar>
|
||||
</section>
|
||||
<section class="window-controls">
|
||||
<Button class="titlebar-button" icon-only @click="() => appWindow.minimize()">
|
||||
<MinimizeIcon />
|
||||
</Button>
|
||||
<Button class="titlebar-button" icon-only @click="() => appWindow.toggleMaximize()">
|
||||
<MaximizeIcon />
|
||||
</Button>
|
||||
<Button
|
||||
class="titlebar-button close"
|
||||
icon-only
|
||||
@click="
|
||||
() => {
|
||||
saveWindowState(StateFlags.ALL)
|
||||
window.getCurrent().close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
<div class="router-view">
|
||||
<ModrinthLoadingIndicator
|
||||
offset-height="var(--appbar-height)"
|
||||
offset-width="var(--sidebar-width)"
|
||||
/>
|
||||
<Notifications ref="notificationsWrapper" />
|
||||
<FakeRowDisplay v-if="phase < 4 || phase > 8" :show-instance="phase === 2" />
|
||||
<FakeGridDisplay v-if="phase === 6 || phase === 7" :show-instances="phase === 6" />
|
||||
<suspense>
|
||||
<FakeSearch v-if="phase === 4 || phase === 5" :show-search="phase === 4" />
|
||||
</suspense>
|
||||
<FakeSettings v-if="phase === 8" />
|
||||
</div>
|
||||
</div>
|
||||
<TutorialTip
|
||||
v-if="phase === 1"
|
||||
class="first-tip highlighted"
|
||||
:progress-function="nextPhase"
|
||||
:progress="phase"
|
||||
title="Enter the Modrinth App!"
|
||||
description="This is the Modrinth App guide. Key parts are marked with a green shadow. Click 'Next' to
|
||||
proceed. You can leave the tutorial anytime using the Exit button above the plus button on the bottom left."
|
||||
/>
|
||||
<div v-if="phase === 1" class="whole-page-shadow" />
|
||||
<TutorialTip
|
||||
v-if="phase === 2"
|
||||
class="sticky-tip"
|
||||
:progress-function="nextPhase"
|
||||
:previous-function="prevPhase"
|
||||
:progress="phase"
|
||||
title="Home page"
|
||||
description="This is the home page. Here you can see all the latest modpacks, mods, and other content on Modrinth. You can also see a few of your installed modpacks here."
|
||||
/>
|
||||
<TutorialTip
|
||||
v-if="phase === 4"
|
||||
class="sticky-tip"
|
||||
:progress-function="nextPhase"
|
||||
:previous-function="prevPhase"
|
||||
:progress="phase"
|
||||
title="Searching for content"
|
||||
description="You can search for content on Modrinth by navigating to the search page. You can search for mods, modpacks, and more, and install them directly from here."
|
||||
/>
|
||||
<TutorialTip
|
||||
v-if="phase === 6"
|
||||
class="sticky-tip"
|
||||
:progress-function="nextPhase"
|
||||
:previous-function="prevPhase"
|
||||
:progress="phase"
|
||||
title="Modpack library"
|
||||
description="You can view all your installed modpacks in the library. You can launch any modpack from here, or click the card to view more information about it."
|
||||
/>
|
||||
<TutorialTip
|
||||
v-if="phase === 8"
|
||||
class="sticky-tip"
|
||||
:progress-function="nextPhase"
|
||||
:previous-function="prevPhase"
|
||||
:progress="phase"
|
||||
title="Settings"
|
||||
description="You will be able to view and change the settings for the Modrinth App here. You can change the appearance, set and download new Java versions, and more."
|
||||
/>
|
||||
<TutorialTip
|
||||
v-if="phase === 9"
|
||||
class="final-tip highlighted"
|
||||
:progress-function="finishOnboarding"
|
||||
:progress="phase"
|
||||
title="Enter the Modrinth App!"
|
||||
description="That's it! You're ready to use the Modrinth App. If you need help, you can always join our discord server!"
|
||||
/>
|
||||
<LoginCard v-else-if="page === 2" :next-page="finishOnboarding" :prev-page="prevPage" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -489,7 +247,8 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.onboarding {
|
||||
background: top linear-gradient(0deg, #31375f, rgba(8, 14, 55, 0)),
|
||||
background:
|
||||
top linear-gradient(0deg, #31375f, rgba(8, 14, 55, 0)),
|
||||
url(https://cdn.modrinth.com/landing-new/landing-lower.webp);
|
||||
background-size: cover;
|
||||
height: 100vh;
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
<script setup>
|
||||
import { Button, Card, ModrinthIcon } from 'omorphia'
|
||||
import { ATLauncherIcon, PrismIcon } from '@/assets/external/index.js'
|
||||
|
||||
defineProps({
|
||||
nextPage: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
prevPage: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
importPage: {
|
||||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="import-card">
|
||||
<div class="base-ellipsis ellipsis-1" />
|
||||
<div class="base-ellipsis ellipsis-2" />
|
||||
<div class="base-ellipsis ellipsis-3" />
|
||||
<div class="base-ellipsis ellipsis-4" />
|
||||
<div class="logo">
|
||||
<ModrinthIcon />
|
||||
</div>
|
||||
<div class="launcher-stamp top-left">
|
||||
<ATLauncherIcon />
|
||||
</div>
|
||||
<div class="launcher-stamp top-right">
|
||||
<PrismIcon />
|
||||
</div>
|
||||
<div class="launcher-stamp bottom-left">
|
||||
<img src="@/assets/external/gdlauncher.png" alt="GDLauncher" />
|
||||
</div>
|
||||
<div class="launcher-stamp bottom-right">
|
||||
<img src="@/assets/external/multimc.webp" alt="MultiMC" />
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<h2>Importing</h2>
|
||||
<div class="markdown-body">
|
||||
<p>
|
||||
You can import projects from other launchers by clicking below, or you can skip ahead.
|
||||
</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<Button class="transparent" @click="prevPage"> Back </Button>
|
||||
<Button color="primary" @click="importPage"> Import </Button>
|
||||
<Button class="transparent" @click="nextPage"> Next </Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.import-card {
|
||||
width: 40rem;
|
||||
height: 32rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.base-ellipsis {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
border-radius: 100%;
|
||||
top: calc(var(--gap-xl) + 5rem);
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100%;
|
||||
background-color: rgba(#1bd96a, 0.1);
|
||||
}
|
||||
|
||||
.ellipsis-1 {
|
||||
width: 15rem;
|
||||
height: 15rem;
|
||||
}
|
||||
|
||||
.ellipsis-2 {
|
||||
width: 30rem;
|
||||
height: 30rem;
|
||||
}
|
||||
|
||||
.ellipsis-3 {
|
||||
width: 45rem;
|
||||
height: 45rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: absolute;
|
||||
top: calc(var(--gap-xl) + 5rem);
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-accent-contrast);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
z-index: 1;
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.launcher-stamp {
|
||||
position: absolute;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
background-color: var(--color-accent-contrast);
|
||||
border-radius: 50%;
|
||||
z-index: 1;
|
||||
opacity: 0.65;
|
||||
padding: var(--gap-lg);
|
||||
|
||||
&.top-left {
|
||||
top: var(--gap-xl);
|
||||
left: 3rem;
|
||||
}
|
||||
|
||||
&.top-right {
|
||||
top: var(--gap-xl);
|
||||
right: 3rem;
|
||||
}
|
||||
|
||||
&.bottom-left {
|
||||
top: 12rem;
|
||||
left: 5.5rem;
|
||||
}
|
||||
|
||||
&.bottom-right {
|
||||
top: 12rem;
|
||||
right: 5.5rem;
|
||||
}
|
||||
|
||||
svg,
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.info-section {
|
||||
position: absolute;
|
||||
bottom: var(--gap-xl);
|
||||
left: 50%;
|
||||
width: 30rem;
|
||||
transform: translateX(-50%);
|
||||
padding: var(--gap-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--gap-md);
|
||||
backdrop-filter: blur(1rem) brightness(0.4);
|
||||
-webkit-backdrop-filter: blur(1rem) brightness(0.4);
|
||||
border-radius: var(--radius-lg);
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
gap: var(--gap-md);
|
||||
width: 100%;
|
||||
align-content: center;
|
||||
|
||||
.transparent {
|
||||
padding: var(--gap-sm) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,72 +0,0 @@
|
||||
<script setup>
|
||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
import { Button, Card } from 'omorphia'
|
||||
|
||||
defineProps({
|
||||
progress: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Tutorial',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: 'This is a tutorial',
|
||||
},
|
||||
progressFunction: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
previousFunction: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="tutorial-card">
|
||||
<h3 class="tutorial-title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="tutorial-body">
|
||||
{{ description }}
|
||||
</div>
|
||||
<div class="tutorial-footer">
|
||||
<Button v-if="previousFunction" class="transparent" @click="previousFunction"> Back </Button>
|
||||
{{ progress }}/9
|
||||
<ProgressBar :progress="(progress / 9) * 100" />
|
||||
<Button color="primary" :action="progressFunction">
|
||||
{{ progress === 9 ? 'Finish' : 'Next' }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tutorial-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
border: 1px solid var(--color-button-bg);
|
||||
width: 22rem;
|
||||
}
|
||||
|
||||
.tutorial-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tutorial-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--gap-sm);
|
||||
|
||||
.transparent {
|
||||
border: 1px solid var(--color-button-bg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -18,28 +18,20 @@ import { invoke } from '@tauri-apps/api/tauri'
|
||||
/// This returns a DeviceLoginSuccess object, with two relevant fields:
|
||||
/// - verification_uri: the URL to go to to complete the flow
|
||||
/// - user_code: the code to enter on the verification_uri page
|
||||
export async function authenticate_begin_flow() {
|
||||
return await invoke('plugin:auth|auth_authenticate_begin_flow')
|
||||
export async function login() {
|
||||
return await invoke('auth_login')
|
||||
}
|
||||
|
||||
/// Authenticate a user with Hydra - part 2
|
||||
/// This completes the authentication flow quasi-synchronously, returning the sign-in credentials
|
||||
/// (and also adding the credentials to the state)
|
||||
/// This returns a Credentials object
|
||||
export async function authenticate_await_completion() {
|
||||
return await invoke('plugin:auth|auth_authenticate_await_completion')
|
||||
}
|
||||
|
||||
export async function cancel_flow() {
|
||||
return await invoke('plugin:auth|auth_cancel_flow')
|
||||
}
|
||||
|
||||
/// Refresh some credentials using Hydra, if needed
|
||||
/// Retrieves the default user
|
||||
/// user is UUID
|
||||
/// update_name is bool
|
||||
/// Returns a Credentials object
|
||||
export async function refresh(user, update_name) {
|
||||
return await invoke('plugin:auth|auth_refresh', { user, update_name })
|
||||
export async function get_default_user() {
|
||||
return await invoke('plugin:auth|auth_get_default_user')
|
||||
}
|
||||
|
||||
/// Updates the default user
|
||||
/// user is UUID
|
||||
export async function set_default_user(user) {
|
||||
return await invoke('plugin:auth|auth_set_default_user', { user })
|
||||
}
|
||||
|
||||
/// Remove a user account from the database
|
||||
@@ -48,13 +40,6 @@ export async function remove_user(user) {
|
||||
return await invoke('plugin:auth|auth_remove_user', { user })
|
||||
}
|
||||
|
||||
// Add a path as a profile in-memory
|
||||
// user is UUID
|
||||
/// Returns a bool
|
||||
export async function has_user(user) {
|
||||
return await invoke('plugin:auth|auth_has_user', { user })
|
||||
}
|
||||
|
||||
/// Returns a list of users
|
||||
/// Returns an Array of Credentials
|
||||
export async function users() {
|
||||
|
||||
@@ -14,43 +14,10 @@ JavaVersion {
|
||||
|
||||
*/
|
||||
|
||||
/// Get all JREs that exist on the system
|
||||
// Returns an array of JavaVersion
|
||||
export async function get_all_jre() {
|
||||
return await invoke('plugin:jre|jre_get_all_jre')
|
||||
}
|
||||
|
||||
// Finds all the installation of Java 7, if it exists
|
||||
// Returns [JavaVersion]
|
||||
export async function find_jre_8_jres() {
|
||||
const jres = await invoke('plugin:jre|jre_get_all_jre')
|
||||
const version = '1.8'
|
||||
const allowHigher = false
|
||||
return await invoke('plugin:jre|jre_find_filtered_jres', { jres, version, allowHigher })
|
||||
}
|
||||
|
||||
// Finds the installation of Java 17, if it exists
|
||||
// Returns [JavaVersion]
|
||||
export async function find_jre_17_jres() {
|
||||
const jres = await invoke('plugin:jre|jre_get_all_jre')
|
||||
const version = '1.17'
|
||||
const allowHigher = false
|
||||
return await invoke('plugin:jre|jre_find_filtered_jres', { jres, version, allowHigher })
|
||||
}
|
||||
|
||||
// Finds the highest version of Java 18+, if it exists
|
||||
// Returns [JavaVersion]
|
||||
export async function find_jre_18plus_jres() {
|
||||
const jres = await invoke('plugin:jre|jre_get_all_jre')
|
||||
const version = '1.18'
|
||||
const allowHigher = true
|
||||
return await invoke('plugin:jre|jre_find_filtered_jres', { jres, version, allowHigher })
|
||||
}
|
||||
|
||||
// Validates globals. Recommend directing the user to reassigned the globals if this returns false
|
||||
// Returns [JavaVersion]
|
||||
export async function validate_globals() {
|
||||
return await invoke('plugin:jre|jre_validate_globals')
|
||||
export async function find_filtered_jres(version) {
|
||||
return await invoke('plugin:jre|jre_find_filtered_jres', { version })
|
||||
}
|
||||
|
||||
// Gets java version from a specific path by trying to run 'java -version' on it.
|
||||
@@ -65,15 +32,6 @@ export async function test_jre(path, majorVersion, minorVersion) {
|
||||
return await invoke('plugin:jre|jre_test_jre', { path, majorVersion, minorVersion })
|
||||
}
|
||||
|
||||
// Autodetect Java globals, by searching the users computer.
|
||||
// Returns a *NEW* JavaGlobals that can be put into Settings
|
||||
export async function autodetect_java_globals() {
|
||||
const java8 = await find_jre_8_jres()
|
||||
const java17 = await find_jre_17_jres()
|
||||
const java18plus = await find_jre_18plus_jres()
|
||||
return await invoke('plugin:jre|jre_autodetect_java_globals', { java8, java17, java18plus })
|
||||
}
|
||||
|
||||
// Automatically installs specified java version
|
||||
export async function auto_install_java(javaVersion) {
|
||||
return await invoke('plugin:jre|jre_auto_install_java', { javaVersion })
|
||||
|
||||
@@ -54,7 +54,3 @@ export async function delete_logs(profilePath) {
|
||||
export async function get_latest_log_cursor(profilePath, cursor) {
|
||||
return await invoke('plugin:logs|logs_get_latest_log_cursor', { profilePath, cursor })
|
||||
}
|
||||
// For std log (from modrinth app written latest_stdout.log, contains stdout and stderr)
|
||||
export async function get_std_log_cursor(profilePath, cursor) {
|
||||
return await invoke('plugin:logs|logs_get_std_log_cursor', { profilePath, cursor })
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ function optOutTrackingWrapper(originalOptOutTracking) {
|
||||
}
|
||||
}
|
||||
export const mixpanel_opt_out_tracking = optOutTrackingWrapper(
|
||||
mixpanel.opt_out_tracking.bind(mixpanel)
|
||||
mixpanel.opt_out_tracking.bind(mixpanel),
|
||||
)
|
||||
|
||||
// mixpanel_opt_in_tracking()
|
||||
@@ -37,7 +37,7 @@ function optInTrackingWrapper(originalOptInTracking) {
|
||||
}
|
||||
}
|
||||
export const mixpanel_opt_in_tracking = optInTrackingWrapper(
|
||||
mixpanel.opt_in_tracking.bind(mixpanel)
|
||||
mixpanel.opt_in_tracking.bind(mixpanel),
|
||||
)
|
||||
|
||||
// mixpanel_init
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function install(projectId, versionId, packTitle, iconUrl) {
|
||||
profile_creator.gameVersion,
|
||||
profile_creator.modloader,
|
||||
profile_creator.loaderVersion,
|
||||
profile_creator.icon
|
||||
profile_creator.icon,
|
||||
)
|
||||
|
||||
return await invoke('plugin:pack|pack_install', { location, profile })
|
||||
@@ -39,7 +39,7 @@ export async function install_from_file(path) {
|
||||
profile_creator.gameVersion,
|
||||
profile_creator.modloader,
|
||||
profile_creator.loaderVersion,
|
||||
profile_creator.icon
|
||||
profile_creator.icon,
|
||||
)
|
||||
return await invoke('plugin:pack|pack_install', { location, profile })
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export async function export_profile_mrpack(
|
||||
includedOverrides,
|
||||
versionId,
|
||||
description,
|
||||
name
|
||||
name,
|
||||
) {
|
||||
return await invoke('plugin:profile|profile_export_mrpack', {
|
||||
path,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user