Files
modrinth/apps/app/src/main.rs
T
Truman GaoandProspector e79e0d024b feat: app onboarding and library (#6923)
* refactor: remove unused legacy onboarded flag

* sqlx prepare

* feat: add onboarding checklist app db and add vue provider

* feat: add welcome page

* feat: add onboarding checklist component

* feat: move checklist visibility source of truth in app db

* feat: split up import modpack button

* feat-small: style tweak

* feat: remove discover section from homepage and add library

* refactor: move welcome screen to component

* refactor: move grid display into /library

* refactor: rename existing components

* refactor: split up the current library page files

* feat: add create group modal

* feat: add instance group definitions

* feat: update accordian for instance groups

* feat: add delete group

* feat: implement rename instance groups

* refactor: move instance group api out

* feat: add context menu for groups

* feat: style instance items in library

* feat: add selected instance item state

* feat: implement action bar for selection

* feat: drag and drop to move between groups

* feat: show ungrouped with header

* feat: improve edit group name

* small fix

* small style fixes

* feat: improve selection behaviour

* fix: dont toggle group when context menu open

* feat: drag and drop multiple selected

* small

* feat: add remove from group in action bar

* feat: add delay on icon hiding in inline input

* feat: improve icon hiding delay

* fix: bad routes.js

* feat: implement sort and filter options

* refactor: dont prefix filenames

* feat: change open/collapse group target

* feat: improve ungrouped behaviour

* feat: add shift to select multiple instances

* fix: icon disappear transition

* feat: add esc while dragging to cancel

* feat: update sorting and moved label

* feat: update context menu

* fix: multiple context menus can be opened

* refactor: instance groups to be identified by ID instead of name, names become display data

* feat: creating new group always adds instance

* feat: create group button, fix instance selection, and increase group name max length

* feat: strokes

* fix: show getting started checklist in all sidebar

* feat: set size on microsoft login screen

* fix: use ButtonStyled in Chips component

* feat: creating groups with selected instances and fix some moving selection and duplication bugs

* pnpm prepr

* feat: add tooltip for instance already in group

* feat: create instance with content in creation flow modal

- search modpack becomes search projects
- creation flow modal context receives prepareProjectInstall and createProjectInstall callbacks, from content-install.ts (shared with ContentInstallModal)

* feat: update instance card styles

* refactor: separate out instance card content

* update styles

* refactor: instance card

* fix: width of instance

* feat: update empty state

* feat: update sort and grouping dropdowns

* feat: better context menu options

* feat: add group action buttons

* feat: add/remove favourites group

* remove manual sort

* feat: improve the "New group" jank

* feat: add group by instance type and change group sorting

* feat: welcome screen styles match figma

* feat: not signed in skins demo

* feat: new hosting empty state

* feat: new instance card style

* feat: add tooltip and spice up animation more

* feat: improve the transitions for groups after dropping many instances

* feat: set instance group membership for bulk moving

* feat: remove fade out

* fix: allow dragging from buttons inside instance card

* fix: library page load in behaviour

* small fix

* feat: add group ordering and reordering

* feat: add drag to reorder groups

* fix: switching page not clearing selected instances

* style fixes

* style fix

* fix spacing

* feat: jump in changes

* feat: add styled newly created instances

* feat: improve jump in cards styles

* fix: welcome screen style

* fix: empty state styles

* feat: max 5 jump in items

* feat: max new instances to 3

* fix: search icon

* feat: clear selection after move

* feat: skins list use check icon styles

* feat: list will be deleted instances and default sort last played

* fix: stroke

* remove: no more group settings in instance general settings

* fix: resolving modpack server install

* feat: icon editor

* feat: add three hue shifted variants

* fix: surprise me doesnt pick symbol

* feat: add customize icon to create instance modal

* feat: skins loading state style

* fix: welcome logo

* fix: copy

* fix: left over usage of ButtonStyled from main's refactor

* feat: some leaked merge stuff

* feat: server empty state styles

* remove: instance helper text

* remove: auto link in set up stage

* feat: auto focus on search when modal open

* fix: delete instance modal style

* remove: modpack meta repeating

* fix: icon

* fix: font weight

* fix: dropdown filter preview heights

* pnpm prepr

* fix: sign into modrinth flashing change in position

* feat: add avatar transparent corner gets padding

* feat: drag and hold over group opens group

* feat: add random icon for custom instance

* feat: update loading icon

* polish: empty state for search

* polish: jump in cards

* fix: avatar could skip checking for image if tauri loads it first

* feat: add icon customize and randomizer in content install modal for add to new instances

* fix: avatar cannot read image pixels due to cors

* refactor: popup notifications toast type for instance sharing notifications

* feat: apply random icons modal and add popup notification for it

* polish: style

* fix: set_webview_visible

* QA

* qa

* feat: add inner white 15 opacity stroke around avatar

* add to catalog

* pnpm prepr

* fix: app event import

* feat: add new icons and backgrounds for icon editor

* feat: recents list generate from randomized icons and save icon in editor + icon editor modal fixes

* qa

* refactor: recipe -> config

* fix: event bus

* polish

* feat: add safe guard against randomizing empty icons

* feat: navigate to instance after creation

* fix: icon file names

* i18n on welcome screen

* i18n pass on PR

* fix: inconsistent max group name length

* refactor: use sqlx macro

* feat: add instance icon background selection drag

* feat: update catalog order

* fix: remove gradient for welcome screen

* remove: frog

* fix: min width

* feat: update corner size

* fix: context menu styles

* prepr

* fix: instance name trimming

* reorder

* fix: instance card loading state stayed until hovering

* feat: add icon randomizer blacklist

* blog

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-18 01:08:09 +00:00

423 lines
16 KiB
Rust

#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
#![recursion_limit = "256"]
use native_dialog::{DialogBuilder, MessageLevel};
use std::env;
use std::sync::atomic::Ordering;
use tauri::{Listener, Manager};
use tauri_plugin_fs::FsExt;
use theseus::prelude::*;
mod api;
mod error;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(feature = "updater")]
mod updater_impl;
#[cfg(not(feature = "updater"))]
mod updater_impl_noop;
// Should be called in launcher initialization
#[tracing::instrument(skip_all)]
#[tauri::command]
async fn initialize_state(
app: tauri::AppHandle,
events: tauri::ipc::Channel<tauri::ipc::InvokeResponseBody>,
) -> api::Result<()> {
tracing::info!("Initializing app event state...");
theseus::EventState::init(app.clone(), events).await?;
tracing::info!("Initializing app state...");
State::init(app.config().identifier.clone()).await?;
let state = State::get().await?;
app.asset_protocol_scope()
.allow_directory(state.directories.caches_dir(), true)?;
app.asset_protocol_scope()
.allow_directory(state.directories.caches_dir().join("icons"), true)?;
app.fs_scope()
.allow_directory(state.directories.instances_dir(), true)?;
Ok(())
}
// Should be call once Vue has mounted the app
#[tracing::instrument(skip_all)]
#[tauri::command]
fn show_window(app: tauri::AppHandle) {
let win = app.get_window("main").unwrap();
if let Err(e) = win.show() {
DialogBuilder::message()
.set_level(MessageLevel::Error)
.set_title("Initialization error")
.set_text(format!(
"Cannot display application window due to an error:\n{e}"
))
.alert()
.show()
.unwrap();
panic!("cannot display application window")
} else {
let _ = win.set_focus();
}
}
#[tauri::command]
fn is_dev() -> bool {
cfg!(debug_assertions)
}
#[tauri::command]
fn are_updates_enabled() -> bool {
cfg!(feature = "updater")
&& env::var("MODRINTH_EXTERNAL_UPDATE_PROVIDER").is_err()
}
#[cfg(feature = "updater")]
pub use updater_impl::*;
#[cfg(not(feature = "updater"))]
pub use updater_impl_noop::*;
// Toggles decorations
#[tauri::command]
async fn toggle_decorations(b: bool, window: tauri::Window) -> api::Result<()> {
window.set_decorations(b).map_err(|e| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to toggle decorations: {e}"
)))
})?;
Ok(())
}
#[tauri::command]
fn restart_app(app: tauri::AppHandle) {
app.restart();
}
#[tauri::command]
async fn set_restart_after_pending_update(
should_restart: bool,
) -> api::Result<()> {
let state = State::get().await?;
state
.restart_after_pending_update
.store(should_restart, Ordering::Relaxed);
Ok(())
}
// if Tauri app is called with arguments, then those arguments will be treated as commands
// ie: deep links or filepaths for .mrpacks
fn main() {
#[cfg(feature = "export-app-events")]
theseus::export_app_event_bindings(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../app-frontend/src/generated/app-events"),
)
.expect("failed to export app event TypeScript bindings");
/*
tracing is set basd on the environment variable RUST_LOG=xxx, depending on the amount of logs to show
ERROR > WARN > INFO > DEBUG > TRACE
eg. RUST_LOG=info will show info, warn, and error logs
RUST_LOG="theseus=trace" will show *all* messages but from theseus only (and not dependencies using similar crates)
RUST_LOG="theseus=trace" will show *all* messages but from theseus only (and not dependencies using similar crates)
Error messages returned to Tauri will display as traced error logs if they return an error.
This will also include an attached span trace if the error is from a tracing error, and the level is set to info, debug, or trace
on unix:
RUST_LOG="theseus=trace" {run command}
*/
let tauri_context = tauri::generate_context!();
let _log_guard = theseus::start_logger(&tauri_context.config().identifier);
tracing::info!("Initialized tracing subscriber. Loading Modrinth App!");
let mut builder = tauri::Builder::default();
#[cfg(feature = "updater")]
{
use tauri_plugin_http::reqwest::header::{HeaderValue, USER_AGENT};
use theseus::launcher_user_agent;
builder = builder.plugin(
tauri_plugin_updater::Builder::new()
.header(
USER_AGENT,
HeaderValue::from_str(&launcher_user_agent()).unwrap(),
)
.unwrap()
.build(),
);
}
builder = builder
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if let Some(payload) = args.get(1) {
tracing::info!("Handling command-line deep link");
let payload = payload.clone();
tauri::async_runtime::spawn(api::utils::handle_command(
payload,
));
}
if let Some(win) = app.get_window("main") {
let _ = win.set_focus();
}
}))
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
.plugin(
tauri_plugin_window_state::Builder::default()
.with_filename("app-window-state.json")
.with_denylist(&["signin"])
// Use *only* POSITION and SIZE state flags, because saving VISIBLE causes the `visible: false` to not take effect
.with_state_flags(
tauri_plugin_window_state::StateFlags::POSITION
| tauri_plugin_window_state::StateFlags::SIZE
| tauri_plugin_window_state::StateFlags::MAXIMIZED,
)
.build(),
)
.setup(|app| {
#[cfg(target_os = "macos")]
{
let payload = macos::deep_link::get_or_init_payload(app);
let mtx_copy = payload.payload;
app.listen("deep-link://new-url", move |url| {
let mtx_copy_copy = mtx_copy.clone();
let request = url.payload().to_owned();
let actual_request =
serde_json::from_str::<Vec<String>>(&request)
.ok()
.map(|mut x| x.remove(0))
.unwrap_or(request);
tauri::async_runtime::spawn(async move {
tracing::info!("Handling macOS deep link");
let mut payload = mtx_copy_copy.lock().await;
if payload.is_none() {
*payload = Some(actual_request.clone());
}
let _ =
api::utils::handle_command(actual_request).await;
});
});
};
#[cfg(not(target_os = "macos"))]
app.listen("deep-link://new-url", |url| {
let payload = url.payload().to_owned();
tracing::info!("Handling deep link");
tauri::async_runtime::spawn(api::utils::handle_command(
payload,
));
});
#[cfg(not(target_os = "linux"))]
if let Some(window) = app.get_window("main")
&& let Err(e) = window.set_shadow(true)
{
tracing::warn!("Failed to set window shadow: {e}");
}
Ok(())
});
builder = builder
.plugin(api::auth::init())
.plugin(api::mr_auth::init())
.plugin(api::onboarding_checklist::init())
.plugin(api::import::init())
.plugin(api::install::init())
.plugin(api::instance::init())
.plugin(api::logs::init())
.plugin(api::jre::init())
.plugin(api::metadata::init())
.plugin(api::minecraft_skins::init())
.plugin(api::process::init())
.plugin(api::reports::init())
.plugin(api::settings::init())
.plugin(api::shortcuts::init())
.plugin(api::tags::init())
.plugin(api::users::init())
.plugin(api::utils::init())
.plugin(api::cache::init())
.plugin(api::files::init())
.plugin(api::ads::init())
.plugin(api::friends::init())
.plugin(api::worlds::init())
.manage(PendingUpdateData::default())
.invoke_handler(tauri::generate_handler![
initialize_state,
is_dev,
are_updates_enabled,
get_update_size,
enqueue_update_for_installation,
remove_enqueued_update,
set_restart_after_pending_update,
toggle_decorations,
show_window,
restart_app,
]);
tracing::info!("Initializing app...");
let app = builder.build(tauri_context);
match app {
Ok(app) => {
app.run(|app, event| {
#[cfg(not(any(feature = "updater", target_os = "macos")))]
let _ = app;
if matches!(&event, tauri::RunEvent::ExitRequested { .. })
&& let Err(error) = tauri::async_runtime::block_on(
theseus::minecraft_skins::flush_pending_skin_change(),
)
{
tracing::warn!(
"Failed to flush pending Minecraft skin change before exit: {error}"
);
}
#[cfg(feature = "updater")]
if matches!(&event, tauri::RunEvent::Exit) {
let update_data = app.state::<PendingUpdateData>().inner();
let should_restart = State::get_if_initialized()
.map(|s| {
s.restart_after_pending_update.load(Ordering::Relaxed)
})
.unwrap_or(false);
if let Some((update, data)) = &*update_data.0.lock().unwrap()
{
fn set_changelog_toast(version: Option<String>) {
let toast_result: theseus::Result<()> = tauri::async_runtime::block_on(async move {
let mut settings = settings::get().await?;
settings.pending_update_toast_for_version = version;
settings::set(settings).await?;
Ok(())
});
if let Err(e) = toast_result {
tracing::warn!(
"Failed to set pending_update_toast: {e}"
)
}
}
set_changelog_toast(Some(update.version.clone()));
let update = if should_restart {
(**update).clone()
} else {
(**update).clone().restart_after_install(false)
};
match update.install(data) {
Ok(()) => {
if should_restart {
tracing::info!(
"Pending update installed successfully (version {}); restarting because user requested reload",
update.version
);
app.restart();
} else {
tracing::info!(
"Pending update installed successfully (version {}); exiting without relaunch (user did not request reload)",
update.version
);
}
}
Err(e) => {
tracing::error!(
"Pending update install failed (version {}): {e}",
update.version
);
set_changelog_toast(None);
DialogBuilder::message()
.set_level(MessageLevel::Error)
.set_title("Update error")
.set_text(format!("Failed to install update due to an error:\n{e}"))
.alert()
.show()
.unwrap();
}
}
}
}
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Opened { urls } = event {
tracing::info!("Handling webview open {urls:?}");
let file = urls
.into_iter()
.find_map(|url| url.to_file_path().ok());
if let Some(file) = file {
let payload =
macos::deep_link::get_or_init_payload(app);
let mtx_copy = payload.payload;
let request = file.to_string_lossy().to_string();
tauri::async_runtime::spawn(async move {
let mut payload = mtx_copy.lock().await;
if payload.is_none() {
*payload = Some(request.clone());
}
let _ = api::utils::handle_command(request).await;
});
}
}
});
}
Err(e) => {
tracing::error!("Error while running tauri application: {:?}", e);
#[cfg(target_os = "windows")]
{
// tauri doesn't expose runtime errors, so matching a string representation seems like the only solution
if format!("{e:?}").contains(
"Runtime(CreateWebview(WebView2Error(WindowsError",
) {
DialogBuilder::message()
.set_level(MessageLevel::Error)
.set_title("Initialization error")
.set_text("Your Microsoft Edge WebView2 installation is corrupt.\n\nMicrosoft Edge WebView2 is required to run Modrinth App.\n\nLearn how to repair it at https://support.modrinth.com/en/articles/8797765-corrupted-microsoft-edge-webview2-installation")
.alert()
.show()
.unwrap();
panic!("webview2 initialization failed")
}
}
DialogBuilder::message()
.set_level(MessageLevel::Error)
.set_title("Initialization error")
.set_text(format!(
"Cannot initialize application due to an error:\n{e:?}"
))
.alert()
.show()
.unwrap();
panic!("{1}: {:?}", e, "error while running tauri application")
}
}
}