Compare commits

..

62 Commits

Author SHA1 Message Date
Carter
9351e6b361 last second changes for auth and sidebar 2024-01-30 18:48:06 -08:00
Carter
16dfd1d4a1 Send instance to top when playing 2024-01-30 12:32:29 -08:00
Carter
694ee7e89f split from page components 2024-01-30 12:29:27 -08:00
Carter
a48186fa63 Fix painting error with :hover filter overrides 2024-01-23 16:45:43 -08:00
Carter
b888d65ad6 refactor sidebar to open and close 2024-01-23 16:21:50 -08:00
Carter
9b7779f8eb clean up breadcrumbs in prep for refactor 2024-01-23 16:12:57 -08:00
Carter
b1496a4f24 clean up css 2024-01-22 13:43:09 -08:00
Carter
0aff72be50 Add back create modal 2024-01-22 13:40:49 -08:00
Carter
3fd1a6bb93 Replace dropdownbutton 2024-01-22 13:07:10 -08:00
Carter
233c9adf47 update auth to use pinia store 2024-01-22 12:34:52 -08:00
Carter
44bb793609 clean up nav/logo 2024-01-22 12:18:41 -08:00
Carter
4eb88119a3 consistency changes 2024-01-22 12:06:53 -08:00
Carter
77d44c697e fix icon buttons 2024-01-22 11:29:24 -08:00
Carter
e27d2ebd2e clean up login state 2024-01-22 11:22:47 -08:00
Carter
3afac8e66b create mr_auth composables 2024-01-22 11:19:57 -08:00
Carter
afec787883 Add Card import to ModpackVersionModal.vue 2024-01-19 13:12:33 -08:00
Carter
c140c65216 Update search button styling in Mods.vue 2024-01-19 13:12:18 -08:00
Carter
1529ef1aff Left align buttons 2024-01-18 08:09:37 -08:00
Carter
f4ee876fea navigation controls wrap fix 2024-01-17 14:57:20 -08:00
Carter
39b80cb484 modernize iconified-input 2024-01-17 14:01:57 -08:00
Carter
cce9d348a9 Add vintl 2024-01-17 14:01:46 -08:00
Carter
17c0ba4662 Update omorphia to 0.7.3 2024-01-17 08:11:59 -08:00
Carter
ad1f9b3626 Merge branch 'master' of https://github.com/modrinth/theseus into home-refresh 2024-01-15 11:56:07 -08: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
Carter
32a2ec4366 Redesign sidebar buttons 2024-01-02 13:29:14 -08: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
Geometrically
afaec4b1bf Bump Theseus Version (#818)
* push to test on windows

* Fix windows sup

* Fix macos

* Fix back

* new resolver for windows testing

* Custom macos handling for some versions

* Fix 1.13+ broken

* fix arg parsing mac

* small winblows fix

* remove debug info; set meta url

* run lint + fix clippy

* Remove useless commnet
2023-10-21 13:08:47 -07:00
Carter
7fb8850071 reduce side effects during migration hydration (#803) 2023-10-15 22:12:26 -07:00
Carter
8ccc7dfcd2 Fix launcher duplicate profile function (#804) 2023-10-12 10:41:44 -07:00
ToBinio
da07d7328d fix google.svg (#795) 2023-10-09 10:34:56 -07:00
Jackson Kruger
772597ce2a Make export selection consistent between platforms and allow selecting which projects to export (#789)
* Experimenting with tests

* Overhaul handling of paths for pack files to always use standardized style

Also allows disabling export of all items

* Minor improvements

* Revert test things

* Minor tweaks

* Fix clippy warning
2023-10-09 10:34:19 -07:00
ToBinio
e76a7d57c0 fix categories duplicating (#773) 2023-10-06 19:59:18 -07:00
Jackson Kruger
ebc4da6c29 Use 'neoforge' instead of 'neo-forge' in mrpacks (#787) 2023-10-04 15:42:09 -07:00
Miraculixx
f73c112e07 Fixing bug report template - Wrong nesting (#756)
* Wrap string in quotes to support brackets

* Moving string to label
2023-10-02 10:28:45 -07:00
chaos
7fbc9fa357 Allow mods to be added to unlocked instances. (#760)
* Allow mods to be added to unlocked instances.

* Learn about optional chaining?.
2023-10-02 10:27:04 -07:00
chaos
6f8ffcaf35 Overflow and mrpack fixes, comfortable memory slider and more. (#762)
* Add overflow rules for certain elements.

* Round scrollbar corners to be more attractive.

* Avoid corners in avatar & fix white corner in logs

* Increase step to 64 for memory related inputs.

* Fix overflow on title in mod browse. Fixes #740

* Fix overflow in instance mod browsing.

* Add checks for instances without versions.
2023-10-02 10:26:22 -07:00
92 changed files with 2142 additions and 865 deletions

View File

@@ -26,7 +26,8 @@ body:
validations:
required: false
- type: textarea
attributes: System information
attributes:
label: System information
description: Add any information about what OS you are on (like Windows or Mac), and what version of the app you are using.
- type: textarea
attributes:

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

6
Cargo.lock generated
View File

@@ -4685,7 +4685,7 @@ dependencies = [
[[package]]
name = "theseus"
version = "0.5.4"
version = "0.6.3"
dependencies = [
"async-recursion",
"async-tungstenite",
@@ -4733,7 +4733,7 @@ dependencies = [
[[package]]
name = "theseus_cli"
version = "0.5.4"
version = "0.6.3"
dependencies = [
"argh",
"color-eyre",
@@ -4760,7 +4760,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "0.5.4"
version = "0.6.3"
dependencies = [
"chrono",
"cocoa",

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus"
version = "0.5.4"
version = "0.6.3"
authors = ["Jai A <jaiagr+gpg@pm.me>"]
edition = "2018"

View File

@@ -68,10 +68,14 @@ pub async fn refresh(user: uuid::Uuid) -> crate::Result<Credentials> {
}
// 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. Make sure you own the game and have set a username through the official Minecraft launcher."
.to_string())
})?;
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?;

View File

@@ -41,7 +41,7 @@ pub async fn wait_finish(device_code: String) -> crate::Result<Credentials> {
}
xsts_token::XSTSResponse::Success { token: xsts_token } => {
// Get xsts bearer token from xsts token
let bearer_token =
let (bearer_token, expires_in) =
bearer_token::fetch_bearer(&xsts_token, &xbl_token.uhs)
.await
.map_err(|err| {
@@ -63,8 +63,7 @@ pub async fn wait_finish(device_code: String) -> crate::Result<Credentials> {
player_info.name,
bearer_token,
oauth.refresh_token,
chrono::Utc::now()
+ chrono::Duration::seconds(oauth.expires_in),
chrono::Utc::now() + chrono::Duration::seconds(expires_in),
);
// Put credentials into state

View File

@@ -1,11 +1,9 @@
//! 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;
use super::{stages::auth_retry, MICROSOFT_CLIENT_ID};
#[derive(Serialize, Deserialize, Debug)]
pub struct DeviceLoginSuccess {
@@ -19,22 +17,26 @@ pub struct DeviceLoginSuccess {
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?;
let resp = auth_retry(|| REQWEST_CLIENT.get("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode")
.header("Content-Length", "0")
.query(&[
("client_id", MICROSOFT_CLIENT_ID),
(
"scope",
"XboxLive.signin XboxLive.offline_access profile openid email",
),
])
.send()
).await?;
match req.status() {
reqwest::StatusCode::OK => Ok(req.json().await?),
match resp.status() {
reqwest::StatusCode::OK => Ok(resp.json().await?),
_ => {
let microsoft_error = req.json::<MicrosoftError>().await?;
let microsoft_error = resp.json::<MicrosoftError>().await?;
Err(crate::ErrorKind::HydraError(format!(
"Error from Microsoft: {:?}",
microsoft_error.error_description

View File

@@ -8,6 +8,8 @@ use crate::{
util::fetch::REQWEST_CLIENT,
};
use super::stages::auth_retry;
#[derive(Debug, Deserialize)]
pub struct OauthSuccess {
pub token_type: String,
@@ -22,14 +24,21 @@ pub async fn refresh(refresh_token: String) -> crate::Result<OauthSuccess> {
params.insert("grant_type", "refresh_token");
params.insert("client_id", MICROSOFT_CLIENT_ID);
params.insert("refresh_token", &refresh_token);
params.insert(
"redirect_uri",
"https://login.microsoftonline.com/common/oauth2/nativeclient",
);
// Poll the URL in a loop until we are successful.
// On an authorization_pending response, wait 5 seconds and try again.
let resp = REQWEST_CLIENT
let resp =
auth_retry(|| {
REQWEST_CLIENT
.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/token")
.header("Content-Type", "application/x-www-form-urlencoded")
.form(&params)
.send()
})
.await?;
match resp.status() {

View File

@@ -1,29 +1,42 @@
use serde::Deserialize;
use serde_json::json;
use super::auth_retry;
const MCSERVICES_AUTH_URL: &str =
"https://api.minecraftservices.com/launcher/login";
"https://api.minecraftservices.com/authentication/login_with_xbox";
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?;
#[derive(Deserialize)]
pub struct BearerTokenResponse {
access_token: String,
expires_in: i64,
}
serde_json::from_str::<serde_json::Value>(&body)?
.get("access_token")
.and_then(serde_json::Value::as_str)
.map(String::from)
.ok_or(
#[tracing::instrument]
pub async fn fetch_bearer(
token: &str,
uhs: &str,
) -> crate::Result<(String, i64)> {
let body = auth_retry(|| {
let client = reqwest::Client::new();
client
.post(MCSERVICES_AUTH_URL)
.header("Accept", "application/json")
.json(&json!({
"identityToken": format!("XBL3.0 x={};{}", uhs, token),
}))
.send()
})
.await?
.text()
.await?;
serde_json::from_str::<BearerTokenResponse>(&body)
.map(|x| (x.access_token, x.expires_in))
.map_err(|_| {
crate::ErrorKind::HydraError(format!(
"Response didn't contain valid bearer token. body: {body}"
))
.into(),
)
.into()
})
}

View File

@@ -1,7 +1,37 @@
//! MSA authentication stages
use futures::Future;
use reqwest::Response;
const RETRY_COUNT: usize = 9; // Does command 3 times
const RETRY_WAIT: std::time::Duration = std::time::Duration::from_secs(2);
pub mod bearer_token;
pub mod player_info;
pub mod poll_response;
pub mod xbl_signin;
pub mod xsts_token;
#[tracing::instrument(skip(reqwest_request))]
pub async fn auth_retry<F>(
reqwest_request: impl Fn() -> F,
) -> crate::Result<reqwest::Response>
where
F: Future<Output = Result<Response, reqwest::Error>>,
{
let mut resp = reqwest_request().await?;
for i in 0..RETRY_COUNT {
if resp.status().is_success() {
break;
}
tracing::debug!(
"Request failed with status code {}, retrying...",
resp.status()
);
if i < RETRY_COUNT - 1 {
tokio::time::sleep(RETRY_WAIT).await;
}
resp = reqwest_request().await?;
}
Ok(resp)
}

View File

@@ -1,6 +1,10 @@
//! Fetch player info for display
use serde::Deserialize;
use crate::util::fetch::REQWEST_CLIENT;
use super::auth_retry;
const PROFILE_URL: &str = "https://api.minecraftservices.com/minecraft/profile";
#[derive(Deserialize)]
@@ -13,21 +17,30 @@ impl Default for PlayerInfo {
fn default() -> Self {
Self {
id: "606e2ff0ed7748429d6ce1d3321c7838".to_string(),
name: String::from("???"),
name: String::from("Unknown"),
}
}
}
#[tracing::instrument]
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?;
auth_retry(|| {
REQWEST_CLIENT
.get("https://api.minecraftservices.com/entitlements/mcstore")
.bearer_auth(token)
.send()
})
.await?;
let response = auth_retry(|| {
REQWEST_CLIENT
.get(PROFILE_URL)
.header(reqwest::header::AUTHORIZATION, format!("Bearer {token}"))
.send()
})
.await?;
let resp = response.error_for_status()?.json().await?;
Ok(resp)
}

View File

@@ -8,6 +8,8 @@ use crate::{
util::fetch::REQWEST_CLIENT,
};
use super::auth_retry;
#[derive(Debug, Deserialize)]
pub struct OauthSuccess {
pub token_type: String,
@@ -17,23 +19,29 @@ pub struct OauthSuccess {
pub refresh_token: String,
}
#[tracing::instrument]
pub async fn poll_response(device_code: String) -> crate::Result<OauthSuccess> {
let mut params = HashMap::new();
params.insert("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
params.insert("client_id", MICROSOFT_CLIENT_ID);
params.insert("device_code", &device_code);
params.insert(
"scope",
"XboxLive.signin XboxLive.offline_access profile openid email",
);
// Poll the URL in a loop until we are successful.
// On an authorization_pending response, wait 5 seconds and try again.
loop {
let resp = REQWEST_CLIENT
let resp = auth_retry(|| {
REQWEST_CLIENT
.post(
"https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
)
.header("Content-Type", "application/x-www-form-urlencoded")
.form(&params)
.send()
.await?;
})
.await?;
match resp.status() {
StatusCode::OK => {

View File

@@ -1,5 +1,9 @@
use serde_json::json;
use crate::util::fetch::REQWEST_CLIENT;
use super::auth_retry;
const XBL_AUTH_URL: &str = "https://user.auth.xboxlive.com/user/authenticate";
// Deserialization
@@ -9,25 +13,25 @@ pub struct XBLLogin {
}
// Impl
#[tracing::instrument]
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 response = auth_retry(|| {
REQWEST_CLIENT
.post(XBL_AUTH_URL)
.header(reqwest::header::ACCEPT, "application/json")
.json(&json!({
"Properties": {
"AuthMethod": "RPS",
"SiteName": "user.auth.xboxlive.com",
"RpsTicket": format!("d={token}")
},
"RelyingParty": "http://auth.xboxlive.com",
"TokenType": "JWT"
}))
.send()
})
.await?;
let body = response.text().await?;
let json = serde_json::from_str::<serde_json::Value>(&body)?;
let token = Some(&json)

View File

@@ -1,5 +1,9 @@
use serde_json::json;
use crate::util::fetch::REQWEST_CLIENT;
use super::auth_retry;
const XSTS_AUTH_URL: &str = "https://xsts.auth.xboxlive.com/xsts/authorize";
pub enum XSTSResponse {
@@ -7,23 +11,25 @@ pub enum XSTSResponse {
Success { token: String },
}
#[tracing::instrument]
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 resp = auth_retry(|| {
REQWEST_CLIENT
.post(XSTS_AUTH_URL)
.header(reqwest::header::ACCEPT, "application/json")
.json(&json!({
"Properties": {
"SandboxId": "RETAIL",
"UserTokens": [
token
]
},
"RelyingParty": "rp://api.minecraftservices.com/",
"TokenType": "JWT"
}))
.send()
})
.await?;
let status = resp.status();
let body = resp.text().await?;

View File

@@ -127,7 +127,7 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
// removes the old installation of java
if let Some(file) = archive.file_names().next() {
if let Some(dir) = file.split("/").next() {
if let Some(dir) = file.split('/').next() {
let path = path.join(dir);
if path.exists() {

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,8 +137,7 @@ 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> =
@@ -201,8 +199,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 +227,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 +236,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 +258,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 {

View File

@@ -245,8 +245,12 @@ async fn import_atlauncher_unmanaged(
if let Some(profile_val) =
crate::api::profile::get(&profile_path, None).await?
{
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;
crate::launcher::install_minecraft(
&profile_val,
Some(loading_bar),
false,
)
.await?;
{
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;

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()
@@ -199,8 +175,12 @@ pub async fn import_curseforge(
if let Some(profile_val) =
crate::api::profile::get(&profile_path, None).await?
{
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;
crate::launcher::install_minecraft(
&profile_val,
Some(loading_bar),
false,
)
.await?;
{
let state = State::get().await?;

View File

@@ -112,8 +112,12 @@ pub async fn import_gdlauncher(
if let Some(profile_val) =
crate::api::profile::get(&profile_path, None).await?
{
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;
crate::launcher::install_minecraft(
&profile_val,
Some(loading_bar),
false,
)
.await?;
{
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;

View File

@@ -323,8 +323,12 @@ async fn import_mmc_unmanaged(
if let Some(profile_val) =
crate::api::profile::get(&profile_path, None).await?
{
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;
crate::launcher::install_minecraft(
&profile_val,
Some(loading_bar),
false,
)
.await?;
{
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;

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

@@ -10,7 +10,7 @@ use crate::util::fetch::{
fetch, fetch_advanced, fetch_json, write_cached_icon,
};
use crate::util::io;
use crate::State;
use crate::{InnerProjectPathUnix, State};
use reqwest::Method;
use serde::{Deserialize, Serialize};
@@ -33,7 +33,7 @@ pub struct PackFormat {
#[derive(Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PackFile {
pub path: String,
pub path: InnerProjectPathUnix,
pub hashes: HashMap<PackFileHash, String>,
pub env: Option<HashMap<EnvType, SideType>>,
pub downloads: Vec<String>,
@@ -66,12 +66,21 @@ pub enum EnvType {
}
#[derive(Serialize, Deserialize, Clone, Copy, Hash, PartialEq, Eq, Debug)]
#[serde(rename_all = "kebab-case")]
pub enum PackDependency {
#[serde(rename = "forge")]
Forge,
#[serde(rename = "neoforge")]
#[serde(alias = "neo-forge")]
NeoForge,
#[serde(rename = "fabric-loader")]
FabricLoader,
#[serde(rename = "quilt-loader")]
QuiltLoader,
#[serde(rename = "minecraft")]
Minecraft,
}
@@ -378,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

@@ -189,10 +189,7 @@ pub async fn install_zipped_mrpack_files(
.await?;
drop(creds);
// Convert windows path to unix path.
// .mrpacks no longer generate windows paths, but this is here for backwards compatibility before this was fixed
// https://github.com/modrinth/theseus/issues/595
let project_path = project.path.replace('\\', "/");
let project_path = project.path.to_string();
let path =
std::path::Path::new(&project_path).components().next();
@@ -286,8 +283,12 @@ pub async fn install_zipped_mrpack_files(
}
if let Some(profile_val) = profile::get(&profile_path, None).await? {
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;
crate::launcher::install_minecraft(
&profile_val,
Some(loading_bar),
false,
)
.await?;
State::sync().await?;
}
@@ -403,7 +404,10 @@ pub async fn remove_all_related_files(
// Iterate over all Modrinth project file paths in the json, and remove them
// (There should be few, but this removes any files the .mrpack intended as Modrinth projects but were unrecognized)
for file in pack.files {
let path = profile_path.get_full_path().await?.join(file.path);
let path: PathBuf = profile_path
.get_full_path()
.await?
.join(file.path.to_string());
if path.exists() {
io::remove_file(&path).await?;
}

View File

@@ -125,7 +125,7 @@ pub async fn profile_create(
}
if !skip_install_profile.unwrap_or(false) {
crate::launcher::install_minecraft(&profile, None).await?;
crate::launcher::install_minecraft(&profile, None, false).await?;
}
State::sync().await?;
@@ -163,6 +163,7 @@ pub async fn profile_create_from_creator(
pub async fn profile_create_from_duplicate(
copy_from: ProfilePathId,
) -> crate::Result<ProfilePathId> {
// Original profile
let profile = profile::get(&copy_from, None).await?.ok_or_else(|| {
ErrorKind::UnmanagedProfileError(copy_from.to_string())
})?;
@@ -190,7 +191,13 @@ pub async fn profile_create_from_duplicate(
)
.await?;
crate::launcher::install_minecraft(&profile, Some(bar)).await?;
let duplicated_profile =
profile::get(&profile_path_id, None).await?.ok_or_else(|| {
ErrorKind::UnmanagedProfileError(profile_path_id.to_string())
})?;
crate::launcher::install_minecraft(&duplicated_profile, Some(bar), false)
.await?;
{
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;
@@ -265,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,7 +8,7 @@ use crate::pack::install_from::{
EnvType, PackDependency, PackFile, PackFileHash, PackFormat,
};
use crate::prelude::{JavaVersion, ProfilePathId, ProjectPathId};
use crate::state::{ProjectMetadata, SideType};
use crate::state::{InnerProjectPathUnix, ProjectMetadata, SideType};
use crate::util::fetch;
use crate::util::io::{self, IOError};
@@ -25,8 +25,9 @@ use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use serde_json::json;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::iter::FromIterator;
use std::{
future::Future,
path::{Path, PathBuf},
@@ -280,9 +281,9 @@ pub async fn list(
/// Installs/Repairs a profile
#[tracing::instrument]
pub async fn install(path: &ProfilePathId) -> crate::Result<()> {
pub async fn install(path: &ProfilePathId, force: bool) -> crate::Result<()> {
if let Some(profile) = get(path, None).await? {
crate::launcher::install_minecraft(&profile, None).await?;
crate::launcher::install_minecraft(&profile, None, force).await?;
} else {
return Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
.as_error());
@@ -388,6 +389,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?;
}
@@ -570,7 +575,7 @@ pub async fn remove_project(
pub async fn export_mrpack(
profile_path: &ProfilePathId,
export_path: PathBuf,
included_overrides: Vec<String>, // which folders to include in the overrides
included_export_candidates: Vec<String>, // which folders/files to include in the export
version_id: Option<String>,
description: Option<String>,
_name: Option<String>,
@@ -585,8 +590,8 @@ pub async fn export_mrpack(
))
})?;
// remove .DS_Store files from included_overrides
let included_overrides = included_overrides
// remove .DS_Store files from included_export_candidates
let included_export_candidates = included_export_candidates
.into_iter()
.filter(|x| {
if let Some(f) = PathBuf::from(x).file_name() {
@@ -607,13 +612,17 @@ pub async fn export_mrpack(
// Create mrpack json configuration file
let version_id = version_id.unwrap_or("1.0.0".to_string());
let packfile =
let mut packfile =
create_mrpack_json(&profile, version_id, description).await?;
let modrinth_path_list = get_modrinth_pack_list(&packfile);
let included_candidates_set =
HashSet::<_>::from_iter(included_export_candidates.iter());
packfile.files.retain(|f| {
included_candidates_set.contains(&f.path.get_topmost_two_components())
});
// Build vec of all files in the folder
let mut path_list = Vec::new();
build_folder(profile_base_path, &mut path_list).await?;
add_all_recursive_folder_paths(profile_base_path, &mut path_list).await?;
// Initialize loading bar
let loading_bar = init_loading(
@@ -631,38 +640,13 @@ pub async fn export_mrpack(
for path in path_list {
emit_loading(&loading_bar, 1.0, None).await?;
// Get local path of file, relative to profile folder
let relative_path = path.strip_prefix(profile_base_path)?;
// Get highest level folder pair ('a/b' in 'a/b/c', 'a' in 'a')
// We only go one layer deep for the sake of not having a huge list of overrides
let topmost_two = relative_path.iter().take(2).collect::<Vec<_>>();
// a,b => a/b
// a => a
let topmost = match topmost_two.len() {
2 => PathBuf::from(topmost_two[0]).join(topmost_two[1]),
1 => PathBuf::from(topmost_two[0]),
_ => {
return Err(crate::ErrorKind::OtherError(
"No topmost folder found".to_string(),
)
.into())
}
}
.to_string_lossy()
.to_string();
if !included_overrides.contains(&topmost) {
continue;
}
let relative_path: std::borrow::Cow<str> =
relative_path.to_string_lossy();
let relative_path = relative_path.replace('\\', "/");
let relative_path = relative_path.trim_start_matches('/').to_string();
if modrinth_path_list.contains(&relative_path) {
let relative_path = ProjectPathId::from_fs_path(&path)
.await?
.get_inner_path_unix();
if packfile.files.iter().any(|f| f.path == relative_path)
|| !included_candidates_set
.contains(&relative_path.get_topmost_two_components())
{
continue;
}
@@ -696,30 +680,28 @@ pub async fn export_mrpack(
Ok(())
}
// Given a folder path, populate a Vec of all the subfolders
// Intended to be used for finding potential override folders
// Given a folder path, populate a Vec of all the subfolders and files, at most 2 layers deep
// profile
// -- folder1
// -- folder2
// -- innerfolder
// -- innerfile
// -- folder2file
// -- file1
// => [folder1, folder2]
// => [folder1, folder2/innerfolder, folder2/folder2file, file1]
#[tracing::instrument]
pub async fn get_potential_override_folders(
profile_path: ProfilePathId,
) -> crate::Result<Vec<PathBuf>> {
pub async fn get_pack_export_candidates(
profile_path: &ProfilePathId,
) -> crate::Result<Vec<InnerProjectPathUnix>> {
// First, get a dummy mrpack json for the files within
let profile: Profile =
get(&profile_path, None).await?.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to export a nonexistent or unloaded profile at path {}!",
profile_path
))
})?;
// dummy mrpack to get pack list
let mrpack = create_mrpack_json(&profile, "0".to_string(), None).await?;
let mrpack_files = get_modrinth_pack_list(&mrpack);
let profile: Profile = get(profile_path, None).await?.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to export a nonexistent or unloaded profile at path {}!",
profile_path
))
})?;
let mut path_list: Vec<PathBuf> = Vec::new();
let mut path_list: Vec<InnerProjectPathUnix> = Vec::new();
let profile_base_dir = profile.get_profile_full_path().await?;
let mut read_dir = io::read_dir(&profile_base_dir).await?;
@@ -738,16 +720,16 @@ pub async fn get_potential_override_folders(
.map_err(|e| IOError::with_path(e, &profile_base_dir))?
{
let path: PathBuf = entry.path();
let name = path.strip_prefix(&profile_base_dir)?.to_path_buf();
if !mrpack_files.contains(&name.to_string_lossy().to_string()) {
path_list.push(name);
if let Ok(project_path) =
ProjectPathId::from_fs_path(&path).await
{
path_list.push(project_path.get_inner_path_unix());
}
}
} else {
// One layer of files/folders if its a file
let name = path.strip_prefix(&profile_base_dir)?.to_path_buf();
if !mrpack_files.contains(&name.to_string_lossy().to_string()) {
path_list.push(name);
if let Ok(project_path) = ProjectPathId::from_fs_path(&path).await {
path_list.push(project_path.get_inner_path_unix());
}
}
}
@@ -934,19 +916,6 @@ pub async fn try_update_playtime(path: &ProfilePathId) -> crate::Result<()> {
res
}
fn get_modrinth_pack_list(packfile: &PackFormat) -> Vec<String> {
packfile
.files
.iter()
.map(|f| {
let path = PathBuf::from(f.path.clone());
let name = path.to_string_lossy();
let name = name.replace('\\', "/");
name.trim_start_matches('/').to_string()
})
.collect::<Vec<String>>()
}
/// Creates a json configuration for a .mrpack zipped file
// Version ID of uploaded version (ie 1.1.5), not the unique identifying ID of the version (nvrqJg44)
#[tracing::instrument(skip_all)]
@@ -997,7 +966,7 @@ pub async fn create_mrpack_json(
.projects
.iter()
.filter_map(|(mod_path, project)| {
let path: String = mod_path.get_inner_path_unix().ok()?;
let path = mod_path.get_inner_path_unix();
// Only Modrinth projects have a modrinth metadata field for the modrinth.json
Some(Ok(match project.metadata {
@@ -1069,13 +1038,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)
@@ -1087,7 +1060,7 @@ fn sanitize_loader_version_string(s: &str, loader: PackDependency) -> &str {
// Given a folder path, populate a Vec of all the files in the folder, recursively
#[async_recursion::async_recursion]
pub async fn build_folder(
pub async fn add_all_recursive_folder_paths(
path: &Path,
path_list: &mut Vec<PathBuf>,
) -> crate::Result<()> {
@@ -1099,7 +1072,7 @@ pub async fn build_folder(
{
let path = entry.path();
if path.is_dir() {
build_folder(&path, path_list).await?;
add_all_recursive_folder_paths(&path, path_list).await?;
} else {
path_list.push(path);
}

View File

@@ -1,6 +1,7 @@
//! Theseus profile management interface
use std::path::PathBuf;
use std::path::{PathBuf, Path};
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::{io, fetch},
};
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,52 @@ 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 +187,19 @@ 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 +214,31 @@ 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

@@ -34,6 +34,9 @@ pub enum ErrorKind {
#[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

@@ -1,6 +1,6 @@
//! Minecraft CLI argument logic
// TODO: Rafactor this section
use super::{auth::Credentials, parse_rule};
use super::auth::Credentials;
use crate::launcher::parse_rules;
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;
@@ -23,12 +24,13 @@ pub fn get_class_paths(
libraries: &[Library],
client_path: &Path,
java_arch: &str,
minecraft_updated: bool,
) -> crate::Result<String> {
let mut cps = libraries
.iter()
.filter_map(|library| {
if let Some(rules) = &library.rules {
if !rules.iter().any(|x| parse_rule(x, java_arch)) {
if !parse_rules(rules, java_arch, minecraft_updated) {
return None;
}
}
@@ -39,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!(
@@ -54,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>>(
@@ -143,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)
}
@@ -335,7 +339,7 @@ where
}
}
Argument::Ruled { rules, value } => {
if rules.iter().any(|x| parse_rule(x, java_arch)) {
if parse_rules(rules, java_arch, true) {
match value {
ArgumentValue::Single(arg) => {
parsed_arguments.push(parse_function(

View File

@@ -64,7 +64,7 @@ pub async fn refresh_credentials(
.as_error())
}
xsts_token::XSTSResponse::Success { token: xsts_token } => {
let bearer_token =
let (bearer_token, expires_in) =
bearer_token::fetch_bearer(&xsts_token, &xbl_token.uhs)
.await
.map_err(|err| {
@@ -76,8 +76,7 @@ pub async fn refresh_credentials(
credentials.access_token = bearer_token;
credentials.refresh_token = oauth.refresh_token;
credentials.expires =
Utc::now() + Duration::seconds(oauth.expires_in);
credentials.expires = Utc::now() + Duration::seconds(expires_in);
}
}

View File

@@ -1,5 +1,6 @@
//! Downloader for Minecraft data
use crate::launcher::parse_rules;
use crate::state::CredentialsStore;
use crate::{
event::{
@@ -26,11 +27,13 @@ pub async fn download_minecraft(
version: &GameVersionInfo,
loading_bar: &LoadingBarId,
java_arch: &str,
force: bool,
minecraft_updated: bool,
) -> crate::Result<()> {
tracing::info!("Downloading Minecraft version {}", version.id);
// 5
let assets_index =
download_assets_index(st, version, Some(loading_bar)).await?;
download_assets_index(st, version, Some(loading_bar), force).await?;
let amount = if version
.processors
@@ -45,9 +48,9 @@ pub async fn download_minecraft(
tokio::try_join! {
// Total loading sums to 90/60
download_client(st, version, Some(loading_bar)), // 10
download_assets(st, version.assets == "legacy", &assets_index, Some(loading_bar), amount), // 40
download_libraries(st, version.libraries.as_slice(), &version.id, Some(loading_bar), amount, java_arch) // 40
download_client(st, version, Some(loading_bar), force), // 10
download_assets(st, version.assets == "legacy", &assets_index, Some(loading_bar), amount, force), // 40
download_libraries(st, version.libraries.as_slice(), &version.id, Some(loading_bar), amount, java_arch, force, minecraft_updated) // 40
}?;
tracing::info!("Done downloading Minecraft!");
@@ -105,6 +108,7 @@ pub async fn download_client(
st: &State,
version_info: &GameVersionInfo,
loading_bar: Option<&LoadingBarId>,
force: bool,
) -> crate::Result<()> {
let version = &version_info.id;
tracing::debug!("Locating client for version {version}");
@@ -123,7 +127,7 @@ pub async fn download_client(
.await
.join(format!("{version}.jar"));
if !path.exists() {
if !path.exists() || force {
let bytes = fetch(
&client_download.url,
Some(&client_download.sha1),
@@ -148,6 +152,7 @@ pub async fn download_assets_index(
st: &State,
version: &GameVersionInfo,
loading_bar: Option<&LoadingBarId>,
force: bool,
) -> crate::Result<AssetsIndex> {
tracing::debug!("Loading assets index");
let path = st
@@ -156,7 +161,7 @@ pub async fn download_assets_index(
.await
.join(format!("{}.json", &version.asset_index.id));
let res = if path.exists() {
let res = if path.exists() && !force {
io::read(path)
.err_into::<crate::Error>()
.await
@@ -183,6 +188,7 @@ pub async fn download_assets(
index: &AssetsIndex,
loading_bar: Option<&LoadingBarId>,
loading_amount: f64,
force: bool,
) -> crate::Result<()> {
tracing::debug!("Loading assets");
let num_futs = index.objects.len();
@@ -206,7 +212,7 @@ pub async fn download_assets(
let fetch_cell = OnceCell::<bytes::Bytes>::new();
tokio::try_join! {
async {
if !resource_path.exists() {
if !resource_path.exists() || force {
let resource = fetch_cell
.get_or_try_init(|| fetch(&url, Some(hash), &st.fetch_semaphore, &CredentialsStore(None)))
.await?;
@@ -216,13 +222,14 @@ pub async fn download_assets(
Ok::<_, crate::Error>(())
},
async {
if with_legacy {
let resource_path = st.directories.legacy_assets_dir().await.join(
name.replace('/', &String::from(std::path::MAIN_SEPARATOR))
);
if with_legacy && !resource_path.exists() || force {
let resource = fetch_cell
.get_or_try_init(|| fetch(&url, Some(hash), &st.fetch_semaphore, &CredentialsStore(None)))
.await?;
let resource_path = st.directories.legacy_assets_dir().await.join(
name.replace('/', &String::from(std::path::MAIN_SEPARATOR))
);
write(&resource_path, resource, &st.io_semaphore).await?;
tracing::trace!("Fetched legacy asset with hash {hash}");
}
@@ -239,6 +246,7 @@ pub async fn download_assets(
#[tracing::instrument(skip(st, libraries))]
#[theseus_macros::debug_pin]
#[allow(clippy::too_many_arguments)]
pub async fn download_libraries(
st: &State,
libraries: &[Library],
@@ -246,6 +254,8 @@ pub async fn download_libraries(
loading_bar: Option<&LoadingBarId>,
loading_amount: f64,
java_arch: &str,
force: bool,
minecraft_updated: bool,
) -> crate::Result<()> {
tracing::debug!("Loading libraries");
@@ -258,7 +268,7 @@ pub async fn download_libraries(
stream::iter(libraries.iter())
.map(Ok::<&Library, crate::Error>), None, loading_bar,loading_amount,num_files, None,|library| async move {
if let Some(rules) = &library.rules {
if !rules.iter().any(|x| super::parse_rule(x, java_arch)) {
if !parse_rules(rules, java_arch, minecraft_updated) {
tracing::trace!("Skipped library {}", &library.name);
return Ok(());
}
@@ -270,7 +280,7 @@ pub async fn download_libraries(
let path = st.directories.libraries_dir().await.join(&artifact_path);
match library.downloads {
_ if path.exists() => Ok(()),
_ if path.exists() && !force => Ok(()),
Some(d::minecraft::LibraryDownloads {
artifact: Some(ref artifact),
..

View File

@@ -13,10 +13,10 @@ use crate::{
};
use chrono::Utc;
use daedalus as d;
use daedalus::minecraft::VersionInfo;
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;
@@ -25,14 +25,48 @@ mod args;
pub mod auth;
pub mod download;
// All nones -> disallowed
// 1+ true -> allowed
// 1+ false -> disallowed
#[tracing::instrument]
pub fn parse_rule(rule: &d::minecraft::Rule, java_version: &str) -> bool {
pub fn parse_rules(
rules: &[d::minecraft::Rule],
java_version: &str,
minecraft_updated: bool,
) -> bool {
let mut x = rules
.iter()
.map(|x| parse_rule(x, java_version, minecraft_updated))
.collect::<Vec<Option<bool>>>();
if rules
.iter()
.all(|x| matches!(x.action, RuleAction::Disallow))
{
x.push(Some(true))
}
!(x.iter().any(|x| x == &Some(false)) || x.iter().all(|x| x.is_none()))
}
// if anything is disallowed, it should NOT be included
// if anything is not disallowed, it shouldn't factor in final result
// if anything is not allowed, it shouldn't factor in final result
// if anything is allowed, it should be included
#[tracing::instrument]
pub fn parse_rule(
rule: &d::minecraft::Rule,
java_version: &str,
minecraft_updated: bool,
) -> Option<bool> {
use d::minecraft::{Rule, RuleAction};
let res = match rule {
Rule {
os: Some(ref os), ..
} => crate::util::platform::os_rule(os, java_version),
} => {
crate::util::platform::os_rule(os, java_version, minecraft_updated)
}
Rule {
features: Some(ref features),
..
@@ -44,12 +78,24 @@ pub fn parse_rule(rule: &d::minecraft::Rule, java_version: &str) -> bool {
|| !features.is_quick_play_realms.unwrap_or(true)
|| !features.is_quick_play_singleplayer.unwrap_or(true)
}
_ => false,
_ => return Some(true),
};
match rule.action {
RuleAction::Allow => res,
RuleAction::Disallow => !res,
RuleAction::Allow => {
if res {
Some(true)
} else {
None
}
}
RuleAction::Disallow => {
if res {
Some(false)
} else {
None
}
}
}
}
@@ -102,6 +148,7 @@ pub async fn get_java_version_from_profile(
pub async fn install_minecraft(
profile: &Profile,
existing_loading_bar: Option<LoadingBarId>,
repairing: bool,
) -> crate::Result<()> {
let sync_projects = existing_loading_bar.is_some();
let loading_bar = init_or_edit_loading(
@@ -130,18 +177,26 @@ 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 = metadata
let version_index = metadata
.minecraft
.versions
.iter()
.find(|it| it.id == profile.metadata.game_version)
.position(|it| it.id == profile.metadata.game_version)
.ok_or(crate::ErrorKind::LauncherError(format!(
"Invalid game version: {}",
profile.metadata.game_version
)))?;
let version = &metadata.minecraft.versions[version_index];
let minecraft_updated = version_index
<= metadata
.minecraft
.versions
.iter()
.position(|x| x.id == "22w16a")
.unwrap_or(0);
let version_jar = profile
.metadata
@@ -156,7 +211,7 @@ pub async fn install_minecraft(
&state,
version,
profile.metadata.loader_version.as_ref(),
None,
Some(repairing),
Some(&loading_bar),
)
.await?;
@@ -185,6 +240,8 @@ pub async fn install_minecraft(
&version_info,
&loading_bar,
&java_version.architecture,
repairing,
minecraft_updated,
)
.await?;
@@ -325,7 +382,7 @@ pub async fn launch_minecraft(
}
if profile.install_stage != ProfileInstallStage::Installed {
install_minecraft(profile, None).await?;
install_minecraft(profile, None, false).await?;
}
let state = State::get().await?;
@@ -334,15 +391,23 @@ pub async fn launch_minecraft(
let instance_path = profile.get_profile_full_path().await?;
let instance_path = &io::canonicalize(instance_path)?;
let version = metadata
let version_index = metadata
.minecraft
.versions
.iter()
.find(|it| it.id == profile.metadata.game_version)
.position(|it| it.id == profile.metadata.game_version)
.ok_or(crate::ErrorKind::LauncherError(format!(
"Invalid game version: {}",
profile.metadata.game_version
)))?;
let version = &metadata.minecraft.versions[version_index];
let minecraft_updated = version_index
<= metadata
.minecraft
.versions
.iter()
.position(|x| x.id == "22w16a")
.unwrap_or(0);
let version_jar = profile
.metadata
@@ -418,6 +483,7 @@ pub async fn launch_minecraft(
version_info.libraries.as_slice(),
&client_path,
&java_version.architecture,
minecraft_updated,
)?,
&version_jar,
*memory,
@@ -445,9 +511,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

@@ -22,4 +22,5 @@ pub use api::*;
pub use error::*;
pub use event::{EventState, LoadingBar, LoadingBarType};
pub use logger::start_logger;
pub use state::InnerProjectPathUnix;
pub use state::State;

View File

@@ -271,8 +271,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(|| {

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

@@ -383,7 +383,7 @@ pub async fn init_watcher() -> crate::Result<Debouncer<RecommendedWatcher>> {
// At this point, new_path is the path to the profile, and subfile is whether it's a subfile of the profile or not
let profile_path_id =
ProfilePathId::new(&PathBuf::from(
ProfilePathId::new(PathBuf::from(
new_path.file_name().unwrap_or_default(),
));

View File

@@ -322,29 +322,6 @@ pub async fn create_account(
get_creds_from_res(response, semaphore).await
}
pub async fn login_minecraft(
flow: &str,
semaphore: &FetchSemaphore,
) -> crate::Result<ModrinthCredentialsResult> {
let resp = fetch_advanced(
Method::POST,
&format!("{MODRINTH_API_URL}auth/login/minecraft"),
None,
Some(serde_json::json!({
"flow": flow,
})),
None,
None,
semaphore,
&CredentialsStore(None),
)
.await?;
let response = serde_json::from_slice::<HashMap<String, Value>>(&resp)?;
get_result_from_res("session", response, semaphore).await
}
pub async fn refresh_credentials(
credentials_store: &mut CredentialsStore,
semaphore: &FetchSemaphore,

View File

@@ -72,8 +72,8 @@ impl ProfilePathId {
}
// Create a new ProfilePathId from a relative path
pub fn new(path: &Path) -> Self {
ProfilePathId(PathBuf::from(path))
pub fn new(path: impl Into<PathBuf>) -> Self {
ProfilePathId(path.into())
}
pub async fn get_full_path(&self) -> crate::Result<PathBuf> {
@@ -95,6 +95,45 @@ impl std::fmt::Display for ProfilePathId {
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash)]
#[serde(into = "RawProjectPath", from = "RawProjectPath")]
pub struct InnerProjectPathUnix(pub String);
impl InnerProjectPathUnix {
pub fn get_topmost_two_components(&self) -> String {
self.to_string()
.split('/')
.take(2)
.collect::<Vec<_>>()
.join("/")
}
}
impl std::fmt::Display for InnerProjectPathUnix {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl From<RawProjectPath> for InnerProjectPathUnix {
fn from(value: RawProjectPath) -> Self {
// Convert windows path to unix path.
// .mrpacks no longer generate windows paths, but this is here for backwards compatibility before this was fixed
// https://github.com/modrinth/theseus/issues/595
InnerProjectPathUnix(value.0.replace('\\', "/"))
}
}
#[derive(Serialize, Deserialize)]
#[serde(transparent)]
struct RawProjectPath(pub String);
impl From<InnerProjectPathUnix> for RawProjectPath {
fn from(value: InnerProjectPathUnix) -> Self {
RawProjectPath(value.0)
}
}
/// newtype wrapper over a Profile path, to be usable as a clear identifier for the kind of path used
/// eg: for "a/b/c/profiles/My Mod/mods/myproj", the ProjectPathId would be "mods/myproj"
#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash)]
@@ -102,11 +141,14 @@ impl std::fmt::Display for ProfilePathId {
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 path: PathBuf = io::canonicalize(path)?;
let profiles_dir: PathBuf = io::canonicalize(
pub async fn from_fs_path(path: &PathBuf) -> crate::Result<Self> {
// 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 = std::fs::canonicalize(path)?;
let path = path
.strip_prefix(profiles_dir)
.ok()
@@ -131,13 +173,14 @@ impl ProjectPathId {
// Gets inner path in unix convention as a String
// ie: 'mods\myproj' -> 'mods/myproj'
// Used for exporting to mrpack, which should have a singular convention
pub fn get_inner_path_unix(&self) -> crate::Result<String> {
Ok(self
.0
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/"))
pub fn get_inner_path_unix(&self) -> InnerProjectPathUnix {
InnerProjectPathUnix(
self.0
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/"),
)
}
// Create a new ProjectPathId from a relative path
@@ -849,8 +892,6 @@ impl Profiles {
// Fetch online from Modrinth each latest version
future::try_join_all(modrinth_updatables.into_iter().map(
|(profile_path, linked_project)| {
let profile_path = profile_path;
let linked_project = linked_project;
let state = state.clone();
async move {
let creds = state.credentials.read().await;

View File

@@ -815,7 +815,7 @@ pub async fn infer_data_from_files(
let mut corrected_hashmap = HashMap::new();
let mut stream = tokio_stream::iter(return_projects);
while let Some((h, v)) = stream.next().await {
let h = ProjectPathId::from_fs_path(h).await?;
let h = ProjectPathId::from_fs_path(&h).await?;
corrected_hashmap.insert(h, v);
}

View File

@@ -35,6 +35,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,
@@ -99,6 +101,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,

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::{path::Path, io::Write};
use tempfile::NamedTempFile;
use tauri::async_runtime::spawn_blocking;
#[derive(Debug, thiserror::Error)]
pub enum IOError {
@@ -113,15 +116,39 @@ 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

@@ -43,7 +43,9 @@ pub async fn get_all_jre() -> Result<Vec<JavaVersion>, JREError> {
r"C:\Program Files (x86)\Eclipse Adoptium",
];
for java_path in java_paths {
let Ok(java_subpaths) = std::fs::read_dir(java_path) else {continue };
let Ok(java_subpaths) = std::fs::read_dir(java_path) else {
continue;
};
for java_subpath in java_subpaths.flatten() {
let path = java_subpath.path();
jre_paths.insert(path.join("bin"));
@@ -97,7 +99,7 @@ pub fn get_paths_from_jre_winregkey(jre_key: RegKey) -> HashSet<PathBuf> {
for subkey_value in subkey_value_names {
let path: Result<String, std::io::Error> =
subkey.get_value(subkey_value);
let Ok(path) = path else {continue};
let Ok(path) = path else { continue };
jre_paths.insert(PathBuf::from(path).join("bin"));
}
@@ -264,7 +266,9 @@ pub async fn check_java_at_filepaths(
pub async fn check_java_at_filepath(path: &Path) -> Option<JavaVersion> {
// Attempt to canonicalize the potential java filepath
// If it fails, this path does not exist and None is returned (no Java here)
let Ok(path) = io::canonicalize(path) else { return None };
let Ok(path) = io::canonicalize(path) else {
return None;
};
// Checks for existence of Java at this filepath
// Adds JAVA_BIN to the end of the path if it is not already there

View File

@@ -56,7 +56,12 @@ pub const ARCH_WIDTH: &str = "64";
pub const ARCH_WIDTH: &str = "32";
// Platform rule handling
pub fn os_rule(rule: &OsRule, java_arch: &str) -> bool {
pub fn os_rule(
rule: &OsRule,
java_arch: &str,
// Minecraft updated over 1.18.2 (supports MacOS Natively)
minecraft_updated: bool,
) -> bool {
let mut rule_match = true;
if let Some(ref arch) = rule.arch {
@@ -64,8 +69,14 @@ pub fn os_rule(rule: &OsRule, java_arch: &str) -> bool {
}
if let Some(name) = &rule.name {
rule_match &=
&Os::native() == name || &Os::native_arch(java_arch) == name;
if minecraft_updated
&& (name != &Os::LinuxArm64 || name != &Os::LinuxArm32)
{
rule_match &=
&Os::native() == name || &Os::native_arch(java_arch) == name;
} else {
rule_match &= &Os::native_arch(java_arch) == name;
}
}
if let Some(version) = &rule.version {

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus_cli"
version = "0.5.4"
version = "0.6.3"
authors = ["Jai A <jaiagr+gpg@pm.me>"]
edition = "2018"

View File

@@ -175,12 +175,13 @@ impl ProfileInit {
.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}"
)
})?;
let loader_version = loaders
.iter()
.find(|&x| filter(x))
.cloned()
.ok_or_else(|| {
eyre::eyre!("Invalid version {version} for modloader {loader}")
})?;
Some((loader_version, loader))
} else {

View File

@@ -1,7 +1,7 @@
{
"name": "theseus_gui",
"private": true,
"version": "0.5.4",
"version": "0.6.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,11 +14,12 @@
},
"dependencies": {
"@tauri-apps/api": "^1.3.0",
"@vintl/vintl": "^4.4.1",
"dayjs": "^1.11.7",
"floating-vue": "^2.0.0-beta.20",
"mixpanel-browser": "^2.47.0",
"ofetch": "^1.0.1",
"omorphia": "^0.4.38",
"omorphia": "^0.7.3",
"pinia": "^2.1.3",
"qrcode.vue": "^3.4.0",
"tauri-plugin-window-state-api": "github:tauri-apps/tauri-plugin-window-state#v1",

View File

@@ -8,6 +8,9 @@ dependencies:
'@tauri-apps/api':
specifier: ^1.3.0
version: 1.3.0
'@vintl/vintl':
specifier: ^4.4.1
version: 4.4.1(vue@3.3.4)
dayjs:
specifier: ^1.11.7
version: 1.11.7
@@ -21,8 +24,8 @@ dependencies:
specifier: ^1.0.1
version: 1.0.1
omorphia:
specifier: ^0.4.38
version: 0.4.38
specifier: ^0.7.3
version: 0.7.3(vue@3.3.4)
pinia:
specifier: ^2.1.3
version: 2.1.3(vue@3.3.4)
@@ -31,7 +34,7 @@ dependencies:
version: 3.4.0(vue@3.3.4)
tauri-plugin-window-state-api:
specifier: github:tauri-apps/tauri-plugin-window-state#v1
version: github.com/tauri-apps/tauri-plugin-window-state/5ea9eb0d4a9affd17269f92c0085935046be3f4a
version: github.com/tauri-apps/tauri-plugin-window-state/91fafb628cd0c83ad52bdf9029cad212381f740a
vite-svg-loader:
specifier: ^4.0.0
version: 4.0.0
@@ -105,6 +108,118 @@ packages:
'@babel/helper-validator-identifier': 7.19.1
to-fast-properties: 2.0.0
/@braw/async-computed@5.0.2(vue@3.3.4):
resolution: {integrity: sha512-fThqjZBTPvWtbD90Nkd4IldN7dpCkxfvthuk12ZBjkPPjh+wuRGi3HYiUqUSAOOVS0NHSxpsQFfg+qO275FtYA==}
peerDependencies:
vue: ^2.7 || ^3.2.45
dependencies:
vue: 3.3.4
dev: false
/@codemirror/autocomplete@6.12.0(@codemirror/language@6.10.0)(@codemirror/state@6.4.0)(@codemirror/view@6.23.0)(@lezer/common@1.2.1):
resolution: {integrity: sha512-r4IjdYFthwbCQyvqnSlx0WBHRHi8nBvU+WjJxFUij81qsBfhNudf/XKKmmC2j3m0LaOYUQTf3qiEK1J8lO1sdg==}
peerDependencies:
'@codemirror/language': ^6.0.0
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
'@lezer/common': ^1.0.0
dependencies:
'@codemirror/language': 6.10.0
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.0
'@lezer/common': 1.2.1
dev: false
/@codemirror/commands@6.3.3:
resolution: {integrity: sha512-dO4hcF0fGT9tu1Pj1D2PvGvxjeGkbC6RGcZw6Qs74TH+Ed1gw98jmUgd2axWvIZEqTeTuFrg1lEB1KV6cK9h1A==}
dependencies:
'@codemirror/language': 6.10.0
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.0
'@lezer/common': 1.2.1
dev: false
/@codemirror/lang-css@6.2.1(@codemirror/view@6.23.0):
resolution: {integrity: sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==}
dependencies:
'@codemirror/autocomplete': 6.12.0(@codemirror/language@6.10.0)(@codemirror/state@6.4.0)(@codemirror/view@6.23.0)(@lezer/common@1.2.1)
'@codemirror/language': 6.10.0
'@codemirror/state': 6.4.0
'@lezer/common': 1.2.1
'@lezer/css': 1.1.7
transitivePeerDependencies:
- '@codemirror/view'
dev: false
/@codemirror/lang-html@6.4.7:
resolution: {integrity: sha512-y9hWSSO41XlcL4uYwWyk0lEgTHcelWWfRuqmvcAmxfCs0HNWZdriWo/EU43S63SxEZpc1Hd50Itw7ktfQvfkUg==}
dependencies:
'@codemirror/autocomplete': 6.12.0(@codemirror/language@6.10.0)(@codemirror/state@6.4.0)(@codemirror/view@6.23.0)(@lezer/common@1.2.1)
'@codemirror/lang-css': 6.2.1(@codemirror/view@6.23.0)
'@codemirror/lang-javascript': 6.2.1
'@codemirror/language': 6.10.0
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.0
'@lezer/common': 1.2.1
'@lezer/css': 1.1.7
'@lezer/html': 1.3.8
dev: false
/@codemirror/lang-javascript@6.2.1:
resolution: {integrity: sha512-jlFOXTejVyiQCW3EQwvKH0m99bUYIw40oPmFjSX2VS78yzfe0HELZ+NEo9Yfo1MkGRpGlj3Gnu4rdxV1EnAs5A==}
dependencies:
'@codemirror/autocomplete': 6.12.0(@codemirror/language@6.10.0)(@codemirror/state@6.4.0)(@codemirror/view@6.23.0)(@lezer/common@1.2.1)
'@codemirror/language': 6.10.0
'@codemirror/lint': 6.4.2
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.0
'@lezer/common': 1.2.1
'@lezer/javascript': 1.4.13
dev: false
/@codemirror/lang-markdown@6.2.4:
resolution: {integrity: sha512-UghkA1vSMs8bT7RSZM6vsIocigyah2bV00eRQuZy76401UmFZdsTsbQNBGdyxRQDOLeEvF5iFwap0BM8LKyd+g==}
dependencies:
'@codemirror/autocomplete': 6.12.0(@codemirror/language@6.10.0)(@codemirror/state@6.4.0)(@codemirror/view@6.23.0)(@lezer/common@1.2.1)
'@codemirror/lang-html': 6.4.7
'@codemirror/language': 6.10.0
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.0
'@lezer/common': 1.2.1
'@lezer/markdown': 1.2.0
dev: false
/@codemirror/language@6.10.0:
resolution: {integrity: sha512-2vaNn9aPGCRFKWcHPFksctzJ8yS5p7YoaT+jHpc0UGKzNuAIx4qy6R5wiqbP+heEEdyaABA582mNqSHzSoYdmg==}
dependencies:
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.0
'@lezer/common': 1.2.1
'@lezer/highlight': 1.2.0
'@lezer/lr': 1.3.14
style-mod: 4.1.0
dev: false
/@codemirror/lint@6.4.2:
resolution: {integrity: sha512-wzRkluWb1ptPKdzlsrbwwjYCPLgzU6N88YBAmlZi8WFyuiEduSd05MnJYNogzyc8rPK7pj6m95ptUApc8sHKVA==}
dependencies:
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.0
crelt: 1.0.6
dev: false
/@codemirror/state@6.4.0:
resolution: {integrity: sha512-hm8XshYj5Fo30Bb922QX9hXB/bxOAVH+qaqHBzw5TKa72vOeslyGwd4X8M0c1dJ9JqxlaMceOQ8RsL9tC7gU0A==}
dev: false
/@codemirror/view@6.23.0:
resolution: {integrity: sha512-/51px9N4uW8NpuWkyUX+iam5+PM6io2fm+QmRnzwqBy5v/pwGg9T0kILFtYeum8hjuvENtgsGNKluOfqIICmeQ==}
dependencies:
'@codemirror/state': 6.4.0
style-mod: 4.1.0
w3c-keyname: 2.2.8
dev: false
/@esbuild/android-arm64@0.17.19:
resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==}
engines: {node: '>=12'}
@@ -350,6 +465,79 @@ packages:
'@floating-ui/core': 0.3.1
dev: false
/@formatjs/ecma402-abstract@1.18.2:
resolution: {integrity: sha512-+QoPW4csYALsQIl8GbN14igZzDbuwzcpWrku9nyMXlaqAlwRBgl5V+p0vWMGFqHOw37czNXaP/lEk4wbLgcmtA==}
dependencies:
'@formatjs/intl-localematcher': 0.5.4
tslib: 2.6.2
dev: false
/@formatjs/fast-memoize@2.2.0:
resolution: {integrity: sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA==}
dependencies:
tslib: 2.6.2
dev: false
/@formatjs/icu-messageformat-parser@2.7.5:
resolution: {integrity: sha512-zCB53HdGDibh6/2ISEN3TGsFQruQ6gGKMFV94qHNyVrs0tNO6ncKhV0vq0n3Ydz8ipIQ2GaYAvfCoimNOVvKqA==}
dependencies:
'@formatjs/ecma402-abstract': 1.18.2
'@formatjs/icu-skeleton-parser': 1.7.2
tslib: 2.6.2
dev: false
/@formatjs/icu-skeleton-parser@1.7.2:
resolution: {integrity: sha512-nlIXVv280bjGW3ail5Np1+xgGKBnMhwQQIivgbk9xX0af8ESQO+y2VW9TOY7mCrs3WH786uVpZlLimXAlXH7SA==}
dependencies:
'@formatjs/ecma402-abstract': 1.18.2
tslib: 2.6.2
dev: false
/@formatjs/intl-displaynames@6.6.6:
resolution: {integrity: sha512-Dg5URSjx0uzF8VZXtHb6KYZ6LFEEhCbAbKoYChYHEOnMFTw/ZU3jIo/NrujzQD2EfKPgQzIq73LOUvW6Z/LpFA==}
dependencies:
'@formatjs/ecma402-abstract': 1.18.2
'@formatjs/intl-localematcher': 0.5.4
tslib: 2.6.2
dev: false
/@formatjs/intl-listformat@7.5.5:
resolution: {integrity: sha512-XoI52qrU6aBGJC9KJddqnacuBbPlb/bXFN+lIFVFhQ1RnFHpzuFrlFdjD9am2O7ZSYsyqzYRpkVcXeT1GHkwDQ==}
dependencies:
'@formatjs/ecma402-abstract': 1.18.2
'@formatjs/intl-localematcher': 0.5.4
tslib: 2.6.2
dev: false
/@formatjs/intl-localematcher@0.4.2:
resolution: {integrity: sha512-BGdtJFmaNJy5An/Zan4OId/yR9Ih1OojFjcduX/xOvq798OgWSyDtd6Qd5jqJXwJs1ipe4Fxu9+cshic5Ox2tA==}
dependencies:
tslib: 2.6.2
dev: false
/@formatjs/intl-localematcher@0.5.4:
resolution: {integrity: sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==}
dependencies:
tslib: 2.6.2
dev: false
/@formatjs/intl@2.9.11:
resolution: {integrity: sha512-wJF5GKuopgeKy75e11JPjueC/XKAxrOndqVEZqg5zDrGuxALUD6Vo/x+oDTQwVZYf2zJnEzqZlUGtv5gSi/ChQ==}
peerDependencies:
typescript: ^4.7 || 5
peerDependenciesMeta:
typescript:
optional: true
dependencies:
'@formatjs/ecma402-abstract': 1.18.2
'@formatjs/fast-memoize': 2.2.0
'@formatjs/icu-messageformat-parser': 2.7.5
'@formatjs/intl-displaynames': 6.6.6
'@formatjs/intl-listformat': 7.5.5
intl-messageformat: 10.5.10
tslib: 2.6.2
dev: false
/@humanwhocodes/config-array@0.11.8:
resolution: {integrity: sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==}
engines: {node: '>=10.10.0'}
@@ -373,6 +561,53 @@ packages:
/@jridgewell/sourcemap-codec@1.4.15:
resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==}
/@lezer/common@1.2.1:
resolution: {integrity: sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==}
dev: false
/@lezer/css@1.1.7:
resolution: {integrity: sha512-7BlFFAKNn/b39jJLrhdLSX5A2k56GIJvyLqdmm7UU+7XvequY084iuKDMAEhAmAzHnwDE8FK4OQtsIUssW91tg==}
dependencies:
'@lezer/common': 1.2.1
'@lezer/highlight': 1.2.0
'@lezer/lr': 1.3.14
dev: false
/@lezer/highlight@1.2.0:
resolution: {integrity: sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==}
dependencies:
'@lezer/common': 1.2.1
dev: false
/@lezer/html@1.3.8:
resolution: {integrity: sha512-EXseJ3pUzWxE6XQBQdqWHZqqlGQRSuNMBcLb6mZWS2J2v+QZhOObD+3ZIKIcm59ntTzyor4LqFTb72iJc3k23Q==}
dependencies:
'@lezer/common': 1.2.1
'@lezer/highlight': 1.2.0
'@lezer/lr': 1.3.14
dev: false
/@lezer/javascript@1.4.13:
resolution: {integrity: sha512-5IBr8LIO3xJdJH1e9aj/ZNLE4LSbdsx25wFmGRAZsj2zSmwAYjx26JyU/BYOCpRQlu1jcv1z3vy4NB9+UkfRow==}
dependencies:
'@lezer/common': 1.2.1
'@lezer/highlight': 1.2.0
'@lezer/lr': 1.3.14
dev: false
/@lezer/lr@1.3.14:
resolution: {integrity: sha512-z5mY4LStlA3yL7aHT/rqgG614cfcvklS+8oFRFBYrs4YaWLJyKKM4+nN6KopToX0o9Hj6zmH6M5kinOYuy06ug==}
dependencies:
'@lezer/common': 1.2.1
dev: false
/@lezer/markdown@1.2.0:
resolution: {integrity: sha512-d7MwsfAukZJo1GpPrcPGa3MxaFFOqNp0gbqF+3F7pTeNDOgeJN1muXzx1XXDPt+Ac+/voCzsH7qXqnn+xReG/g==}
dependencies:
'@lezer/common': 1.2.1
'@lezer/highlight': 1.2.0
dev: false
/@nodelib/fs.scandir@2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -419,8 +654,8 @@ packages:
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
dev: false
/@tauri-apps/api@1.4.0:
resolution: {integrity: sha512-Jd6HPoTM1PZSFIzq7FB8VmMu3qSSyo/3lSwLpoapW+lQ41CL5Dow2KryLg+gyazA/58DRWI9vu/XpEeHK4uMdw==}
/@tauri-apps/api@1.5.3:
resolution: {integrity: sha512-zxnDjHHKjOsrIzZm6nO5Xapb/BxqUq1tc7cGkFXsFkGTsSWgCPH1D8mm0XS9weJY2OaR73I3k3S+b7eSzJDfqA==}
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
dev: false
@@ -541,6 +776,21 @@ packages:
resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==}
dev: true
/@vintl/vintl@4.4.1(vue@3.3.4):
resolution: {integrity: sha512-1fAnK1Ru4GlUH6v2UPqPMFXvatiZuDlgF3GBrUYDBvs4mzg+j3cmH9GgX7DqBtpRLI1iqcoQF10cnJs/e/0Dvw==}
peerDependencies:
vue: ^3.2.47
dependencies:
'@braw/async-computed': 5.0.2(vue@3.3.4)
'@formatjs/icu-messageformat-parser': 2.7.5
'@formatjs/intl': 2.9.11
'@formatjs/intl-localematcher': 0.4.2
intl-messageformat: 10.5.10
vue: 3.3.4
transitivePeerDependencies:
- typescript
dev: false
/@vitejs/plugin-vue@4.2.3(vite@4.3.9)(vue@3.3.4):
resolution: {integrity: sha512-R6JDUfiZbJA9cMiguQ7jxALsgiprjBeHL5ikpXfJCH62pPHtI+JdJ5xWj6Ev73yXSlYl86+blXn1kZHQ7uElxw==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -629,6 +879,10 @@ packages:
/@vue/shared@3.3.4:
resolution: {integrity: sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==}
/@yr/monotone-cubic-spline@1.0.3:
resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==}
dev: false
/acorn-jsx@5.3.2(acorn@8.8.2):
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -672,6 +926,18 @@ packages:
picomatch: 2.3.1
dev: true
/apexcharts@3.45.1:
resolution: {integrity: sha512-pPjj/SA6dfPvR/IKRZF0STdfBGpBh3WRt7K0DFuW9P8erypYkX17EHu3/molPRfo2zSiQwTVpshHC5ncysqfkA==}
dependencies:
'@yr/monotone-cubic-spline': 1.0.3
svg.draggable.js: 2.2.2
svg.easing.js: 2.0.0
svg.filter.js: 2.0.2
svg.pathmorphing.js: 0.1.3
svg.resize.js: 1.4.3
svg.select.js: 3.0.1
dev: false
/argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -726,7 +992,7 @@ packages:
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: true
/color-convert@2.0.1:
@@ -753,6 +1019,10 @@ packages:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
dev: true
/crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
dev: false
/cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
@@ -1106,8 +1376,8 @@ packages:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
dev: true
/fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
/fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
requiresBuild: true
@@ -1193,6 +1463,15 @@ packages:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
dev: true
/intl-messageformat@10.5.10:
resolution: {integrity: sha512-3yzwX6t/my9WRtNiqP05r+/UkpWxwstQiwaHAiuHmDRt7ykzWJ+nceOVjNLZYYWGiSltY+C+Likd8OIVkASepw==}
dependencies:
'@formatjs/ecma402-abstract': 1.18.2
'@formatjs/fast-memoize': 2.2.0
'@formatjs/icu-messageformat-parser': 2.7.5
tslib: 2.6.2
dev: false
/is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
@@ -1355,9 +1634,17 @@ packages:
ufo: 1.1.2
dev: false
/omorphia@0.4.38:
resolution: {integrity: sha512-V0vEarmAart6Gf5WuPUZ58TuIiQf7rI5HJpmYU7FVbtdvZ3q08VqyKZflCddbeBSFQ4/N+A+sNr/ELf/jz+Cug==}
/omorphia@0.7.3(vue@3.3.4):
resolution: {integrity: sha512-Xk9o3xk/rFuZeR0LLoJNbOEvnQjAh6wOTZrksgCDAVuX30cSnpuihI9lsZNlH+4V1TXOB5FpVSHBEA/+LGzwHQ==}
peerDependencies:
vue: ^3.3.4
dependencies:
'@codemirror/commands': 6.3.3
'@codemirror/lang-markdown': 6.2.4
'@codemirror/language': 6.10.0
'@codemirror/state': 6.4.0
'@codemirror/view': 6.23.0
apexcharts: 3.45.1
dayjs: 1.11.7
floating-vue: 2.0.0-beta.20(vue@3.3.4)
highlight.js: 11.8.0
@@ -1366,6 +1653,7 @@ packages:
vue: 3.3.4
vue-router: 4.2.1(vue@3.3.4)
vue-select: 4.0.0-beta.6(vue@3.3.4)
vue3-apexcharts: 1.4.4(apexcharts@3.45.1)(vue@3.3.4)
xss: 1.0.14
dev: false
@@ -1521,7 +1809,7 @@ packages:
engines: {node: '>=10.0.0'}
hasBin: true
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: true
/rollup@3.23.0:
@@ -1529,7 +1817,7 @@ packages:
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: true
/run-parallel@1.2.0:
@@ -1589,6 +1877,10 @@ packages:
engines: {node: '>=8'}
dev: true
/style-mod@4.1.0:
resolution: {integrity: sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==}
dev: false
/supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -1596,6 +1888,60 @@ packages:
has-flag: 4.0.0
dev: true
/svg.draggable.js@2.2.2:
resolution: {integrity: sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==}
engines: {node: '>= 0.8.0'}
dependencies:
svg.js: 2.7.1
dev: false
/svg.easing.js@2.0.0:
resolution: {integrity: sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==}
engines: {node: '>= 0.8.0'}
dependencies:
svg.js: 2.7.1
dev: false
/svg.filter.js@2.0.2:
resolution: {integrity: sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==}
engines: {node: '>= 0.8.0'}
dependencies:
svg.js: 2.7.1
dev: false
/svg.js@2.7.1:
resolution: {integrity: sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==}
dev: false
/svg.pathmorphing.js@0.1.3:
resolution: {integrity: sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==}
engines: {node: '>= 0.8.0'}
dependencies:
svg.js: 2.7.1
dev: false
/svg.resize.js@1.4.3:
resolution: {integrity: sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==}
engines: {node: '>= 0.8.0'}
dependencies:
svg.js: 2.7.1
svg.select.js: 2.1.2
dev: false
/svg.select.js@2.1.2:
resolution: {integrity: sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==}
engines: {node: '>= 0.8.0'}
dependencies:
svg.js: 2.7.1
dev: false
/svg.select.js@3.0.1:
resolution: {integrity: sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==}
engines: {node: '>= 0.8.0'}
dependencies:
svg.js: 2.7.1
dev: false
/svgo@3.0.2:
resolution: {integrity: sha512-Z706C1U2pb1+JGP48fbazf3KxHrWOsLme6Rv7imFBn5EnuanDW1GPaA/P1/dvObE670JDePC3mnj0k0B7P0jjQ==}
engines: {node: '>=14.0.0'}
@@ -1624,6 +1970,10 @@ packages:
is-number: 7.0.0
dev: true
/tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
dev: false
/type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -1704,7 +2054,7 @@ packages:
rollup: 3.23.0
sass: 1.62.1
optionalDependencies:
fsevents: 2.3.2
fsevents: 2.3.3
dev: true
/vue-demi@0.14.5(vue@3.3.4):
@@ -1789,6 +2139,16 @@ packages:
vue-resize: 2.0.0-alpha.1(vue@3.3.4)
dev: false
/vue3-apexcharts@1.4.4(apexcharts@3.45.1)(vue@3.3.4):
resolution: {integrity: sha512-TH89uZrxGjaDvkaYAISvj8+k6Bf1rUKFillc8oJirs5XZEPiwM1ELKZQ786wz0rfPqkSHHny2lqqUCK7Rw+LcQ==}
peerDependencies:
apexcharts: '> 3.0.0'
vue: '> 3.0.0'
dependencies:
apexcharts: 3.45.1
vue: 3.3.4
dev: false
/vue@3.3.4:
resolution: {integrity: sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==}
dependencies:
@@ -1798,6 +2158,10 @@ packages:
'@vue/server-renderer': 3.3.4(vue@3.3.4)
'@vue/shared': 3.3.4
/w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
dev: false
/which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -1838,10 +2202,10 @@ packages:
engines: {node: '>=10'}
dev: true
github.com/tauri-apps/tauri-plugin-window-state/5ea9eb0d4a9affd17269f92c0085935046be3f4a:
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-window-state/tar.gz/5ea9eb0d4a9affd17269f92c0085935046be3f4a}
github.com/tauri-apps/tauri-plugin-window-state/91fafb628cd0c83ad52bdf9029cad212381f740a:
resolution: {tarball: https://codeload.github.com/tauri-apps/tauri-plugin-window-state/tar.gz/91fafb628cd0c83ad52bdf9029cad212381f740a}
name: tauri-plugin-window-state-api
version: 0.0.0
dependencies:
'@tauri-apps/api': 1.4.0
'@tauri-apps/api': 1.5.3
dev: false

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus_gui"
version = "0.5.4"
version = "0.6.3"
description = "A Tauri App"
authors = ["you"]
license = ""
@@ -57,6 +57,6 @@ objc = "0.2.7"
# 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

@@ -3,7 +3,7 @@ use daedalus::modded::LoaderVersion;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use theseus::prelude::*;
use theseus::{prelude::*, InnerProjectPathUnix};
use uuid::Uuid;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
@@ -32,7 +32,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
profile_edit,
profile_edit_icon,
profile_export_mrpack,
profile_get_potential_override_folders,
profile_get_pack_export_candidates,
])
.build()
}
@@ -117,8 +117,8 @@ pub async fn profile_check_installed(
/// Installs/Repairs a profile
/// invoke('plugin:profile|profile_install')
#[tauri::command]
pub async fn profile_install(path: ProfilePathId) -> Result<()> {
profile::install(&path).await?;
pub async fn profile_install(path: ProfilePathId, force: bool) -> Result<()> {
profile::install(&path, force).await?;
Ok(())
}
@@ -228,20 +228,13 @@ pub async fn profile_export_mrpack(
Ok(())
}
// Given a folder path, populate a Vec of all the subfolders
// Intended to be used for finding potential override folders
// profile
// -- folder1
// -- folder2
// -- file1
// => [folder1, folder2]
/// See [`profile::get_pack_export_candidates`]
#[tauri::command]
pub async fn profile_get_potential_override_folders(
pub async fn profile_get_pack_export_candidates(
profile_path: ProfilePathId,
) -> Result<Vec<PathBuf>> {
let overrides =
profile::get_potential_override_folders(profile_path).await?;
Ok(overrides)
) -> Result<Vec<InnerProjectPathUnix>> {
let candidates = profile::get_pack_export_candidates(&profile_path).await?;
Ok(candidates)
}
// Run minecraft using a profile using the default credentials

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

@@ -8,7 +8,7 @@
},
"package": {
"productName": "Modrinth App",
"version": "0.5.4"
"version": "0.6.3"
},
"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

@@ -1,20 +1,25 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { RouterView, RouterLink, useRouter, useRoute } from 'vue-router'
import {
HomeIcon,
SearchIcon,
LibraryIcon,
PlusIcon,
SettingsIcon,
FileIcon,
Button,
Notifications,
XIcon,
Card,
TextLogo,
PlusIcon,
Avatar,
} from 'omorphia'
import { useLoading, useTheming } from '@/store/state'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import { useInstances } from '@/store/instances'
// import AccountsCard from './components/ui/AccountsCard.vue'
import AccountDropdown from '@/components/ui/platform/AccountDropdown.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { get } from '@/helpers/settings'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
@@ -23,45 +28,70 @@ import SplashScreen from '@/components/ui/SplashScreen.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'
import { MinimizeIcon, MaximizeIcon, ChatIcon } from '@/assets/icons'
import { type } from '@tauri-apps/api/os'
import { appWindow } from '@tauri-apps/api/window'
import {
MinimizeIcon,
MaximizeIcon,
ChatIcon,
ArrowLeftFromLineIcon,
ArrowRightFromLineIcon,
} from '@/assets/icons'
import { isDev, getOS, isOffline, showLauncherLogsFolder } from '@/helpers/utils.js'
import {
mixpanel_track,
mixpanel_init,
mixpanel_opt_out_tracking,
mixpanel_is_loaded,
} from '@/helpers/mixpanel'
} from '@/helpers/mixpanel.js'
import { useDisableClicks } from '@/composables/click.js'
import { openExternal } from '@/helpers/external.js'
import { await_sync, check_safe_loading_bars_complete } from '@/helpers/state.js'
import { install_from_file } from '@/helpers/pack.js'
import { iconPathAsUrl } from '@/helpers/icon'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import StickyTitleBar from '@/components/ui/tutorial/StickyTitleBar.vue'
import OnboardingScreen from '@/components/ui/tutorial/OnboardingScreen.vue'
import { saveWindowState, StateFlags } from 'tauri-plugin-window-state-api'
import { getVersion } from '@tauri-apps/api/app'
import { window as TauriWindow } from '@tauri-apps/api'
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 { type } from '@tauri-apps/api/os'
import { appWindow } from '@tauri-apps/api/window'
import { storeToRefs } from 'pinia'
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 sidebarOpen = ref(false)
const onboardingVideo = ref()
const failureText = ref(null)
const os = ref('')
const instances = useInstances()
const { instancesByPlayed } = storeToRefs(instances)
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'
@@ -69,6 +99,9 @@ defineExpose({
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
@@ -144,18 +177,12 @@ const handleClose = async () => {
await TauriWindow.getCurrent().close()
}
const openSupport = async () => {
window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: 'https://discord.gg/modrinth',
},
})
}
const openSupport = () => openExternal(window, 'https://support.modrinth.com/')
TauriWindow.getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, async () => {
await handleClose()
onMounted(() => {
return TauriWindow.getCurrent().listen(TauriEvent.WINDOW_CLOSE_REQUESTED, async () => {
await handleClose()
})
})
const router = useRouter()
@@ -176,47 +203,9 @@ watch(notificationsWrapper, () => {
notifications.setNotifs(notificationsWrapper.value)
})
document.querySelector('body').addEventListener('click', function (e) {
let target = e.target
while (target != null) {
if (target.matches('a')) {
if (
target.href &&
['http://', 'https://', 'mailto:', 'tel:'].some((v) => target.href.startsWith(v)) &&
!target.classList.contains('router-link-active') &&
!target.href.startsWith('http://localhost') &&
!target.href.startsWith('https://tauri.localhost')
) {
window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: target.href,
},
})
}
e.preventDefault()
break
}
target = target.parentElement
}
})
useDisableClicks(document, window)
document.querySelector('body').addEventListener('auxclick', function (e) {
// disables middle click -> new tab
if (e.button === 1) {
e.preventDefault()
// instead do a left click
const event = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
})
e.target.dispatchEvent(event)
}
})
const accounts = ref(null)
// const accounts = ref(null)
command_listener(async (e) => {
if (e.event === 'RunMRPack') {
@@ -232,6 +221,10 @@ command_listener(async (e) => {
urlModal.value.show(e)
}
})
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
</script>
<template>
@@ -278,8 +271,6 @@ command_listener(async (e) => {
<div class="button-row push-right">
<Button @click="showLauncherLogsFolder"><FileIcon />Open launcher logs</Button>
<Button @click="openSupport"><ChatIcon />Get support</Button>
</div>
</Card>
</div>
@@ -287,14 +278,35 @@ command_listener(async (e) => {
<SplashScreen v-else-if="!videoPlaying && isLoading" app-loading />
<OnboardingScreen v-else-if="showOnboarding" :finish="() => (showOnboarding = false)" />
<div v-else class="container">
<div class="nav-container">
<div class="nav-section">
<suspense>
<div
class="nav-container"
data-tauri-drag-region
:class="`${sidebarOpen ? 'nav-container__open' : ''}`"
:style="{
'--sidebar-label-opacity': sidebarOpen ? '1' : '0',
}"
>
<div class="pages-list">
<div class="square-collapsed-space">
<Button
transparent
icon-only
class="collapsed-button non-collapse"
@click="toggleSidebar"
>
<ArrowRightFromLineIcon v-if="!sidebarOpen" />
<ArrowLeftFromLineIcon v-else />
</Button>
</div>
</div>
<div class="pages-list">
<!-- <suspense>
<AccountsCard ref="accounts" mode="small" />
</suspense>
</suspense> -->
<div class="pages-list">
<RouterLink v-tooltip="'Home'" to="/" class="btn icon-only collapsed-button">
<HomeIcon />
<span class="collapsed-button__label">Home</span>
</RouterLink>
<RouterLink
v-tooltip="'Browse'"
@@ -305,35 +317,73 @@ command_listener(async (e) => {
}"
>
<SearchIcon />
<span class="collapsed-button__label">Browse</span>
</RouterLink>
<RouterLink v-tooltip="'Library'" to="/library" class="btn icon-only collapsed-button">
<LibraryIcon />
<span class="collapsed-button__label">Library</span>
</RouterLink>
<Suspense>
<suspense>
<InstanceCreationModal ref="installationModal" />
</Suspense>
</suspense>
</div>
</div>
<div class="divider">
<hr />
</div>
<div class="instances pages-list">
<RouterLink
v-for="instance in instancesByPlayed"
:key="instance.id"
v-tooltip="instance.metadata.name"
:to="`/instance/${encodeURIComponent(instance.path)}`"
class="btn icon-only collapsed-button"
>
<Avatar
class="collapsed-button__icon"
:src="iconPathAsUrl(instance.metadata?.icon)"
size="xs"
/>
<span class="collapsed-button__label">{{ instance.metadata.name }}</span>
</RouterLink>
</div>
<div class="settings pages-list">
<Button
v-tooltip="'Get Support'"
transparent
icon-only
class="page-item collapsed-button"
@click="openSupport"
>
<ChatIcon />
<span class="collapsed-button__label">Support</span>
</Button>
<RouterLink v-tooltip="'Settings'" to="/settings" class="btn icon-only collapsed-button">
<SettingsIcon />
<span class="collapsed-button__label">Settings</span>
</RouterLink>
<Button
v-tooltip="'Create profile'"
class="sleek-primary collapsed-button"
class="page-item collapsed-button"
icon-only
:disabled="offline"
@click="() => $refs.installationModal.show()"
>
<PlusIcon />
<span class="collapsed-button__label">Create profile</span>
</Button>
<RouterLink v-tooltip="'Settings'" to="/settings" class="btn icon-only collapsed-button">
<SettingsIcon />
</RouterLink>
<AccountDropdown />
</div>
</div>
<div class="view">
<div class="appbar-row">
<!-- Top Bar -->
<div data-tauri-drag-region class="appbar">
<section class="navigation-controls">
<Breadcrumbs data-tauri-drag-region />
<router-link :to="'/'">
<TextLogo class="logo" :animate="false" />
</router-link>
<Breadcrumbs after-logo data-tauri-drag-region />
</section>
<section class="mod-stats">
<Suspense>
@@ -341,7 +391,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>
@@ -365,7 +415,7 @@ command_listener(async (e) => {
<div class="router-view">
<ModrinthLoadingIndicator
offset-height="var(--appbar-height)"
offset-width="var(--sidebar-width)"
:offset-width="sidebarOpen ? 'var(--sidebar-open-width)' : 'var(--sidebar-width)'"
/>
<RouterView v-slot="{ Component }">
<template v-if="Component">
@@ -387,9 +437,18 @@ command_listener(async (e) => {
transition: all ease-in-out 0.1s;
}
.logo {
height: calc(var(--appbar-height) - 2.5rem);
width: auto;
min-height: 100%;
color: var(--color-contrast);
}
.navigation-controls {
flex-grow: 1;
width: min-content;
display: flex;
flex-direction: row;
align-items: center;
}
.appbar-row {
@@ -412,7 +471,7 @@ command_listener(async (e) => {
background-color: var(--color-raised-bg);
color: var(--color-base);
border-radius: 0;
height: 3.25rem;
height: var(--appbar-height);
&.close {
&:hover,
@@ -431,8 +490,17 @@ command_listener(async (e) => {
}
.container {
--appbar-height: 3.25rem;
--appbar-height: 4.5rem;
--sidebar-gap: 0.35rem;
--sidebar-width: 4.5rem;
--sidebar-open-width: 15rem;
--sidebar-padding: 0.75rem;
--sidebar-icon-size: 1.5rem;
--sidebar-button-size: calc(var(--sidebar-width) - calc(var(--sidebar-padding) * 2));
--sidebar-open-button-size: calc(var(--sidebar-open-width) - calc(var(--sidebar-padding) * 2));
height: 100vh;
display: flex;
@@ -446,11 +514,13 @@ command_listener(async (e) => {
.appbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-grow: 1;
background: var(--color-raised-bg);
text-align: center;
padding: var(--gap-md);
height: 3.25rem;
height: var(--appbar-height);
gap: var(--gap-sm);
//no select
user-select: none;
@@ -459,7 +529,7 @@ command_listener(async (e) => {
.router-view {
width: 100%;
height: calc(100% - 3.125rem);
height: calc(100% - var(--appbar-height));
overflow: auto;
overflow-x: hidden;
background-color: var(--color-bg);
@@ -478,7 +548,7 @@ command_listener(async (e) => {
.appbar-failure {
display: flex; /* Change to flex to align items horizontally */
justify-content: flex-end; /* Align items to the right */
height: 3.25rem;
height: var(--appbar-height);
//no select
user-select: none;
-webkit-user-select: none;
@@ -518,12 +588,75 @@ command_listener(async (e) => {
.nav-container {
display: flex;
flex-direction: column;
padding-left: var(--sidebar-padding);
padding-right: var(--sidebar-padding);
padding-bottom: var(--sidebar-padding);
align-items: center;
justify-content: space-between;
height: 100%;
background-color: var(--color-raised-bg);
box-shadow: var(--shadow-inset-sm), var(--shadow-floating);
padding: var(--gap-md);
transition: all ease-in-out 0.1s;
width: var(--sidebar-width);
}
.nav-container__open {
width: var(--sidebar-open-width);
}
.square-collapsed-space {
height: var(--appbar-height);
width: 100%;
user-select: none;
-webkit-user-select: none;
display: flex;
justify-content: flex-start;
align-items: center;
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
.square-collapsed-space {
height: auto;
padding-bottom: var(--gap-md);
}
}
.divider {
height: auto;
width: 100%;
hr {
background-color: var(--color-button-bg);
border: none;
color: var(--color-button-bg);
height: 1px;
width: 100%;
margin: 0;
}
margin-top: var(--sidebar-gap);
// div should always have + 1 --sidebar-gap margin to the bottom to be equal
margin-bottom: calc(var(--sidebar-gap) * 2);
padding-left: var(--sidebar-padding);
padding-right: var(--sidebar-padding);
}
.instances {
flex: 1;
flex-flow: column wrap; // This hides any elements that aren't fully visible
overflow: hidden;
}
.pages-list {
@@ -531,9 +664,12 @@ command_listener(async (e) => {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
gap: 0.5rem;
width: 100%;
gap: var(--sidebar-gap);
.page-item,
a {
display: flex;
align-items: center;
@@ -541,19 +677,20 @@ command_listener(async (e) => {
background: inherit;
transition: all ease-in-out 0.1s;
color: var(--color-base);
box-shadow: none;
&.router-link-active {
color: var(--color-contrast);
background: var(--color-button-bg);
box-shadow: var(--shadow-floating);
color: var(--color-brand);
background: var(--color-brand-highlight);
}
&:hover {
background-color: var(--color-button-bg);
color: var(--color-contrast);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
text-decoration: none;
background: var(--color-button-bg);
}
&.router-link-active:hover {
color: var(--color-brand);
background: var(--color-brand-highlight);
}
}
@@ -563,78 +700,45 @@ command_listener(async (e) => {
}
}
.collapsed-button {
height: 3rem !important;
width: 3rem !important;
padding: 0.75rem;
border-radius: var(--radius-md);
box-shadow: none;
svg {
width: 1.5rem !important;
height: 1.5rem !important;
max-width: 1.5rem !important;
max-height: 1.5rem !important;
}
}
.instance-list {
display: flex;
flex-direction: column;
justify-content: center;
width: 70%;
margin: 0.4rem;
p:nth-child(1) {
font-size: 0.6rem;
:deep {
.non-collapse {
width: var(--sidebar-button-size) !important;
}
& > p {
color: var(--color-base);
margin: 0.8rem 0;
font-size: 0.7rem;
line-height: 0.8125rem;
font-weight: 500;
text-transform: uppercase;
}
}
.user-section {
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
height: 4.375rem;
section {
display: flex;
flex-direction: column;
.collapsed-button {
justify-content: flex-start;
text-align: left;
margin-left: 0.5rem;
}
.username {
margin-bottom: 0.3rem;
font-weight: 400;
line-height: 1.25rem;
color: var(--color-contrast);
}
// width: var(--sidebar-icon-size);
height: var(--sidebar-button-size);
width: 100%;
a {
font-weight: 400;
color: var(--color-secondary);
}
}
flex-shrink: 0;
.nav-section {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
width: 100%;
height: 100%;
gap: 1rem;
padding: var(--sidebar-padding) !important;
border-radius: 99999px;
box-shadow: none;
white-space: nowrap;
overflow: hidden;
transition: all ease-in-out 0.1s;
.collapsed-button__icon,
svg {
width: var(--sidebar-icon-size) !important;
height: var(--sidebar-icon-size) !important;
flex-shrink: 0;
border-radius: var(--radius-xs);
}
.collapsed-button__label {
word-spacing: normal; // Why is this even needed?
opacity: var(--sidebar-label-opacity);
transition: all ease-in-out 0.1s;
}
}
}
.video {

View File

@@ -1,20 +1,22 @@
<svg
data-v-8c2610d6=""
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
viewBox="0 0 100 100"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linejoin: round; stroke-miterlimit: 2;"><circle cx="50" cy="50" r="50" style="fill:#fff;"
/>
<g transform="translate(14.39 14.302) scale(.09916)"><clipPath id="a"><path d="M0 0h705.6v720H0z"/></clipPath>
<g clip-path="url(#a)"><path d="M-4117.16-2597.44v139.42h193.74c-8.51 44.84-34.04 82.8-72.33 108.33l116.84 90.66c68.07-62.84 107.35-155.13 107.35-264.77 0-25.53-2.29-50.07-6.55-73.63l-339.05-.01Z" style="fill:#4285f4;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
<path
d="m-4318.92-2463.46-26.35 20.17-93.28 72.65c59.24 117.49 180.65 198.66 321.38 198.66 97.2 0 178.69-32.07 238.25-87.05l-116.83-90.66c-32.08 21.6-72.99 34.69-121.42 34.69-93.6 0-173.13-63.16-201.6-148.25l-.15-.21Z"
style="fill:#34a853;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
<path
d="M-4438.55-2693.33c-24.54 48.44-38.61 103.09-38.61 161.34 0 58.26 14.07 112.91 38.61 161.35 0 .32 119.79-92.95 119.79-92.95-7.2-21.6-11.46-44.5-11.46-68.4 0-23.89 4.26-46.8 11.46-68.4l-119.79-92.94Z"
style="fill:#fbbc05;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
<path
d="M-4117.16-2748.64c53.02 0 100.14 18.33 137.78 53.67l103.09-103.09c-62.51-58.25-143.67-93.93-240.87-93.93-140.73 0-262.15 80.84-321.39 198.66l119.79 92.95c28.47-85.09 108-148.26 201.6-148.26Z"
style="fill:#ea4335;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
</g>
<g transform="translate(14.39 14.302) scale(.09916)">
<path
d="M-4117.16-2597.44v139.42h193.74c-8.51 44.84-34.04 82.8-72.33 108.33l116.84 90.66c68.07-62.84 107.35-155.13 107.35-264.77 0-25.53-2.29-50.07-6.55-73.63l-339.05-.01Z"
style="fill:#4285f4;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
<path
d="m-4318.92-2463.46-26.35 20.17-93.28 72.65c59.24 117.49 180.65 198.66 321.38 198.66 97.2 0 178.69-32.07 238.25-87.05l-116.83-90.66c-32.08 21.6-72.99 34.69-121.42 34.69-93.6 0-173.13-63.16-201.6-148.25l-.15-.21Z"
style="fill:#34a853;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
<path
d="M-4438.55-2693.33c-24.54 48.44-38.61 103.09-38.61 161.34 0 58.26 14.07 112.91 38.61 161.35 0 .32 119.79-92.95 119.79-92.95-7.2-21.6-11.46-44.5-11.46-68.4 0-23.89 4.26-46.8 11.46-68.4l-119.79-92.94Z"
style="fill:#fbbc05;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
<path
d="M-4117.16-2748.64c53.02 0 100.14 18.33 137.78 53.67l103.09-103.09c-62.51-58.25-143.67-93.93-240.87-93.93-140.73 0-262.15 80.84-321.39 198.66l119.79 92.95c28.47-85.09 108-148.26 201.6-148.26Z"
style="fill:#ea4335;fill-rule:nonzero;" transform="translate(4477.16 2891.98)"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left-from-line"><path d="m9 6-6 6 6 6"/><path d="M3 12h14"/><path d="M21 19V5"/></svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right-from-line"><path d="M3 5v14"/><path d="M21 12H7"/><path d="m15 18 6-6-6-6"/></svg>

After

Width:  |  Height:  |  Size: 296 B

View File

@@ -12,3 +12,5 @@ export { default as NewInstanceImage } from './new-instance.svg'
export { default as MenuIcon } from './menu.svg'
export { default as BugIcon } from './bug.svg'
export { default as ChatIcon } from './messages-square.svg'
export { default as ArrowLeftFromLineIcon } from './arrow-left-from-line.svg'
export { default as ArrowRightFromLineIcon } from './arrow-right-from-line.svg'

View File

@@ -0,0 +1,35 @@
// Quick snippets stolen from knossos project to make omorphia components fit
.btn,
.button-base,
a {
// filter will change
will-change: filter;
}
.iconified-input {
align-items: center;
display: inline-flex;
position: relative;
input {
padding-left: 2.25rem;
width: 100%;
}
&:focus-within svg {
color: var(--color-button-text-active);
opacity: 1;
}
svg {
position: absolute;
left: 0.75rem;
height: 1.25rem;
width: 1.25rem;
z-index: 1;
color: var(--color-button-text);
opacity: 0.6;
}
}

View File

@@ -15,7 +15,7 @@ import {
XIcon,
Button,
formatCategoryHeader,
ModalConfirm,
ConfirmModal,
} from 'omorphia'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import dayjs from 'dayjs'
@@ -233,7 +233,7 @@ const filteredResults = computed(() => {
})
</script>
<template>
<ModalConfirm
<ConfirmModal
ref="confirmModal"
title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it."
@@ -246,7 +246,7 @@ const filteredResults = computed(() => {
<div class="iconified-input">
<SearchIcon />
<input v-model="search" type="text" placeholder="Search" class="search-input" />
<Button @click="() => (search = '')">
<Button class="r-btn" @click="() => (search = '')">
<XIcon />
</Button>
</div>

View File

@@ -11,7 +11,7 @@ import {
ExternalIcon,
EyeIcon,
ChevronRightIcon,
ModalConfirm,
ConfirmModal,
} from 'omorphia'
import Instance from '@/components/ui/Instance.vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
@@ -203,8 +203,8 @@ const handleOptionsClick = async (args) => {
}
}
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
@@ -227,7 +227,7 @@ onUnmounted(() => {
</script>
<template>
<ModalConfirm
<ConfirmModal
ref="deleteConfirmModal"
title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it."

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,7 +56,7 @@
</Button>
</Card>
</transition>
<Modal ref="loginModal" class="modal" header="Signing in">
<Modal ref="loginModal" class="modal" header="Signing in" :noblur="!themeStore.advancedRendering">
<div class="modal-body">
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
<div class="modal-text">
@@ -83,6 +83,7 @@
v-tooltip="'Open link'"
icon-only
color="raised"
class="r-btn"
@click="() => clipboardWrite(loginUrl)"
>
<GlobeIcon />
@@ -114,6 +115,7 @@ import {
} from '@/helpers/auth'
import { get, set } from '@/helpers/settings'
import { handleError } from '@/store/state.js'
import { useTheming } from '@/store/theme.js'
import { mixpanel_track } from '@/helpers/mixpanel'
import QrcodeVue from 'qrcode.vue'
import { process_listener } from '@/helpers/events'
@@ -130,6 +132,7 @@ const emit = defineEmits(['change'])
const loginCode = ref(null)
const themeStore = useTheming()
const settings = ref({})
const accounts = ref([])
const loginUrl = ref('')

View File

@@ -1,53 +1,60 @@
<template>
<div class="breadcrumbs">
<Button class="breadcrumbs__back transparent" icon-only @click="$router.back()">
<ChevronLeftIcon />
</Button>
<Button class="breadcrumbs__forward transparent" icon-only @click="$router.forward()">
<ChevronRightIcon />
</Button>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<div v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name" class="breadcrumbs__item">
<div
v-if="props.afterLogo && breadcrumbContext.routeBreadcrumbs.value?.length > 0"
class="breadcrumbs__item"
>
<ChevronRightIcon class="chevron" />
</div>
<div
v-for="breadcrumb in breadcrumbContext.routeBreadcrumbs.value"
:key="breadcrumb.name"
class="breadcrumbs__item"
>
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id)),
query: breadcrumb.query,
}"
>{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name
}}
>
{{ breadcrumbName(breadcrumb.name) }}
</router-link>
<span v-else class="selected">{{
breadcrumb.name.charAt(0) === '?'
? breadcrumbData.getName(breadcrumb.name.slice(1))
: breadcrumb.name
}}</span>
<span v-else class="selected">
{{ breadcrumbName(breadcrumb.name) }}
</span>
<ChevronRightIcon v-if="breadcrumb.link" class="chevron" />
</div>
</div>
</template>
<script setup>
import { ChevronRightIcon, Button, ChevronLeftIcon } from 'omorphia'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { ChevronRightIcon } from 'omorphia'
import { useBreadcrumbs, useBreadcrumbContext } from '@/store/breadcrumbs'
import { useRoute } from 'vue-router'
import { computed } from 'vue'
const route = useRoute()
const props = defineProps({
afterLogo: {
type: Boolean,
default: false,
},
})
const breadcrumbData = useBreadcrumbs()
const breadcrumbs = computed(() => {
const additionalContext =
route.meta.useContext === true
? breadcrumbData.context
: route.meta.useRootContext === true
? breadcrumbData.rootContext
: null
return additionalContext ? [additionalContext, ...route.meta.breadcrumb] : route.meta.breadcrumb
const route = useRoute()
const breadcrumbContext = useBreadcrumbContext(route)
breadcrumbData.$subscribe(() => {
breadcrumbData?.resetToNames(breadcrumbContext.routeBreadcrumbs.value)
})
const breadcrumbName = (bcn) => {
if (bcn.charAt(0) === '?') {
return breadcrumbData.getName(bcn.slice(1))
}
return bcn
}
</script>
<style scoped lang="scss">
@@ -61,7 +68,10 @@ const breadcrumbs = computed(() => {
vertical-align: center;
margin: auto 0;
.chevron,
.chevron {
margin: auto 0.5rem;
}
a {
margin: auto 0;
}

View File

@@ -2,10 +2,9 @@
import { Button, Checkbox, Modal, XIcon, PlusIcon } from 'omorphia'
import { PackageIcon, VersionIcon } from '@/assets/icons'
import { ref } from 'vue'
import { export_profile_mrpack, get_potential_override_folders } from '@/helpers/profile.js'
import { export_profile_mrpack, get_pack_export_candidates } from '@/helpers/profile.js'
import { open } from '@tauri-apps/api/dialog'
import { handleError } from '@/store/notifications.js'
import { sep } from '@tauri-apps/api/path'
import { useTheming } from '@/store/theme'
const props = defineProps({
@@ -34,8 +33,9 @@ const themeStore = useTheming()
const initFiles = async () => {
const newFolders = new Map()
const sep = '/'
files.value = []
await get_potential_override_folders(props.instance.path).then((filePaths) =>
await get_pack_export_candidates(props.instance.path).then((filePaths) =>
filePaths
.map((folder) => ({
path: folder,
@@ -112,7 +112,7 @@ const exportPack = async () => {
<div class="iconified-input">
<PackageIcon />
<input v-model="nameInput" type="text" placeholder="Modpack name" class="input" />
<Button @click="nameInput = ''">
<Button class="r-btn" @click="nameInput = ''">
<XIcon />
</Button>
</div>
@@ -122,7 +122,7 @@ const exportPack = async () => {
<div class="iconified-input">
<VersionIcon />
<input v-model="versionInput" type="text" placeholder="1.0.0" class="input" />
<Button @click="versionInput = ''">
<Button class="r-btn" @click="versionInput = ''">
<XIcon />
</Button>
</div>
@@ -292,9 +292,11 @@ const exportPack = async () => {
.textarea-wrapper {
// margin-top: 1rem;
height: 12rem;
textarea {
max-height: 12rem;
}
.preview {
overflow-y: auto;
}

View File

@@ -187,7 +187,7 @@ onUnmounted(() => unlisten())
<div class="instance">
<Card class="instance-card-item button-base" @click="seeInstance" @mouseenter="checkProcess">
<Avatar
size="sm"
size="lg"
:src="
props.instance.metadata
? !props.instance.metadata.icon ||

View File

@@ -20,7 +20,13 @@
</div>
<div class="input-row">
<p class="input-label">Name</p>
<input v-model="profile_name" autocomplete="off" class="text-input" type="text" />
<input
v-model="profile_name"
autocomplete="off"
class="text-input"
type="text"
maxlength="100"
/>
</div>
<div class="input-row">
<p class="input-label">Loader</p>
@@ -104,7 +110,7 @@
placeholder="Path to launcher"
@change="setPath"
/>
<Button @click="() => (selectedLauncherPath = '')">
<Button class="r-btn" @click="() => (selectedLauncherPath = '')">
<XIcon />
</Button>
</div>

View File

@@ -247,13 +247,15 @@ const check_valid = computed(() => {
</Button>
<div
v-tooltip="
profile.metadata.linked_data && !profile.installedMod
? 'Unpair an instance to add mods.'
profile.metadata.linked_data?.locked && !profile.installedMod
? 'Unpair or unlock an instance to add mods.'
: ''
"
>
<Button
:disabled="profile.installedMod || profile.installing || profile.metadata.linked_data"
:disabled="
profile.installedMod || profile.installing || profile.metadata.linked_data?.locked
"
@click="install(profile)"
>
<DownloadIcon v-if="!profile.installedMod && !profile.installing" />
@@ -263,7 +265,7 @@ const check_valid = computed(() => {
? 'Installing...'
: profile.installedMod
? 'Installed'
: profile.metadata.linked_data
: profile.metadata.linked_data && profile.metadata.linked_data.locked
? 'Paired'
: 'Install'
}}

View File

@@ -1,5 +1,5 @@
<script setup>
import { Button, Modal, CheckIcon, Badge } from 'omorphia'
import { Button, Modal, CheckIcon, Badge, Card } from 'omorphia'
import { computed, ref } from 'vue'
import { useTheming } from '@/store/theme'
import { update_managed_modrinth_version } from '@/helpers/profile'

View File

@@ -1,14 +1,10 @@
<template>
<div class="action-groups">
<a href="https://discord.modrinth.com" class="link">
<ChatIcon />
<span> Get support </span>
</a>
<Button
v-if="currentLoadingBars.length > 0"
ref="infoButton"
icon-only
class="icon-button show-card-icon"
class="download icon-button"
@click="toggleCard()"
>
<DownloadIcon />
@@ -34,17 +30,17 @@
<DropdownIcon />
</div>
</div>
<Button v-tooltip="'Stop instance'" icon-only class="icon-button stop" @click="stop()">
<Button v-tooltip="'Stop instance'" icon-only class="stop icon-button" @click="stop()">
<StopCircleIcon />
</Button>
<Button v-tooltip="'View logs'" icon-only class="icon-button" @click="goToTerminal()">
<Button v-tooltip="'View logs'" icon-only class="utility icon-button" @click="goToTerminal()">
<TerminalSquareIcon />
</Button>
<Button
v-if="currentLoadingBars.length > 0"
ref="infoButton"
icon-only
class="icon-button show-card-icon"
class="download icon-button"
@click="toggleCard()"
>
<DownloadIcon />
@@ -84,7 +80,7 @@
<Button
v-tooltip="'Stop instance'"
icon-only
class="icon-button stop"
class="stop icon-button"
@click.stop="stop(profile.path)"
>
<StopCircleIcon />
@@ -92,7 +88,7 @@
<Button
v-tooltip="'View logs'"
icon-only
class="icon-button"
class="utility icon-button"
@click.stop="goToTerminal(profile.path)"
>
<TerminalSquareIcon />
@@ -124,7 +120,6 @@ import { refreshOffline, isOffline } from '@/helpers/utils.js'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { handleError } from '@/store/notifications.js'
import { mixpanel_track } from '@/helpers/mixpanel'
import { ChatIcon } from '@/assets/icons'
const router = useRouter()
const card = ref(null)
@@ -327,12 +322,16 @@ onBeforeUnmount(() => {
.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;
}
padding: 0 !important;
}
.stop {
color: var(--color-red);
}
.utility {
color: var(--color-contrast);
}
.info-card {
@@ -394,7 +393,7 @@ onBeforeUnmount(() => {
}
}
.show-card-icon {
.download {
color: var(--color-brand);
}

View File

@@ -0,0 +1,94 @@
<template>
<div class="account-dropdown">
<Modal
ref="modrinthLoginModal"
class="login-screen-modal"
:noblur="!themeStore.advancedRendering"
>
<ModrinthLoginScreen :modal="true" :prev-page="signInAfter" :next-page="signInAfter" />
</Modal>
<PopoutMenu class="btn btn-transparent collapsed-button" direction="up" position="right">
<Avatar class="collapsed-button__icon" circle size="sm" :src="auth?.user?.avatar_url" />
<span class="collapsed-button__label">
<template v-if="auth?.user">
{{ auth.user.username }}
</template>
<template v-else> Sign in </template>
</span>
<template #menu>
<div class="selection-menu">
<template v-if="auth?.user">
<Button color="danger" transparent hover-filled-only @click="() => mrAuth.logout()">
<LogOutIcon /> Sign out
</Button>
</template>
<template v-else>
<Button
color="primary"
transparent
hover-filled-only
@click="() => $refs.modrinthLoginModal.show()"
>
<LogInIcon /> Sign in
</Button>
</template>
</div>
</template>
</PopoutMenu>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Avatar, Button, PopoutMenu, LogOutIcon, LogInIcon, Modal } from 'omorphia'
import { useTheming } from '@/store/state'
import { useModrinthAuth } from '@/store/mr_auth.js'
import ModrinthLoginScreen from '@/components/ui/tutorial/ModrinthLoginScreen.vue'
import { storeToRefs } from 'pinia'
const themeStore = useTheming()
const mrAuth = useModrinthAuth()
const { auth } = storeToRefs(mrAuth)
const modrinthLoginModal = ref(null)
const signInAfter = async () => {
modrinthLoginModal.value?.hide()
await mrAuth.get()
}
</script>
<style scoped lang="scss">
.account-dropdown {
width: 100%;
}
.selection-menu {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.35rem;
width: max-content;
.btn {
width: 100%;
justify-content: start;
}
}
:deep {
.login-screen-modal {
.modal-container .modal-body {
width: auto;
.content {
background: none;
}
}
}
}
</style>

View File

@@ -23,7 +23,7 @@ defineProps({
<div class="iconified-input">
<SearchIcon />
<input v-model="search" type="text" placeholder="Search" class="search-input" />
<Button @click="() => (search = '')">
<Button class="r-btn" @click="() => (search = '')">
<XIcon />
</Button>
</div>

View File

@@ -189,7 +189,7 @@ defineProps({
type="text"
:placeholder="`Search ${projectType}s...`"
/>
<Button @click="() => (query = '')">
<Button class="r-btn" @click="() => (query = '')">
<XIcon />
</Button>
</div>

View File

@@ -132,7 +132,7 @@ const next = async () => {
placeholder="Path to launcher"
@change="setPath"
/>
<Button @click="() => (selectedLauncherPath = '')">
<Button class="r-btn" @click="() => (selectedLauncherPath = '')">
<XIcon />
</Button>
</div>

View File

@@ -2,11 +2,13 @@
import { Button, LogInIcon, Modal, ClipboardCopyIcon, GlobeIcon, Card } from 'omorphia'
import { authenticate_await_completion, authenticate_begin_flow } from '@/helpers/auth.js'
import { handleError } from '@/store/notifications.js'
import { useTheming } from '@/store/theme.js'
import mixpanel from 'mixpanel-browser'
import { get, set } from '@/helpers/settings.js'
import { ref } from 'vue'
import QrcodeVue from 'qrcode.vue'
const themeStore = useTheming()
const loginUrl = ref(null)
const loginModal = ref()
const loginCode = ref(null)
@@ -94,7 +96,7 @@ const clipboardWrite = async (a) => {
</div>
</Card>
</div>
<Modal ref="loginModal" header="Signing in">
<Modal ref="loginModal" header="Signing in" :noblur="!themeStore.advancedRendering">
<div class="modal-body">
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
<div class="modal-text">
@@ -117,7 +119,7 @@ const clipboardWrite = async (a) => {
<div class="iconified-input">
<LogInIcon />
<input type="text" :value="loginUrl" readonly />
<Button v-tooltip="'Open link'" icon-only color="raised" @click="openUrl">
<Button v-tooltip="'Open link'" icon-only color="raised" class="r-btn" @click="openUrl">
<GlobeIcon />
</Button>
</div>

View File

@@ -0,0 +1,47 @@
import { openExternal } from '@/helpers/external'
import { onMounted } from 'vue'
const disableMiddleClick = (e) => {
// disables middle click -> new tab
if (e.button === 1) {
e.preventDefault()
// instead do a left click
const event = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
})
e.target.dispatchEvent(event)
}
}
const disableExternalNavigation = (e, window) => {
let target = e.target
while (target != null) {
if (target.matches('a')) {
if (
target.href &&
['http://', 'https://', 'mailto:', 'tel:'].some((v) => target.href.startsWith(v)) &&
!target.classList.contains('router-link-active') &&
!target.href.startsWith('http://localhost') &&
!target.href.startsWith('https://tauri.localhost')
) {
openExternal(window, target.href)
}
e.preventDefault()
break
}
target = target.parentElement
}
}
export const useDisableClicks = (document, window) => {
onMounted(() => {
document
.querySelector('body')
.addEventListener('click', (e) => disableExternalNavigation(e, window))
document.querySelector('body').addEventListener('auxclick', disableMiddleClick)
})
}

View File

@@ -0,0 +1,9 @@
export const openExternal = (window, url) => {
window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: url,
},
})
}

View File

@@ -0,0 +1,12 @@
import { convertFileSrc } from '@tauri-apps/api/tauri'
export const iconPathAsUrl = (iconPath) => {
if (!iconPath) {
return ''
}
const startsWithHttp = iconPath.startsWith('http')
if (startsWithHttp) {
return iconPath
}
return convertFileSrc(iconPath)
}

View File

@@ -50,6 +50,7 @@ export async function delete_logs(profilePath) {
new_file: bool <- the cursor was too far, meaning that the file was likely rotated/reset. This signals to the frontend to clear the log and start over with this struct.
}
*/
// From latest.log directly
export async function get_latest_log_cursor(profilePath, cursor) {
return await invoke('plugin:logs|logs_get_latest_log_cursor', { profilePath, cursor })
}

View File

@@ -17,6 +17,8 @@ import { invoke } from '@tauri-apps/api/tauri'
*/
export async function create(name, gameVersion, modloader, loaderVersion, icon, noWatch) {
//Trim string name to avoid "Unable to find directory"
name = name.trim()
return await invoke('plugin:profile_create|profile_create', {
name,
gameVersion,
@@ -72,8 +74,8 @@ export async function check_installed(path, projectId) {
}
// Installs/Repairs a profile
export async function install(path) {
return await invoke('plugin:profile|profile_install', { path })
export async function install(path, force) {
return await invoke('plugin:profile|profile_install', { path, force })
}
// Updates all of a profile's projects
@@ -151,8 +153,8 @@ export async function export_profile_mrpack(
// -- file1
// => [mods, resourcepacks]
// allows selection for 'included_overrides' in export_profile_mrpack
export async function get_potential_override_folders(profilePath) {
return await invoke('plugin:profile|profile_get_potential_override_folders', { profilePath })
export async function get_pack_export_candidates(profilePath) {
return await invoke('plugin:profile|profile_get_pack_export_candidates', { profilePath })
}
// Run Minecraft using a pathed profile

View File

@@ -43,3 +43,7 @@ export async function set(settings) {
export async function change_config_dir(newConfigDir) {
return await invoke('plugin:settings|settings_change_config_dir', { newConfigDir })
}
export async function is_dir_writeable(newConfigDir) {
return await invoke('plugin:settings|settings_is_dir_writeable', { newConfigDir })
}

View File

@@ -2,8 +2,10 @@ import { createApp } from 'vue'
import router from '@/routes'
import App from '@/App.vue'
import { createPinia } from 'pinia'
import { createPlugin as createVintl } from '@vintl/vintl/plugin'
import 'omorphia/dist/style.css'
import '@/assets/stylesheets/global.scss'
import '@/assets/stylesheets/components.scss'
import 'floating-vue/dist/style.css'
import FloatingVue from 'floating-vue'
import { get_opening_command, initialize_state } from '@/helpers/state'
@@ -14,9 +16,25 @@ import { isDev } from './helpers/utils.js'
const pinia = createPinia()
const vintl = createVintl({
controllerOpts: {
defaultLocale: 'en-US',
locale: 'en-US',
locales: [
{
tag: 'en-US',
meta: {
displayName: 'American English',
},
},
],
},
})
let app = createApp(App)
app.use(router)
app.use(pinia)
app.use(vintl)
app.use(FloatingVue)
app.mixin(loadCssMixin)
@@ -54,7 +72,7 @@ initialize_state()
.finally(() => {
mountedApp.initialize()
get_opening_command().then((command) => {
console.log(JSON.stringify(command)) // change me to use whatever FE command handler is made
console.log('Opening Command', JSON.stringify(command)) // change me to use whatever FE command handler is made
})
})
})

View File

@@ -549,7 +549,11 @@ onUnmounted(() => unlistenOffline())
size="sm"
/>
<div class="small-instance_info">
<span class="title">{{ instanceContext.metadata.name }}</span>
<span class="title">{{
instanceContext.metadata.name.length > 20
? instanceContext.metadata.name.substring(0, 20) + '...'
: instanceContext.metadata.name
}}</span>
<span>
{{
instanceContext.metadata.loader.charAt(0).toUpperCase() +
@@ -701,7 +705,7 @@ onUnmounted(() => unlistenOffline())
:placeholder="`Search ${projectType}s...`"
@input="onSearchChange(1)"
/>
<Button @click="() => clearSearch()">
<Button class="r-btn" @click="() => clearSearch()">
<XIcon />
</Button>
</div>
@@ -839,13 +843,6 @@ onUnmounted(() => unlistenOffline())
min-height: min-content !important;
}
.iconified-input {
input {
max-width: none !important;
flex-basis: auto;
}
}
.search-panel-container {
display: inline-flex;
flex-direction: row;

View File

@@ -1,14 +1,13 @@
<script setup>
import { ref, onUnmounted, shallowRef, computed } from 'vue'
import { ref, onUnmounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import RowDisplay from '@/components/RowDisplay.vue'
import { list } from '@/helpers/profile.js'
import { offline_listener, profile_listener } from '@/helpers/events'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useFetch } from '@/helpers/fetch.js'
import { handleError } from '@/store/notifications.js'
import dayjs from 'dayjs'
import { isOffline } from '@/helpers/utils'
import { useInstances } from '@/store/instances'
import { storeToRefs } from 'pinia'
const featuredModpacks = ref({})
const featuredMods = ref({})
@@ -19,18 +18,17 @@ const breadcrumbs = useBreadcrumbs()
breadcrumbs.setRootContext({ name: 'Home', link: route.path })
const recentInstances = shallowRef([])
const offline = ref(await isOffline())
const getInstances = async () => {
const profiles = await list(true).catch(handleError)
recentInstances.value = Object.values(profiles).sort((a, b) => {
return dayjs(b.metadata.last_played ?? 0).diff(dayjs(a.metadata.last_played ?? 0))
})
const instancesStore = useInstances()
const { instancesByPlayed } = storeToRefs(instancesStore)
const getInstances = async () => {
await instancesStore.refreshInstances()
// filter? TODO: Change this to be reactive along with fetching the rest.
let filters = []
for (const instance of recentInstances.value) {
for (const instance of instancesByPlayed.value) {
if (instance.metadata.linked_data && instance.metadata.linked_data.project_id) {
filters.push(`NOT"project_id"="${instance.metadata.linked_data.project_id}"`)
}
@@ -84,7 +82,7 @@ const unlistenOffline = await offline_listener(async (b) => {
// computed sums of recentInstances, featuredModpacks, featuredMods, treating them as arrays if they are not
const total = computed(() => {
return (
(recentInstances.value?.length ?? 0) +
(instancesByPlayed.value?.length ?? 0) +
(featuredModpacks.value?.length ?? 0) +
(featuredMods.value?.length ?? 0)
)
@@ -104,7 +102,7 @@ onUnmounted(() => {
{
label: 'Jump back in',
route: '/library',
instances: recentInstances,
instances: instancesByPlayed,
downloaded: true,
},
{

View File

@@ -1,23 +1,23 @@
<script setup>
import { onUnmounted, ref, shallowRef } from 'vue'
import { onUnmounted, ref } from 'vue'
import GridDisplay from '@/components/GridDisplay.vue'
import { list } from '@/helpers/profile.js'
import { useRoute } from 'vue-router'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { offline_listener, profile_listener } from '@/helpers/events.js'
import { handleError } from '@/store/notifications.js'
import { Button, PlusIcon } from 'omorphia'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { NewInstanceImage } from '@/assets/icons'
import { isOffline } from '@/helpers/utils'
import { useInstances } from '@/store/instances'
import { storeToRefs } from 'pinia'
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
breadcrumbs.setRootContext({ name: 'Library', link: route.path })
const profiles = await list(true).catch(handleError)
const instances = shallowRef(Object.values(profiles))
const instancesStore = useInstances()
const { instanceList } = storeToRefs(instancesStore)
const offline = ref(await isOffline())
const unlistenOffline = await offline_listener((b) => {
@@ -25,9 +25,9 @@ const unlistenOffline = await offline_listener((b) => {
})
const unlistenProfile = await profile_listener(async () => {
const profiles = await list(true).catch(handleError)
instances.value = Object.values(profiles)
await instancesStore.refreshInstances()
})
onUnmounted(() => {
unlistenProfile()
unlistenOffline()
@@ -35,7 +35,7 @@ onUnmounted(() => {
</script>
<template>
<GridDisplay v-if="instances.length > 0" label="Instances" :instances="instances" />
<GridDisplay v-if="instanceList.length > 0" label="Instances" :instances="instanceList" />
<div v-else class="no-instance">
<div class="icon">
<NewInstanceImage />

View File

@@ -14,27 +14,38 @@ import {
UpdatedIcon,
} from 'omorphia'
import { handleError, useTheming } from '@/store/state'
import { change_config_dir, get, set } from '@/helpers/settings'
import { is_dir_writeable, change_config_dir, get, set } from '@/helpers/settings'
import { get_max_memory } from '@/helpers/jre'
import { get as getCreds, logout } from '@/helpers/mr_auth.js'
import { useModrinthAuth } from '@/store/mr_auth.js'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import ModrinthLoginScreen from '@/components/ui/tutorial/ModrinthLoginScreen.vue'
import { mixpanel_opt_out_tracking, mixpanel_opt_in_tracking } from '@/helpers/mixpanel'
import { open } from '@tauri-apps/api/dialog'
import { getOS } from '@/helpers/utils.js'
import { version } from '../../package.json'
import { storeToRefs } from 'pinia'
const pageOptions = ['Home', 'Library']
const themeStore = useTheming()
const fetchSettings = await get().catch(handleError)
const accessSettings = async () => {
const settings = await get()
if (!fetchSettings.java_globals.JAVA_8)
fetchSettings.java_globals.JAVA_8 = { path: '', version: '' }
if (!fetchSettings.java_globals.JAVA_17)
fetchSettings.java_globals.JAVA_17 = { path: '', version: '' }
if (!settings.java_globals.JAVA_8) settings.java_globals.JAVA_8 = { path: '', version: '' }
if (!settings.java_globals.JAVA_17) settings.java_globals.JAVA_17 = { path: '', version: '' }
fetchSettings.javaArgs = fetchSettings.custom_java_args.join(' ')
fetchSettings.envArgs = fetchSettings.custom_env_args.map((x) => x.join('=')).join(' ')
settings.javaArgs = settings.custom_java_args.join(' ')
settings.envArgs = settings.custom_env_args.map((x) => x.join('=')).join(' ')
return settings
}
// const launcherVersion = await get_launcher_version().catch(handleError)
const fetchSettings = await accessSettings().catch(handleError)
const settings = ref(fetchSettings)
const settingsDir = ref(settings.value.loaded_config_dir)
@@ -43,6 +54,10 @@ const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1
watch(
settings,
async (oldSettings, newSettings) => {
if (oldSettings.loaded_config_dir !== newSettings.loaded_config_dir) {
return
}
const setSettings = JSON.parse(JSON.stringify(newSettings))
if (setSettings.opt_out_analytics) {
@@ -93,21 +108,28 @@ watch(
{ deep: true }
)
const credentials = ref(await getCreds().catch(handleError))
const mrAuth = useModrinthAuth()
const { auth } = storeToRefs(mrAuth)
const loginScreenModal = ref()
async function logOut() {
await logout().catch(handleError)
credentials.value = await getCreds().catch(handleError)
}
async function signInAfter() {
loginScreenModal.value.hide()
credentials.value = await getCreds().catch(handleError)
await mrAuth.get()
}
async function findLauncherDir() {
const newDir = await open({ multiple: false, directory: true })
const newDir = await open({
multiple: false,
directory: true,
title: 'Select a new app directory',
})
const writeable = await is_dir_writeable(newDir)
if (!writeable) {
handleError('The selected directory does not have proper permissions for write access.')
return
}
if (newDir) {
settingsDir.value = newDir
@@ -116,7 +138,9 @@ async function findLauncherDir() {
}
async function refreshDir() {
await change_config_dir(settingsDir.value)
await change_config_dir(settingsDir.value).catch(handleError)
settings.value = await accessSettings().catch(handleError)
settingsDir.value = settings.value.loaded_config_dir
}
</script>
@@ -138,14 +162,18 @@ async function refreshDir() {
<div class="adjacent-input">
<label for="theme">
<span class="label__title">Manage account</span>
<span v-if="credentials" class="label__description">
You are currently logged in as {{ credentials.user.username }}.
<span v-if="auth" class="label__description">
You are currently logged in as {{ auth?.user.username }}.
</span>
<span v-else> Sign in to your Modrinth account. </span>
</label>
<button v-if="credentials" class="btn" @click="logOut"><LogOutIcon /> Sign out</button>
<button v-if="auth" class="btn" @click="mrAuth.logout()">
<LogOutIcon />
Sign out
</button>
<button v-else class="btn" @click="$refs.loginScreenModal.show()">
<LogInIcon /> Sign in
<LogInIcon />
Sign in
</button>
</div>
<label for="theme">
@@ -158,7 +186,7 @@ async function refreshDir() {
<div class="iconified-input">
<BoxIcon />
<input id="appDir" v-model="settingsDir" type="text" class="input" />
<Button @click="findLauncherDir">
<Button class="r-btn" @click="findLauncherDir">
<FolderSearchIcon />
</Button>
</div>
@@ -232,6 +260,22 @@ async function refreshDir() {
"
/>
</div>
<div v-if="getOS() != 'MacOS'" class="adjacent-input">
<label for="native-decorations">
<span class="label__title">Native decorations</span>
<span class="label__description">Use system window frame (app restart required).</span>
</label>
<Toggle
id="native-decorations"
:model-value="settings.native_decorations"
:checked="settings.native_decorations"
@update:model-value="
(e) => {
settings.native_decorations = e
}
"
/>
</div>
<div class="adjacent-input">
<label for="opening-page">
<span class="label__title">Default landing page</span>
@@ -385,7 +429,7 @@ async function refreshDir() {
v-model="settings.memory.maximum"
:min="8"
:max="maxMemory"
:step="1"
:step="64"
unit="mb"
/>
</div>
@@ -490,6 +534,19 @@ async function refreshDir() {
/>
</div>
</Card>
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">About</span>
</h3>
</div>
<div>
<label>
<span class="label__title">App version</span>
<span class="label__description">Theseus v{{ version }} </span>
</label>
</div>
</Card>
</div>
</template>

View File

@@ -146,22 +146,47 @@ import {
} from '@/helpers/process'
import { offline_listener, process_listener, profile_listener } from '@/helpers/events'
import { useRoute, useRouter } from 'vue-router'
import { ref, onUnmounted } from 'vue'
import { ref, onUnmounted, defineProps, watch } from 'vue'
import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
import { isOffline, showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { mixpanel_track } from '@/helpers/mixpanel'
import { convertFileSrc } from '@tauri-apps/api/tauri'
import { useFetch } from '@/helpers/fetch'
import { useInstances } from '@/store/instances'
const route = useRoute()
const props = defineProps({
id: {
type: String,
required: false,
default: null,
},
})
const router = useRouter()
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
const instance = ref(await get(route.params.id).catch(handleError))
const instance = ref(await get(route.params.id || props.id).catch(handleError))
watch(
() => route.params.id,
async (id) => {
if (!id) return
instance.value = await get(id).catch(handleError)
}
)
const instancesStore = useInstances()
breadcrumbs.setName(
'Instance',
instance.value.metadata.name.length > 40
? instance.value.metadata.name.substring(0, 40) + '...'
: instance.value.metadata.name
)
breadcrumbs.setName('Instance', instance.value.metadata.name)
breadcrumbs.setContext({
name: instance.value.metadata.name,
link: route.path,
@@ -188,6 +213,8 @@ const startInstance = async (context) => {
game_version: instance.value.metadata.game_version,
source: context,
})
await instancesStore.refreshInstances()
}
const checkProcess = async () => {
@@ -203,7 +230,7 @@ const checkProcess = async () => {
// Get information on associated modrinth versions, if any
const modrinthVersions = ref([])
if (!(await isOffline()) && instance.value.metadata.linked_data) {
if (!(await isOffline()) && instance.value.metadata.linked_data?.project_id) {
modrinthVersions.value = await useFetch(
`https://api.modrinth.com/v2/project/${instance.value.metadata.linked_data.project_id}/version`,
'project'
@@ -366,6 +393,8 @@ Button {
.name {
font-size: 1.25rem;
color: var(--color-contrast);
overflow: hidden;
text-overflow: ellipsis;
}
.metadata {
@@ -411,6 +440,7 @@ Button {
width: 100%;
color: var(--color-primary);
box-shadow: none;
justify-content: start;
&.router-link-exact-active {
box-shadow: var(--shadow-inset-lg);

View File

@@ -139,7 +139,7 @@ const props = defineProps({
const currentLiveLog = ref(null)
const currentLiveLogCursor = ref(0)
const emptyText = ['No live game detected.', 'Start your game to proceed']
const emptyText = ['No live game detected.', 'Start your game to proceed.']
const logs = ref([])
await setLogs()
@@ -216,7 +216,7 @@ const processedLogs = computed(() => {
return processed
})
async function getLiveLog() {
async function getLiveStdLog() {
if (route.params.id) {
const uuids = await get_uuids_by_profile_path(route.params.id).catch(handleError)
let returnValue
@@ -240,34 +240,26 @@ async function getLiveLog() {
}
async function getLogs() {
return (await get_logs(props.instance.path, true).catch(handleError)).reverse().map((log) => {
if (log.filename == 'latest.log') {
log.name = 'Latest Log'
} else {
let filename = log.filename.split('.')[0]
let day = dayjs(filename.slice(0, 10))
if (day.isValid()) {
if (day.isToday()) {
log.name = 'Today'
} else if (day.isYesterday()) {
log.name = 'Yesterday'
} else {
log.name = day.format('MMMM D, YYYY')
}
// Displays as "Today-1", "Today-2", etc, matching minecraft log naming but with the date
log.name = log.name + filename.slice(10)
} else {
log.name = filename
}
}
log.stdout = 'Loading...'
return log
})
return (await get_logs(props.instance.path, true).catch(handleError))
.reverse()
.filter(
// filter out latest_stdout.log or anything without .log in it
(log) =>
log.filename !== 'latest_stdout.log' &&
log.filename !== 'latest_stdout' &&
log.stdout !== '' &&
log.filename.includes('.log')
)
.map((log) => {
log.name = log.filename || 'Unknown'
log.stdout = 'Loading...'
return log
})
}
async function setLogs() {
const [liveLog, allLogs] = await Promise.all([getLiveLog(), getLogs()])
logs.value = [liveLog, ...allLogs]
const [liveStd, allLogs] = await Promise.all([getLiveStdLog(), getLogs()])
logs.value = [liveStd, ...allLogs]
}
const copyLog = () => {
@@ -426,7 +418,7 @@ function handleUserScroll() {
interval.value = setInterval(async () => {
if (logs.value.length > 0) {
logs.value[0] = await getLiveLog()
logs.value[0] = await getLiveStdLog()
const scroll = logContainer.value.getScroll()
// Allow resetting of userScrolled if the user scrolls to the bottom
@@ -524,13 +516,26 @@ onUnmounted(() => {
display: flex;
padding: 0.6rem;
flex-direction: row;
overflow: auto;
gap: 0.5rem;
&::-webkit-scrollbar-track,
&::-webkit-scrollbar-thumb {
border-radius: 10px;
}
}
:deep(.vue-recycle-scroller__item-wrapper) {
overflow: visible; /* Enables horizontal scrolling */
}
:deep(.vue-recycle-scroller) {
&::-webkit-scrollbar-corner {
background-color: var(--color-bg);
border-radius: 0 0 10px 0;
}
}
.scroller {
height: 100%;
}

View File

@@ -20,7 +20,7 @@
class="text-input"
autocomplete="off"
/>
<Button @click="() => (searchFilter = '')">
<Button class="r-btn" @click="() => (searchFilter = '')">
<XIcon />
</Button>
</div>
@@ -43,23 +43,27 @@
Update all
</Button>
<DropdownButton
v-if="!isPackLocked"
:options="['search', 'from_file']"
default-value="search"
name="add-content-dropdown"
color="primary"
@option-click="handleContentOptionClick"
>
<template #search>
<div v-if="!isPackLocked" class="joined-buttons">
<Button color="primary" @click="onSearchContent">
<SearchIcon />
<span class="no-wrap"> Add content </span>
</template>
<template #from_file>
<FolderOpenIcon />
<span class="no-wrap"> Add from file </span>
</template>
</DropdownButton>
Add content
</Button>
<OverflowMenu
:options="[
{
id: 'file',
action: onFileContent,
},
]"
class="btn btn-primary btn-dropdown-animation icon-only"
>
<DropdownIcon />
<template #file>
<FolderOpenIcon />
Add from file
</template>
</OverflowMenu>
</div>
</Card>
<Pagination
v-if="projects.length > 0"
@@ -283,23 +287,26 @@
</div>
<h3>No projects found</h3>
<p class="empty-subtitle">Add a project to get started</p>
<div class="empty-action">
<DropdownButton
:options="['search', 'from_file']"
default-value="search"
name="add-content-dropdown-from-empty"
color="primary"
@option-click="handleContentOptionClick"
<div v-if="!isPackLocked" class="joined-buttons">
<Button color="primary" @click="onSearchContent">
<SearchIcon />
Add content
</Button>
<OverflowMenu
:options="[
{
id: 'file',
action: onFileContent,
},
]"
class="btn btn-primary btn-dropdown-animation icon-only"
>
<template #search>
<SearchIcon />
<span class="no-wrap"> Add content </span>
</template>
<template #from_file>
<DropdownIcon />
<template #file>
<FolderOpenIcon />
<span class="no-wrap"> Add from file </span>
Add from file
</template>
</DropdownButton>
</OverflowMenu>
</div>
</div>
<Pagination
@@ -378,7 +385,6 @@ import {
FolderOpenIcon,
Checkbox,
formatProjectType,
DropdownButton,
Modal,
XIcon,
ShareIcon,
@@ -391,6 +397,7 @@ import {
CodeIcon,
Pagination,
DropdownSelect,
OverflowMenu,
} from 'omorphia'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
@@ -433,10 +440,22 @@ const props = defineProps({
return false
},
},
playing: {
type: Boolean,
default() {
return false
},
},
versions: {
type: Array,
required: true,
},
installed: {
type: Boolean,
default() {
return true
},
},
})
const projects = ref([])
@@ -542,6 +561,8 @@ const ascending = ref(true)
const sortColumn = ref('Name')
const currentPage = ref(1)
watch(searchFilter, () => (currentPage.value = 1))
const selected = computed(() =>
Array.from(selectionMap.value)
.filter((args) => {
@@ -842,21 +863,21 @@ const handleRightClick = (event, mod) => {
}
}
const handleContentOptionClick = async (args) => {
if (args.option === 'search') {
await router.push({
path: `/browse/${props.instance.metadata.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: props.instance.path },
})
} else if (args.option === 'from_file') {
const newProject = await open({ multiple: true })
if (!newProject) return
const onSearchContent = async () => {
await router.push({
path: `/browse/${props.instance.metadata.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: props.instance.path },
})
}
for (const project of newProject) {
await add_project_from_path(props.instance.path, project, 'mod').catch(handleError)
}
initProjects(await get(props.instance.path).catch(handleError))
const onFileContent = async () => {
const newProject = await open({ multiple: true })
if (!newProject) return
for (const project of newProject) {
await add_project_from_path(props.instance.path, project, 'mod').catch(handleError)
}
initProjects(await get(props.instance.path).catch(handleError))
}
watch(selectAll, () => {
@@ -957,9 +978,17 @@ onUnmounted(() => {
white-space: nowrap;
align-items: center;
:deep(.dropdown-row) {
.btn {
height: 2.5rem !important;
:deep {
.popup-container {
.btn {
height: 2.5rem !important;
}
}
.dropdown-row {
.btn {
height: 2.5rem !important;
}
}
}

View File

@@ -1,5 +1,5 @@
<template>
<ModalConfirm
<ConfirmModal
ref="modal_confirm"
title="Are you sure you want to delete this instance?"
description="If you proceed, all data for your instance will be removed. You will not be able to recover it."
@@ -243,7 +243,7 @@
:disabled="!overrideMemorySettings"
:min="8"
:max="maxMemory"
:step="1"
:step="64"
unit="mb"
/>
</div>
@@ -264,7 +264,17 @@
Make the game start in full screen when launched (using options.txt).
</span>
</label>
<Checkbox id="fullscreen" v-model="fullscreenSetting" :disabled="!overrideWindowSettings" />
<Toggle
id="fullscreen"
:model-value="fullscreenSetting"
:checked="fullscreenSetting"
:disabled="!overrideWindowSettings"
@update:model-value="
(e) => {
fullscreenSetting = e
}
"
/>
</div>
<div class="adjacent-input">
<label for="width">
@@ -362,7 +372,10 @@
<span class="label__description">
<strong>Version: </strong>
{{
installedVersionData.name.charAt(0).toUpperCase() + installedVersionData.name.slice(1)
installedVersionData?.name != null
? installedVersionData.name.charAt(0).toUpperCase() +
installedVersionData.name.slice(1)
: getLocalVersion(props.instance.path)
}}
</span>
</label>
@@ -401,7 +414,7 @@
</Button>
</div>
<div class="adjacent-input">
<div v-if="props.instance.metadata.linked_data.project_id" class="adjacent-input">
<label for="change-modpack-version">
<span class="label__title">Change modpack version</span>
<span class="label__description">
@@ -465,7 +478,7 @@
id="repair-profile"
color="highlight"
:disabled="installing || inProgress || repairing || offline"
@click="repairProfile"
@click="repairProfile(true)"
>
<HammerIcon /> Repair
</Button>
@@ -512,10 +525,11 @@ import {
SaveIcon,
LockIcon,
HammerIcon,
ModalConfirm,
ConfirmModal,
DownloadIcon,
ClipboardCopyIcon,
Button,
Toggle,
} from 'omorphia'
import { SwapIcon } from '@/assets/icons'
@@ -547,8 +561,11 @@ import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { handleError } from '@/store/notifications.js'
import { mixpanel_track } from '@/helpers/mixpanel'
import { useTheming } from '@/store/theme.js'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
const breadcrumbs = useBreadcrumbs()
const router = useRouter()
const props = defineProps({
@@ -576,9 +593,11 @@ const modpackVersionModal = ref(null)
const instancesList = Object.values(await list(true))
const availableGroups = ref([
...instancesList.reduce((acc, obj) => {
return acc.concat(obj.metadata.groups)
}, []),
...new Set(
instancesList.reduce((acc, obj) => {
return acc.concat(obj.metadata.groups)
}, [])
),
])
async function resetIcon() {
@@ -641,9 +660,10 @@ const unlinkModpack = ref(false)
const inProgress = ref(false)
const installing = computed(() => props.instance.install_stage !== 'installed')
const installedVersion = computed(() => props.instance?.metadata?.linked_data?.version_id)
const installedVersionData = computed(() =>
props.versions.find((version) => version.id === installedVersion.value)
)
const installedVersionData = computed(() => {
if (!installedVersion.value) return null
return props.versions.find((version) => version.id === installedVersion.value)
})
watch(
[
@@ -671,6 +691,15 @@ watch(
{ deep: true }
)
const getLocalVersion = (path) => {
const pathSlice = path.split(' ').slice(-1).toString()
// If the path ends in (1), (2), etc. it's a duplicate instance and no version can be obtained.
if (/^\(\d\)/.test(pathSlice)) {
return 'Unknown'
}
return pathSlice
}
const editProfileObject = computed(() => {
const editProfile = {
metadata: {
@@ -727,6 +756,9 @@ const editProfileObject = computed(() => {
if (unlinkModpack.value) {
editProfile.metadata.linked_data = null
}
breadcrumbs.setName('Instance', editProfile.metadata.name)
return editProfile
})
@@ -740,9 +772,9 @@ async function duplicateProfile() {
})
}
async function repairProfile() {
async function repairProfile(force) {
repairing.value = true
await install(props.instance.path).catch(handleError)
await install(props.instance.path, force).catch(handleError)
repairing.value = false
mixpanel_track('InstanceRepair', {
@@ -895,7 +927,7 @@ async function saveGvLoaderEdits() {
editProfile.metadata.loader_version = selectableLoaderVersions.value[loaderVersionIndex.value]
}
await edit(props.instance.path, editProfile).catch(handleError)
await repairProfile()
await repairProfile(false)
editing.value = false
changeVersionsModal.value.hide()

View File

@@ -14,7 +14,11 @@
size="sm"
/>
<div class="small-instance_info">
<span class="title">{{ instance.metadata.name }}</span>
<span class="title">{{
instance.metadata.name.length > 20
? instance.metadata.name.substring(0, 20) + '...'
: instance.metadata.name
}}</span>
<span>
{{
instance.metadata.loader.charAt(0).toUpperCase() + instance.metadata.loader.slice(1)
@@ -321,7 +325,9 @@ async function fetchProjectData() {
installed.value =
instance.value?.path &&
(await check_installed(instance.value.path, data.value.id).catch(handleError))
breadcrumbs.setName('Project', data.value.title)
installedVersion.value = instance.value
? Object.values(instance.value.projects).find(
(p) => p?.metadata?.version?.project_id === data.value.id

View File

@@ -283,6 +283,7 @@ watch([filterVersions, filterLoader, filterGameVersions], () => {
display: flex;
flex-direction: column;
gap: 0.25rem;
text-wrap: wrap;
.version-badge {
display: flex;

View File

@@ -1,3 +1,5 @@
import { computed } from 'vue'
import { defineStore } from 'pinia'
export const useBreadcrumbs = defineStore('breadcrumbsStore', {
@@ -34,3 +36,28 @@ export const useBreadcrumbs = defineStore('breadcrumbsStore', {
},
},
})
export const useBreadcrumbContext = (route) => {
const breadcrumbs = useBreadcrumbs()
const routeContext = computed(() => {
const { meta } = route
if (meta?.useContext) {
return breadcrumbs.context
} else if (meta?.useRootContext) {
return breadcrumbs.rootContext
} else {
return null
}
})
const routeBreadcrumbs = computed(() => {
const { meta } = route
return routeContext.value ? [routeContext.value, ...meta.breadcrumb] : meta.breadcrumb
})
return {
routeContext,
routeBreadcrumbs,
}
}

View File

@@ -0,0 +1,43 @@
import { ref, onMounted, computed } from 'vue'
import dayjs from 'dayjs'
import { list } from '@/helpers/profile.js'
import { handleError } from '@/store/notifications'
import { defineStore } from 'pinia'
export const useInstances = defineStore('instancesStore', () => {
const instances = ref({})
const instanceList = computed(() => {
return Object.values(instances.value)
})
const instancesByPlayed = computed(() => {
return instanceList.value.sort((a, b) => {
return dayjs(b?.metadata?.last_played ?? 0).diff(dayjs(a?.metadata?.last_played ?? 0))
})
})
const setInstances = async () => {
try {
const p = await list(true)
instances.value = p
} catch (error) {
handleError(error)
}
}
onMounted(async () => {
await setInstances()
})
const refreshInstances = async () => {
await setInstances()
}
return {
instanceList,
instancesByPlayed,
refreshInstances,
}
})

View File

@@ -0,0 +1,40 @@
import { ref, onMounted } from 'vue'
import { get as getCredentials, logout as removeCredentials } from '@/helpers/mr_auth.js'
import { handleError } from '@/store/state.js'
import { defineStore } from 'pinia'
export const useModrinthAuth = defineStore('modrinthAuthStore', () => {
const auth = ref(null)
const get = async () => {
try {
const creds = await getCredentials()
auth.value = creds
return creds
} catch (error) {
handleError(error)
}
return null
}
const logout = async () => {
try {
const result = await removeCredentials()
auth.value = null
return result
} catch (error) {
handleError(error)
}
return null
}
onMounted(() => {
get()
})
return {
auth,
get,
logout,
}
})

View File

@@ -2,10 +2,9 @@ import { defineStore } from 'pinia'
export const useTheming = defineStore('themeStore', {
state: () => ({
themeOptions: ['dark'],
themeOptions: ['dark', 'light', 'oled'],
advancedRendering: true,
selectedTheme: 'dark',
darkTheme: true,
}),
actions: {
setThemeState(newTheme) {
@@ -15,8 +14,9 @@ export const useTheming = defineStore('themeStore', {
this.setThemeClass()
},
setThemeClass() {
document.getElementsByTagName('html')[0].classList.remove('dark-mode')
document.getElementsByTagName('html')[0].classList.remove('light-mode')
for (const theme of this.themeOptions) {
document.getElementsByTagName('html')[0].classList.remove(`${theme}-mode`)
}
document.getElementsByTagName('html')[0].classList.add(`${this.selectedTheme}-mode`)
},
},