Compare commits

..

20 Commits

Author SHA1 Message Date
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
40 changed files with 554 additions and 153 deletions

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.6.0"
version = "0.6.2"
dependencies = [
"async-recursion",
"async-tungstenite",
@@ -4733,7 +4733,7 @@ dependencies = [
[[package]]
name = "theseus_cli"
version = "0.6.0"
version = "0.6.2"
dependencies = [
"argh",
"color-eyre",
@@ -4760,7 +4760,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "0.6.0"
version = "0.6.2"
dependencies = [
"chrono",
"cocoa",

View File

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

View File

@@ -5,7 +5,7 @@ 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 {
@@ -28,13 +28,13 @@ pub async fn init() -> crate::Result<DeviceLoginSuccess> {
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.post("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode")
.header("Content-Type", "application/x-www-form-urlencoded").form(&params).send()).await?;
match req.status() {
reqwest::StatusCode::OK => Ok(req.json().await?),
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,
@@ -25,11 +27,14 @@ pub async fn refresh(refresh_token: String) -> crate::Result<OauthSuccess> {
// 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,20 +1,25 @@
use serde_json::json;
use super::auth_retry;
const MCSERVICES_AUTH_URL: &str =
"https://api.minecraftservices.com/launcher/login";
#[tracing::instrument]
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?;
let body = auth_retry(|| {
let client = reqwest::Client::new();
client
.post(MCSERVICES_AUTH_URL)
.json(&json!({
"xtoken": format!("XBL3.0 x={};{}", uhs, token),
"platform": "PC_LAUNCHER"
}))
.send()
})
.await?
.text()
.await?;
serde_json::from_str::<serde_json::Value>(&body)?
.get("access_token")

View File

@@ -1,7 +1,37 @@
//! MSA authentication stages
use futures::Future;
use reqwest::Response;
const RETRY_COUNT: usize = 2; // 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)]
@@ -18,16 +22,17 @@ impl Default for PlayerInfo {
}
}
#[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?;
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,6 +19,7 @@ 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");
@@ -26,14 +29,16 @@ pub async fn poll_response(device_code: String) -> crate::Result<OauthSuccess> {
// 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,26 @@ 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")
.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?;
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

@@ -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,23 @@ 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_std_log_cursor(
profile_path: ProfilePathId,
cursor: u64, // 0 to start at beginning of file
) -> crate::Result<LatestLogCursor> {
get_generic_live_log_cursor(profile_path, "latest_stdout.log", cursor).await
}
#[tracing::instrument]
pub async fn get_generic_live_log_cursor(
profile_path: ProfilePathId,
log_file_name: &str,
mut cursor: u64, // 0 to start at beginning of file
) -> crate::Result<LatestLogCursor> {
let profile_path =
@@ -253,8 +266,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

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

View File

@@ -389,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?;
}
@@ -1034,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)

View File

@@ -1,6 +1,7 @@
//! Theseus profile management interface
use std::path::PathBuf;
use tokio::fs;
use io::IOError;
use tokio::sync::RwLock;
@@ -188,3 +189,17 @@ pub async fn set_config_dir(new_config_dir: PathBuf) -> crate::Result<()> {
Ok(())
}
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

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

View File

@@ -512,8 +512,8 @@ pub async fn launch_minecraft(
.collect::<Vec<_>>(),
)
.current_dir(instance_path.clone())
.stdout(Stdio::null())
.stderr(Stdio::null());
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// CARGO-set DYLD_LIBRARY_PATH breaks Minecraft on macOS during testing on playground
#[cfg(target_os = "macos")]

View File

@@ -1,12 +1,21 @@
use super::DirectoryInfo;
use super::{Profile, ProfilePathId};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use std::{collections::HashMap, sync::Arc};
use sysinfo::PidExt;
use tokio::fs::File;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::process::Child;
use tokio::process::ChildStderr;
use tokio::process::ChildStdout;
use tokio::process::Command;
use tokio::sync::RwLock;
use tracing::error;
use crate::event::emit::emit_process;
use crate::event::ProcessPayloadType;
@@ -192,6 +201,7 @@ impl ChildType {
pub struct MinecraftChild {
pub uuid: Uuid,
pub profile_relative_path: ProfilePathId,
pub output: Option<SharedOutput>,
pub manager: Option<JoinHandle<crate::Result<i32>>>, // None when future has completed and been handled
pub current_child: Arc<RwLock<ChildType>>,
pub last_updated_playtime: DateTime<Utc>, // The last time we updated the playtime for the associated profile
@@ -271,7 +281,43 @@ 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 mut child = mc_command.spawn().map_err(IOError::from)?;
// Create std watcher threads for stdout and stderr
let log_path = DirectoryInfo::profile_logs_dir(&profile_relative_path)
.await?
.join("latest_stdout.log");
let shared_output =
SharedOutput::build(&log_path, censor_strings).await?;
if let Some(child_stdout) = child.stdout.take() {
let stdout_clone = shared_output.clone();
tokio::spawn(async move {
if let Err(e) = stdout_clone.read_stdout(child_stdout).await {
error!("Stdout process died with error: {}", e);
let _ = stdout_clone
.push_line(format!(
"Stdout process died with error: {}",
e
))
.await;
}
});
}
if let Some(child_stderr) = child.stderr.take() {
let stderr_clone = shared_output.clone();
tokio::spawn(async move {
if let Err(e) = stderr_clone.read_stderr(child_stderr).await {
error!("Stderr process died with error: {}", e);
let _ = stderr_clone
.push_line(format!(
"Stderr process died with error: {}",
e
))
.await;
}
});
}
let child = ChildType::TokioChild(child);
// Slots child into manager
@@ -312,6 +358,7 @@ impl Children {
let mchild = MinecraftChild {
uuid,
profile_relative_path,
output: Some(shared_output),
current_child,
manager,
last_updated_playtime,
@@ -402,6 +449,7 @@ impl Children {
let mchild = MinecraftChild {
uuid: cached_process.uuid,
profile_relative_path: cached_process.profile_relative_path,
output: None, // No output for cached/rescued processes
current_child,
manager,
last_updated_playtime,
@@ -710,3 +758,117 @@ impl Default for Children {
Self::new()
}
}
// SharedOutput, a wrapper around a String that can be read from and written to concurrently
// Designed to be used with ChildStdout and ChildStderr in a tokio thread to have a simple String storage for the output of a child process
#[derive(Debug, Clone)]
pub struct SharedOutput {
log_file: Arc<RwLock<File>>,
censor_strings: HashMap<String, String>,
}
impl SharedOutput {
#[tracing::instrument(skip(censor_strings))]
async fn build(
log_file_path: &Path,
censor_strings: HashMap<String, String>,
) -> crate::Result<Self> {
// create log_file_path parent if it doesn't exist
let parent_folder = log_file_path.parent().ok_or_else(|| {
crate::ErrorKind::LauncherError(format!(
"Could not get parent folder of {:?}",
log_file_path
))
})?;
tokio::fs::create_dir_all(parent_folder)
.await
.map_err(|e| IOError::with_path(e, parent_folder))?;
Ok(SharedOutput {
log_file: Arc::new(RwLock::new(
File::create(log_file_path)
.await
.map_err(|e| IOError::with_path(e, log_file_path))?,
)),
censor_strings,
})
}
async fn read_stdout(
&self,
child_stdout: ChildStdout,
) -> crate::Result<()> {
let mut buf_reader = BufReader::new(child_stdout);
let mut buf = Vec::new();
while buf_reader
.read_until(b'\n', &mut buf)
.await
.map_err(IOError::from)?
> 0
{
let line = String::from_utf8_lossy(&buf).into_owned();
let val_line = self.censor_log(line.clone());
{
let mut log_file = self.log_file.write().await;
log_file
.write_all(val_line.as_bytes())
.await
.map_err(IOError::from)?;
}
buf.clear();
}
Ok(())
}
async fn read_stderr(
&self,
child_stderr: ChildStderr,
) -> crate::Result<()> {
let mut buf_reader = BufReader::new(child_stderr);
let mut buf = Vec::new();
// TODO: these can be asbtracted into noe function
while buf_reader
.read_until(b'\n', &mut buf)
.await
.map_err(IOError::from)?
> 0
{
let line = String::from_utf8_lossy(&buf).into_owned();
let val_line = self.censor_log(line.clone());
{
let mut log_file = self.log_file.write().await;
log_file
.write_all(val_line.as_bytes())
.await
.map_err(IOError::from)?;
}
buf.clear();
}
Ok(())
}
async fn push_line(&self, line: String) -> crate::Result<()> {
let val_line = self.censor_log(line.clone());
{
let mut log_file = self.log_file.write().await;
log_file
.write_all(val_line.as_bytes())
.await
.map_err(IOError::from)?;
}
Ok(())
}
fn censor_log(&self, mut val: String) -> String {
for (find, replace) in &self.censor_strings {
val = val.replace(find, replace);
}
val
}
}

View File

@@ -159,7 +159,6 @@ impl DirectoryInfo {
/// 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

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

View File

@@ -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,6 +1,6 @@
[package]
name = "theseus_cli"
version = "0.6.0"
version = "0.6.2"
authors = ["Jai A <jaiagr+gpg@pm.me>"]
edition = "2018"

View File

@@ -1,7 +1,7 @@
{
"name": "theseus_gui",
"private": true,
"version": "0.6.0",
"version": "0.6.2",
"type": "module",
"scripts": {
"dev": "vite",

View File

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

@@ -23,6 +23,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
logs_delete_logs,
logs_delete_logs_by_filename,
logs_get_latest_log_cursor,
logs_get_std_log_cursor,
])
.build()
}
@@ -90,3 +91,12 @@ pub async fn logs_get_latest_log_cursor(
) -> Result<LatestLogCursor> {
Ok(logs::get_latest_log_cursor(profile_path, cursor).await?)
}
/// Get live stdout log from a cursor
#[tauri::command]
pub async fn logs_get_std_log_cursor(
profile_path: ProfilePathId,
cursor: u64, // 0 to start at beginning of file
) -> Result<LatestLogCursor> {
Ok(logs::get_std_log_cursor(profile_path, cursor).await?)
}

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.6.0"
"version": "0.6.2"
},
"tauri": {
"allowlist": {

View File

@@ -51,6 +51,7 @@ const isLoading = ref(true)
const videoPlaying = ref(false)
const offline = ref(false)
const showOnboarding = ref(false)
const nativeDecorations = ref(false)
const onboardingVideo = ref()
@@ -60,8 +61,14 @@ const os = ref('')
defineExpose({
initialize: async () => {
isLoading.value = false
const { theme, opt_out_analytics, collapsed_navigation, advanced_rendering, fully_onboarded } =
await get()
const {
native_decorations,
theme,
opt_out_analytics,
collapsed_navigation,
advanced_rendering,
fully_onboarded,
} = await get()
// video should play if the user is not on linux, and has not onboarded
os.value = await getOS()
videoPlaying.value = !fully_onboarded && os.value !== 'Linux'
@@ -69,6 +76,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
@@ -341,7 +351,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>

View File

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

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"
@@ -42,7 +42,7 @@
<div v-if="displayAccounts.length > 0" class="account-group">
<div v-for="account in displayAccounts" :key="account.id" class="account-row">
<Button class="option account" @click="setAccount(account)">
<Avatar :src="`https://mc-heads.net/avatar/${account.id}/128`" class="icon" />
<Avatar :src="`https://mc-heads.net/avatar/${selectedAccount.id}/128`" class="icon" />
<p>{{ account.username }}</p>
</Button>
<Button v-tooltip="'Log out'" icon-only @click="logout(account.id)">
@@ -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">
@@ -114,6 +114,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 +131,7 @@ const emit = defineEmits(['change'])
const loginCode = ref(null)
const themeStore = useTheming()
const settings = ref({})
const accounts = ref([])
const loginUrl = ref('')

View File

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

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

View File

@@ -50,6 +50,11 @@ 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 })
}
// For std log (from modrinth app written latest_stdout.log, contains stdout and stderr)
export async function get_std_log_cursor(profilePath, cursor) {
return await invoke('plugin:logs|logs_get_std_log_cursor', { profilePath, cursor })
}

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,

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

@@ -14,13 +14,15 @@ 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 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'
const pageOptions = ['Home', 'Library']
@@ -38,6 +40,8 @@ const accessSettings = async () => {
return settings
}
// const launcherVersion = await get_launcher_version().catch(handleError)
const fetchSettings = await accessSettings().catch(handleError)
const settings = ref(fetchSettings)
@@ -115,7 +119,18 @@ async function signInAfter() {
}
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
@@ -153,9 +168,13 @@ async function refreshDir() {
</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="credentials" class="btn" @click="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">
@@ -242,6 +261,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>
@@ -500,6 +535,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

@@ -102,7 +102,7 @@ import {
delete_logs_by_filename,
get_logs,
get_output_by_filename,
get_latest_log_cursor,
get_std_log_cursor,
} from '@/helpers/logs.js'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import dayjs from 'dayjs'
@@ -216,14 +216,14 @@ 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
if (uuids.length === 0) {
returnValue = emptyText.join('\n')
} else {
const logCursor = await get_latest_log_cursor(
const logCursor = await get_std_log_cursor(
props.instance.path,
currentLiveLogCursor.value
).catch(handleError)
@@ -240,34 +240,42 @@ 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)
return (await get_logs(props.instance.path, true).catch(handleError))
.reverse()
.filter(
(log) =>
log.filename !== 'latest_stdout.log' &&
log.filename !== 'latest_stdout' &&
log.stdout !== ''
)
.map((log) => {
if (log.filename == 'latest.log') {
log.name = 'Latest Log'
} else {
log.name = filename
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
})
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 +434,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

View File

@@ -542,6 +542,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) => {

View File

@@ -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">
@@ -519,6 +529,7 @@ import {
DownloadIcon,
ClipboardCopyIcon,
Button,
Toggle,
} from 'omorphia'
import { SwapIcon } from '@/assets/icons'
@@ -550,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({
@@ -742,6 +756,9 @@ const editProfileObject = computed(() => {
if (unlinkModpack.value) {
editProfile.metadata.linked_data = null
}
breadcrumbs.setName('Instance', editProfile.metadata.name)
return editProfile
})