Files
Disy a477b1fb9a
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
feat: integrate StarLight updates and improve font settings and skin editor loading
2026-09-19 18:14:41 +08:00

1158 lines
40 KiB
Rust

#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
#![recursion_limit = "256"]
use native_dialog::{DialogBuilder, MessageLevel};
use serde::{Deserialize, Serialize};
use std::sync::atomic::Ordering;
use std::{
env, fs,
io::Read,
path::{Component, Path, PathBuf},
};
use tauri::{
Listener, Manager,
http::{Response, StatusCode, header},
};
use tauri_plugin_fs::FsExt;
use theseus::prelude::*;
mod api;
mod error;
mod lightweight_mode;
mod mod_translation;
mod portable;
mod seed_map;
#[derive(Default, Deserialize, Serialize)]
struct UpdateChannelState {
active_channel: Option<String>,
immediate_update_fetch: Option<bool>,
updates_paused: Option<bool>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdatePreferences {
immediate_update_fetch: bool,
updates_paused: bool,
}
#[cfg(target_os = "macos")]
mod macos;
#[cfg(feature = "updater")]
mod updater_impl;
#[cfg(not(feature = "updater"))]
mod updater_impl_noop;
const BLOCKBENCH_SKIN_RESOURCE_DIR: &str = "resources/blockbench-skin";
fn blockbench_skin_response(
path: &str,
resource_dir: &Path,
) -> Response<Vec<u8>> {
let requested_path = path.trim_start_matches('/');
let requested_path = if requested_path.is_empty() {
"index.html"
} else {
requested_path
};
let relative_path = Path::new(requested_path);
if relative_path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Vec::new())
.expect("failed to build Blockbench skin response");
}
let is_compressed_bundle = requested_path == "dist/skin.bundle.js";
let file_path = resource_dir.join(if is_compressed_bundle {
PathBuf::from("dist/skin.bundle.js.gz")
} else {
relative_path.to_path_buf()
});
let contents = match fs::read(&file_path) {
Ok(contents) => contents,
Err(error) => {
tracing::warn!(path = %file_path.display(), %error, "Skin editor resource could not be read");
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Vec::new())
.expect("failed to build Blockbench skin response");
}
};
let contents = if is_compressed_bundle {
let mut decompressed = Vec::new();
if let Err(error) = flate2::read::GzDecoder::new(contents.as_slice())
.read_to_end(&mut decompressed)
{
tracing::warn!(path = %file_path.display(), %error, "Skin editor bundle could not be decoded");
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Vec::new())
.expect("failed to build Blockbench skin response");
}
decompressed
} else {
contents
};
let content_type = match relative_path
.extension()
.and_then(|extension| extension.to_str())
{
Some("css") => "text/css; charset=utf-8",
Some("js") => "text/javascript; charset=utf-8",
Some("json") => "application/json; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("webp") => "image/webp",
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
Some("ttf") => "font/ttf",
Some("ico") => "image/x-icon",
_ => "text/html; charset=utf-8",
};
let response =
Response::builder().header(header::CONTENT_TYPE, content_type);
response
.body(contents)
.expect("failed to build Blockbench skin response")
}
fn skin_editor_resource_errors(resource_dir: &Path) -> Vec<String> {
["index.html", "css/setup.css", "dist/skin.bundle.js"]
.into_iter()
.filter(|path| {
let response = blockbench_skin_response(path, resource_dir);
!response.status().is_success() || response.body().is_empty()
})
.map(str::to_owned)
.collect()
}
#[cfg(test)]
mod skin_editor_tests {
use super::*;
use std::io::Write;
fn editor_resources() -> tempfile::TempDir {
let directory = tempfile::tempdir().unwrap();
fs::create_dir(directory.path().join("css")).unwrap();
fs::create_dir(directory.path().join("dist")).unwrap();
fs::write(directory.path().join("index.html"), "<!doctype html>")
.unwrap();
fs::write(directory.path().join("css/setup.css"), "body {}").unwrap();
let mut bundle = flate2::write::GzEncoder::new(
Vec::new(),
flate2::Compression::default(),
);
bundle.write_all(b"window.editor = true;").unwrap();
fs::write(
directory.path().join("dist/skin.bundle.js.gz"),
bundle.finish().unwrap(),
)
.unwrap();
directory
}
#[test]
fn packaged_skin_editor_bundle_is_readable() {
let directory = editor_resources();
assert!(skin_editor_resource_errors(directory.path()).is_empty());
let response =
blockbench_skin_response("/dist/skin.bundle.js", directory.path());
assert_eq!(response.body(), b"window.editor = true;");
assert_eq!(
response.headers()[header::CONTENT_TYPE],
"text/javascript; charset=utf-8"
);
}
#[test]
fn missing_skin_editor_resources_are_reported() {
let directory = tempfile::tempdir().unwrap();
assert_eq!(
skin_editor_resource_errors(directory.path()),
["index.html", "css/setup.css", "dist/skin.bundle.js"]
);
}
#[test]
fn corrupt_skin_editor_bundle_is_reported() {
let directory = editor_resources();
fs::write(
directory.path().join("dist/skin.bundle.js.gz"),
"invalid gzip",
)
.unwrap();
assert_eq!(
skin_editor_resource_errors(directory.path()),
["dist/skin.bundle.js"]
);
}
}
#[tauri::command]
fn get_skin_editor_resource_errors(
app: tauri::AppHandle,
) -> Result<Vec<String>, String> {
let resource_dir = app
.path()
.resource_dir()
.map_err(|error| error.to_string())?
.join(BLOCKBENCH_SKIN_RESOURCE_DIR);
Ok(skin_editor_resource_errors(&resource_dir))
}
fn is_allowed_blockbench_skin_request(
request: &tauri::http::Request<Vec<u8>>,
) -> bool {
if !matches!(
request.uri().host(),
Some("localhost") | Some("axolotl-skin.localhost")
) {
return false;
}
const ALLOWED_ORIGINS: [&str; 6] = [
"http://localhost:5201",
"http://tauri.localhost",
"https://tauri.localhost",
"tauri://localhost",
"axolotl-skin://localhost",
"http://axolotl-skin.localhost",
];
let is_allowed_source = |value: &str| {
ALLOWED_ORIGINS.iter().any(|origin| {
value == *origin || value.starts_with(&format!("{origin}/"))
})
};
if let Some(origin) = request
.headers()
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
{
if !is_allowed_source(origin) {
return false;
}
if let Some(referer) = request
.headers()
.get(header::REFERER)
.and_then(|value| value.to_str().ok())
{
return is_skin_editor_referer(referer, &is_allowed_source);
}
return true;
}
let Some(referer) = request
.headers()
.get(header::REFERER)
.and_then(|value| value.to_str().ok())
else {
// WKWebView omits both Origin and Referer for custom-scheme iframe
// navigations and their subresources. The handler only exposes packaged
// editor assets, so a host-validated request remains safe to serve.
return true;
};
is_skin_editor_referer(referer, &is_allowed_source)
}
fn is_skin_editor_referer(
referer: &str,
is_allowed_source: &impl Fn(&str) -> bool,
) -> bool {
let Ok(url) = referer.parse::<url::Url>() else {
return false;
};
if !is_allowed_source(url.origin().ascii_serialization().as_str()) {
return false;
}
if url.path() == "/index.html" {
return url
.query_pairs()
.any(|(key, value)| key == "embed" && value == "skin");
}
// Top-level iframe navigation initiated by the launcher, whose route
// path (e.g. `/` or `/lab/skin-editor`) is never `/index.html`.
true
}
// Should be called in launcher initialization
#[tracing::instrument(skip_all)]
#[tauri::command]
async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
tracing::info!("Initializing app event state...");
theseus::EventState::init(app.clone()).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.asset_protocol_scope()
.allow_directory(state.directories.instances_dir(), true)?;
app.asset_protocol_scope()
.allow_directory(state.directories.servers_dir(), true)?;
app.fs_scope()
.allow_directory(state.directories.instances_dir(), true)?;
app.asset_protocol_scope()
.allow_directory(state.directories.servers_dir(), true)?;
app.fs_scope()
.allow_directory(state.directories.servers_dir(), true)?;
Ok(())
}
/// Directory that contains the launcher executable. Used as the default
/// external game-directory root for the one-click StarLight install flow.
#[tauri::command]
fn get_launcher_root_dir() -> api::Result<String> {
let exe = std::env::current_exe().map_err(|error| {
theseus::Error::from(theseus::ErrorKind::FSError(error.to_string()))
})?;
let dir = exe.parent().ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::FSError(
"Launcher executable has no parent directory".to_string(),
))
})?;
Ok(dir.to_string_lossy().into_owned())
}
#[tauri::command]
fn get_update_channel(app: tauri::AppHandle) -> api::Result<String> {
let channel = read_update_channel_state(&app)?
.active_channel
.unwrap_or_else(|| "release".to_string());
match channel.as_str() {
"release" | "beta" => Ok(channel),
_ => Err(theseus::Error::from(theseus::ErrorKind::FSError(
"Update channel settings are invalid".to_string(),
))
.into()),
}
}
#[tauri::command]
async fn get_current_app_database_path(
app: tauri::AppHandle,
) -> api::Result<String> {
Ok(theseus::current_app_database_path(&app.config().identifier)
.await?
.to_string_lossy()
.into_owned())
}
#[tauri::command]
fn set_update_channel(
app: tauri::AppHandle,
channel: String,
) -> api::Result<()> {
if !matches!(channel.as_str(), "release" | "beta") {
return Err(theseus::Error::from(theseus::ErrorKind::FSError(
"Invalid update channel".to_string(),
))
.into());
}
let mut state = read_update_channel_state(&app)?;
state.active_channel = Some(channel);
write_update_channel_state(&app, &state)?;
Ok(())
}
#[tauri::command]
fn get_update_preferences(
app: tauri::AppHandle,
) -> api::Result<UpdatePreferences> {
let state = read_update_channel_state(&app)?;
let is_beta = state.active_channel.as_deref() == Some("beta");
Ok(UpdatePreferences {
immediate_update_fetch: is_beta
|| state.immediate_update_fetch.unwrap_or(false),
updates_paused: state.updates_paused.unwrap_or(false),
})
}
#[tauri::command]
fn set_update_preferences(
app: tauri::AppHandle,
immediate_update_fetch: bool,
updates_paused: bool,
) -> api::Result<()> {
let mut state = read_update_channel_state(&app)?;
state.immediate_update_fetch = Some(immediate_update_fetch);
state.updates_paused = Some(updates_paused);
write_update_channel_state(&app, &state)
}
fn update_channel_state_path(app: &tauri::AppHandle) -> api::Result<PathBuf> {
let settings_dir = theseus::DirectoryInfo::initial_settings_dir_path(
&app.config().identifier,
)
.ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::FSError(
"Could not find update channel settings directory".to_string(),
))
})?;
Ok(settings_dir.join("update-channel.json"))
}
fn read_update_channel_state(
app: &tauri::AppHandle,
) -> api::Result<UpdateChannelState> {
let path = update_channel_state_path(app)?;
match std::fs::read_to_string(path) {
Ok(contents) => serde_json::from_str(&contents).map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(
error.to_string(),
))
.into()
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
Ok(UpdateChannelState::default())
}
Err(error) => Err(error.into()),
}
}
fn write_update_channel_state(
app: &tauri::AppHandle,
state: &UpdateChannelState,
) -> api::Result<()> {
let path = update_channel_state_path(app)?;
let settings_dir = path
.parent()
.expect("Update channel state path has a parent directory");
std::fs::create_dir_all(settings_dir)?;
let temporary_path = settings_dir.join("update-channel.json.tmp");
let contents = serde_json::to_vec(state).map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(error.to_string()))
})?;
std::fs::write(&temporary_path, contents)?;
std::fs::rename(temporary_path, path)?;
Ok(())
}
#[tauri::command]
async fn copy_release_database_to_beta(
app: tauri::AppHandle,
) -> api::Result<()> {
theseus::copy_release_database_to_beta(&app.config().identifier).await?;
Ok(())
}
#[tauri::command]
async fn copy_database_between_channels(
app: tauri::AppHandle,
source_channel: String,
target_channel: String,
) -> api::Result<()> {
theseus::copy_database_between_channels(
&app.config().identifier,
&source_channel,
&target_channel,
)
.await?;
Ok(())
}
#[tauri::command]
async fn beta_database_exists(app: tauri::AppHandle) -> api::Result<bool> {
Ok(theseus::beta_database_exists(&app.config().identifier).await?)
}
#[tauri::command]
async fn backup_app_db_for_update(
app: tauri::AppHandle,
version: String,
) -> api::Result<()> {
theseus::backup_current_app_db_for_update(
&app.config().identifier,
&version,
)
.await?;
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 set_transparent_window_frame(
enabled: bool,
window: tauri::Window,
) -> Result<(), String> {
#[cfg(windows)]
{
use windows::Win32::Graphics::Dwm::{
DWMWA_BORDER_COLOR, DWMWA_COLOR_DEFAULT, DWMWA_COLOR_NONE,
DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_DEFAULT, DWMWCP_ROUND,
DwmSetWindowAttribute,
};
window.set_shadow(true).map_err(|error| error.to_string())?;
let hwnd = window.hwnd().map_err(|error| error.to_string())?;
let corner_preference = if enabled {
DWMWCP_ROUND
} else {
DWMWCP_DEFAULT
};
let border_color = if enabled {
DWMWA_COLOR_NONE
} else {
DWMWA_COLOR_DEFAULT
};
unsafe {
DwmSetWindowAttribute(
hwnd,
DWMWA_WINDOW_CORNER_PREFERENCE,
std::ptr::from_ref(&corner_preference).cast(),
std::mem::size_of_val(&corner_preference) as u32,
)
.map_err(|error| error.to_string())?;
DwmSetWindowAttribute(
hwnd,
DWMWA_BORDER_COLOR,
std::ptr::from_ref(&border_color).cast(),
std::mem::size_of_val(&border_color) as u32,
)
.map_err(|error| error.to_string())?;
}
}
#[cfg(not(windows))]
let _ = (enabled, window);
Ok(())
}
#[tauri::command]
fn is_dev() -> bool {
cfg!(debug_assertions)
}
#[tauri::command]
fn are_updates_enabled() -> bool {
// StarLight launcher ships without the built-in update checker.
// Keep the backend interface in place for a future user-supplied
// update URL to be wired in by replacing this no-op gate later.
false
}
#[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]
fn exit_app(app: tauri::AppHandle) {
app.exit(0);
}
#[tauri::command]
async fn check_symlink_capability() -> api::Result<String> {
let capability = theseus::symlink::check_symlink_capability().await?;
Ok(match capability {
theseus::SymlinkCapability::Supported => "supported",
theseus::SymlinkCapability::RequiresAdmin => "requires_admin",
theseus::SymlinkCapability::Unsupported => "unsupported",
}
.to_string())
}
#[tauri::command]
fn is_elevated() -> bool {
theseus::is_process_elevated()
}
#[tauri::command]
fn allow_symlink_target(app: tauri::AppHandle, path: String) {
use tauri_plugin_fs::FsExt;
let _ = app.fs_scope().allow_directory(&path, true);
}
#[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(())
}
// macOS caps each process at 256 open file descriptors by default
// (RLIMIT_NOFILE soft limit). The launcher's high download concurrency
// (up to 128 concurrent downloads, each split into multiple segments)
// easily exhausts this, causing "Too many open files (os error 24)"
// failures while installing modpacks. Raise the soft limit up to macOS's
// per-process ceiling (kern.maxfilesperproc, ~10240) at startup.
#[cfg(target_os = "macos")]
fn raise_file_descriptor_limit() {
// SAFETY: Called at the start of main() before any threads or tokio
// runtime are spawned.
unsafe {
let mut limit = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
if libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) != 0 {
eprintln!(
"Failed to read RLIMIT_NOFILE: {}",
std::io::Error::last_os_error()
);
return;
}
// The hard limit is usually unlimited (-1); cap the target so
// setrlimit is accepted by the kernel.
const TARGET: libc::rlim_t = 10240;
let new_cur = if limit.rlim_max == libc::RLIM_INFINITY {
TARGET
} else {
limit.rlim_max.min(TARGET)
};
if new_cur <= limit.rlim_cur {
return;
}
limit.rlim_cur = new_cur;
if libc::setrlimit(libc::RLIMIT_NOFILE, &limit) != 0 {
eprintln!(
"Failed to raise RLIMIT_NOFILE to {new_cur}: {}",
std::io::Error::last_os_error()
);
}
}
}
// if Tauri app is called with arguments, then those arguments will be treated as commands
// ie: deep links or filepaths for .mrpacks
fn main() {
// Initialize portable mode first (checks .Axolotl folder and sets THESEUS_CONFIG_DIR)
// SAFETY: Called at the start of main() before any threads or tokio runtime are spawned
let _portable = unsafe { portable::init_portable_mode() };
// macOS limits the per-process file descriptor count to 256 by default,
// which the launcher's download concurrency exhausts during installs.
#[cfg(target_os = "macos")]
raise_file_descriptor_limit();
#[cfg(target_os = "windows")]
if std::env::args_os().any(|argument| argument == "--memory-optimize") {
std::process::exit(theseus::memory::optimize_current_process_context());
}
// Short-lived elevated helper entry: create a directory/file link with
// administrator privileges, report the outcome through a result file, and
// exit immediately. The main launcher process is never elevated.
#[cfg(target_os = "windows")]
{
let args: Vec<std::ffi::OsString> = std::env::args_os().collect();
if let Some(index) = args
.iter()
.position(|argument| argument == "--elevated-create-link")
{
let payload = args
.get(index + 1)
.cloned()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
std::process::exit(theseus::symlink::create_link_elevated_helper(
&payload,
));
}
}
// Workaround: NVIDIA's proprietary EGL driver crashes WebKitGTK's DMA-BUF renderer
#[cfg(target_os = "linux")]
if env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none()
&& std::path::Path::new("/proc/driver/nvidia/version").exists()
{
// SAFETY: This is called before any threads are spawned in main()
unsafe { env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1") }
}
/*
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}
*/
// Configure the tokio runtime with a larger thread stack size to prevent
// stack overflows on Windows during deep async call chains (e.g. pack
// import). The /STACK linker flag in .cargo/config.toml only affects the
// main thread, not tokio worker threads.
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_stack_size(16 * 1024 * 1024)
.build()
.expect("failed to build tokio runtime");
let handle = rt.handle().clone();
// SAFETY: tauri::async_runtime::set() takes a Handle by value but does not
// take ownership of the Runtime itself — Tauri expects the runtime to
// outlive main(). Since the Handle borrows the Runtime internally, we must
// leak the Runtime here so its worker threads and timer heap live for the
// process lifetime. The OS will reclaim the leaked pages on exit. The
// alternative (wrapping in an Arc and passing it to a permanent scope) is
// not supported by the tauri::async_runtime API.
std::mem::forget(rt);
tauri::async_runtime::set(handle);
let tauri_context = tauri::generate_context!();
let _log_guard = theseus::start_logger(&tauri_context.config().identifier);
tracing::info!("Initialized tracing subscriber. Loading Axolotl Launcher!");
let mut builder = tauri::Builder::default().register_uri_scheme_protocol(
"axolotl-skin",
move |context, request| {
if !is_allowed_blockbench_skin_request(&request) {
tracing::warn!(
path = request.uri().path(),
"Skin editor resource request was rejected"
);
return Response::builder()
.status(StatusCode::FORBIDDEN)
.body(Vec::new())
.expect(
"failed to build Blockbench skin forbidden response",
);
}
let resource_dir = context
.app_handle()
.path()
.resource_dir()
.expect("failed to resolve Tauri resource directory")
.join(BLOCKBENCH_SKIN_RESOURCE_DIR);
blockbench_skin_response(request.uri().path(), &resource_dir)
},
);
builder = builder
.plugin(
tauri::plugin::Builder::<tauri::Wry>::new("skin-editor-errors")
.js_init_script_on_all_frames(include_str!(
"skin_editor_bridge.js"
))
.build(),
)
.plugin(
tauri::plugin::Builder::<tauri::Wry>::new("skin-site-session")
.js_init_script_on_all_frames(include_str!(
"skin_site_bridge.js"
))
.build(),
)
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if let Some(payload) = args.get(1) {
tracing::info!("Handling deep link from arg {payload}");
let payload = payload.clone();
tauri::async_runtime::spawn(api::utils::handle_command(
payload,
));
}
// A second launch must restore the launcher UI. The main window
// may be hidden or destroyed when the app is in lightweight mode,
// so focusing it is not enough (see #490).
let app = app.clone();
tauri::async_runtime::spawn(async move {
let _ =
app.state::<lightweight_mode::LightweightMode>().exit(&app);
});
}))
.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({
let mut window_state_builder =
tauri_plugin_window_state::Builder::default()
.with_filename("app-window-state.json")
// 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,
);
// Use THESEUS_CONFIG_DIR for window state if set (portable mode)
if let Some(config_dir) = std::env::var_os("THESEUS_CONFIG_DIR") {
window_state_builder =
window_state_builder.with_state_dir(config_dir);
}
window_state_builder.build()
})
.setup(|app| {
lightweight_mode::init(&app.handle());
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(4)).await;
if let Some(window) = app_handle.get_window("main")
&& !window.is_visible().unwrap_or(true)
{
let _ = window.show();
}
});
#[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 deep link {actual_request}");
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 {payload}");
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::ai::init())
.plugin(api::auth::init())
.plugin(api::mr_auth::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::mcarchive::init())
.plugin(api::minecraft_skins::init())
.plugin(api::mod_translation::init())
.plugin(api::process::init())
.plugin(api::planet_minecraft::init())
.plugin(api::settings::init())
.plugin(api::storage::init())
.plugin(api::system_accent::init())
.plugin(api::seed_map::init())
.plugin(api::schematic_preview::init())
.plugin(api::shortcuts::init())
.plugin(api::tags::init())
.plugin(api::translation::init())
.plugin(api::utils::init())
.plugin(api::cache::init())
.plugin(api::content_favorites::init())
.plugin(api::content_search::init())
.plugin(api::curseforge::init())
.plugin(api::datapacks::init())
.plugin(api::drop::init())
.plugin(api::files::init())
.plugin(api::friends::init())
.plugin(api::worlds::init())
.plugin(api::terracotta::init())
.plugin(api::multiplayer::init())
.manage(api::files::StudioWatchers::default())
.plugin(api::servers::init())
.manage(PendingUpdateData::default())
.invoke_handler(tauri::generate_handler![
initialize_state,
get_skin_editor_resource_errors,
get_launcher_root_dir,
get_update_channel,
get_current_app_database_path,
set_update_channel,
get_update_preferences,
set_update_preferences,
copy_release_database_to_beta,
copy_database_between_channels,
beta_database_exists,
backup_app_db_for_update,
is_dev,
portable::is_portable_mode,
are_updates_enabled,
get_update_size,
check_app_update,
enqueue_update_for_installation,
remove_enqueued_update,
set_restart_after_pending_update,
is_apt_linux,
toggle_decorations,
set_transparent_window_frame,
show_window,
restart_app,
exit_app,
check_symlink_capability,
is_elevated,
allow_symlink_target,
lightweight_mode::lightweight_mode_frontend_ready,
lightweight_mode::lightweight_mode_set_route,
lightweight_mode::lightweight_mode_enter,
]);
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}"
);
}
if matches!(&event, tauri::RunEvent::ExitRequested { .. })
&& let Err(error) = tauri::async_runtime::block_on(
theseus::multiplayer::shutdown(),
)
{
tracing::warn!(
"Failed to stop multiplayer services 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}"
)
}
}
let version = update.version.clone();
let install_result = if is_apt_linux() {
tauri::async_runtime::block_on(
install_apt_package(&version, data),
)
.map_err(|error| error.to_string())
} else {
let update = if should_restart {
(**update).clone()
} else {
(**update).clone().restart_after_install(false)
};
// Persist the trigger before installing: on Windows
// the updater plugin launches the NSIS installer and
// exits the process via `std::process::exit(0)`
// without returning, so the success path below never
// runs there.
#[cfg(target_os = "windows")]
set_changelog_toast(Some(update.version.clone()));
update.install(data).map_err(|error| error.to_string())
};
match install_result {
Ok(()) => {
set_changelog_toast(Some(version.clone()));
if should_restart {
tracing::info!(
"Pending update installed successfully (version {}); restarting because user requested reload",
version
);
app.restart();
} else {
tracing::info!(
"Pending update installed successfully (version {}); exiting without relaunch (user did not request reload)",
version
);
}
}
Err(e) => {
tracing::error!(
"Pending update install failed (version {}): {e}",
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 Axolotl Launcher.\n\nRepair or reinstall the Microsoft Edge WebView2 Runtime, then start Axolotl again.")
.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")
}
}
}