Compare commits

...

36 Commits

Author SHA1 Message Date
ToBinio
08b26f9d5d fix version page (#1121) 2024-04-23 12:09:02 -07:00
Geometrically
99ea06e22a Bump to 0.7.0 + Add sign-in error (#1133) 2024-04-23 12:05:28 -07:00
Geometrically
49cecf837b Fix java installs (#1123)
* Fix java installs

* Finish java installs
2024-04-18 20:28:52 -07:00
Geometrically
2877919639 Switch to official launcher auth (#1118)
* Switch to official launcher auth

* add debug info

* Fix build
2024-04-15 13:58:20 -07:00
MelanX
76447019c0 Fix client-overrides not getting extracted (#1120)
Closes #1112

According to the [docs](https://support.modrinth.com/en/articles/8802351-modrinth-modpack-format-mrpack#h_3ad1b429f0), it needs to be `client-overrides`, not `client_overrides`. This PR fixes this typo.
2024-04-14 10:10:13 -07:00
ToBinio
3e7fd80824 chore: update dependencies (#1103)
* update trivial dependencies

* switch to sha1_smol

* update async_zip

* fix cli

* clippy & fmt

* js lints

* fix build for ci
2024-04-07 12:13:35 -07:00
KnifesmithCode
6699b4cb33 Use Tokio directly instead of Tauri in io.rs (#1087) 2024-04-03 14:44:16 -07:00
Wyatt Verchere
3ff0ff238a adds credentials refresh save (#1030) 2024-01-30 19:31:08 -05:00
Wyatt Verchere
0d3f007dd4 Config transfer (#951)
* fixed config dir issue

* jackson's sync write
2024-01-05 14:00:48 -05:00
Carter
9702dae19d Switch from stdout log to latest log MOD-595 (#964)
* Switch from stdout log to latest log

* remove std capture

* Remove unused functions
2024-01-05 14:00:08 -05:00
chaos
f6a697780b Remove lwjgl debugging arg (#959) 2023-12-28 17:00:27 -05:00
maxomatic458
ef8b525376 fix custom profile import for CF (#914)
* use minecraftinstance.json for CF

* fix fabric version
2023-12-13 17:26:56 -07:00
Geometrically
e39635c75b Fix auth (finally) (#937)
* Finish auth

* Clippy + fix avatar on alts

* add retrying to entitlement request
2023-12-12 20:57:01 -07:00
chaos
260744c8af Wrap version names. Closes #908 (#928) 2023-12-11 20:53:18 -07:00
Emma Alexia
54114e6e94 Fix #901 - add YT nocookie and Discord to CSP (#904) 2023-12-11 20:52:28 -07:00
Emma Alexia
1bd721d523 Enable light mode and OLED mode as options (#936)
Will eventually need the new component from knossos to be ported, but this will suffice for now
2023-12-11 20:51:29 -07:00
Jai A
c1518c52f3 fix avatar merge 2023-11-21 10:16:36 -07:00
ToBinio
531b38e562 display App version in settings (#801)
Co-authored-by: Geometrically <18202329+Geometrically@users.noreply.github.com>
2023-11-21 08:38:22 -07:00
chaos
fd299aabe8 Check for write access before change. (#890)
* Check for write access before change. Closes #862

* Formatting.
2023-11-21 08:37:05 -07:00
chaos
4b1a3eb41e Add missing noblur value to modals. Closes #713 (#891) 2023-11-21 08:35:57 -07:00
chaos
a5739fa7e2 Bump version + revert to mc-heads.net (#895) 2023-11-21 08:35:32 -07:00
Wyatt Verchere
25662d1402 Auth retrying, std logs (#879) 2023-11-17 21:49:32 -07:00
Brayden Zee
01ab507e3a Search pagination fix (#800)
* Fix account tool tip displaying in the wrong place

* Set page to one after search
2023-11-15 17:22:49 -07:00
Ryan Lauderbach
4491d50935 Update breadcrumbs on profile name update (#643)
* Update breadcrumbs on profile name update

* Fix formatting
2023-11-15 16:43:19 -07:00
fxd
3c2889714a native decorations toggle (#541)
* add native decorations toggle

* osname mac -> MacOS

* remove newlines
2023-11-15 16:42:59 -07:00
Octelly
eb6e7d1491 fixed typo (#493)
duplicate "used"
2023-11-15 16:41:34 -07:00
MrLiam2614
a8eb561774 Fixed bug when creating instance starting or ending with spaces has errors (#845)
Based on issue #808
2023-11-15 16:39:53 -07:00
ToBinio
6152eeefe3 fix rendering/calculation of maxProjectsPerRow in homepage (#872) 2023-11-15 16:39:17 -07:00
ToBinio
b8b1668fee change instance fullscreen-checkbox to toggle (#848) 2023-11-15 16:38:35 -07:00
ToBinio
aaf808477e disable disabled projects on update (#871) 2023-11-15 16:38:13 -07:00
chaos
8e3ddbcfaf Update avatar URLs to use Crafatar API. (#877) 2023-11-15 16:37:54 -07:00
Geometrically
a17e096d94 Bump version + fix neoforge 1.20.2+ (#863) 2023-11-08 15:07:53 -08:00
Jackson Kruger
f5c7f90d19 Fix handling of paths longer than 260 chars on Windows during export (#847) 2023-10-30 18:27:30 -07:00
Carter
bd18dbdbe8 correct file import linked_data option logic (#835) 2023-10-23 17:04:02 -07:00
Geometrically
696000546b Fix apple build (#825)
* Fix apple build

* fix pr
2023-10-21 18:55:50 -07:00
Geometrically
dc5785c874 Fix apple build (#824) 2023-10-21 18:47:15 -07:00
123 changed files with 4642 additions and 7456 deletions

View File

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

View File

@@ -78,6 +78,7 @@ jobs:
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}

1
.gitignore vendored
View File

@@ -100,6 +100,7 @@ fabric.properties
# that are supposed to be shared within teams.
.idea/*
.vscode/*
!.idea/codeStyles
!.idea/runConfigurations

View File

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

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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,6 @@
members = [
"theseus",
"theseus_cli",
"theseus_playground",
"theseus_gui/src-tauri",
"theseus_macros"

View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus"
version = "0.6.0"
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"]

View File

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

View File

@@ -1,85 +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 =
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(oauth.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)
}
}
}

View File

@@ -1,45 +0,0 @@
//! Login route for Hydra, redirects to the Microsoft login page before going to the redirect route
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::{hydra::MicrosoftError, util::fetch::REQWEST_CLIENT};
use super::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
let client_id = MICROSOFT_CLIENT_ID;
// Get device code
// Define the parameters
let mut params = HashMap::new();
params.insert("client_id", client_id);
params.insert("scope", "XboxLive.signin offline_access");
// urlencoding::encode("XboxLive.signin offline_access"));
let req = REQWEST_CLIENT.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode")
.header("Content-Type", "application/x-www-form-urlencoded").form(&params).send().await?;
match req.status() {
reqwest::StatusCode::OK => Ok(req.json().await?),
_ => {
let microsoft_error = req.json::<MicrosoftError>().await?;
Err(crate::ErrorKind::HydraError(format!(
"Error from Microsoft: {:?}",
microsoft_error.error_description
))
.into())
}
}
}

View File

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

View File

@@ -1,60 +0,0 @@
use std::collections::HashMap;
use reqwest::StatusCode;
use serde::Deserialize;
use crate::{
hydra::{MicrosoftError, MICROSOFT_CLIENT_ID},
util::fetch::REQWEST_CLIENT,
};
#[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);
// Poll the URL in a loop until we are successful.
// On an authorization_pending response, wait 5 seconds and try again.
let resp = REQWEST_CLIENT
.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")
.header("Content-Type", "application/x-www-form-urlencoded")
.form(&params)
.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())
}
}
}

View File

@@ -1,29 +0,0 @@
use serde_json::json;
const MCSERVICES_AUTH_URL: &str =
"https://api.minecraftservices.com/launcher/login";
pub async fn fetch_bearer(token: &str, uhs: &str) -> crate::Result<String> {
let client = reqwest::Client::new();
let body = client
.post(MCSERVICES_AUTH_URL)
.json(&json!({
"xtoken": format!("XBL3.0 x={};{}", uhs, token),
"platform": "PC_LAUNCHER"
}))
.send()
.await?
.text()
.await?;
serde_json::from_str::<serde_json::Value>(&body)?
.get("access_token")
.and_then(serde_json::Value::as_str)
.map(String::from)
.ok_or(
crate::ErrorKind::HydraError(format!(
"Response didn't contain valid bearer token. body: {body}"
))
.into(),
)
}

View File

@@ -1,7 +0,0 @@
//! MSA authentication stages
pub mod bearer_token;
pub mod player_info;
pub mod poll_response;
pub mod xbl_signin;
pub mod xsts_token;

View File

@@ -1,33 +0,0 @@
//! Fetch player info for display
use serde::Deserialize;
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"),
}
}
}
pub async fn fetch_info(token: &str) -> crate::Result<PlayerInfo> {
let client = reqwest::Client::new();
let resp = client
.get(PROFILE_URL)
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(resp)
}

View File

@@ -1,91 +0,0 @@
use std::collections::HashMap;
use reqwest::StatusCode;
use serde::Deserialize;
use crate::{
hydra::{MicrosoftError, MICROSOFT_CLIENT_ID},
util::fetch::REQWEST_CLIENT,
};
#[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 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);
// Poll the URL in a loop until we are successful.
// On an authorization_pending response, wait 5 seconds and try again.
loop {
let resp = REQWEST_CLIENT
.post(
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
)
.header("Content-Type", "application/x-www-form-urlencoded")
.form(&params)
.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());
}
}
}
}
}
}

View File

@@ -1,55 +0,0 @@
use serde_json::json;
const XBL_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
// Deserialization
pub struct XBLLogin {
pub token: String,
pub uhs: String,
}
// Impl
pub async fn login_xbl(token: &str) -> crate::Result<XBLLogin> {
let client = reqwest::Client::new();
let body = client
.post(XBL_AUTH_URL)
.header(reqwest::header::ACCEPT, "application/json")
.header("x-xbl-contract-version", "1")
.json(&json!({
"Properties": {
"AuthMethod": "RPS",
"SiteName": "user.auth.xboxlive.com",
"RpsTicket": format!("d={token}")
},
"RelyingParty": "http://auth.xboxlive.com",
"TokenType": "JWT"
}))
.send()
.await?
.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 })
}

View File

@@ -1,56 +0,0 @@
use serde_json::json;
const XSTS_AUTH_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
pub enum XSTSResponse {
Unauthorized(String),
Success { token: String },
}
pub async fn fetch_token(token: &str) -> crate::Result<XSTSResponse> {
let client = reqwest::Client::new();
let resp = 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"),
},
))
}
}

View File

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

View File

@@ -1,7 +1,7 @@
use std::io::{Read, SeekFrom};
use crate::{
prelude::Credentials,
prelude::{Credentials, DirectoryInfo},
util::io::{self, IOError},
{state::ProfilePathId, State},
};
@@ -74,7 +74,6 @@ pub async fn get_logs(
profile_path: ProfilePathId,
clear_contents: Option<bool>,
) -> crate::Result<Vec<Logs>> {
let state = State::get().await?;
let profile_path =
if let Some(p) = crate::profile::get(&profile_path, None).await? {
p.profile_id()
@@ -85,7 +84,7 @@ pub async fn get_logs(
.into());
};
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
let logs_folder = DirectoryInfo::profile_logs_dir(&profile_path).await?;
let mut logs = Vec::new();
if logs_folder.exists() {
for entry in std::fs::read_dir(&logs_folder)
@@ -138,12 +137,17 @@ pub async fn get_output_by_filename(
file_name: &str,
) -> crate::Result<CensoredString> {
let state = State::get().await?;
let logs_folder =
state.directories.profile_logs_dir(profile_subpath).await?;
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() {
@@ -201,8 +205,7 @@ pub async fn delete_logs(profile_path: ProfilePathId) -> crate::Result<()> {
.into());
};
let state = State::get().await?;
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
let logs_folder = DirectoryInfo::profile_logs_dir(&profile_path).await?;
for entry in std::fs::read_dir(&logs_folder)
.map_err(|e| IOError::with_path(e, &logs_folder))?
{
@@ -230,8 +233,7 @@ pub async fn delete_logs_by_filename(
.into());
};
let state = State::get().await?;
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
let logs_folder = DirectoryInfo::profile_logs_dir(&profile_path).await?;
let path = logs_folder.join(filename);
io::remove_dir_all(&path).await?;
Ok(())
@@ -240,6 +242,15 @@ pub async fn delete_logs_by_filename(
#[tracing::instrument]
pub async fn get_latest_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.log", cursor).await
}
#[tracing::instrument]
pub async fn get_generic_live_log_cursor(
profile_path: ProfilePathId,
log_file_name: &str,
mut cursor: u64, // 0 to start at beginning of file
) -> crate::Result<LatestLogCursor> {
let profile_path =
@@ -253,8 +264,8 @@ pub async fn get_latest_log_cursor(
};
let state = State::get().await?;
let logs_folder = state.directories.profile_logs_dir(&profile_path).await?;
let path = logs_folder.join("latest.log");
let logs_folder = DirectoryInfo::profile_logs_dir(&profile_path).await?;
let path = logs_folder.join(log_file_name);
if !path.exists() {
// Allow silent failure if latest.log doesn't exist (as the instance may have been launched, but not yet created the file)
return Ok(LatestLogCursor {
@@ -291,8 +302,14 @@ pub async fn get_latest_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,

View 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)
}

View File

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

View File

@@ -16,37 +16,22 @@ use crate::{
use super::{copy_dotminecraft, recache_icon};
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FlameManifest {
pub manifest_version: u8,
pub name: String,
pub minecraft: FlameMinecraft,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FlameMinecraft {
pub version: String,
pub mod_loaders: Vec<FlameModLoader>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FlameModLoader {
pub id: String,
pub primary: bool,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct MinecraftInstance {
pub name: Option<String>,
pub base_mod_loader: Option<MinecraftInstanceModLoader>,
pub profile_image_path: Option<PathBuf>,
pub installed_modpack: Option<InstalledModpack>,
pub game_version: String, // Minecraft game version. Non-prioritized, use this if Vanilla
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct MinecraftInstanceModLoader {
pub name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct InstalledModpack {
pub thumbnail_url: Option<String>,
}
@@ -113,35 +98,26 @@ pub async fn import_curseforge(
}
}
// Curseforge vanilla profile may not have a manifest.json, so we allow it to not exist
if curseforge_instance_folder.join("manifest.json").exists() {
// Load manifest.json
let cf_manifest: String = io::read_to_string(
&curseforge_instance_folder.join("manifest.json"),
)
.await?;
let cf_manifest: FlameManifest =
serde_json::from_str::<FlameManifest>(&cf_manifest)?;
let game_version = cf_manifest.minecraft.version;
// base mod loader is always None for vanilla
if let Some(instance_mod_loader) = minecraft_instance.base_mod_loader {
let game_version = minecraft_instance.game_version;
// CF allows Forge, Fabric, and Vanilla
let mut mod_loader = None;
let mut loader_version = None;
for loader in cf_manifest.minecraft.mod_loaders {
match loader.id.split_once('-') {
Some(("forge", version)) => {
mod_loader = Some(ModLoader::Forge);
loader_version = Some(version.to_string());
}
Some(("fabric", version)) => {
mod_loader = Some(ModLoader::Fabric);
loader_version = Some(version.to_string());
}
_ => {}
match instance_mod_loader.name.split('-').collect::<Vec<&str>>()[..] {
["forge", version] => {
mod_loader = Some(ModLoader::Forge);
loader_version = Some(version.to_string());
}
["fabric", version, _game_version] => {
mod_loader = Some(ModLoader::Fabric);
loader_version = Some(version.to_string());
}
_ => {}
}
let mod_loader = mod_loader.unwrap_or(ModLoader::Vanilla);
let loader_version = if mod_loader != ModLoader::Vanilla {
@@ -170,7 +146,7 @@ pub async fn import_curseforge(
})
.await?;
} else {
// If no manifest is found, it's a vanilla profile
// create a vanilla profile
crate::api::profile::edit(&profile_path, |prof| {
prof.metadata.name = override_title
.clone()

View File

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

View File

@@ -387,18 +387,26 @@ pub async fn set_profile_information(
.clone()
.unwrap_or_else(|| backup_name.to_string());
prof.install_stage = ProfileInstallStage::PackInstalling;
prof.metadata.linked_data = Some(LinkedData {
project_id: description.project_id.clone(),
version_id: description.version_id.clone(),
locked: if !ignore_lock {
Some(
description.project_id.is_some()
&& description.version_id.is_some(),
)
} else {
prof.metadata.linked_data.as_ref().and_then(|x| x.locked)
},
});
let project_id = description.project_id.clone();
let version_id = description.version_id.clone();
prof.metadata.linked_data = if project_id.is_some()
&& version_id.is_some()
{
Some(LinkedData {
project_id,
version_id,
locked: if !ignore_lock {
Some(true)
} else {
prof.metadata.linked_data.as_ref().and_then(|x| x.locked)
},
})
} else {
None
};
prof.metadata.icon = description.icon.clone();
prof.metadata.game_version = game_version.clone();
prof.metadata.loader_version = loader_version.clone();

View File

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

View File

@@ -272,8 +272,8 @@ pub(crate) async fn get_loader_version_from_loader(
let loader_version = loaders
.iter()
.find(|&x| filter(x))
.cloned()
.find(filter)
.or(
// If stable was searched for but not found, return latest by default
if version == "stable" {

View File

@@ -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,
};
@@ -389,6 +390,10 @@ pub async fn update_project(
.add_project_version(update_version.id.clone())
.await?;
if project.disabled {
profile.toggle_disable_project(&path).await?;
}
if path != project_path.clone() {
profile.remove_project(project_path, Some(true)).await?;
}
@@ -604,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());
@@ -656,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?;
@@ -666,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?;
@@ -741,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
@@ -763,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;
@@ -1034,13 +1035,17 @@ fn sanitize_loader_version_string(s: &str, loader: PackDependency) -> &str {
// If one, take the first
// If none, take the whole thing
PackDependency::Forge | PackDependency::NeoForge => {
let mut split: std::str::Split<'_, char> = s.split('-');
match split.next() {
Some(first) => match split.next() {
Some(second) => second,
None => first,
},
None => s,
if s.starts_with("1.") {
let mut split: std::str::Split<'_, char> = s.split('-');
match split.next() {
Some(first) => match split.next() {
Some(second) => second,
None => first,
},
None => s,
}
} else {
s
}
}
// For quilt, etc we take the whole thing, as it functions like: 0.20.0-beta.11 (and should not be split here)

View File

@@ -1,6 +1,7 @@
//! Theseus profile management interface
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use tokio::fs;
use io::IOError;
use tokio::sync::RwLock;
@@ -9,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::{
@@ -76,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: {}",
@@ -84,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(),
@@ -99,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 = {
@@ -131,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
@@ -180,11 +217,30 @@ 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 {
Ok(_) => {
fs::remove_file(temp_path).await?;
Ok(true)
}
Err(e) => {
tracing::error!("Error writing to new config dir: {}", e);
Ok(false)
}
}
}

View File

@@ -25,15 +25,17 @@ 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),
#[error("I/O (std) error: {0}")]
StdIOError(#[from] std::io::Error),
#[error("Error launching Minecraft: {0}")]
LauncherError(String),

View File

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

View File

@@ -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},
@@ -11,6 +11,7 @@ use daedalus::{
modded::SidedDataEntry,
};
use dunce::canonicalize;
use std::collections::HashSet;
use std::io::{BufRead, BufReader};
use std::{collections::HashMap, path::Path};
use uuid::Uuid;
@@ -40,9 +41,9 @@ pub fn get_class_paths(
Some(get_lib_path(libraries_path, &library.name, false))
})
.collect::<Result<Vec<_>, _>>()?;
.collect::<Result<HashSet<_>, _>>()?;
cps.push(
cps.insert(
canonicalize(client_path)
.map_err(|_| {
crate::ErrorKind::LauncherError(format!(
@@ -55,7 +56,10 @@ pub fn get_class_paths(
.to_string(),
);
Ok(cps.join(classpath_separator(java_arch)))
Ok(cps
.into_iter()
.collect::<Vec<_>>()
.join(classpath_separator(java_arch)))
}
pub fn get_class_paths_jar<T: AsRef<str>>(
@@ -144,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)
}

View File

@@ -1,85 +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 =
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(oauth.expires_in);
}
}
Ok(())
}

View File

@@ -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)
@@ -177,7 +168,7 @@ pub async fn install_minecraft(
let state = State::get().await?;
let instance_path =
&io::canonicalize(&profile.get_profile_full_path().await?)?;
&io::canonicalize(profile.get_profile_full_path().await?)?;
let metadata = state.metadata.read().await;
let version_index = metadata
@@ -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::null())
.stderr(Stdio::null());
.current_dir(instance_path.clone());
// CARGO-set DYLD_LIBRARY_PATH breaks Minecraft on macOS during testing on playground
#[cfg(target_os = "macos")]

View File

@@ -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());

View File

@@ -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),
}

View File

@@ -3,7 +3,6 @@ use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde::Serialize;
use std::{collections::HashMap, sync::Arc};
use sysinfo::PidExt;
use tokio::process::Child;
use tokio::process::Command;
use tokio::sync::RwLock;
@@ -13,7 +12,6 @@ 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;
@@ -117,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,
@@ -271,8 +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 child = mc_command.spawn().map_err(IOError::from)?;
let child = ChildType::TokioChild(child);
let mc_proc = mc_command.spawn().map_err(IOError::from)?;
let child = ChildType::TokioChild(mc_proc);
// Slots child into manager
let pid = child.id().ok_or_else(|| {
@@ -356,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());
}
}

View File

@@ -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,13 +155,12 @@ 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
#[inline]
pub async fn profile_logs_dir(
&self,
profile_id: &ProfilePathId,
) -> crate::Result<PathBuf> {
Ok(profile_id.get_full_path().await?.join("logs"))

View 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()
}

View File

@@ -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}"),
}
}
});

View File

@@ -351,6 +351,7 @@ pub async fn refresh_credentials(
}
}
credentials_store.save().await?;
Ok(())
}

View File

@@ -142,10 +142,13 @@ pub struct ProjectPathId(pub PathBuf);
impl ProjectPathId {
// Create a new ProjectPathId from a full file path
pub async fn from_fs_path(path: &PathBuf) -> crate::Result<Self> {
let profiles_dir: PathBuf = io::canonicalize(
// This is avoiding dunce::canonicalize deliberately. On Windows, paths will always be convert to UNC,
// but this is ok because we are stripping that with the prefix. Using std::fs avoids different behaviors with dunce that
// come with too-long paths
let profiles_dir: PathBuf = std::fs::canonicalize(
State::get().await?.directories.profiles_dir().await,
)?;
let path: PathBuf = io::canonicalize(path)?;
let path: PathBuf = std::fs::canonicalize(path)?;
let path = path
.strip_prefix(profiles_dir)
.ok()

View File

@@ -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()
{

View File

@@ -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,
@@ -35,6 +30,8 @@ pub struct Settings {
#[serde(default)]
pub hide_on_process: bool,
#[serde(default)]
pub native_decorations: bool,
#[serde(default)]
pub default_page: DefaultPage,
#[serde(default)]
pub developer_mode: bool,
@@ -91,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,
@@ -99,6 +95,7 @@ impl Settings {
collapsed_navigation: false,
disable_discord_rpc: false,
hide_on_process: false,
native_decorations: false,
default_page: DefaultPage::Home,
developer_mode: false,
opt_out_analytics: false,
@@ -115,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)?)

View File

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

View File

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

View File

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

View File

@@ -1,34 +0,0 @@
[package]
name = "theseus_cli"
version = "0.6.0"
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"

View File

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

View File

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

View File

@@ -1,371 +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().cloned().find(filter).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}")),
}
}

View File

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

View File

@@ -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}:"))
);
}

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />

View File

@@ -1,7 +1,7 @@
{
"name": "theseus_gui",
"private": true,
"version": "0.6.0",
"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"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus_gui"
version = "0.6.0"
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,13 +50,13 @@ 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]
# by default Tauri runs in production mode
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem
# this feature is used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]

View File

@@ -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?)
}

View File

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

View File

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

View File

@@ -8,7 +8,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![
settings_get,
settings_set,
settings_change_config_dir
settings_change_config_dir,
settings_is_dir_writeable
])
.build()
}
@@ -37,3 +38,11 @@ pub async fn settings_change_config_dir(new_config_dir: PathBuf) -> Result<()> {
settings::set_config_dir(new_config_dir).await?;
Ok(())
}
#[tauri::command]
pub async fn settings_is_dir_writeable(
new_config_dir: PathBuf,
) -> Result<bool> {
let res = settings::is_dir_writeable(new_config_dir).await?;
Ok(res)
}

View File

@@ -146,6 +146,7 @@ fn main() {
initialize_state,
is_dev,
toggle_decorations,
api::auth::auth_login,
]);
builder

View File

@@ -8,7 +8,7 @@
},
"package": {
"productName": "Modrinth App",
"version": "0.6.0"
"version": "0.7.0"
},
"tauri": {
"allowlist": {
@@ -83,7 +83,7 @@
}
},
"security": {
"csp": "default-src 'self'; connect-src https://modrinth.com https://*.modrinth.com https://mixpanel.com https://*.mixpanel.com https://*.cloudflare.com https://api.mclo.gs; font-src https://cdn-raw.modrinth.com/fonts/inter/; img-src tauri: https: data: blob: 'unsafe-inline' asset: https://asset.localhost; script-src https://*.cloudflare.com 'self'; frame-src https://*.cloudflare.com https://www.youtube.com 'self'; style-src unsafe-inline 'self'"
"csp": "default-src 'self'; connect-src https://modrinth.com https://*.modrinth.com https://mixpanel.com https://*.mixpanel.com https://*.cloudflare.com https://api.mclo.gs; font-src https://cdn-raw.modrinth.com/fonts/inter/; img-src tauri: https: data: blob: 'unsafe-inline' asset: https://asset.localhost; script-src https://*.cloudflare.com 'self'; frame-src https://*.cloudflare.com https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self'; style-src unsafe-inline 'self'"
},
"updater": {
"active": true,

View File

@@ -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,17 +41,17 @@ 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)
const onboardingVideo = ref()
@@ -60,15 +61,23 @@ const os = ref('')
defineExpose({
initialize: async () => {
isLoading.value = false
const { theme, opt_out_analytics, collapsed_navigation, advanced_rendering, fully_onboarded } =
await get()
const {
native_decorations,
theme,
opt_out_analytics,
collapsed_navigation,
advanced_rendering,
fully_onboarded,
} = 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
nativeDecorations.value = native_decorations
if (os.value !== 'MacOS') appWindow.setDecorations(native_decorations)
themeStore.setThemeState(theme)
themeStore.collapsedNavigation = collapsed_navigation
themeStore.advancedRendering = advanced_rendering
@@ -97,7 +106,7 @@ defineExpose({
title: 'Warning',
text: e.message,
type: 'warn',
})
}),
)
if (showOnboarding.value) {
@@ -117,7 +126,7 @@ const confirmClose = async () => {
{
title: 'Modrinth',
type: 'warning',
}
},
)
return confirmed
}
@@ -170,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) {
@@ -235,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()">
@@ -284,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">
@@ -341,7 +348,7 @@ command_listener(async (e) => {
</Suspense>
</section>
</div>
<section class="window-controls">
<section v-if="!nativeDecorations" class="window-controls">
<Button class="titlebar-button" icon-only @click="() => appWindow.minimize()">
<MinimizeIcon />
</Button>
@@ -379,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.

View File

@@ -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,
]
],
)
}

View File

@@ -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,14 +198,14 @@ 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
}
}
const maxInstancesPerRow = ref(0)
const maxProjectsPerRow = ref(0)
const maxInstancesPerRow = ref(1)
const maxProjectsPerRow = ref(1)
const calculateCardsPerRow = () => {
// Calculate how many cards fit in one row

View File

@@ -66,7 +66,7 @@ export default defineComponent({
zIndex: 6,
},
},
slots
slots,
)
},
})

View File

@@ -2,7 +2,7 @@
<div
v-if="mode !== 'isolated'"
ref="button"
v-tooltip="'Minecraft accounts'"
v-tooltip.right="'Minecraft accounts'"
class="button-base avatar-button"
:class="{ expanded: mode === 'expanded' }"
@click="showCard = !showCard"
@@ -56,67 +56,22 @@
</Button>
</Card>
</transition>
<Modal ref="loginModal" class="modal" header="Signing in">
<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 { mixpanel_track } from '@/helpers/mixpanel'
import QrcodeVue from 'qrcode.vue'
import { process_listener } from '@/helpers/events'
import { handleSevereError } from '@/store/error.js'
defineProps({
mode: {
@@ -128,15 +83,11 @@ defineProps({
const emit = defineEmits(['change'])
const loginCode = ref(null)
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({
@@ -145,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')
}

View File

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

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

View File

@@ -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()
}
@@ -292,9 +292,11 @@ const exportPack = async () => {
.textarea-wrapper {
// margin-top: 1rem;
height: 12rem;
textarea {
max-height: 12rem;
}
.preview {
overflow-y: auto;
}

View File

@@ -82,7 +82,7 @@ defineExpose({
selectedVersions,
extMarkInstalled,
projectIdVal,
projectTypeVal
projectTypeVal,
) => {
instance.value = instanceVal
projectTitle.value = projectTitleVal

View File

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

View File

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

View File

@@ -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, [])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +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 mixpanel from 'mixpanel-browser'
import { get, set } from '@/helpers/settings.js'
import { ref } from 'vue'
import QrcodeVue from 'qrcode.vue'
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: {
@@ -24,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>
@@ -85,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">
<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">
@@ -186,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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

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