Compare commits

...

6 Commits

Author SHA1 Message Date
Adrian O.V
49bfb0637f Community requested enhancements (#556)
* Make images lazy and fix #198

* Fix console spam

* Fix bug with bad pagination impl

* Fixes #232

* Finalize more bug fixes

* run lint

* Improve minecraft sign in, improve onboarding

* Linter

* Added back button

* Implement #530

* run linter

* Address changes

* Bump version + run fmt

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
2023-08-14 14:02:22 -07:00
Wyatt Verchere
d6ee1ff25a Beta bugs (#562)
* fixed bugs

* added logging for atlauncher

* draft: improving imports time

* more improvements

* more

* prettier, etc

* small changes

* emma suggested change

* rev

* removed atlauncher debug
2023-08-14 13:23:42 -07:00
chaos
a1a5b8ed9c Fix z-index and add scrolling for AccountsCard (#585)
* Fix z-index and add scrolling for AccountsCard

* Run Prettier on code.
2023-08-14 13:18:37 -07:00
Geometrically
5f0d44a881 more bug fixes (#485)
* more bug fixes

* remove console log
2023-08-05 17:43:21 -07:00
Wyatt Verchere
d968ad383c Discord and playtime (#462)
* initial

* Fixed java thing

* fixes

* internet check change

* some fix/test commit

* Fix render issues on windows

* bump version

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: Jai A <jai@modrinth.com>
2023-08-05 16:41:08 -07:00
Adrian O.V
5ee64f2705 New features (#477)
* Linking/Unlinking, Dir changing, CDN

* Fixes #435

* Progress bar

* Create splashscreen.html

* Run lint

* add rust part

* remove splashscreen code

---------

Co-authored-by: Jai A <jaiagr+gpg@pm.me>
2023-08-05 14:44:02 -07:00
75 changed files with 1350 additions and 601 deletions

2
.gitignore vendored
View File

@@ -110,3 +110,5 @@ fabric.properties
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
theseus.iml

6
Cargo.lock generated
View File

@@ -4609,7 +4609,7 @@ dependencies = [
[[package]]
name = "theseus"
version = "0.4.0"
version = "0.5.1"
dependencies = [
"async-recursion",
"async-tungstenite",
@@ -4654,7 +4654,7 @@ dependencies = [
[[package]]
name = "theseus_cli"
version = "0.4.0"
version = "0.5.1"
dependencies = [
"argh",
"color-eyre",
@@ -4681,7 +4681,7 @@ dependencies = [
[[package]]
name = "theseus_gui"
version = "0.4.0"
version = "0.5.1"
dependencies = [
"chrono",
"cocoa",

11
theseus.iml Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/theseus/library" isTestSource="false" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

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

View File

@@ -6,7 +6,7 @@ use std::path::PathBuf;
use crate::event::emit::{emit_loading, init_loading};
use crate::state::CredentialsStore;
use crate::util::fetch::{fetch_advanced, fetch_json};
use crate::util::io;
use crate::util::jre::extract_java_majorminor_version;
use crate::{
state::JavaGlobals,
@@ -117,10 +117,6 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
let path = state.directories.java_versions_dir().await;
if path.exists() {
io::remove_dir_all(&path).await?;
}
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(file))
.map_err(|_| {
crate::Error::from(crate::ErrorKind::InputError(

View File

@@ -7,10 +7,13 @@ use crate::ErrorKind;
pub async fn authenticate_begin_flow(provider: &str) -> crate::Result<String> {
let state = crate::State::get().await?;
// Don't start an uncompleteable new flow if there's an existing locked one
let mut write: tokio::sync::RwLockWriteGuard<'_, Option<ModrinthAuthFlow>> =
state.modrinth_auth_flow.write().await;
let mut flow = ModrinthAuthFlow::new(provider).await?;
let url = flow.prepare_login_url().await?;
let mut write = state.modrinth_auth_flow.write().await;
*write = Some(flow);
Ok(url)

View File

@@ -3,13 +3,12 @@ use std::{collections::HashMap, path::PathBuf};
use serde::{Deserialize, Serialize};
use crate::{
event::LoadingBarId,
pack::{
self,
import::{self, copy_dotminecraft},
install_from::CreatePackDescription,
},
prelude::{ModLoader, ProfilePathId},
prelude::{ModLoader, Profile, ProfilePathId},
state::{LinkedData, ProfileInstallStage},
util::io,
State,
@@ -33,8 +32,6 @@ pub struct ATLauncher {
pub modrinth_project: Option<ATLauncherModrinthProject>,
pub modrinth_version: Option<ATLauncherModrinthVersion>,
pub modrinth_manifest: Option<pack::install_from::PackFormat>,
pub mods: Vec<ATLauncherMod>,
}
#[derive(Serialize, Deserialize)]
@@ -57,13 +54,9 @@ pub struct ATLauncherModrinthProject {
pub slug: String,
pub project_type: String,
pub team: String,
pub title: String,
pub description: String,
pub body: String,
pub client_side: Option<String>,
pub server_side: Option<String>,
pub categories: Vec<String>,
pub icon_url: String,
}
#[derive(Serialize, Deserialize, Debug)]
@@ -110,7 +103,16 @@ pub async fn is_valid_atlauncher(instance_folder: PathBuf) -> bool {
.unwrap_or("".to_string());
let instance: Result<ATInstance, serde_json::Error> =
serde_json::from_str::<ATInstance>(&instance);
instance.is_ok()
if let Err(e) = instance {
tracing::warn!(
"Could not parse instance.json at {}: {}",
instance_folder.display(),
e
);
false
} else {
true
}
}
#[tracing::instrument]
@@ -169,7 +171,6 @@ pub async fn import_atlauncher(
backup_name,
description,
atinstance,
None,
)
.await?;
Ok(())
@@ -181,7 +182,6 @@ async fn import_atlauncher_unmanaged(
backup_name: String,
description: CreatePackDescription,
atinstance: ATInstance,
existing_loading_bar: Option<LoadingBarId>,
) -> crate::Result<()> {
let mod_loader = format!(
"\"{}\"",
@@ -230,19 +230,28 @@ async fn import_atlauncher_unmanaged(
// Moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc)
let state = State::get().await?;
copy_dotminecraft(
let loading_bar = copy_dotminecraft(
profile_path.clone(),
minecraft_folder,
&state.io_semaphore,
None,
)
.await?;
if let Some(profile_val) =
crate::api::profile::get(&profile_path, None).await?
{
crate::launcher::install_minecraft(&profile_val, existing_loading_bar)
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;
{
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;
Profile::watch_fs(
&profile_val.get_profile_full_path().await?,
&mut file_watcher,
)
.await?;
}
State::sync().await?;
}
Ok(())

View File

@@ -2,6 +2,7 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::prelude::Profile;
use crate::state::CredentialsStore;
use crate::{
prelude::{ModLoader, ProfilePathId},
@@ -187,18 +188,29 @@ pub async fn import_curseforge(
// Copy in contained folders as overrides
let state = State::get().await?;
copy_dotminecraft(
let loading_bar = copy_dotminecraft(
profile_path.clone(),
curseforge_instance_folder,
&state.io_semaphore,
None,
)
.await?;
if let Some(profile_val) =
crate::api::profile::get(&profile_path, None).await?
{
crate::launcher::install_minecraft(&profile_val, None).await?;
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;
{
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;
Profile::watch_fs(
&profile_val.get_profile_full_path().await?,
&mut file_watcher,
)
.await?;
}
State::sync().await?;
}

View File

@@ -3,7 +3,7 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::{
prelude::{ModLoader, ProfilePathId},
prelude::{ModLoader, Profile, ProfilePathId},
state::ProfileInstallStage,
util::io,
State,
@@ -101,18 +101,28 @@ pub async fn import_gdlauncher(
// Copy in contained folders as overrides
let state = State::get().await?;
copy_dotminecraft(
let loading_bar = copy_dotminecraft(
profile_path.clone(),
gdlauncher_instance_folder,
&state.io_semaphore,
None,
)
.await?;
if let Some(profile_val) =
crate::api::profile::get(&profile_path, None).await?
{
crate::launcher::install_minecraft(&profile_val, None).await?;
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;
{
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;
Profile::watch_fs(
&profile_val.get_profile_full_path().await?,
&mut file_watcher,
)
.await?;
}
State::sync().await?;
}

View File

@@ -7,7 +7,7 @@ use crate::{
import::{self, copy_dotminecraft},
install_from::{self, CreatePackDescription, PackDependency},
},
prelude::ProfilePathId,
prelude::{Profile, ProfilePathId},
util::io,
State,
};
@@ -119,6 +119,26 @@ pub struct MMCComponentRequirement {
pub suggests: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
#[serde(untagged)]
enum MMCLauncherEnum {
General(MMCLauncherGeneral),
Instance(MMCLauncher),
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
struct MMCLauncherGeneral {
pub general: MMCLauncher,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "PascalCase")]
pub struct MMCLauncher {
instance_dir: String,
}
// Checks if if its a folder, and the folder contains instance.cfg and mmc-pack.json, and they both parse
#[tracing::instrument]
pub async fn is_valid_mmc(instance_folder: PathBuf) -> bool {
@@ -134,9 +154,19 @@ pub async fn is_valid_mmc(instance_folder: PathBuf) -> bool {
&& serde_json::from_str::<MMCPack>(&mmc_pack).is_ok()
}
#[tracing::instrument]
pub async fn get_instances_subpath(config: PathBuf) -> Option<String> {
let launcher = io::read_to_string(&config).await.ok()?;
let launcher: MMCLauncherEnum = serde_ini::from_str(&launcher).ok()?;
match launcher {
MMCLauncherEnum::General(p) => Some(p.general.instance_dir),
MMCLauncherEnum::Instance(p) => Some(p.instance_dir),
}
}
// Loading the INI (instance.cfg) file
async fn load_instance_cfg(file_path: &Path) -> crate::Result<MMCInstance> {
let instance_cfg = io::read_to_string(file_path).await?;
let instance_cfg: String = io::read_to_string(file_path).await?;
let instance_cfg_enum: MMCInstanceEnum =
serde_ini::from_str::<MMCInstanceEnum>(&instance_cfg)?;
match instance_cfg_enum {
@@ -281,18 +311,28 @@ async fn import_mmc_unmanaged(
// Moves .minecraft folder over (ie: overrides such as resourcepacks, mods, etc)
let state = State::get().await?;
copy_dotminecraft(
let loading_bar = copy_dotminecraft(
profile_path.clone(),
minecraft_folder,
&state.io_semaphore,
None,
)
.await?;
if let Some(profile_val) =
crate::api::profile::get(&profile_path, None).await?
{
crate::launcher::install_minecraft(&profile_val, None).await?;
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;
{
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;
Profile::watch_fs(
&profile_val.get_profile_full_path().await?,
&mut file_watcher,
)
.await?;
}
State::sync().await?;
}
Ok(())

View File

@@ -7,6 +7,10 @@ use io::IOError;
use serde::{Deserialize, Serialize};
use crate::{
event::{
emit::{emit_loading, init_or_edit_loading},
LoadingBarId,
},
prelude::ProfilePathId,
state::Profiles,
util::{
@@ -51,11 +55,20 @@ pub async fn get_importable_instances(
) -> crate::Result<Vec<String>> {
// Some launchers have a different folder structure for instances
let instances_subfolder = match launcher_type {
ImportLauncherType::GDLauncher
| ImportLauncherType::MultiMC
| ImportLauncherType::PrismLauncher
| ImportLauncherType::ATLauncher => "instances",
ImportLauncherType::Curseforge => "Instances",
ImportLauncherType::GDLauncher | ImportLauncherType::ATLauncher => {
"instances".to_string()
}
ImportLauncherType::Curseforge => "Instances".to_string(),
ImportLauncherType::MultiMC => {
mmc::get_instances_subpath(base_path.clone().join("multimc.cfg"))
.await
.unwrap_or_else(|| "instances".to_string())
}
ImportLauncherType::PrismLauncher => mmc::get_instances_subpath(
base_path.clone().join("prismlauncher.cfg"),
)
.await
.unwrap_or_else(|| "instances".to_string()),
ImportLauncherType::Unknown => {
return Err(crate::ErrorKind::InputError(
"Launcher type Unknown".to_string(),
@@ -63,7 +76,8 @@ pub async fn get_importable_instances(
.into())
}
};
let instances_folder = base_path.join(instances_subfolder);
let instances_folder = base_path.join(&instances_subfolder);
let mut instances = Vec::new();
let mut dir = io::read_dir(&instances_folder).await.map_err(| _ | {
crate::ErrorKind::InputError(format!(
@@ -238,55 +252,61 @@ pub async fn recache_icon(
}
async fn copy_dotminecraft(
profile_path: ProfilePathId,
profile_path_id: ProfilePathId,
dotminecraft: PathBuf,
io_semaphore: &IoSemaphore,
) -> crate::Result<()> {
existing_loading_bar: Option<LoadingBarId>,
) -> crate::Result<LoadingBarId> {
// Get full path to profile
let profile_path = profile_path.get_full_path().await?;
let profile_path = profile_path_id.get_full_path().await?;
// std fs copy every file in dotminecraft to profile_path
let mut dir = io::read_dir(&dotminecraft).await?;
while let Some(entry) = dir
.next_entry()
.await
.map_err(|e| IOError::with_path(e, &dotminecraft))?
{
let path = entry.path();
copy_dir_to(
&path,
&profile_path.join(path.file_name().ok_or_else(|| {
// Gets all subfiles recursively in src
let subfiles = get_all_subfiles(&dotminecraft).await?;
let total_subfiles = subfiles.len() as u64;
let loading_bar = init_or_edit_loading(
existing_loading_bar,
crate::LoadingBarType::CopyProfile {
import_location: dotminecraft.clone(),
profile_name: profile_path_id.to_string(),
},
total_subfiles as f64,
"Copying files in profile",
)
.await?;
// Copy each file
for src_child in subfiles {
let dst_child =
src_child.strip_prefix(&dotminecraft).map_err(|_| {
crate::ErrorKind::InputError(format!(
"Invalid file: {}",
&path.display()
&src_child.display()
))
})?),
io_semaphore,
)
.await?;
})?;
let dst_child = profile_path.join(dst_child);
// sleep for cpu for 1 millisecond
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
fetch::copy(&src_child, &dst_child, io_semaphore).await?;
emit_loading(&loading_bar, 1.0, None).await?;
}
Ok(())
Ok(loading_bar)
}
/// Recursively fs::copy every file in src to dest
/// Recursively get a list of all subfiles in src
/// uses async recursion
#[theseus_macros::debug_pin]
#[async_recursion::async_recursion]
#[tracing::instrument]
async fn copy_dir_to(
src: &Path,
dst: &Path,
io_semaphore: &IoSemaphore,
) -> crate::Result<()> {
async fn get_all_subfiles(src: &Path) -> crate::Result<Vec<PathBuf>> {
if !src.is_dir() {
fetch::copy(src, dst, io_semaphore).await?;
return Ok(());
return Ok(vec![src.to_path_buf()]);
}
// Create the destination directory
io::create_dir_all(&dst).await?;
// Iterate over the directory
let mut files = Vec::new();
let mut dir = io::read_dir(&src).await?;
while let Some(child) = dir
.next_entry()
@@ -294,21 +314,7 @@ async fn copy_dir_to(
.map_err(|e| IOError::with_path(e, src))?
{
let src_child = child.path();
let dst_child = dst.join(src_child.file_name().ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Invalid file: {}",
&src_child.display()
))
})?);
if src_child.is_dir() {
// Recurse into sub-directory
copy_dir_to(&src_child, &dst_child, io_semaphore).await?;
} else {
// Copy file
fetch::copy(&src_child, &dst_child, io_semaphore).await?;
}
files.append(&mut get_all_subfiles(&src_child).await?);
}
Ok(())
Ok(files)
}

View File

@@ -65,7 +65,7 @@ pub enum EnvType {
Server,
}
#[derive(Serialize, Deserialize, Clone, Hash, PartialEq, Eq, Debug)]
#[derive(Serialize, Deserialize, Clone, Copy, Hash, PartialEq, Eq, Debug)]
#[serde(rename_all = "kebab-case")]
pub enum PackDependency {
Forge,
@@ -101,6 +101,7 @@ pub struct CreatePackProfile {
pub icon_url: Option<String>, // the URL icon for a profile (ONLY USED FOR TEMPORARY PROFILES)
pub linked_data: Option<LinkedData>, // the linked project ID (mainly for modpacks)- used for updating
pub skip_install_profile: Option<bool>,
pub no_watch: Option<bool>,
}
// default
@@ -115,6 +116,7 @@ impl Default for CreatePackProfile {
icon_url: None,
linked_data: None,
skip_install_profile: Some(true),
no_watch: Some(false),
}
}
}

View File

@@ -273,9 +273,7 @@ pub async fn install_zipped_mrpack_files(
profile::edit_icon(&profile_path, Some(&potential_icon)).await?;
}
if let Some(profile_val) =
crate::api::profile::get(&profile_path, None).await?
{
if let Some(profile_val) = profile::get(&profile_path, None).await? {
crate::launcher::install_minecraft(&profile_val, Some(loading_bar))
.await?;

View File

@@ -32,6 +32,7 @@ pub async fn profile_create(
icon_url: Option<String>, // the URL icon for a profile (ONLY USED FOR TEMPORARY PROFILES)
linked_data: Option<LinkedData>, // the linked project ID (mainly for modpacks)- used for updating
skip_install_profile: Option<bool>,
no_watch: Option<bool>,
) -> crate::Result<ProfilePathId> {
name = profile::sanitize_profile_name(&name);
@@ -112,7 +113,9 @@ pub async fn profile_create(
{
let mut profiles = state.profiles.write().await;
profiles.insert(profile.clone()).await?;
profiles
.insert(profile.clone(), no_watch.unwrap_or_default())
.await?;
}
if !skip_install_profile.unwrap_or(false) {
@@ -146,6 +149,7 @@ pub async fn profile_create_from_creator(
profile.icon_url,
profile.linked_data,
profile.skip_install_profile,
profile.no_watch,
)
.await
}

View File

@@ -10,6 +10,7 @@ use crate::pack::install_from::{
use crate::prelude::{JavaVersion, ProfilePathId, ProjectPathId};
use crate::state::ProjectMetadata;
use crate::util::fetch;
use crate::util::io::{self, IOError};
use crate::{
auth::{self, refresh},
@@ -22,6 +23,7 @@ pub use crate::{
};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use serde_json::json;
use std::collections::HashMap;
@@ -541,23 +543,6 @@ pub async fn remove_project(
}
}
/// Gets whether project is a managed modrinth pack
#[tracing::instrument]
pub async fn is_managed_modrinth_pack(
profile: &ProfilePathId,
) -> crate::Result<bool> {
if let Some(profile) = get(profile, None).await? {
if let Some(linked_data) = profile.metadata.linked_data {
return Ok(linked_data.project_id.is_some()
&& linked_data.version_id.is_some());
}
Ok(false)
} else {
Err(crate::ErrorKind::UnmanagedProfileError(profile.to_string())
.as_error())
}
}
/// Exports the profile to a Modrinth-formatted .mrpack file
// Version ID of uploaded version (ie 1.1.5), not the unique identifying ID of the version (nvrqJg44)
#[tracing::instrument(skip_all)]
@@ -878,6 +863,65 @@ pub async fn run_credentials(
Ok(mc_process)
}
/// Update playtime- sending a request to the server to update the playtime
#[tracing::instrument]
#[theseus_macros::debug_pin]
pub async fn try_update_playtime(path: &ProfilePathId) -> crate::Result<()> {
let state = State::get().await?;
let profile = get(path, None).await?.ok_or_else(|| {
crate::ErrorKind::OtherError(format!(
"Tried to update playtime for a nonexistent or unloaded profile at path {}!",
path
))
})?;
let updated_recent_playtime = profile.metadata.recent_time_played;
let res = if updated_recent_playtime > 0 {
// Create update struct to send to Labrinth
let modrinth_pack_version_id =
profile.metadata.linked_data.and_then(|l| l.version_id);
let playtime_update_json = json!({
"seconds": updated_recent_playtime,
"loader": profile.metadata.loader.to_string(),
"game_version": profile.metadata.game_version,
"parent": modrinth_pack_version_id,
});
// Copy this struct for every Modrinth project in the profile
let mut hashmap: HashMap<String, serde_json::Value> = HashMap::new();
for (_, project) in profile.projects {
if let ProjectMetadata::Modrinth { version, .. } = project.metadata
{
hashmap.insert(version.id, playtime_update_json.clone());
}
}
let creds = state.credentials.read().await;
fetch::post_json(
"https://api.modrinth.com/analytics/playtime",
serde_json::to_value(hashmap)?,
&state.fetch_semaphore,
&creds,
)
.await
} else {
Ok(())
};
// If successful, update the profile metadata to match submitted
if res.is_ok() {
let mut profiles = state.profiles.write().await;
if let Some(profile) = profiles.0.get_mut(path) {
profile.metadata.submitted_time_played += updated_recent_playtime;
profile.metadata.recent_time_played = 0;
}
}
// Sync either way
State::sync().await?;
res
}
fn get_modrinth_pack_list(packfile: &PackFormat) -> Vec<String> {
packfile
.files
@@ -930,7 +974,7 @@ pub async fn create_mrpack_json(
// But the values are sanitized to only include the version number
let dependencies = dependencies
.into_iter()
.map(|(k, v)| (k, sanitize_loader_version_string(&v).to_string()))
.map(|(k, v)| (k, sanitize_loader_version_string(&v, k).to_string()))
.collect::<HashMap<_, _>>();
let files: Result<Vec<PackFile>, crate::ErrorKind> = profile
@@ -999,18 +1043,26 @@ pub async fn create_mrpack_json(
})
}
fn sanitize_loader_version_string(s: &str) -> &str {
// Split on '-'
// If two or more, take the second
// If one, take the first
// If none, take the whole thing
let mut split: std::str::Split<'_, char> = s.split('-');
match split.next() {
Some(first) => match split.next() {
Some(second) => second,
None => first,
},
None => s,
fn sanitize_loader_version_string(s: &str, loader: PackDependency) -> &str {
match loader {
// Split on '-'
// If two or more, take the second
// If one, take the first
// If none, take the whole thing
PackDependency::Forge => {
let mut split: std::str::Split<'_, char> = s.split('-');
match split.next() {
Some(first) => match split.next() {
Some(second) => second,
None => first,
},
None => s,
}
}
// For quilt, etc we take the whole thing, as it functions like: 0.20.0-beta.11 (and should not be split here)
PackDependency::QuiltLoader
| PackDependency::FabricLoader
| PackDependency::Minecraft => s,
}
}

View File

@@ -185,6 +185,10 @@ pub enum LoadingBarType {
ConfigChange {
new_path: PathBuf,
},
CopyProfile {
import_location: PathBuf,
profile_name: String,
},
}
#[derive(Serialize, Clone)]

View File

@@ -320,7 +320,7 @@ pub async fn download_libraries(
let reader = std::io::Cursor::new(&data);
if let Ok(mut archive) = zip::ZipArchive::new(reader) {
match archive.extract(st.directories.version_natives_dir(version).await) {
Ok(_) => tracing::info!("Fetched native {}", &library.name),
Ok(_) => tracing::debug!("Fetched native {}", &library.name),
Err(err) => tracing::error!("Failed extracting native {}. err: {}", &library.name, err)
}
} else {

View File

@@ -103,6 +103,7 @@ pub async fn install_minecraft(
profile: &Profile,
existing_loading_bar: Option<LoadingBarId>,
) -> crate::Result<()> {
let sync_projects = existing_loading_bar.is_some();
let loading_bar = init_or_edit_loading(
existing_loading_bar,
LoadingBarType::MinecraftDownload {
@@ -123,6 +124,10 @@ pub async fn install_minecraft(
.await?;
State::sync().await?;
if sync_projects {
Profile::sync_projects_task(profile.profile_id(), true);
}
let state = State::get().await?;
let instance_path =
&io::canonicalize(&profile.get_profile_full_path().await?)?;
@@ -456,28 +461,28 @@ pub async fn launch_minecraft(
// Uses 'a:b' syntax which is not quite yaml
use regex::Regex;
let options_path = instance_path.join("options.txt");
let mut options_string = String::new();
if options_path.exists() {
options_string = io::read_to_string(&options_path).await?;
}
for (key, value) in mc_set_options {
let re = Regex::new(&format!(r"(?m)^{}:.*$", regex::escape(key)))?;
// check if the regex exists in the file
if !re.is_match(&options_string) {
// The key was not found in the file, so append it
options_string.push_str(&format!("\n{}:{}", key, value));
} else {
let replaced_string = re
.replace_all(&options_string, &format!("{}:{}", key, value))
.to_string();
options_string = replaced_string;
if !mc_set_options.is_empty() {
let options_path = instance_path.join("options.txt");
let mut options_string = String::new();
if options_path.exists() {
options_string = io::read_to_string(&options_path).await?;
}
for (key, value) in mc_set_options {
let re = Regex::new(&format!(r"(?m)^{}:.*$", regex::escape(key)))?;
// check if the regex exists in the file
if !re.is_match(&options_string) {
// The key was not found in the file, so append it
options_string.push_str(&format!("\n{}:{}", key, value));
} else {
let replaced_string = re
.replace_all(&options_string, &format!("{}:{}", key, value))
.to_string();
options_string = replaced_string;
}
}
}
io::write(&options_path, options_string).await?;
io::write(&options_path, options_string).await?;
}
// Get Modrinth logs directories
let datetime_string =

View File

@@ -1,4 +1,5 @@
use super::{Profile, ProfilePathId};
use chrono::{DateTime, Utc};
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use std::{collections::HashMap, sync::Arc};
@@ -12,6 +13,7 @@ use tracing::error;
use crate::event::emit::emit_process;
use crate::event::ProcessPayloadType;
use crate::profile;
use crate::util::io::IOError;
use tokio::task::JoinHandle;
@@ -29,6 +31,7 @@ pub struct MinecraftChild {
pub manager: Option<JoinHandle<crate::Result<ExitStatus>>>, // None when future has completed and been handled
pub current_child: Arc<RwLock<Child>>,
pub output: SharedOutput,
pub last_updated_playtime: DateTime<Utc>, // The last time we updated the playtime for the associated profile
}
impl Children {
@@ -94,6 +97,7 @@ impl Children {
post_command,
pid,
current_child.clone(),
profile_relative_path.clone(),
)));
emit_process(
@@ -104,6 +108,8 @@ impl Children {
)
.await?;
let last_updated_playtime = Utc::now();
// Create MinecraftChild
let mchild = MinecraftChild {
uuid,
@@ -111,6 +117,7 @@ impl Children {
current_child,
output: shared_output,
manager,
last_updated_playtime,
};
let mchild = Arc::new(RwLock::new(mchild));
@@ -128,11 +135,13 @@ impl Children {
post_command: Option<Command>,
mut current_pid: u32,
current_child: Arc<RwLock<Child>>,
associated_profile: ProfilePathId,
) -> crate::Result<ExitStatus> {
let current_child = current_child.clone();
// Wait on current Minecraft Child
let mut mc_exit_status;
let mut last_updated_playtime = Utc::now();
loop {
if let Some(t) = current_child
.write()
@@ -145,8 +154,60 @@ impl Children {
}
// sleep for 10ms
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
// Auto-update playtime every minute
let diff = Utc::now()
.signed_duration_since(last_updated_playtime)
.num_seconds();
if diff >= 60 {
if let Err(e) = profile::edit(&associated_profile, |prof| {
prof.metadata.recent_time_played += diff as u64;
async { Ok(()) }
})
.await
{
tracing::warn!(
"Failed to update playtime for profile {}: {}",
associated_profile,
e
);
}
last_updated_playtime = Utc::now();
}
}
// Now fully complete- update playtime one last time
let diff = Utc::now()
.signed_duration_since(last_updated_playtime)
.num_seconds();
if let Err(e) = profile::edit(&associated_profile, |prof| {
prof.metadata.recent_time_played += diff as u64;
async { Ok(()) }
})
.await
{
tracing::warn!(
"Failed to update playtime for profile {}: {}",
associated_profile,
e
);
}
// Publish play time update
// Allow failure, it will be stored locally and sent next time
// Sent in another thread as first call may take a couple seconds and hold up process ending
tokio::spawn(async move {
if let Err(e) =
profile::try_update_playtime(&associated_profile).await
{
tracing::warn!(
"Failed to update playtime for profile {}: {}",
associated_profile,
e
);
}
});
{
// Clear game played for Discord RPC
// May have other active processes, so we clear to the next running process

View File

@@ -99,6 +99,7 @@ impl DiscordGuard {
Ok(())
}
/*
/// Clear the activity
pub async fn clear_activity(
&self,
@@ -137,7 +138,7 @@ impl DiscordGuard {
res.map_err(could_not_clear_err)?;
}
Ok(())
}
}*/
/// Clear the activity, but if there is a running profile, set the activity to that instead
pub async fn clear_to_default(
@@ -160,7 +161,7 @@ impl DiscordGuard {
)
.await?;
} else {
self.clear_activity(reconnect_if_fail).await?;
self.set_activity("Idling...", reconnect_if_fail).await?;
}
Ok(())
}

View File

@@ -156,7 +156,7 @@ impl State {
)));
emit_loading(&loading_bar, 10.0, None).await?;
let is_offline = !fetch::check_internet(&fetch_semaphore, 3).await;
let is_offline = !fetch::check_internet(3).await;
let metadata_fut =
Metadata::init(&directories, !is_offline, &io_semaphore);
@@ -185,6 +185,10 @@ impl State {
let safety_processes = SafeProcesses::new();
let discord_rpc = DiscordGuard::init().await?;
{
// Add default Idling to discord rich presence
let _ = discord_rpc.set_activity("Idling...", true).await;
}
// Starts a loop of checking if we are online, and updating
Self::offine_check_loop();
@@ -323,7 +327,7 @@ impl State {
/// Refreshes whether or not the launcher should be offline, by whether or not there is an internet connection
pub async fn refresh_offline(&self) -> crate::Result<()> {
let is_online = fetch::check_internet(&self.fetch_semaphore, 3).await;
let is_online = fetch::check_internet(3).await;
let mut offline = self.offline.write().await;
@@ -341,7 +345,7 @@ pub async fn init_watcher() -> crate::Result<Debouncer<RecommendedWatcher>> {
let (mut tx, mut rx) = channel(1);
let file_watcher = new_debouncer(
Duration::from_secs_f32(0.25),
Duration::from_secs_f32(2.0),
None,
move |res: DebounceEventResult| {
futures::executor::block_on(async {
@@ -349,19 +353,17 @@ pub async fn init_watcher() -> crate::Result<Debouncer<RecommendedWatcher>> {
})
},
)?;
tokio::task::spawn(async move {
let span = tracing::span!(tracing::Level::INFO, "init_watcher");
tracing::info!(parent: &span, "Initting watcher");
while let Some(res) = rx.next().await {
let _span = span.enter();
match res {
Ok(mut events) => {
let mut visited_paths = Vec::new();
// sort events by e.path
events.sort_by(|a, b| a.path.cmp(&b.path));
events.iter().for_each(|e| {
tracing::debug!(
"File watcher event: {:?}",
serde_json::to_string(&e.path).unwrap()
);
let mut new_path = PathBuf::new();
let mut components_iterator = e.path.components();
let mut found = false;
@@ -394,7 +396,10 @@ pub async fn init_watcher() -> crate::Result<Debouncer<RecommendedWatcher>> {
Profile::crash_task(profile_path_id);
} else if !visited_paths.contains(&new_path) {
if subfile {
Profile::sync_projects_task(profile_path_id);
Profile::sync_projects_task(
profile_path_id,
false,
);
visited_paths.push(new_path);
} else {
Profiles::sync_available_profiles_task(

View File

@@ -222,7 +222,7 @@ pub async fn login_password(
) -> crate::Result<ModrinthCredentialsResult> {
let resp = fetch_advanced(
Method::POST,
&format!("https://{MODRINTH_API_URL}auth/login"),
&format!("{MODRINTH_API_URL}auth/login"),
None,
Some(serde_json::json!({
"username": username,

View File

@@ -183,6 +183,10 @@ pub struct ProfileMetadata {
pub date_modified: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_played: Option<DateTime<Utc>>,
#[serde(default)]
pub submitted_time_played: u64,
#[serde(default)]
pub recent_time_played: u64,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -265,6 +269,8 @@ impl Profile {
date_created: Utc::now(),
date_modified: Utc::now(),
last_played: None,
submitted_time_played: 0,
recent_time_played: 0,
},
projects: HashMap::new(),
java: None,
@@ -324,47 +330,48 @@ impl Profile {
});
}
pub fn sync_projects_task(profile_path_id: ProfilePathId) {
pub fn sync_projects_task(profile_path_id: ProfilePathId, force: bool) {
let span = tracing::span!(
tracing::Level::INFO,
"sync_projects_task",
?profile_path_id,
?force
);
tokio::task::spawn(async move {
let span =
tracing::span!(tracing::Level::INFO, "sync_projects_task");
tracing::debug!(
parent: &span,
"Syncing projects for profile {}",
profile_path_id
);
let res = async {
let _span = span.enter();
let state = State::get().await?;
let profile = crate::api::profile::get(&profile_path_id, None).await?;
if let Some(profile) = profile {
let paths = profile.get_profile_full_project_paths().await?;
if profile.install_stage != ProfileInstallStage::PackInstalling || force {
let paths = profile.get_profile_full_project_paths().await?;
let caches_dir = state.directories.caches_dir();
let creds = state.credentials.read().await;
let projects = crate::state::infer_data_from_files(
profile.clone(),
paths,
caches_dir,
&state.io_semaphore,
&state.fetch_semaphore,
&creds,
)
.await?;
drop(creds);
let caches_dir = state.directories.caches_dir();
let creds = state.credentials.read().await;
let projects = crate::state::infer_data_from_files(
profile.clone(),
paths,
caches_dir,
&state.io_semaphore,
&state.fetch_semaphore,
&creds,
)
.await?;
drop(creds);
let mut new_profiles = state.profiles.write().await;
if let Some(profile) = new_profiles.0.get_mut(&profile_path_id) {
profile.projects = projects;
let mut new_profiles = state.profiles.write().await;
if let Some(profile) = new_profiles.0.get_mut(&profile_path_id) {
profile.projects = projects;
}
emit_profile(
profile.uuid,
&profile_path_id,
&profile.metadata.name,
ProfilePayloadType::Synced,
)
.await?;
}
emit_profile(
profile.uuid,
&profile_path_id,
&profile.metadata.name,
ProfilePayloadType::Synced,
)
.await?;
} else {
tracing::warn!(
"Unable to fetch single profile projects: path {profile_path_id} invalid",
@@ -832,12 +839,17 @@ impl Profiles {
drop(creds);
// Versions are pre-sorted in labrinth (by versions.sort_by(|a, b| b.inner.date_published.cmp(&a.inner.date_published));)
// so we can just take the first one
// so we can just take the first one for which the loader matches
let mut new_profiles = state.profiles.write().await;
if let Some(profile) =
new_profiles.0.get_mut(&profile_path)
{
if let Some(recent_version) = versions.get(0) {
let loader = profile.metadata.loader;
let recent_version = versions.iter().find(|x| {
x.loaders
.contains(&loader.as_api_str().to_string())
});
if let Some(recent_version) = recent_version {
profile.modrinth_update_version =
Some(recent_version.id.clone());
} else {
@@ -871,7 +883,11 @@ impl Profiles {
#[tracing::instrument(skip(self, profile))]
#[theseus_macros::debug_pin]
pub async fn insert(&mut self, profile: Profile) -> crate::Result<&Self> {
pub async fn insert(
&mut self,
profile: Profile,
no_watch: bool,
) -> crate::Result<&Self> {
emit_profile(
profile.uuid,
&profile.profile_id(),
@@ -880,13 +896,15 @@ impl Profiles {
)
.await?;
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;
Profile::watch_fs(
&profile.get_profile_full_path().await?,
&mut file_watcher,
)
.await?;
if !no_watch {
let state = State::get().await?;
let mut file_watcher = state.file_watcher.write().await;
Profile::watch_fs(
&profile.get_profile_full_path().await?,
&mut file_watcher,
)
.await?;
}
let profile_name = profile.profile_id();
profile_name.check_valid_utf()?;
@@ -978,9 +996,10 @@ impl Profiles {
dirs,
)
.await?,
false,
)
.await?;
Profile::sync_projects_task(profile_path_id);
Profile::sync_projects_task(profile_path_id, false);
}
Ok::<(), crate::Error>(())
}

View File

@@ -281,7 +281,6 @@ pub async fn infer_data_from_files(
) -> crate::Result<HashMap<ProjectPathId, Project>> {
let mut file_path_hashes = HashMap::new();
// TODO: Make this concurrent and use progressive hashing to avoid loading each JAR in memory
for path in paths {
if !path.exists() {
continue;
@@ -297,10 +296,19 @@ pub async fn infer_data_from_files(
.await
.map_err(|e| IOError::with_path(e, &path))?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer).await.map_err(IOError::from)?;
let mut buffer = [0u8; 4096]; // Buffer to read chunks
let mut hasher = sha2::Sha512::new(); // Hasher
let hash = format!("{:x}", sha2::Sha512::digest(&buffer));
loop {
let bytes_read =
file.read(&mut buffer).await.map_err(IOError::from)?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
let hash = format!("{:x}", hasher.finalize());
file_path_hashes.insert(hash, path.clone());
}

View File

@@ -213,18 +213,41 @@ pub async fn fetch_mirrors(
}
/// Using labrinth API, checks if an internet response can be found, with a timeout in seconds
#[tracing::instrument(skip(semaphore))]
#[tracing::instrument]
#[theseus_macros::debug_pin]
pub async fn check_internet(semaphore: &FetchSemaphore, timeout: u64) -> bool {
let result = fetch(
"https://api.modrinth.com",
None,
semaphore,
&CredentialsStore(None),
);
let result =
tokio::time::timeout(Duration::from_secs(timeout), result).await;
matches!(result, Ok(Ok(_)))
pub async fn check_internet(timeout: u64) -> bool {
REQWEST_CLIENT
.get("https://launcher-files.modrinth.com/detect.txt")
.timeout(Duration::from_secs(timeout))
.send()
.await
.is_ok()
}
/// Posts a JSON to a URL
#[tracing::instrument(skip(json_body, semaphore))]
#[theseus_macros::debug_pin]
pub async fn post_json<T>(
url: &str,
json_body: serde_json::Value,
semaphore: &FetchSemaphore,
credentials: &CredentialsStore,
) -> crate::Result<T>
where
T: DeserializeOwned,
{
let io_semaphore = semaphore.0.read().await;
let _permit = io_semaphore.acquire().await?;
let mut req = REQWEST_CLIENT.post(url).json(&json_body);
if let Some(creds) = &credentials.0 {
req = req.header("Authorization", &creds.session);
}
let result = req.send().await?.error_for_status()?;
let value = result.json().await?;
Ok(value)
}
pub async fn read_json<T>(

View File

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

View File

@@ -196,6 +196,7 @@ impl ProfileInit {
None,
None,
None,
None,
)
.await?;

View File

@@ -1,7 +1,7 @@
{
"name": "theseus_gui",
"private": true,
"version": "0.4.0",
"version": "0.5.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -18,7 +18,7 @@
"floating-vue": "^2.0.0-beta.20",
"mixpanel-browser": "^2.47.0",
"ofetch": "^1.0.1",
"omorphia": "^0.4.34",
"omorphia": "^0.4.38",
"pinia": "^2.1.3",
"qrcode.vue": "^3.4.0",
"tauri-plugin-window-state-api": "github:tauri-apps/tauri-plugin-window-state#v1",

View File

@@ -21,8 +21,8 @@ dependencies:
specifier: ^1.0.1
version: 1.0.1
omorphia:
specifier: ^0.4.34
version: 0.4.34
specifier: ^0.4.38
version: 0.4.38
pinia:
specifier: ^2.1.3
version: 2.1.3(vue@3.3.4)
@@ -1348,8 +1348,8 @@ packages:
ufo: 1.1.2
dev: false
/omorphia@0.4.34:
resolution: {integrity: sha512-6uAH1kgzbYYmJDM41Vy4/MhzT9kRj+s1t8IknHKeOQqmVft+wPtv/pbA7pqTMfCzBOarLKKO5s4sNlz8TeMmaQ==}
/omorphia@0.4.38:
resolution: {integrity: sha512-V0vEarmAart6Gf5WuPUZ58TuIiQf7rI5HJpmYU7FVbtdvZ3q08VqyKZflCddbeBSFQ4/N+A+sNr/ELf/jz+Cug==}
dependencies:
dayjs: 1.11.7
floating-vue: 2.0.0-beta.20(vue@3.3.4)

View File

@@ -1,6 +1,6 @@
[package]
name = "theseus_gui"
version = "0.4.0"
version = "0.5.1"
description = "A Tauri App"
authors = ["you"]
license = ""

View File

@@ -24,7 +24,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
profile_remove_project,
profile_update_managed_modrinth,
profile_repair_managed_modrinth,
profile_is_managed_modrinth,
profile_run,
profile_run_wait,
profile_run_credentials,
@@ -190,12 +189,6 @@ pub async fn profile_repair_managed_modrinth(
Ok(profile::update::repair_managed_modrinth(&path).await?)
}
// Gets if a profile is managed by Modrinth
#[tauri::command]
pub async fn profile_is_managed_modrinth(path: ProfilePathId) -> Result<bool> {
Ok(profile::is_managed_modrinth_pack(&path).await?)
}
// Exports a profile to a .mrpack file (export_location should end in .mrpack)
// invoke('profile_export_mrpack')
#[tauri::command]

View File

@@ -17,6 +17,7 @@ pub async fn profile_create(
modloader: ModLoader, // the modloader to use
loader_version: Option<String>, // the modloader version to use, set to "latest", "stable", or the ID of your chosen loader
icon: Option<PathBuf>, // the icon for the profile
no_watch: Option<bool>,
) -> Result<ProfilePathId> {
let res = profile::create::profile_create(
name,
@@ -27,6 +28,7 @@ pub async fn profile_create(
None,
None,
None,
no_watch,
)
.await?;
Ok(res)

View File

@@ -142,7 +142,7 @@ fn main() {
.invoke_handler(tauri::generate_handler![
initialize_state,
is_dev,
toggle_decorations
toggle_decorations,
]);
builder

View File

@@ -8,7 +8,7 @@
},
"package": {
"productName": "Modrinth App",
"version": "0.4.0"
"version": "0.5.1"
},
"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; font-src https://cdn-raw.modrinth.com/fonts/inter/; img-src tauri: https: data: blob: 'unsafe-inline' asset: https://asset.localhost"
"csp": "default-src 'self'; connect-src https://modrinth.com https://*.modrinth.com https://mixpanel.com https://*.mixpanel.com https://*.cloudflare.com; 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 'self'; style-src unsafe-inline 'self'"
},
"updater": {
"active": true,
@@ -96,11 +96,11 @@
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"fullscreen": false,
"height": 650,
"height": 800,
"resizable": true,
"title": "Modrinth App",
"width": 1280,
"minHeight": 630,
"minHeight": 700,
"minWidth": 1100,
"visible": false,
"decorations": false

View File

@@ -5,11 +5,11 @@
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"fullscreen": false,
"height": 650,
"height": 800,
"resizable": true,
"title": "Modrinth App",
"width": 1280,
"minHeight": 630,
"minHeight": 700,
"minWidth": 1100,
"visible": false,
"decorations": true

View File

@@ -177,6 +177,20 @@ document.querySelector('body').addEventListener('click', function (e) {
}
})
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)
command_listener((e) => {
@@ -204,10 +218,11 @@ command_listener((e) => {
<AccountsCard ref="accounts" mode="small" />
</suspense>
<div class="pages-list">
<RouterLink to="/" class="btn icon-only collapsed-button">
<RouterLink v-tooltip="'Home'" to="/" class="btn icon-only collapsed-button">
<HomeIcon />
</RouterLink>
<RouterLink
v-tooltip="'Browse'"
to="/browse/modpack"
class="btn icon-only collapsed-button"
:class="{
@@ -216,7 +231,7 @@ command_listener((e) => {
>
<SearchIcon />
</RouterLink>
<RouterLink to="/library" class="btn icon-only collapsed-button">
<RouterLink v-tooltip="'Library'" to="/library" class="btn icon-only collapsed-button">
<LibraryIcon />
</RouterLink>
<Suspense>
@@ -226,6 +241,7 @@ command_listener((e) => {
</div>
<div class="settings pages-list">
<Button
v-tooltip="'Create profile'"
class="sleek-primary collapsed-button"
icon-only
:disabled="offline"
@@ -233,7 +249,7 @@ command_listener((e) => {
>
<PlusIcon />
</Button>
<RouterLink to="/settings" class="btn icon-only collapsed-button">
<RouterLink v-tooltip="'Settings'" to="/settings" class="btn icon-only collapsed-button">
<SettingsIcon />
</RouterLink>
</div>
@@ -246,7 +262,7 @@ command_listener((e) => {
</section>
<section class="mod-stats">
<Suspense>
<RunningAppBar data-tauri-drag-region />
<RunningAppBar />
</Suspense>
</section>
</div>
@@ -276,7 +292,7 @@ command_listener((e) => {
offset-height="var(--appbar-height)"
offset-width="var(--sidebar-width)"
/>
<RouterView v-slot="{ Component }" class="main-view">
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loading.startLoading()" @resolve="loading.stopLoading()">
<component :is="Component"></component>

View File

@@ -1,6 +1,5 @@
<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;"

Before

Width:  |  Height:  |  Size: 1.6 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-bug"><rect width="8" height="14" x="8" y="6" rx="4"/><path d="m19 7-3 2"/><path d="m5 7 3 2"/><path d="m19 19-3-2"/><path d="m5 19 3-2"/><path d="M20 13h-4"/><path d="M4 13h4"/><path d="m10 4 1 2"/><path d="m14 4-1 2"/></svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -10,3 +10,5 @@ export { default as TextInputIcon } from './text-cursor-input.svg'
export { default as AddProjectImage } from './add-project.svg'
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'

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-messages-square"><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2v5Z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/></svg>

After

Width:  |  Height:  |  Size: 359 B

View File

@@ -128,3 +128,10 @@ input {
background-color: var(--color-raised-bg);
box-shadow: none !important;
}
img {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}

View File

@@ -200,6 +200,25 @@ const filteredResults = computed(() => {
return instanceMap.set('None', instances)
}
// For 'name', we intuitively expect the sorting to apply to the name of the group first, not just the name of the instance
// ie: Category A should come before B, even if the first instance in B comes before the first instance in A
if (sortBy.value === 'Name') {
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
// None should always be first
if (a[0] === 'None' && b[0] !== 'None') {
return -1
}
if (a[0] !== 'None' && b[0] === 'None') {
return 1
}
return a[0].localeCompare(b[0])
})
instanceMap.clear()
sortedEntries.forEach((entry) => {
instanceMap.set(entry[0], entry[1])
})
}
return instanceMap
})
</script>
@@ -226,6 +245,7 @@ const filteredResults = computed(() => {
<DropdownSelect
v-model="sortBy"
class="sort-dropdown"
name="Sort Dropdown"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
placeholder="Select..."
/>
@@ -235,6 +255,7 @@ const filteredResults = computed(() => {
<DropdownSelect
v-model="filters"
class="filter-dropdown"
name="Filter Dropdown"
:options="['All profiles', 'Custom instances', 'Downloaded modpacks']"
placeholder="Select..."
/>
@@ -244,6 +265,7 @@ const filteredResults = computed(() => {
<DropdownSelect
v-model="group"
class="group-dropdown"
name="Group Dropdown"
:options="['Category', 'Loader', 'Game version', 'None']"
placeholder="Select..."
/>
@@ -265,7 +287,7 @@ const filteredResults = computed(() => {
<Instance
v-for="(instance, index) in instanceSection.value"
ref="instanceComponents"
:key="instance.id"
:key="instance.path"
:instance="instance"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instanceComponents[index])"
/>

View File

@@ -2,6 +2,7 @@
<div
v-if="mode !== 'isolated'"
ref="button"
v-tooltip="'Minecraft accounts'"
class="button-base avatar-button"
:class="{ expanded: mode === 'expanded' }"
@click="showCard = !showCard"
@@ -11,18 +12,9 @@
:src="
selectedAccount
? `https://mc-heads.net/avatar/${selectedAccount.id}/128`
: 'https://cdn.discordapp.com/attachments/817413688771608587/1129829843425570867/unnamed.png'
: 'https://launcher-files.modrinth.com/assets/steve_head.png'
"
/>
<div v-show="mode === 'expanded'" class="avatar-text">
<div class="text no-select">
{{ selectedAccount ? selectedAccount.username : 'Offline' }}
</div>
<p class="accounts-text no-select">
<UsersIcon />
Accounts
</p>
</div>
</div>
<transition name="fade">
<Card
@@ -64,10 +56,50 @@
</Button>
</Card>
</transition>
<Modal ref="loginModal" class="modal" header="Signing in">
<div class="modal-body">
<QrcodeVue :value="loginUrl" class="qr-code" margin="3" size="160" />
<div class="modal-text">
<p>
Sign into Microsoft with your browser. If your browser didn't open, you can copy and open
the link below, or scan the QR code with your device.
</p>
<div class="iconified-input">
<LogInIcon />
<input type="text" :value="loginUrl" readonly />
<Button
v-tooltip="'Copy link'"
icon-only
color="raised"
@click="() => navigator.clipboard.writeText(loginUrl)"
>
<ClipboardCopyIcon />
</Button>
</div>
<div class="button-row">
<Button @click="openUrl">
<GlobeIcon />
Open link
</Button>
<Button class="transparent" @click="loginModal.hide"> Cancel </Button>
</div>
</div>
</div>
</Modal>
</template>
<script setup>
import { Avatar, Button, Card, PlusIcon, TrashIcon, UsersIcon, LogInIcon } from 'omorphia'
import {
Avatar,
Button,
Card,
PlusIcon,
TrashIcon,
LogInIcon,
Modal,
GlobeIcon,
ClipboardCopyIcon,
} from 'omorphia'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import {
users,
@@ -76,10 +108,10 @@ import {
authenticate_await_completion,
} from '@/helpers/auth'
import { get, set } from '@/helpers/settings'
import { WebviewWindow } from '@tauri-apps/api/window'
import { handleError } from '@/store/state.js'
import { get as getCreds, login_minecraft } from '@/helpers/mr_auth'
import { mixpanel_track } from '@/helpers/mixpanel'
import QrcodeVue from 'qrcode.vue'
defineProps({
mode: {
@@ -93,6 +125,9 @@ const emit = defineEmits(['change'])
const settings = ref({})
const accounts = ref([])
const loginUrl = ref('')
const loginModal = ref(null)
async function refreshValues() {
settings.value = await get().catch(handleError)
accounts.value = await users().catch(handleError)
@@ -118,12 +153,18 @@ async function setAccount(account) {
async function login() {
const url = await authenticate_begin_flow().catch(handleError)
loginUrl.value = url
const window = new WebviewWindow('loginWindow', {
title: 'Modrinth App',
url: url,
await window.__TAURI_INVOKE__('tauri', {
__tauriModule: 'Shell',
message: {
cmd: 'open',
path: url,
},
})
loginModal.value.show()
const loggedIn = await authenticate_await_completion().catch(handleError)
if (loggedIn && loggedIn[0]) {
@@ -139,7 +180,8 @@ async function login() {
}
}
}
await window.close()
loginModal.value.hide()
mixpanel_track('AccountLogIn')
}
@@ -212,7 +254,7 @@ onBeforeUnmount(() => {
flex-direction: column;
top: 0.5rem;
left: 5.5rem;
z-index: 9;
z-index: 11;
gap: 0.5rem;
padding: 1rem;
border: 1px solid var(--color-button-bg);
@@ -220,6 +262,18 @@ onBeforeUnmount(() => {
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
max-height: 98vh;
overflow-y: auto;
&::-webkit-scrollbar-track {
border-top-right-radius: 1rem;
border-bottom-right-radius: 1rem;
}
&::-webkit-scrollbar {
border-top-right-radius: 1rem;
border-bottom-right-radius: 1rem;
}
&.hidden {
display: none;
@@ -317,4 +371,37 @@ onBeforeUnmount(() => {
gap: 0.25rem;
margin: 0;
}
.qr-code {
background-color: white !important;
border-radius: var(--radius-md);
}
.modal-body {
display: flex;
flex-direction: row;
gap: var(--gap-lg);
align-items: center;
padding: var(--gap-lg);
.modal-text {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
h2,
p {
margin: 0;
}
}
}
.button-row {
display: flex;
flex-direction: row;
}
.modal {
position: absolute;
}
</style>

View File

@@ -1,5 +1,11 @@
<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">
<router-link
@@ -25,7 +31,7 @@
</template>
<script setup>
import { ChevronRightIcon } from 'omorphia'
import { ChevronRightIcon, Button, ChevronLeftIcon } from 'omorphia'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useRoute } from 'vue-router'
import { computed } from 'vue'
@@ -60,6 +66,18 @@ const breadcrumbs = computed(() => {
margin: auto 0;
}
}
.breadcrumbs__back,
.breadcrumbs__forward {
margin: auto 0;
color: var(--color-base);
height: unset;
width: unset;
}
.breadcrumbs__forward {
margin-right: 1rem;
}
}
.selected {

View File

@@ -188,6 +188,8 @@ const exportPack = async () => {
}
.select-checkbox {
gap: var(--gap-sm);
button.checkbox {
border: none;
}

View File

@@ -25,6 +25,7 @@
v-model="selectedVersion"
:options="versions"
placeholder="Select version"
name="Version select"
:display-name="
(version) =>
`${version?.name} (${version?.loaders
@@ -165,5 +166,9 @@ td:first-child {
flex-direction: column;
gap: 1rem;
padding: 1rem;
:deep(.animated-dropdown .options) {
max-height: 13.375rem;
}
}
</style>

View File

@@ -181,6 +181,10 @@
: 'Select profiles to import'
}}
</Button>
<ProgressBar
v-if="loading"
:progress="(importedProfiles / (totalProfiles + 0.0001)) * 100"
/>
</div>
</div>
</Modal>
@@ -224,6 +228,7 @@ import {
get_importable_instances,
import_instance,
} from '@/helpers/import.js'
import ProgressBar from '@/components/ui/ProgressBar.vue'
const themeStore = useTheming()
@@ -420,6 +425,8 @@ const profiles = ref(
)
const loading = ref(false)
const importedProfiles = ref(0)
const totalProfiles = ref(0)
const selectedProfileType = ref('MultiMC')
const profileOptions = ref([
@@ -480,6 +487,10 @@ const setPath = () => {
}
const next = async () => {
importedProfiles.value = 0
totalProfiles.value = Array.from(profiles.value.values())
.map((profiles) => profiles.filter((profile) => profile.selected).length)
.reduce((a, b) => a + b, 0)
loading.value = true
for (const launcher of Array.from(profiles.value.entries()).map(([launcher, profiles]) => ({
launcher,
@@ -491,6 +502,7 @@ const next = async () => {
.catch(handleError)
.then(() => console.log(`Successfully Imported ${profile.name} from ${launcher.launcher}`))
profile.selected = false
importedProfiles.value++
}
}
loading.value = false
@@ -628,6 +640,7 @@ const next = async () => {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--gap-md);
.transparent {

View File

@@ -183,7 +183,7 @@ const createInstance = async () => {
await router.push(`/instance/${encodeURIComponent(id)}/`)
const instance = await get(id, true)
await installVersionDependencies(instance, versions.value)
await installVersionDependencies(instance, versions.value[0])
mixpanel_track('InstanceCreate', {
profile_name: name.value,
@@ -204,7 +204,7 @@ const createInstance = async () => {
source: 'ProjectInstallModal',
})
installModal.value.hide()
if (installModal.value) installModal.value.hide()
creatingInstance.value = false
}

View File

@@ -119,7 +119,7 @@ const install = async (e) => {
'background-image': `url(${
project.featured_gallery ??
project.gallery[0] ??
'https://cdn.discordapp.com/attachments/817413688771608587/1119143634319724564/image.png'
'https://launcher-files.modrinth.com/assets/maze-bg.png'
})`,
'no-image': !project.featured_gallery && !project.gallery[0],
}"

View File

@@ -1,5 +1,9 @@
<template>
<div class="action-groups">
<a href="https://discord.gg/modrinth" class="link">
<ChatIcon />
<span> Get support </span>
</a>
<Button
v-if="currentLoadingBars.length > 0"
ref="infoButton"
@@ -120,6 +124,7 @@ 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)
@@ -266,7 +271,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
gap: var(--gap-md);
}
.arrow {
@@ -452,4 +457,14 @@ onBeforeUnmount(() => {
transform: translateY(-100%);
}
}
.link {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
margin: 0;
color: var(--color-text);
text-decoration: none;
}
</style>

View File

@@ -1,17 +1,12 @@
<template>
<div ref="button" class="button-base avatar-button" :class="{ highlighted: showDemo }">
<Avatar
src="https://cdn.discordapp.com/attachments/817413688771608587/1129829843425570867/unnamed.png"
/>
<Avatar src="https://launcher-files.modrinth.com/assets/steve_head.png" />
</div>
<transition name="fade">
<div v-if="showDemo" class="card-section">
<Card ref="card" class="fake-account-card expanded highlighted">
<div class="selected account">
<Avatar
size="xs"
src="https://cdn.discordapp.com/attachments/817413688771608587/1129829843425570867/unnamed.png"
/>
<Avatar size="xs" src="https://launcher-files.modrinth.com/assets/steve_head.png" />
<div>
<h4>Modrinth</h4>
<p>Selected</p>

View File

@@ -1,5 +1,9 @@
<template>
<div class="action-groups">
<Button color="danger" outline @click="exit">
<LogOutIcon />
Exit tutorial
</Button>
<Button v-if="showDownload" ref="infoButton" icon-only class="icon-button show-card-icon">
<DownloadIcon />
</Button>
@@ -36,7 +40,14 @@
</template>
<script setup>
import { Button, DownloadIcon, Card, StopCircleIcon, TerminalSquareIcon } from 'omorphia'
import {
Button,
DownloadIcon,
Card,
StopCircleIcon,
TerminalSquareIcon,
LogOutIcon,
} from 'omorphia'
import ProgressBar from '@/components/ui/ProgressBar.vue'
defineProps({
@@ -48,6 +59,10 @@ defineProps({
type: Boolean,
default: false,
},
exit: {
type: Function,
required: true,
},
})
</script>

View File

@@ -32,6 +32,7 @@ defineProps({
<DropdownSelect
v-model="sortBy"
class="sort-dropdown"
name="Sort Dropdown"
:options="['Name', 'Last played', 'Date created', 'Date modified', 'Game version']"
placeholder="Select..."
/>
@@ -41,6 +42,7 @@ defineProps({
<DropdownSelect
v-model="filters"
class="filter-dropdown"
name="Filter Dropdown"
:options="['All profiles', 'Custom instances', 'Downloaded modpacks']"
placeholder="Select..."
/>
@@ -50,6 +52,7 @@ defineProps({
<DropdownSelect
v-model="group"
class="group-dropdown"
name="Group dropdown"
:options="['Category', 'Loader', 'Game version', 'None']"
placeholder="Select..."
/>
@@ -65,7 +68,7 @@ defineProps({
>
<Avatar
size="sm"
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
src="https://launcher-files.modrinth.com/assets/default_profile.png"
alt="Mod card"
class="mod-image"
/>

View File

@@ -63,7 +63,7 @@ defineProps({
>
<Avatar
size="sm"
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
src="https://launcher-files.modrinth.com/assets/default_profile.png"
alt="Mod card"
class="mod-image"
/>
@@ -79,7 +79,7 @@ defineProps({
<div
class="banner no-image"
:style="{
'background-image': `url(https://cdn.discordapp.com/attachments/817413688771608587/1119143634319724564/image.png)`,
'background-image': `url(https://launcher-files.modrinth.com/assets/maze-bg.png)`,
}"
>
<div class="badges">
@@ -111,7 +111,7 @@ defineProps({
<Avatar
class="icon"
size="sm"
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
src="https://launcher-files.modrinth.com/assets/default_profile.png"
/>
<div class="title">
<div class="title-text">Example Project</div>

View File

@@ -176,7 +176,7 @@ defineProps({
</Card>
</aside>
<div ref="searchWrapper" class="search">
<Promotion class="promotion" />
<Promotion class="promotion" query-param="?r=launcher" />
<Card class="project-type-container">
<NavRow :links="selectableProjectTypes" />
</Card>
@@ -220,7 +220,7 @@ defineProps({
<Card v-for="project in 20" :key="project" class="search-card button-base">
<div class="icon">
<Avatar
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
src="https://launcher-files.modrinth.com/assets/default_profile.png"
size="md"
class="search-icon"
/>

View File

@@ -57,7 +57,7 @@ const openUrl = async () => {
<template>
<div class="login-card">
<img
src="https://cdn.discordapp.com/attachments/1115781524047020123/1119319322028949544/Modrinth_icon.png"
src="https://launcher-files.modrinth.com/assets/default_profile.png"
class="logo"
alt="Minecraft art"
/>

View File

@@ -211,7 +211,7 @@ onMounted(() => {
<div class="link-row">
<a v-if="loggingIn" class="button-base" @click="loggingIn = false"> Create account </a>
<a v-else class="button-base" @click="loggingIn = true">Sign in</a>
<a class="button-base" href="https://staging.modrinth.com/auth/reset-password">
<a class="button-base" href="https://modrinth.com/auth/reset-password">
Forgot password?
</a>
</div>

View File

@@ -8,7 +8,6 @@ import {
SettingsIcon,
XIcon,
Notifications,
LogOutIcon,
} from 'omorphia'
import { appWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from 'tauri-plugin-window-state-api'
@@ -83,15 +82,18 @@ const finishOnboarding = async () => {
async function fetchSettings() {
const fetchSettings = await get().catch(handleError)
if (!fetchSettings.java_globals) {
fetchSettings.java_globals = {}
}
if (!fetchSettings.java_globals.JAVA_17) {
const path = await auto_install_java(17).catch(handleError)
fetchSettings.java_globals.JAVA_17 = await get_jre(path).catch(handleError)
const path1 = await auto_install_java(17).catch(handleError)
fetchSettings.java_globals.JAVA_17 = await get_jre(path1).catch(handleError)
}
if (!fetchSettings.java_globals.JAVA_8) {
const path = await auto_install_java(8).catch(handleError)
fetchSettings.java_globals.JAVA_8 = await get_jre(path).catch(handleError)
const path2 = await auto_install_java(8).catch(handleError)
fetchSettings.java_globals.JAVA_8 = await get_jre(path2).catch(handleError)
}
await set(fetchSettings).catch(handleError)
@@ -157,7 +159,10 @@ onMounted(async () => {
<div class="btn icon-only" :class="{ active: phase < 4 }">
<HomeIcon />
</div>
<div class="btn icon-only" :class="{ active: phase === 4 || phase === 5 }">
<div
class="btn icon-only"
:class="{ active: phase === 4 || phase === 5, highlighted: phase === 4 }"
>
<SearchIcon />
</div>
<div
@@ -172,9 +177,6 @@ onMounted(async () => {
</div>
</div>
<div class="settings pages-list">
<Button class="active" icon-only @click="finishOnboarding">
<LogOutIcon />
</Button>
<Button class="sleek-primary" icon-only>
<PlusIcon />
</Button>
@@ -189,7 +191,11 @@ onMounted(async () => {
<Breadcrumbs data-tauri-drag-region />
</section>
<section class="mod-stats">
<FakeAppBar :show-running="phase === 7" :show-download="phase === 5">
<FakeAppBar
:show-running="phase === 7"
:show-download="phase === 5"
:exit="finishOnboarding"
>
<template #running>
<TutorialTip
:progress-function="nextPhase"
@@ -427,12 +433,6 @@ onMounted(async () => {
background-color: var(--color-brand-highlight);
transition: all ease-in-out 0.1s;
}
&.sleek-exit {
background-color: var(--color-red);
color: var(--color-accent-contrast);
transition: all ease-in-out 0.1s;
}
}
}

View File

@@ -2,7 +2,7 @@ import { ofetch } from 'ofetch'
import { handleError } from '@/store/state.js'
import { getVersion } from '@tauri-apps/api/app'
export const useFetch = async (url, item) => {
export const useFetch = async (url, item, isSilent) => {
try {
const version = await getVersion()
@@ -10,7 +10,9 @@ export const useFetch = async (url, item) => {
headers: { 'User-Agent': `modrinth/theseus/${version} (support@modrinth.com)` },
})
} catch (err) {
handleError({ message: `Error fetching ${item}` })
if (!isSilent) {
handleError({ message: `Error fetching ${item}` })
}
console.error(err)
}
}

View File

@@ -34,7 +34,9 @@ export async function get_importable_instances(launcherType, basePath) {
/// eg: import_instance("profile-name-to-go-to", "MultiMC", "C:/MultiMC", "Instance 1")
export async function import_instance(launcherType, basePath, instanceFolder) {
// create a basic, empty instance (most properties will be filled in by the import process)
const profilePath = await create(instanceFolder, '1.19.4', 'vanilla', 'latest', null)
// We do NOT watch the fs for changes to avoid duplicate events during installation
// fs watching will be enabled once the instance is imported
const profilePath = await create(instanceFolder, '1.19.4', 'vanilla', 'latest', null, true)
return await invoke('plugin:import|import_import_instance', {
profilePath,

View File

@@ -9,17 +9,23 @@ export async function get_game_versions() {
// Gets the fabric versions from daedalus
// Returns Manifest
export async function get_fabric_versions() {
return await invoke('plugin:metadata|metadata_get_fabric_versions')
const c = await invoke('plugin:metadata|metadata_get_fabric_versions')
console.log('Getting fabric versions', c)
return c
}
// Gets the forge versions from daedalus
// Returns Manifest
export async function get_forge_versions() {
return await invoke('plugin:metadata|metadata_get_forge_versions')
const c = await invoke('plugin:metadata|metadata_get_forge_versions')
console.log('Getting forge versions', c)
return c
}
// Gets the quilt versions from daedalus
// Returns Manifest
export async function get_quilt_versions() {
return await invoke('plugin:metadata|metadata_get_quilt_versions')
const c = await invoke('plugin:metadata|metadata_get_quilt_versions')
console.log('Getting quilt versions', c)
return c
}

View File

@@ -16,13 +16,14 @@ import { invoke } from '@tauri-apps/api/tauri'
- icon is a path to an image file, which will be copied into the profile directory
*/
export async function create(name, gameVersion, modloader, loaderVersion, icon) {
export async function create(name, gameVersion, modloader, loaderVersion, icon, noWatch) {
return await invoke('plugin:profile_create|profile_create', {
name,
gameVersion,
modloader,
loaderVersion,
icon,
noWatch,
})
}
@@ -110,11 +111,6 @@ export async function update_repair_modrinth(path) {
return await invoke('plugin:profile|profile_repair_managed_modrinth', { path })
}
// Gets whether a profile is managed by Modrinth
export async function is_managed_modrinth(path) {
return await invoke('plugin:profile|profile_is_managed_modrinth', { path })
}
// Export a profile to .mrpack
/// included_overrides is an array of paths to override folders to include (ie: 'mods', 'resource_packs')
// Version id is optional (ie: 1.1.5)

View File

@@ -29,7 +29,7 @@ import ModInstallModal from '@/components/ui/ModInstallModal.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import IncompatibilityWarningModal from '@/components/ui/IncompatibilityWarningModal.vue'
import { useFetch } from '@/helpers/fetch.js'
import { check_installed, get as getInstance } from '@/helpers/profile.js'
import { check_installed, get, get as getInstance } from '@/helpers/profile.js'
import { convertFileSrc } from '@tauri-apps/api/tauri'
import { isOffline } from '@/helpers/utils'
import { offline_listener } from '@/helpers/events'
@@ -56,6 +56,7 @@ const orFacets = ref([])
const selectedVersions = ref([])
const onlyOpenSource = ref(false)
const showSnapshots = ref(false)
const hideAlreadyInstalled = ref(false)
const selectedEnvironments = ref([])
const sortTypes = readonly([
{ display: 'Relevance', name: 'relevance' },
@@ -143,6 +144,9 @@ if (route.query.m) {
if (route.query.o) {
currentPage.value = Math.ceil(route.query.o / maxResults.value) + 1
}
if (route.query.ai) {
hideAlreadyInstalled.value = route.query.ai === 'true'
}
async function refreshSearch() {
const base = 'https://api.modrinth.com/v2/'
@@ -222,6 +226,16 @@ async function refreshSearch() {
])
}
if (hideAlreadyInstalled.value) {
const installedMods = await get(instanceContext.value.path, false).then((x) =>
Object.values(x.projects)
.filter((x) => x.metadata.project)
.map((x) => x.metadata.project.id)
)
installedMods.map((x) => [`project_id != ${x}`]).forEach((x) => formattedFacets.push(x))
console.log(`facets=${JSON.stringify(formattedFacets)}`)
}
params.push(`facets=${JSON.stringify(formattedFacets)}`)
}
const offset = (currentPage.value - 1) * maxResults.value
@@ -237,9 +251,16 @@ async function refreshSearch() {
let val = `${base}${url}`
const rawResults = await useFetch(val, 'search results')
let rawResults = await useFetch(val, 'search results', offline.value)
if (!rawResults) {
rawResults = {
hits: [],
total_hits: 0,
limit: 1,
}
}
if (instanceContext.value) {
for (let val of rawResults.hits) {
for (val of rawResults.hits) {
val.installed = await check_installed(instanceContext.value.path, val.project_id).then(
(x) => (val.installed = x)
)
@@ -255,7 +276,6 @@ async function onSearchChange(newPageNumber) {
return
}
await refreshSearch()
const obj = getSearchUrl((currentPage.value - 1) * maxResults.value, true)
// Only replace in router if the query is different
@@ -333,6 +353,10 @@ function getSearchUrl(offset, useObj) {
queryItems.push('il=true')
obj.il = true
}
if (hideAlreadyInstalled.value) {
queryItems.push('ai=true')
obj.ai = true
}
let url = `${route.path}`
@@ -510,7 +534,7 @@ onUnmounted(() => unlistenOffline())
</script>
<template>
<div v-if="!offline" ref="searchWrapper" class="search-container">
<div ref="searchWrapper" class="search-container">
<aside class="filter-panel">
<Card v-if="instanceContext" class="small-instance">
<router-link :to="`/instance/${encodeURIComponent(instanceContext.path)}`" class="instance">
@@ -549,6 +573,13 @@ onUnmounted(() => unlistenOffline())
@update:model-value="onSearchChangeToTop(1)"
@click.prevent.stop
/>
<Checkbox
v-model="hideAlreadyInstalled"
label="Hide already installed"
class="filter-checkbox"
@update:model-value="onSearchChangeToTop(1)"
@click.prevent.stop
/>
</Card>
<Card class="search-panel-card">
<Button
@@ -656,7 +687,7 @@ onUnmounted(() => unlistenOffline())
</Card>
</aside>
<div class="search">
<Promotion class="promotion" :external="false" />
<Promotion class="promotion" :external="false" query-param="?r=launcher" />
<Card class="project-type-container">
<NavRow :links="selectableProjectTypes" />
</Card>
@@ -704,7 +735,10 @@ onUnmounted(() => unlistenOffline())
class="pagination-before"
@switch-page="onSearchChange"
/>
<SplashScreen v-if="loading || offline" />
<SplashScreen v-if="loading" />
<section v-else-if="offline && results.total_hits === 0" class="offline">
You are currently offline. Connect to the internet to browse Modrinth!
</section>
<section v-else class="project-list display-mode--list instance-results" role="list">
<SearchCard
v-for="result in results.hits"
@@ -890,6 +924,11 @@ onUnmounted(() => unlistenOffline())
margin: 0 1rem 0.5rem 20.5rem;
width: calc(100% - 20.5rem);
.offline {
margin: 1rem;
text-align: center;
}
.loading {
margin: 2rem;
text-align: center;

View File

@@ -41,23 +41,31 @@ const getInstances = async () => {
const getFeaturedModpacks = async () => {
const response = await useFetch(
`https://api.modrinth.com/v2/search?facets=[["project_type:modpack"]]&limit=10&index=follows&filters=${filter.value}`,
'featured modpacks'
'featured modpacks',
offline.value
)
if (response) featuredModpacks.value = response.hits
if (response) {
featuredModpacks.value = response.hits
} else {
featuredModpacks.value = []
}
}
const getFeaturedMods = async () => {
const response = await useFetch(
'https://api.modrinth.com/v2/search?facets=[["project_type:mod"]]&limit=10&index=follows',
'featured mods'
'featured mods',
offline.value
)
if (response) featuredMods.value = response.hits
if (response) {
featuredMods.value = response.hits
} else {
featuredModpacks.value = []
}
}
await getInstances()
if (!offline.value) {
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
}
await Promise.all([getFeaturedModpacks(), getFeaturedMods()])
const unlistenProfile = await profile_listener(async (e) => {
await getInstances()

View File

@@ -35,12 +35,7 @@ onUnmounted(() => {
</script>
<template>
<GridDisplay
v-if="instances.length > 0"
label="Instances"
:instances="instances"
class="display"
/>
<GridDisplay v-if="instances.length > 0" label="Instances" :instances="instances" />
<div v-else class="no-instance">
<div class="icon">
<NewInstanceImage />
@@ -55,11 +50,6 @@ onUnmounted(() => {
</template>
<style lang="scss" scoped>
.display {
background-color: rgb(30, 31, 34);
min-height: 100%;
}
.no-instance {
display: flex;
flex-direction: column;

View File

@@ -1,13 +1,26 @@
<script setup>
import { ref, watch } from 'vue'
import { Card, Slider, DropdownSelect, Toggle, Modal, LogOutIcon, LogInIcon } from 'omorphia'
import {
Card,
Slider,
DropdownSelect,
Toggle,
Modal,
LogOutIcon,
LogInIcon,
Button,
BoxIcon,
FolderSearchIcon,
UpdatedIcon,
} from 'omorphia'
import { handleError, useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings'
import { 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'
const pageOptions = ['Home', 'Library']
@@ -24,6 +37,7 @@ fetchSettings.javaArgs = fetchSettings.custom_java_args.join(' ')
fetchSettings.envArgs = fetchSettings.custom_env_args.map((x) => x.join('=')).join(' ')
const settings = ref(fetchSettings)
const settingsDir = ref(settings.value.loaded_config_dir)
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
watch(
@@ -91,6 +105,19 @@ async function signInAfter() {
loginScreenModal.value.hide()
credentials.value = await getCreds().catch(handleError)
}
async function findLauncherDir() {
const newDir = await open({ multiple: false, directory: true })
if (newDir) {
settingsDir.value = newDir
await refreshDir()
}
}
async function refreshDir() {
await change_config_dir(settingsDir.value)
}
</script>
<template>
@@ -98,7 +125,7 @@ async function signInAfter() {
<Card>
<div class="label">
<h3>
<span class="label__title size-card-header">Account</span>
<span class="label__title size-card-header">General settings</span>
</h3>
</div>
<Modal
@@ -125,6 +152,25 @@ async function signInAfter() {
<LogInIcon /> Sign in
</button>
</div>
<label for="theme">
<span class="label__title">App directory</span>
<span class="label__description">
The directory where the launcher stores all of its files.
</span>
</label>
<div class="app-directory">
<div class="iconified-input">
<BoxIcon />
<input id="appDir" v-model="settingsDir" type="text" class="input" />
<Button @click="findLauncherDir">
<FolderSearchIcon />
</Button>
</div>
<Button large @click="refreshDir">
<UpdatedIcon />
Refresh
</Button>
</div>
</Card>
<Card>
<div class="label">
@@ -262,10 +308,20 @@ async function signInAfter() {
<span class="label__title">Disable analytics</span>
<span class="label__description">
Modrinth collects anonymized analytics and usage data to improve our user experience and
customize your experience. Opting out will disable this data collection.
customize your experience. By enabling this option, you opt out and your data will no
longer be collected.
</span>
</label>
<Toggle id="opt-out-analytics" v-model="settings.opt_out_analytics" />
<Toggle
id="opt-out-analytics"
:model-value="settings.opt_out_analytics"
:checked="settings.opt_out_analytics"
@update:model-value="
(e) => {
settings.opt_out_analytics = e
}
"
/>
</div>
</Card>
<Card>
@@ -379,7 +435,7 @@ async function signInAfter() {
<label for="fullscreen">
<span class="label__title">Fullscreen</span>
<span class="label__description">
Overwrites the option.txt file to start in full screen when launched.
Overwrites the options.txt file to start in full screen when launched.
</span>
</label>
<Toggle
@@ -455,4 +511,19 @@ async function signInAfter() {
}
}
}
.app-directory {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--gap-sm);
.iconified-input {
flex-grow: 1;
input {
flex-basis: auto;
}
}
}
</style>

View File

@@ -72,17 +72,10 @@
Options
</RouterLink>
</div>
<hr class="card-divider" />
<div class="pages-list">
<Button class="transparent" @click="exportModal.show()">
<PackageIcon />
Export modpack
</Button>
</div>
</Card>
</div>
<div class="content">
<Promotion />
<Promotion query-param="?r=launcher" />
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loadingBar.startLoading()" @resolve="loadingBar.stopLoading()">
@@ -118,7 +111,6 @@
>
<template #filter_update><UpdatedIcon />Select Updatable</template>
</ContextMenu>
<ExportModal ref="exportModal" :instance="instance" />
</template>
<script setup>
import {
@@ -156,15 +148,12 @@ 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 { PackageIcon } from '@/assets/icons/index.js'
import ExportModal from '@/components/ui/ExportModal.vue'
import { convertFileSrc } from '@tauri-apps/api/tauri'
const route = useRoute()
const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const exportModal = ref(null)
const instance = ref(await get(route.params.id).catch(handleError))

View File

@@ -185,12 +185,21 @@ interval.value = setInterval(async () => {
if (logs.value.length > 0) {
logs.value[0] = await getLiveLog()
if (selectedLogIndex.value === 0 && !userScrolled.value) {
await nextTick()
isAutoScrolling.value = true
logContainer.value.scrollTop =
logContainer.value.scrollHeight - logContainer.value.offsetHeight
setTimeout(() => (isAutoScrolling.value = false), 50)
// Allow resetting of userScrolled if the user scrolls to the bottom
if (selectedLogIndex.value === 0) {
if (
logContainer.value.scrollTop + logContainer.value.offsetHeight >=
logContainer.value.scrollHeight - 10
)
userScrolled.value = false
if (!userScrolled.value) {
await nextTick()
isAutoScrolling.value = true
logContainer.value.scrollTop =
logContainer.value.scrollHeight - logContainer.value.offsetHeight
setTimeout(() => (isAutoScrolling.value = false), 50)
}
}
}
}, 250)

View File

@@ -1,13 +1,13 @@
<template>
<Card v-if="projects.length > 0" class="mod-card">
<div class="second-row">
<Chips
v-if="Object.keys(selectableProjectTypes).length > 1"
<div class="dropdown-input">
<DropdownSelect
v-model="selectedProjectType"
:items="Object.keys(selectableProjectTypes)"
:options="Object.keys(selectableProjectTypes)"
default-value="All"
name="project-type-dropdown"
color="primary"
/>
</div>
<div class="card-row">
<div class="iconified-input">
<SearchIcon />
<input
@@ -24,215 +24,253 @@
<XIcon />
</Button>
</div>
<span class="manage">
<DropdownButton
:options="['search', 'from_file']"
default-value="search"
name="add-content-dropdown"
color="primary"
@option-click="handleContentOptionClick"
>
<template #search>
<SearchIcon />
<span class="no-wrap"> Add content </span>
</template>
<template #from_file>
<FolderOpenIcon />
<span class="no-wrap"> Add from file </span>
</template>
</DropdownButton>
</span>
</div>
<div>
<div class="table">
<div class="table-row table-head" :class="{ 'show-options': selected.length > 0 }">
<div class="table-cell table-text">
<Checkbox v-model="selectAll" class="select-checkbox" />
</div>
<div v-if="selected.length === 0" class="table-cell table-text name-cell actions-cell">
<Button class="transparent" @click="sortProjects('Name')">
Name
<DropdownIcon v-if="sortColumn === 'Name'" :class="{ down: ascending }" />
</Button>
</div>
<div v-if="selected.length === 0" class="table-cell table-text">
<Button class="transparent" @click="sortProjects('Version')">
Version
<DropdownIcon v-if="sortColumn === 'Version'" :class="{ down: ascending }" />
</Button>
</div>
<div v-if="selected.length === 0" class="table-cell table-text actions-cell">
<Button class="transparent" @click="sortProjects('Enabled')">
Actions
<DropdownIcon v-if="sortColumn === 'Enabled'" :class="{ down: ascending }" />
</Button>
</div>
<div v-else class="options table-cell name-cell">
<Button
class="transparent share"
@click="() => (showingOptions = !showingOptions)"
@mouseover="selectedOption = 'Share'"
>
<MenuIcon :class="{ open: showingOptions }" />
</Button>
<Button
class="transparent share"
@click="shareNames()"
@mouseover="selectedOption = 'Share'"
>
<ShareIcon />
Share
</Button>
<Button
class="transparent trash"
@click="deleteWarning.show()"
@mouseover="selectedOption = 'Delete'"
>
<TrashIcon />
Delete
</Button>
<Button
class="transparent update"
:disabled="offline"
@click="updateAll()"
@mouseover="selectedOption = 'Update'"
>
<UpdatedIcon />
Update
</Button>
<Button
class="transparent"
@click="toggleSelected()"
@mouseover="selectedOption = 'Toggle'"
>
<ToggleIcon />
Toggle
</Button>
</div>
<Button
v-if="isPackLinked"
v-tooltip="'Modpack is up to date'"
:disabled="updatingModpack || !canUpdatePack"
color="secondary"
@click="updateModpack"
>
<UpdatedIcon />
{{ updatingModpack ? 'Updating' : 'Update modpack' }}
</Button>
<Button v-else @click="exportModal.show()">
<PackageIcon />
Export modpack
</Button>
<DropdownButton
v-if="!isPackLinked"
:options="['search', 'from_file']"
default-value="search"
name="add-content-dropdown"
color="primary"
@option-click="handleContentOptionClick"
>
<template #search>
<SearchIcon />
<span class="no-wrap"> Add content </span>
</template>
<template #from_file>
<FolderOpenIcon />
<span class="no-wrap"> Add from file </span>
</template>
</DropdownButton>
</Card>
<Pagination
v-if="projects.length > 0"
:page="currentPage"
:count="Math.ceil(search.length / 20)"
class="pagination-before"
:link-function="(page) => `?page=${page}`"
@switch-page="switchPage"
/>
<Card
v-if="projects.length > 0"
class="list-card"
:class="{ static: instance.metadata.linked_data }"
>
<div class="table">
<div class="table-row table-head" :class="{ 'show-options': selected.length > 0 }">
<div v-if="!instance.metadata.linked_data" class="table-cell table-text">
<Checkbox v-model="selectAll" class="select-checkbox" />
</div>
<div v-if="showingOptions && selected.length > 0" class="more-box">
<section v-if="selectedOption === 'Share'" class="options">
<Button class="transparent" @click="shareNames()">
<TextInputIcon />
Share names
</Button>
<Button class="transparent" @click="shareUrls()">
<GlobeIcon />
Share URLs
</Button>
<Button class="transparent" @click="shareFileNames()">
<FileIcon />
Share file names
</Button>
<Button class="transparent" @click="shareMarkdown()">
<CodeIcon />
Share as markdown
</Button>
</section>
<section v-if="selectedOption === 'Delete'" class="options">
<Button class="transparent" @click="deleteWarning.show()">
<TrashIcon />
Delete selected
</Button>
<Button class="transparent" @click="deleteDisabledWarning.show()">
<ToggleIcon />
Delete disabled
</Button>
</section>
<section v-if="selectedOption === 'Update'" class="options">
<Button class="transparent" :disabled="offline" @click="updateAll()">
<UpdatedIcon />
Update all
</Button>
<Button class="transparent" @click="selectUpdatable()">
<CheckIcon />
Select updatable
</Button>
</section>
<section v-if="selectedOption === 'Toggle'" class="options">
<Button class="transparent" @click="enableAll()">
<CheckIcon />
Toggle on
</Button>
<Button class="transparent" @click="disableAll()">
<XIcon />
Toggle off
</Button>
<Button class="transparent" @click="hideShowAll()">
<EyeIcon v-if="hideNonSelected" />
<EyeOffIcon v-else />
{{ hideNonSelected ? 'Show' : 'Hide' }} untoggled
</Button>
</section>
<div v-if="selected.length === 0" class="table-cell table-text name-cell actions-cell">
<Button class="transparent" @click="sortProjects('Name')">
Name
<DropdownIcon v-if="sortColumn === 'Name'" :class="{ down: ascending }" />
</Button>
</div>
<div
v-for="mod in search"
:key="mod.file_name"
class="table-row"
@contextmenu.prevent.stop="(c) => handleRightClick(c, mod)"
>
<div class="table-cell table-text">
<Checkbox
:model-value="selectionMap.get(mod.path)"
class="select-checkbox"
@update:model-value="(newValue) => selectionMap.set(mod.path, newValue)"
/>
</div>
<div class="table-cell table-text name-cell">
<router-link
v-if="mod.slug"
:to="{ path: `/project/${mod.slug}/`, query: { i: props.instance.path } }"
:disabled="offline"
class="mod-content"
>
<Avatar :src="mod.icon" />
<div v-tooltip="`${mod.name} by ${mod.author}`" class="mod-text">
<div class="title">{{ mod.name }}</div>
<span class="no-wrap">by {{ mod.author }}</span>
</div>
</router-link>
<div v-else class="mod-content">
<Avatar :src="mod.icon" />
<span v-tooltip="`${mod.name}`" class="title">{{ mod.name }}</span>
<div v-if="selected.length === 0" class="table-cell table-text version">
<Button class="transparent" @click="sortProjects('Version')">
Version
<DropdownIcon v-if="sortColumn === 'Version'" :class="{ down: ascending }" />
</Button>
</div>
<div v-if="selected.length === 0" class="table-cell table-text actions-cell">
<Button
v-if="!instance.metadata.linked_data"
class="transparent"
@click="sortProjects('Enabled')"
>
Actions
<DropdownIcon v-if="sortColumn === 'Enabled'" :class="{ down: ascending }" />
</Button>
</div>
<div v-else-if="!instance.metadata.linked_data" class="options table-cell name-cell">
<Button
class="transparent share"
@click="() => (showingOptions = !showingOptions)"
@mouseover="selectedOption = 'Share'"
>
<MenuIcon :class="{ open: showingOptions }" />
</Button>
<Button
class="transparent share"
@click="shareNames()"
@mouseover="selectedOption = 'Share'"
>
<ShareIcon />
Share
</Button>
<Button
class="transparent trash"
@click="deleteWarning.show()"
@mouseover="selectedOption = 'Delete'"
>
<TrashIcon />
Delete
</Button>
<Button
class="transparent update"
:disabled="offline"
@click="updateAll()"
@mouseover="selectedOption = 'Update'"
>
<UpdatedIcon />
Update
</Button>
<Button
class="transparent"
@click="toggleSelected()"
@mouseover="selectedOption = 'Toggle'"
>
<ToggleIcon />
Toggle
</Button>
</div>
</div>
<div
v-if="showingOptions && selected.length > 0 && !instance.metadata.linked_data"
class="more-box"
>
<section v-if="selectedOption === 'Share'" class="options">
<Button class="transparent" @click="shareNames()">
<TextInputIcon />
Share names
</Button>
<Button class="transparent" @click="shareUrls()">
<GlobeIcon />
Share URLs
</Button>
<Button class="transparent" @click="shareFileNames()">
<FileIcon />
Share file names
</Button>
<Button class="transparent" @click="shareMarkdown()">
<CodeIcon />
Share as markdown
</Button>
</section>
<section v-if="selectedOption === 'Delete'" class="options">
<Button class="transparent" @click="deleteWarning.show()">
<TrashIcon />
Delete selected
</Button>
<Button class="transparent" @click="deleteDisabledWarning.show()">
<ToggleIcon />
Delete disabled
</Button>
</section>
<section v-if="selectedOption === 'Update'" class="options">
<Button class="transparent" :disabled="offline" @click="updateAll()">
<UpdatedIcon />
Update all
</Button>
<Button class="transparent" @click="selectUpdatable()">
<CheckIcon />
Select updatable
</Button>
</section>
<section v-if="selectedOption === 'Toggle'" class="options">
<Button class="transparent" @click="enableAll()">
<CheckIcon />
Toggle on
</Button>
<Button class="transparent" @click="disableAll()">
<XIcon />
Toggle off
</Button>
<Button class="transparent" @click="hideShowAll()">
<EyeIcon v-if="hideNonSelected" />
<EyeOffIcon v-else />
{{ hideNonSelected ? 'Show' : 'Hide' }} untoggled
</Button>
</section>
</div>
<div
v-for="mod in search.slice((currentPage - 1) * 20, currentPage * 20)"
:key="mod.file_name"
class="table-row"
@contextmenu.prevent.stop="(c) => handleRightClick(c, mod)"
>
<div v-if="!instance.metadata.linked_data" class="table-cell table-text checkbox">
<Checkbox
:model-value="selectionMap.get(mod.path)"
class="select-checkbox"
@update:model-value="(newValue) => selectionMap.set(mod.path, newValue)"
/>
</div>
<div class="table-cell table-text name-cell">
<router-link
v-if="mod.slug"
:to="{ path: `/project/${mod.slug}/`, query: { i: props.instance.path } }"
:disabled="offline"
class="mod-content"
>
<Avatar :src="mod.icon" />
<div v-tooltip="`${mod.name} by ${mod.author}`" class="mod-text">
<div class="title">{{ mod.name }}</div>
<span class="no-wrap">by {{ mod.author }}</span>
</div>
</router-link>
<div v-else class="mod-content">
<Avatar :src="mod.icon" />
<span v-tooltip="`${mod.name}`" class="title">{{ mod.name }}</span>
</div>
<div class="table-cell table-text">
<span v-tooltip="`${mod.version}`">{{ mod.version }}</span>
</div>
<div class="table-cell table-text manage">
<Button v-tooltip="'Remove project'" icon-only @click="removeMod(mod)">
<TrashIcon />
</Button>
<AnimatedLogo
v-if="mod.updating"
class="btn icon-only updating-indicator"
></AnimatedLogo>
<Button
v-else
v-tooltip="'Update project'"
:disabled="!mod.outdated || offline"
icon-only
@click="updateProject(mod)"
>
<UpdatedIcon v-if="mod.outdated" />
<CheckIcon v-else />
</Button>
<input
id="switch-1"
autocomplete="off"
type="checkbox"
class="switch stylized-toggle"
:checked="!mod.disabled"
@change="toggleDisableMod(mod)"
/>
<Button
v-tooltip="`Show ${mod.file_name}`"
icon-only
@click="showProfileInFolder(mod.path)"
>
<FolderOpenIcon />
</Button>
</div>
</div>
<div class="table-cell table-text version">
<span v-tooltip="`${mod.version}`">{{ mod.version }}</span>
</div>
<div class="table-cell table-text manage">
<Button
v-if="!instance.metadata.linked_data"
v-tooltip="'Remove project'"
icon-only
@click="removeMod(mod)"
>
<TrashIcon />
</Button>
<AnimatedLogo
v-if="mod.updating && !instance.metadata.linked_data"
class="btn icon-only updating-indicator"
></AnimatedLogo>
<Button
v-else-if="!instance.metadata.linked_data"
v-tooltip="'Update project'"
:disabled="!mod.outdated || offline"
icon-only
@click="updateProject(mod)"
>
<UpdatedIcon v-if="mod.outdated" />
<CheckIcon v-else />
</Button>
<input
v-if="!instance.metadata.linked_data"
id="switch-1"
autocomplete="off"
type="checkbox"
class="switch stylized-toggle"
:checked="!mod.disabled"
@change="toggleDisableMod(mod)"
/>
<Button
v-tooltip="`Show ${mod.file_name}`"
icon-only
@click="showProfileInFolder(mod.path)"
>
<FolderOpenIcon />
</Button>
</div>
</div>
</div>
@@ -309,6 +347,7 @@
share-title="Sharing modpack content"
share-text="Check out the projects I'm using in my modpack!"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
</template>
<script setup>
import {
@@ -320,7 +359,6 @@ import {
SearchIcon,
UpdatedIcon,
AnimatedLogo,
Chips,
FolderOpenIcon,
Checkbox,
formatProjectType,
@@ -335,6 +373,8 @@ import {
EyeOffIcon,
ShareModal,
CodeIcon,
Pagination,
DropdownSelect,
} from 'omorphia'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
@@ -344,6 +384,7 @@ import {
remove_project,
toggle_disable_project,
update_all,
update_managed_modrinth,
update_project,
} from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
@@ -352,7 +393,8 @@ import { open } from '@tauri-apps/api/dialog'
import { listen } from '@tauri-apps/api/event'
import { convertFileSrc } from '@tauri-apps/api/tauri'
import { showProfileInFolder } from '@/helpers/utils.js'
import { MenuIcon, ToggleIcon, TextInputIcon, AddProjectImage } from '@/assets/icons'
import { MenuIcon, ToggleIcon, TextInputIcon, AddProjectImage, PackageIcon } from '@/assets/icons'
import ExportModal from '@/components/ui/ExportModal.vue'
const router = useRouter()
@@ -380,7 +422,15 @@ const props = defineProps({
const projects = ref([])
const selectionMap = ref(new Map())
const showingOptions = ref(false)
const isPackLinked = computed(() => {
return props.instance.metadata.linked_data
})
const canUpdatePack = computed(() => {
return props.instance.metadata.linked_data.version_id !== props.instance.modrinth_update_version
})
const exportModal = ref(null)
console.log(props.instance)
const initProjects = (initInstance) => {
projects.value = []
if (!initInstance || !initInstance.projects) return
@@ -467,6 +517,7 @@ const selectedOption = ref('Share')
const shareModal = ref(null)
const ascending = ref(true)
const sortColumn = ref('Name')
const currentPage = ref(1)
const selected = computed(() =>
Array.from(selectionMap.value)
@@ -776,6 +827,13 @@ const handleContentOptionClick = async (args) => {
}
}
const updatingModpack = ref(false)
const updateModpack = async () => {
updatingModpack.value = true
await update_managed_modrinth(props.instance.path).catch(handleError)
updatingModpack.value = false
}
watch(selectAll, () => {
for (const [key, value] of Array.from(selectionMap.value)) {
if (value !== selectAll.value) {
@@ -791,6 +849,11 @@ const unlisten = await listen('tauri://file-drop', async (event) => {
}
initProjects(await get(props.instance.path).catch(handleError))
})
const switchPage = (page) => {
currentPage.value = page
}
onUnmounted(() => {
unlisten()
})
@@ -827,6 +890,26 @@ onUnmounted(() => {
}
}
.static {
.table-row {
grid-template-areas: 'manage name version';
grid-template-columns: 4.25rem 1fr 1fr;
}
.name-cell {
grid-area: name;
}
.version {
grid-area: version;
}
.manage {
justify-content: center;
grid-area: manage;
}
}
.table-cell {
align-items: center;
}
@@ -841,10 +924,56 @@ onUnmounted(() => {
.mod-card {
display: flex;
flex-direction: column;
flex-direction: row;
flex-wrap: wrap;
gap: var(--gap-sm);
justify-content: center;
overflow: hidden;
justify-content: flex-start;
margin-bottom: 0.5rem;
white-space: nowrap;
align-items: center;
:deep(.dropdown-row) {
.btn {
height: 2.5rem !important;
}
}
.btn {
height: 2.5rem;
}
.dropdown-input {
flex-grow: 1;
.iconified-input {
width: 100%;
input {
flex-basis: unset;
}
}
:deep(.animated-dropdown) {
.render-down {
border-radius: var(--radius-md) 0 0 var(--radius-md) !important;
}
.options-wrapper {
margin-top: 0.25rem;
width: unset;
border-radius: var(--radius-md);
}
.options {
border-radius: var(--radius-md);
border: 1px solid var(--color);
}
}
}
}
.list-card {
margin-top: 0.5rem;
}
.text-combo {
@@ -1016,4 +1145,10 @@ onUnmounted(() => {
margin: 0;
}
}
.dropdown-input {
.selected {
height: 2.5rem;
}
}
</style>

View File

@@ -21,7 +21,12 @@
<div class="input-row">
<p class="input-label">Game Version</p>
<div class="versions">
<DropdownSelect v-model="gameVersion" :options="selectableGameVersions" render-up />
<DropdownSelect
v-model="gameVersion"
:options="selectableGameVersions"
name="Game Version Dropdown"
render-up
/>
<Checkbox v-model="showSnapshots" class="filter-checkbox" label="Include snapshots" />
</div>
</div>
@@ -31,6 +36,7 @@
:model-value="selectableLoaderVersions[loaderVersionIndex]"
:options="selectableLoaderVersions"
:display-name="(option) => option?.id"
name="Version selector"
render-up
@change="(value) => (loaderVersionIndex = value.index)"
/>
@@ -85,7 +91,14 @@
<label for="project-name">
<span class="label__title">Name</span>
</label>
<input id="profile-name" v-model="title" autocomplete="off" maxlength="80" type="text" />
<input
id="profile-name"
v-model="title"
autocomplete="off"
maxlength="80"
type="text"
:disabled="instance.metadata.linked_data"
/>
<div class="adjacent-input">
<label for="edit-versions">
@@ -197,7 +210,9 @@
<div class="adjacent-input">
<label for="fullscreen">
<span class="label__title">Fullscreen</span>
<span class="label__description"> Make the game start in full screen when launched. </span>
<span class="label__description">
Make the game start in full screen when launched (using options.txt).
</span>
</label>
<Checkbox id="fullscreen" v-model="fullscreenSetting" :disabled="!overrideWindowSettings" />
</div>
@@ -289,6 +304,17 @@
<span class="label__title size-card-header">Instance management</span>
</h3>
</div>
<div v-if="instance.metadata.linked_data" class="adjacent-input">
<label for="repair-profile">
<span class="label__title">Unpair instance</span>
<span class="label__description">
Removes the link to an external modpack on the instance. This allows you to edit modpacks
you download through the browse page but you will not be able to update the instance from
a new version of a modpack if you do this.
</span>
</label>
<Button id="repair-profile" @click="unpairProfile"> <XIcon /> Unpair </Button>
</div>
<div class="adjacent-input">
<label for="repair-profile">
<span class="label__title">Repair instance</span>
@@ -297,14 +323,14 @@
launching due to launcher-related errors.
</span>
</label>
<button
<Button
id="repair-profile"
class="btn btn-highlight"
color="highlight"
:disabled="repairing || offline"
@click="repairProfile"
>
<HammerIcon /> Repair
</button>
</Button>
</div>
<div v-if="props.instance.modrinth_update_version" class="adjacent-input">
<label for="repair-profile">
@@ -314,16 +340,15 @@
launching due to your instance diverging from the Modrinth modpack.
</span>
</label>
<button
<Button
id="repair-profile"
class="btn btn-highlight"
color="highlight"
:disabled="repairing || offline"
@click="repairModpack"
>
<DownloadIcon /> Reinstall
</button>
</Button>
</div>
<div class="adjacent-input">
<label for="delete-profile">
<span class="label__title">Delete instance</span>
@@ -332,14 +357,14 @@
no way to recover it.
</span>
</label>
<button
<Button
id="delete-profile"
class="btn btn-danger"
color="danger"
:disabled="removing"
@click="$refs.modal_confirm.show()"
>
<TrashIcon /> Delete
</button>
</Button>
</div>
</Card>
</template>
@@ -361,6 +386,7 @@ import {
HammerIcon,
DownloadIcon,
ModalConfirm,
Button,
} from 'omorphia'
import { Multiselect } from 'vue-multiselect'
import { useRouter } from 'vue-router'
@@ -406,11 +432,9 @@ const groups = ref(props.instance.metadata.groups)
const instancesList = Object.values(await list(true))
const availableGroups = ref([
...new Set(
instancesList.reduce((acc, obj) => {
return acc.concat(obj.metadata.groups)
}, [])
),
...instancesList.reduce((acc, obj) => {
return acc.concat(obj.metadata.groups)
}, []),
])
async function resetIcon() {
@@ -465,6 +489,8 @@ const hooks = ref(props.instance.hooks ?? globalSettings.hooks)
const fullscreenSetting = ref(!!props.instance.fullscreen)
const unlinkModpack = ref(false)
watch(
[
title,
@@ -483,11 +509,12 @@ watch(
fullscreenSetting,
overrideHooks,
hooks,
unlinkModpack,
],
async () => {
const editProfile = {
metadata: {
name: title.value.trim().substring(0, 16) ?? 'Instance',
name: title.value.trim().substring(0, 32) ?? 'Instance',
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
loader_version: props.instance.metadata.loader_version,
linked_data: props.instance.metadata.linked_data,
@@ -537,6 +564,10 @@ watch(
editProfile.hooks = hooks.value
}
if (unlinkModpack.value) {
editProfile.metadata.linked_data = null
}
await edit(props.instance.path, editProfile)
},
{ deep: true }
@@ -544,6 +575,10 @@ watch(
const repairing = ref(false)
async function unpairProfile() {
unlinkModpack.value = true
}
async function repairProfile() {
repairing.value = true
await install(props.instance.path).catch(handleError)

View File

@@ -50,6 +50,7 @@
</Button>
<a
class="open btn icon-only"
target="_blank"
:href="
expandedGalleryItem.url
? expandedGalleryItem.url

View File

@@ -37,6 +37,7 @@
(cat) => data.categories.includes(cat.name) && cat.project_type === 'mod'
)
"
type="ignored"
>
<EnvironmentIndicator
:client-side="data.client_side"
@@ -167,7 +168,7 @@
</Card>
</div>
<div v-if="data" class="content-container">
<Promotion />
<Promotion query-param="?r=launcher" />
<Card class="tabs">
<NavRow
v-if="data.gallery.length > 0"
@@ -205,6 +206,7 @@
:versions="versions"
:members="members"
:dependencies="dependencies"
:instance="instance"
:install="install"
:installed="installed"
:installing="installing"

View File

@@ -83,12 +83,7 @@
<Card v-if="displayDependencies.length > 0">
<h2>Dependencies</h2>
<div v-for="dependency in displayDependencies" :key="dependency.title">
<router-link
v-if="dependency.link"
class="btn dependency"
:to="dependency.link"
@click="testTest"
>
<router-link v-if="dependency.link" class="btn dependency" :to="dependency.link">
<Avatar size="sm" :src="dependency.icon" />
<div>
<span class="title"> {{ dependency.title }} </span> <br />

View File

@@ -167,8 +167,8 @@ import { computed, ref, watch } from 'vue'
import { SwapIcon } from '@/assets/icons/index.js'
const filterVersions = ref([])
const filterLoader = ref([])
const filterGameVersions = ref([])
const filterLoader = ref(props.instance ? [props.instance?.metadata?.loader] : [])
const filterGameVersions = ref(props.instance ? [props.instance?.metadata?.game_version] : [])
const currentPage = ref(1)

View File

@@ -85,6 +85,7 @@ async fn main() -> theseus::Result<()> {
None,
None,
None,
None,
)
.await?;