feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

105
apps/app/src/api/ai.rs Normal file
View File

@ -0,0 +1,105 @@
use crate::api::Result;
use theseus::ai::{
self, AiModelUpdate, AiProviderConfigUpdate, AiProviderDefinition,
AiProviderModel, AiSettings, AiState, OAuthDeviceCode, OAuthPollStatus,
};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("ai")
.invoke_handler(tauri::generate_handler![
ai_get_catalog,
ai_get_state,
ai_update_settings,
ai_update_provider,
ai_set_api_key,
ai_set_credential,
ai_update_model,
ai_remove_model,
ai_fetch_models,
ai_test_provider,
ai_begin_oauth,
ai_poll_oauth,
ai_disconnect_oauth,
])
.build()
}
#[tauri::command]
pub fn ai_get_catalog() -> Vec<AiProviderDefinition> {
ai::catalog()
}
#[tauri::command]
pub async fn ai_get_state() -> Result<AiState> {
Ok(ai::get_state().await?)
}
#[tauri::command]
pub async fn ai_update_settings(settings: AiSettings) -> Result<()> {
Ok(ai::update_settings(settings).await?)
}
#[tauri::command]
pub async fn ai_update_provider(update: AiProviderConfigUpdate) -> Result<()> {
Ok(ai::update_provider(update).await?)
}
#[tauri::command]
pub fn ai_set_api_key(
provider_id: String,
secret: Option<String>,
) -> Result<()> {
Ok(ai::set_api_key(provider_id, secret)?)
}
#[tauri::command]
pub fn ai_set_credential(
provider_id: String,
credential: String,
secret: Option<String>,
) -> Result<()> {
Ok(ai::set_credential(provider_id, credential, secret)?)
}
#[tauri::command]
pub async fn ai_update_model(update: AiModelUpdate) -> Result<()> {
Ok(ai::update_model(update).await?)
}
#[tauri::command]
pub async fn ai_remove_model(
provider_id: String,
model_id: String,
) -> Result<()> {
Ok(ai::remove_model(provider_id, model_id).await?)
}
#[tauri::command]
pub async fn ai_fetch_models(
provider_id: String,
) -> Result<Vec<AiProviderModel>> {
Ok(ai::fetch_models(provider_id).await?)
}
#[tauri::command]
pub async fn ai_test_provider(
provider_id: String,
model_id: String,
) -> Result<String> {
Ok(ai::test_provider(provider_id, model_id).await?)
}
#[tauri::command]
pub async fn ai_begin_oauth(provider_id: String) -> Result<OAuthDeviceCode> {
Ok(ai::begin_oauth(provider_id).await?)
}
#[tauri::command]
pub async fn ai_poll_oauth(flow_id: uuid::Uuid) -> Result<OAuthPollStatus> {
Ok(ai::poll_oauth(flow_id).await?)
}
#[tauri::command]
pub fn ai_disconnect_oauth(provider_id: String) -> Result<()> {
Ok(ai::disconnect_oauth(provider_id)?)
}

623
apps/app/src/api/auth.rs Normal file
View File

@ -0,0 +1,623 @@
use crate::api::Result;
use crate::api::oauth_utils;
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration as StdDuration;
use tauri::plugin::TauriPlugin;
use tauri::{Emitter, Manager, Runtime, UserAttentionType};
use tauri_plugin_opener::OpenerExt;
use theseus::prelude::*;
use tokio::sync::{mpsc, oneshot};
pub fn init<R: Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::<R>::new("auth")
.invoke_handler(tauri::generate_handler![
check_reachable,
check_mojang_services,
set_mojang_auth_use_mirror,
login,
browser_login,
begin_device_login,
poll_device_login,
begin_yggdrasil_login,
finish_yggdrasil_login,
list_yggdrasil_saved_logins,
get_yggdrasil_password,
set_yggdrasil_password,
delete_yggdrasil_password,
add_offline_user,
remove_user,
get_default_user,
set_default_user,
get_users,
])
.build()
}
/// Checks if the authentication servers are reachable.
#[tauri::command]
pub async fn check_reachable() -> Result<()> {
minecraft_auth::check_reachable().await?;
Ok(())
}
/// Checks all Mojang services that the Fallen proxy mirrors.
#[tauri::command]
pub async fn check_mojang_services()
-> Result<Vec<minecraft_auth::MojangServiceStatus>> {
Ok(minecraft_auth::check_mojang_services().await)
}
/// Stores whether the launcher should route Mojang service requests through
/// the Fallen proxy.
#[tauri::command]
pub async fn set_mojang_auth_use_mirror(
use_mirror: bool,
automatic: bool,
) -> Result<()> {
Ok(
minecraft_auth::set_mojang_auth_use_mirror(use_mirror, automatic)
.await?,
)
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MinecraftLoginTroubleLinks {
trouble: String,
browser_login: String,
device_code: String,
}
enum MinecraftLoginAlternative {
Browser,
DeviceCode,
}
#[tauri::command]
pub async fn login<R: Runtime>(
app: tauri::AppHandle<R>,
trouble_links: MinecraftLoginTroubleLinks,
) -> Result<Option<Credentials>> {
let flow = minecraft_auth::begin_login().await?;
let start = Utc::now();
if let Some(window) = app.get_webview_window("signin") {
window.close()?;
}
let (alternative_tx, mut alternative_rx) = mpsc::unbounded_channel();
let trouble_links =
serde_json::to_string(&trouble_links).map_err(|error| {
theseus::ErrorKind::OtherError(format!(
"Failed to serialize Minecraft sign-in help links: {error}"
))
.as_error()
})?;
let help_bar_script = format!(
r#"
(() => {{
const labels = {trouble_links};
const addHelpBar = () => {{
if (document.getElementById('axolotl-minecraft-login-help')) return;
const bar = document.createElement('div');
bar.id = 'axolotl-minecraft-login-help';
bar.style.cssText = 'box-sizing:border-box;position:sticky;top:0;z-index:2147483647;display:flex;align-items:center;gap:8px;width:100%;min-height:36px;padding:8px 16px;background:#fff;color:#1f1f1f;border-bottom:1px solid #d1d1d1;font:13px/20px system-ui,sans-serif;';
const trouble = document.createElement('span');
trouble.textContent = labels.trouble;
bar.append(trouble);
const createLink = (label, destination) => {{
const link = document.createElement('a');
link.href = destination;
link.textContent = label;
link.style.cssText = 'color:#0067b8;text-decoration:underline;cursor:pointer;';
return link;
}};
bar.append(createLink(labels.browserLogin, 'axolotl-auth://browser'));
const separator = document.createElement('span');
separator.textContent = '|';
bar.append(separator);
bar.append(createLink(labels.deviceCode, 'axolotl-auth://device-code'));
document.body.prepend(bar);
}};
if (document.readyState === 'loading') {{
document.addEventListener('DOMContentLoaded', addHelpBar, {{ once: true }});
}} else {{
addHelpBar();
}}
}})();
"#,
);
let window = tauri::WebviewWindowBuilder::new(
&app,
"signin",
tauri::WebviewUrl::External(flow.auth_request_uri.parse().map_err(
|_| {
theseus::ErrorKind::OtherError(
"Error parsing auth redirect URL".to_string(),
)
.as_error()
},
)?),
)
.title("Sign into Axolotl Launcher")
.always_on_top(true)
.center()
.initialization_script(help_bar_script)
.on_navigation(move |url| {
let alternative = match url.host_str() {
Some("browser") if url.scheme() == "axolotl-auth" => {
Some(MinecraftLoginAlternative::Browser)
}
Some("device-code") if url.scheme() == "axolotl-auth" => {
Some(MinecraftLoginAlternative::DeviceCode)
}
_ => None,
};
if let Some(alternative) = alternative {
let _ = alternative_tx.send(alternative);
return false;
}
true
})
.build()?;
window.request_user_attention(Some(UserAttentionType::Critical))?;
while (Utc::now() - start) < Duration::minutes(10) {
tokio::select! {
Some(alternative) = alternative_rx.recv() => {
window.close()?;
return match alternative {
MinecraftLoginAlternative::Browser => browser_login(app).await,
MinecraftLoginAlternative::DeviceCode => {
app.emit("minecraft-device-login-requested", ())?;
Ok(None)
}
};
}
_ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
}
if window.title().is_err() {
// user closed window, cancelling flow
return Ok(None);
}
let callback_url = window.url()?;
if callback_url
.as_str()
.starts_with("https://login.live.com/oauth20_desktop.srf")
&& let Some((_, code)) =
callback_url.query_pairs().find(|x| x.0 == "code")
{
let state = callback_url
.query_pairs()
.find(|x| x.0 == "state")
.map(|(_, state)| state.into_owned())
.ok_or_else(|| {
theseus::ErrorKind::InputError(
"Microsoft sign-in response did not include state"
.into(),
)
.as_error()
})?;
window.close()?;
let val = minecraft_auth::finish_login(&code, &state, flow).await?;
return Ok(Some(val));
}
}
window.close()?;
Ok(None)
}
#[tauri::command]
pub async fn browser_login<R: Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Option<Credentials>> {
let (listen_socket_tx, listen_socket) = oneshot::channel();
let callback_address =
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 53682);
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen_fixed(
callback_address,
listen_socket_tx,
));
listen_socket.await.unwrap()?;
let flow = minecraft_auth::begin_browser_login().await?;
if let Err(error) =
app.opener().open_url(&flow.auth_request_uri, None::<&str>)
{
oauth_utils::auth_code_reply::stop_listeners();
return Err(crate::api::TheseusSerializableError::Theseus(
theseus::ErrorKind::OtherError(format!(
"Failed to open browser sign-in: {error}"
))
.into(),
));
}
let auth_code =
tokio::time::timeout(StdDuration::from_secs(10 * 60), auth_code)
.await
.map_err(|_| {
oauth_utils::auth_code_reply::stop_listeners();
theseus::ErrorKind::OtherError(
"Browser sign-in timed out".into(),
)
.as_error()
})?;
let auth_code = auth_code.map_err(|error| {
theseus::ErrorKind::OtherError(format!(
"Browser sign-in listener stopped unexpectedly: {error}"
))
.as_error()
})?;
let Some(reply) = auth_code? else {
return Ok(None);
};
let state = reply.state.ok_or_else(|| {
theseus::ErrorKind::InputError(
"Microsoft sign-in response did not include state".into(),
)
.as_error()
})?;
let credentials =
minecraft_auth::finish_login(&reply.code, &state, flow).await?;
if let Some(main_window) = app.get_webview_window("main") {
main_window.set_focus().ok();
}
Ok(Some(credentials))
}
#[tauri::command]
pub async fn begin_device_login()
-> Result<minecraft_auth::MinecraftDeviceLoginFlow> {
Ok(minecraft_auth::begin_device_login().await?)
}
#[tauri::command]
pub async fn poll_device_login(
device_code: String,
) -> Result<minecraft_auth::MinecraftDeviceLoginPoll> {
Ok(minecraft_auth::poll_device_login(&device_code).await?)
}
#[tauri::command]
pub async fn begin_yggdrasil_login(
api_root: String,
login: String,
password: String,
) -> Result<minecraft_auth::YggdrasilLoginResult> {
Ok(
minecraft_auth::begin_yggdrasil_login(&api_root, &login, &password)
.await?,
)
}
#[tauri::command]
pub async fn finish_yggdrasil_login(
flow_id: uuid::Uuid,
profile_id: uuid::Uuid,
) -> Result<Credentials> {
Ok(minecraft_auth::finish_yggdrasil_login(flow_id, profile_id).await?)
}
const YGGDRASIL_SAVED_LOGINS_KEY: &str = "yggdrasil-saved-logins";
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct SavedYggdrasilLogin {
pub api_root: String,
pub login: String,
}
#[tauri::command]
pub fn list_yggdrasil_saved_logins() -> Result<Vec<SavedYggdrasilLogin>> {
read_yggdrasil_saved_logins()
}
#[tauri::command]
pub fn get_yggdrasil_password(
api_root: String,
login: String,
) -> Result<Option<String>> {
let entry = yggdrasil_password_entry(&api_root, &login)?;
match entry.get_password() {
Ok(password) => Ok(Some(password)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(error) => Err(keyring_error(error)),
}
}
#[tauri::command]
pub fn set_yggdrasil_password(
api_root: String,
login: String,
password: String,
) -> Result<()> {
if password.is_empty() {
return delete_yggdrasil_password(api_root, login);
}
let saved_login = normalize_saved_yggdrasil_login(&api_root, &login)?;
yggdrasil_password_entry(&saved_login.api_root, &saved_login.login)?
.set_password(&password)
.map_err(keyring_error)?;
let mut saved_logins = read_yggdrasil_saved_logins()?;
upsert_saved_yggdrasil_login(&mut saved_logins, saved_login);
write_yggdrasil_saved_logins(&saved_logins)
}
#[tauri::command]
pub fn delete_yggdrasil_password(
api_root: String,
login: String,
) -> Result<()> {
let saved_login = normalize_saved_yggdrasil_login(&api_root, &login)?;
match yggdrasil_password_entry(&saved_login.api_root, &saved_login.login)?
.delete_credential()
{
Ok(()) | Err(keyring::Error::NoEntry) => {}
Err(error) => return Err(keyring_error(error)),
}
let mut saved_logins = read_yggdrasil_saved_logins()?;
remove_saved_yggdrasil_login(&mut saved_logins, &saved_login);
write_yggdrasil_saved_logins(&saved_logins)
}
fn yggdrasil_password_entry(
api_root: &str,
login: &str,
) -> Result<keyring::Entry> {
let login = login.trim();
if login.is_empty() {
return Err(theseus::ErrorKind::InputError(
"The Yggdrasil account name cannot be empty".to_string(),
)
.as_error()
.into());
}
let api_root = minecraft_auth::normalize_yggdrasil_api_root(api_root)?;
keyring::Entry::new(
theseus::brand::BUNDLE_IDENTIFIER,
&format!("{api_root}\n{login}"),
)
.map_err(keyring_error)
}
fn normalize_saved_yggdrasil_login(
api_root: &str,
login: &str,
) -> Result<SavedYggdrasilLogin> {
let login = login.trim();
if login.is_empty() {
return Err(theseus::ErrorKind::InputError(
"The Yggdrasil account name cannot be empty".to_string(),
)
.as_error()
.into());
}
Ok(SavedYggdrasilLogin {
api_root: minecraft_auth::normalize_yggdrasil_api_root(api_root)?,
login: login.to_string(),
})
}
fn yggdrasil_saved_logins_entry() -> Result<keyring::Entry> {
keyring::Entry::new(
theseus::brand::BUNDLE_IDENTIFIER,
YGGDRASIL_SAVED_LOGINS_KEY,
)
.map_err(keyring_error)
}
fn read_yggdrasil_saved_logins() -> Result<Vec<SavedYggdrasilLogin>> {
match yggdrasil_saved_logins_entry()?.get_password() {
Ok(saved_logins) => match serde_json::from_str(&saved_logins) {
Ok(saved_logins) => Ok(saved_logins),
Err(error) => {
tracing::warn!(
"Ignoring an invalid saved Yggdrasil login index: {error}"
);
Ok(Vec::new())
}
},
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
Err(error) => Err(keyring_error(error)),
}
}
fn write_yggdrasil_saved_logins(
saved_logins: &[SavedYggdrasilLogin],
) -> Result<()> {
let entry = yggdrasil_saved_logins_entry()?;
if saved_logins.is_empty() {
return match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(keyring_error(error)),
};
}
let saved_logins =
serde_json::to_string(saved_logins).map_err(|error| {
theseus::ErrorKind::OtherError(format!(
"Unable to serialize saved Yggdrasil logins: {error}"
))
.as_error()
})?;
entry.set_password(&saved_logins).map_err(keyring_error)
}
fn upsert_saved_yggdrasil_login(
saved_logins: &mut Vec<SavedYggdrasilLogin>,
saved_login: SavedYggdrasilLogin,
) {
saved_logins.retain(|entry| entry != &saved_login);
saved_logins.push(saved_login);
saved_logins.sort_by(|left, right| {
left.login
.cmp(&right.login)
.then_with(|| left.api_root.cmp(&right.api_root))
});
}
fn remove_saved_yggdrasil_login(
saved_logins: &mut Vec<SavedYggdrasilLogin>,
saved_login: &SavedYggdrasilLogin,
) {
saved_logins.retain(|entry| entry != saved_login);
}
fn keyring_error(
error: keyring::Error,
) -> crate::api::TheseusSerializableError {
theseus::ErrorKind::OtherError(format!(
"Unable to access the system credential store: {error}"
))
.as_error()
.into()
}
fn parse_custom_uuid(uuid: Option<String>) -> Result<Option<uuid::Uuid>> {
let Some(uuid) = uuid else {
return Ok(None);
};
let uuid = uuid.trim().replace('-', "");
if uuid.len() != 32
|| !uuid.chars().all(|character| character.is_ascii_hexdigit())
{
return Err(theseus::ErrorKind::InputError(
"Custom UUID must be 32 hexadecimal characters; hyphens are optional"
.to_string(),
)
.as_error()
.into());
}
Ok(Some(uuid::Uuid::parse_str(&uuid).map_err(|_| {
theseus::ErrorKind::InputError("Invalid custom UUID".to_string())
.as_error()
})?))
}
#[tauri::command]
pub async fn add_offline_user(
username: String,
uuid: Option<String>,
) -> Result<Credentials> {
Ok(
minecraft_auth::add_offline_user(&username, parse_custom_uuid(uuid)?)
.await?,
)
}
#[tauri::command]
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
Ok(minecraft_auth::remove_user(user).await?)
}
#[tauri::command]
pub async fn get_default_user(
offline_mode: bool,
) -> Result<Option<uuid::Uuid>> {
Ok(minecraft_auth::get_default_user(offline_mode).await?)
}
#[tauri::command]
pub async fn set_default_user(user: uuid::Uuid) -> Result<()> {
Ok(minecraft_auth::set_default_user(user).await?)
}
/// Get a copy of the list of all user credentials
#[tauri::command]
pub async fn get_users(
offline_mode: bool,
) -> Result<Vec<minecraft_auth::MinecraftUser>> {
Ok(minecraft_auth::users(offline_mode).await?)
}
#[cfg(test)]
mod tests {
use super::*;
fn saved_login(api_root: &str, login: &str) -> SavedYggdrasilLogin {
SavedYggdrasilLogin {
api_root: api_root.to_string(),
login: login.to_string(),
}
}
#[test]
fn saved_login_index_upserts_and_sorts_entries() {
let mut saved_logins =
vec![saved_login("https://example.com", "second")];
upsert_saved_yggdrasil_login(
&mut saved_logins,
saved_login("https://example.com", "first"),
);
upsert_saved_yggdrasil_login(
&mut saved_logins,
saved_login("https://example.com", "second"),
);
assert_eq!(saved_logins.len(), 2);
assert_eq!(saved_logins[0].login, "first");
assert_eq!(saved_logins[1].login, "second");
}
#[test]
fn saved_login_index_removes_only_matching_entry() {
let removed =
saved_login("https://first.example", "player@example.com");
let retained =
saved_login("https://second.example", "player@example.com");
let mut saved_logins = vec![removed.clone(), retained.clone()];
remove_saved_yggdrasil_login(&mut saved_logins, &removed);
assert_eq!(saved_logins, vec![retained]);
}
#[test]
fn parses_custom_offline_uuid() {
let expected =
uuid::Uuid::parse_str("b50ad385-829d-3141-a216-7e7d7539ca7f")
.unwrap();
assert_eq!(
parse_custom_uuid(Some(
"b50ad385829d3141a2167e7d7539ca7f".to_string()
))
.unwrap(),
Some(expected)
);
assert_eq!(
parse_custom_uuid(Some(
"B50AD385-829D-3141-A216-7E7D7539CA7F".to_string()
))
.unwrap(),
Some(expected)
);
assert_eq!(parse_custom_uuid(None).unwrap(), None);
assert!(parse_custom_uuid(Some("not-a-uuid".to_string())).is_err());
assert!(
parse_custom_uuid(Some(
"b50ad385829d3141a2167e7d7539ca7".to_string()
))
.is_err()
);
}
}

161
apps/app/src/api/cache.rs Normal file
View File

@ -0,0 +1,161 @@
use crate::api::search_cancellation::{self, SearchCancellation};
use crate::api::{Result, TheseusSerializableError};
use theseus::prelude::*;
macro_rules! impl_cache_methods {
($(($variant:ident, $type:ty)),*) => {
$(
paste::paste! {
#[tauri::command]
pub async fn [<get_ $variant:snake>](id: &str, cache_behaviour: Option<CacheBehaviour>) -> Result<Option<$type>>
{
Ok(theseus::cache::[<get_ $variant:snake>](id, cache_behaviour).await?)
}
#[tauri::command]
pub async fn [<get_ $variant:snake _many>](
ids: Vec<String>,
cache_behaviour: Option<CacheBehaviour>,
) -> Result<Vec<$type>>
{
let ids = ids.iter().map(|x| &**x).collect::<Vec<&str>>();
let entries =
theseus::cache::[<get_ $variant:snake _many>](&*ids, cache_behaviour).await?;
Ok(entries)
}
}
)*
}
}
impl_cache_methods!(
(Project, Project),
(ProjectV3, ProjectV3),
(Version, Version),
(User, User),
(Team, Vec<TeamMember>),
(Organization, Organization),
(SearchResults, SearchResults)
);
#[tauri::command]
pub async fn get_search_results_v3(
id: &str,
cache_behaviour: Option<CacheBehaviour>,
request_id: Option<String>,
) -> Result<Option<SearchResultsV3>> {
let Some(request_id) = request_id else {
return Ok(
theseus::cache::get_search_results_v3(id, cache_behaviour).await?
);
};
let cancellation = SearchCancellation::register(request_id);
tokio::select! {
result = theseus::cache::get_search_results_v3(id, cache_behaviour) => Ok(result?),
_ = cancellation.cancelled() => Err(TheseusSerializableError::SearchCancelled(
"Modrinth browse search".to_string(),
)),
}
}
#[tauri::command]
pub async fn get_search_results_v3_many(
ids: Vec<String>,
cache_behaviour: Option<CacheBehaviour>,
) -> Result<Vec<SearchResultsV3>> {
let ids = ids.iter().map(|x| &**x).collect::<Vec<&str>>();
Ok(
theseus::cache::get_search_results_v3_many(&ids, cache_behaviour)
.await?,
)
}
#[tauri::command]
pub fn cancel_search_request(request_id: String) {
if let Some(pending) = search_cancellation::cancel(&request_id) {
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
pending.expire();
});
}
}
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("cache")
.invoke_handler(tauri::generate_handler![
get_project,
get_project_many,
get_project_v3,
get_project_v3_many,
get_version,
get_version_many,
get_user,
get_user_many,
get_team,
get_team_many,
get_organization,
get_organization_many,
get_search_results,
get_search_results_many,
get_search_results_v3,
get_search_results_v3_many,
cancel_search_request,
purge_cache_types,
get_project_versions,
])
.build()
}
#[tauri::command]
pub async fn purge_cache_types(cache_types: Vec<String>) -> Result<()> {
let cache_types = supported_cache_types(&cache_types);
Ok(theseus::cache::purge_cache_types(&cache_types).await?)
}
fn supported_cache_types(cache_types: &[String]) -> Vec<CacheValueType> {
cache_types
.iter()
.filter_map(|cache_type| {
let parsed = CacheValueType::from_string(cache_type);
if parsed.as_str() == cache_type {
Some(parsed)
} else {
tracing::warn!(cache_type, "Ignoring unsupported cache type");
None
}
})
.collect()
}
#[tauri::command]
pub async fn get_project_versions(
project_id: &str,
cache_behaviour: Option<CacheBehaviour>,
) -> Result<Option<Vec<Version>>> {
Ok(
theseus::cache::get_project_versions(project_id, cache_behaviour)
.await?,
)
}
#[cfg(test)]
mod tests {
use super::supported_cache_types;
use theseus::prelude::CacheValueType;
#[test]
fn cache_purge_keeps_supported_types_and_ignores_unknown_types() {
let cache_types = vec![
"project".to_string(),
"curseforge_project".to_string(),
"future_cache_type".to_string(),
];
assert_eq!(
supported_cache_types(&cache_types),
vec![CacheValueType::Project, CacheValueType::CurseForgeProject,]
);
}
}

View File

@ -0,0 +1,35 @@
use crate::api::Result;
use theseus::content_favorites::{
self, ContentFavorite, ContentFavoriteInput, ContentFavoriteProvider,
};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("content-favorites")
.invoke_handler(tauri::generate_handler![
content_favorites_list,
content_favorites_add,
content_favorites_remove,
])
.build()
}
#[tauri::command]
pub async fn content_favorites_list() -> Result<Vec<ContentFavorite>> {
Ok(content_favorites::list().await?)
}
#[tauri::command]
pub async fn content_favorites_add(
favorite: ContentFavoriteInput,
) -> Result<ContentFavorite> {
Ok(content_favorites::add(favorite).await?)
}
#[tauri::command]
pub async fn content_favorites_remove(
provider: ContentFavoriteProvider,
project_id: String,
) -> Result<()> {
content_favorites::remove(provider, &project_id).await?;
Ok(())
}

View File

@ -0,0 +1,61 @@
use theseus::content_search::{
ChineseNameLookup, ChineseSearchResolution, ContentIdentityLookup,
ContentSearchExpansion, WikiIdLookup,
};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("content-search")
.invoke_handler(tauri::generate_handler![
resolve_chinese_content_search,
expand_content_search_query,
lookup_chinese_content_names,
lookup_content_wiki_ids,
lookup_content_identities,
])
.build()
}
#[tauri::command]
pub fn resolve_chinese_content_search(
query: String,
) -> ChineseSearchResolution {
theseus::content_search::resolve_chinese_content_search(&query)
}
#[tauri::command]
pub fn expand_content_search_query(query: String) -> ContentSearchExpansion {
theseus::content_search::expand_content_search_query(&query)
}
#[tauri::command]
pub fn lookup_chinese_content_names(
modrinth_slugs: Vec<String>,
curseforge_slugs: Vec<String>,
) -> ChineseNameLookup {
theseus::content_search::lookup_chinese_content_names(
&modrinth_slugs,
&curseforge_slugs,
)
}
#[tauri::command]
pub fn lookup_content_wiki_ids(
modrinth_slugs: Vec<String>,
curseforge_slugs: Vec<String>,
) -> WikiIdLookup {
theseus::content_search::lookup_content_wiki_ids(
&modrinth_slugs,
&curseforge_slugs,
)
}
#[tauri::command]
pub fn lookup_content_identities(
modrinth_slugs: Vec<String>,
curseforge_slugs: Vec<String>,
) -> ContentIdentityLookup {
theseus::content_search::lookup_content_identities(
&modrinth_slugs,
&curseforge_slugs,
)
}

View File

@ -0,0 +1,267 @@
use crate::api::search_cancellation::SearchCancellation;
use crate::api::{Result, TheseusSerializableError};
use theseus::curseforge::{
CurseForgeCapability, CurseForgeCategory, CurseForgeFile,
CurseForgeFilesRequest, CurseForgeFilesResponse,
CurseForgeFingerprintResult, CurseForgeInstallRequest,
CurseForgeInstallResult, CurseForgeManualDownload,
CurseForgeManualDownloadImport, CurseForgeManualDownloadScanResult,
CurseForgeModpackInstallRequest, CurseForgeModpackInstallResult,
CurseForgeProject, CurseForgeRecognitionResult, CurseForgeSearchRequest,
UnifiedSearchResponse,
};
use theseus::prelude::CacheBehaviour;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("curseforge")
.invoke_handler(tauri::generate_handler![
curseforge_capability,
curseforge_validate_credentials,
curseforge_search_projects,
curseforge_get_project,
curseforge_get_projects,
curseforge_get_description,
curseforge_get_files,
curseforge_get_file,
curseforge_get_files_many,
curseforge_get_changelog,
curseforge_get_download_url,
curseforge_get_categories,
curseforge_match_fingerprints,
curseforge_install_file,
curseforge_preview_install_file,
curseforge_update_installed_file,
curseforge_switch_installed_file_version,
curseforge_recognize_instance_files,
curseforge_import_manual_downloads,
curseforge_list_pending_manual_downloads,
curseforge_import_pending_manual_download_file,
curseforge_configure_manual_download_watcher,
curseforge_install_modpack,
curseforge_update_managed_modpack,
])
.build()
}
#[tauri::command]
pub fn curseforge_capability() -> CurseForgeCapability {
theseus::curseforge::capability()
}
#[tauri::command]
pub async fn curseforge_validate_credentials() -> Result<CurseForgeCapability> {
Ok(theseus::curseforge::validate_credentials().await?)
}
#[tauri::command]
pub async fn curseforge_search_projects(
request: CurseForgeSearchRequest,
request_id: Option<String>,
) -> Result<UnifiedSearchResponse> {
let Some(request_id) = request_id else {
return Ok(theseus::curseforge::search_projects(request).await?);
};
let cancellation = SearchCancellation::register(request_id);
tokio::select! {
result = theseus::curseforge::search_projects(request) => Ok(result?),
_ = cancellation.cancelled() => Err(TheseusSerializableError::SearchCancelled(
"CurseForge browse search".to_string(),
)),
}
}
#[tauri::command]
pub async fn curseforge_get_project(
project_id: u32,
) -> Result<CurseForgeProject> {
Ok(theseus::curseforge::get_project(project_id).await?)
}
#[tauri::command]
pub async fn curseforge_get_projects(
project_ids: Vec<u32>,
cache_behaviour: Option<CacheBehaviour>,
) -> Result<Vec<CurseForgeProject>> {
Ok(theseus::curseforge::get_projects_with_cache_behaviour(
project_ids,
cache_behaviour,
)
.await?)
}
#[tauri::command]
pub async fn curseforge_get_description(project_id: u32) -> Result<String> {
Ok(theseus::curseforge::get_description(project_id).await?)
}
#[tauri::command]
pub async fn curseforge_get_files(
project_id: u32,
request: CurseForgeFilesRequest,
) -> Result<CurseForgeFilesResponse> {
Ok(theseus::curseforge::get_files(project_id, request).await?)
}
#[tauri::command]
pub async fn curseforge_get_file(
project_id: u32,
file_id: u32,
) -> Result<CurseForgeFile> {
Ok(theseus::curseforge::get_file(project_id, file_id).await?)
}
#[tauri::command]
pub async fn curseforge_get_files_many(
file_ids: Vec<u32>,
) -> Result<Vec<CurseForgeFile>> {
Ok(theseus::curseforge::get_files_many(file_ids).await?)
}
#[tauri::command]
pub async fn curseforge_get_changelog(
project_id: u32,
file_id: u32,
) -> Result<String> {
Ok(theseus::curseforge::get_changelog(project_id, file_id).await?)
}
#[tauri::command]
pub async fn curseforge_get_download_url(
project_id: u32,
file_id: u32,
) -> Result<Option<String>> {
Ok(theseus::curseforge::get_download_url(project_id, file_id).await?)
}
#[tauri::command]
pub async fn curseforge_get_categories(
class_id: Option<u32>,
) -> Result<Vec<CurseForgeCategory>> {
Ok(theseus::curseforge::get_categories(class_id).await?)
}
#[tauri::command]
pub async fn curseforge_match_fingerprints(
fingerprints: Vec<u64>,
) -> Result<CurseForgeFingerprintResult> {
Ok(theseus::curseforge::match_fingerprints(fingerprints).await?)
}
#[tauri::command]
pub async fn curseforge_install_file(
request: CurseForgeInstallRequest,
) -> Result<CurseForgeInstallResult> {
Ok(theseus::curseforge::install_file(request).await?)
}
#[tauri::command]
pub async fn curseforge_preview_install_file(
request: CurseForgeInstallRequest,
) -> Result<theseus::curseforge::CurseForgeInstallPreview> {
Ok(theseus::curseforge::preview_install_file(request).await?)
}
#[tauri::command]
pub async fn curseforge_update_installed_file(
instance_id: String,
relative_path: String,
) -> Result<CurseForgeInstallResult> {
Ok(
theseus::curseforge::update_installed_file(
&instance_id,
&relative_path,
)
.await?,
)
}
#[tauri::command]
pub async fn curseforge_switch_installed_file_version(
instance_id: String,
relative_path: String,
file_id: u32,
) -> Result<CurseForgeInstallResult> {
Ok(theseus::curseforge::switch_installed_file_version(
&instance_id,
&relative_path,
file_id,
)
.await?)
}
#[tauri::command]
pub async fn curseforge_recognize_instance_files(
instance_id: String,
) -> Result<CurseForgeRecognitionResult> {
Ok(theseus::curseforge::recognize_instance_files(&instance_id).await?)
}
#[tauri::command]
pub async fn curseforge_import_manual_downloads(
instance_id: String,
scan_directory: Option<std::path::PathBuf>,
) -> Result<CurseForgeManualDownloadScanResult> {
Ok(theseus::curseforge::import_manual_downloads(
&instance_id,
scan_directory,
)
.await?)
}
#[tauri::command]
pub async fn curseforge_list_pending_manual_downloads(
instance_id: String,
) -> Result<Vec<CurseForgeManualDownload>> {
Ok(
theseus::curseforge::list_pending_manual_downloads(&instance_id)
.await?,
)
}
#[tauri::command]
pub async fn curseforge_import_pending_manual_download_file(
instance_id: String,
project_id: u32,
file_id: u32,
source_path: std::path::PathBuf,
) -> Result<CurseForgeManualDownloadImport> {
Ok(theseus::curseforge::import_pending_manual_download_file(
&instance_id,
project_id,
file_id,
source_path,
)
.await?)
}
#[tauri::command]
pub async fn curseforge_configure_manual_download_watcher(
enabled: bool,
scan_directory: Option<std::path::PathBuf>,
) -> Result<Option<String>> {
Ok(theseus::curseforge::configure_manual_download_watcher(
enabled,
scan_directory,
)
.await?)
}
#[tauri::command]
pub async fn curseforge_install_modpack(
request: CurseForgeModpackInstallRequest,
) -> Result<CurseForgeModpackInstallResult> {
Ok(theseus::curseforge::install_modpack(request).await?)
}
#[tauri::command]
pub async fn curseforge_update_managed_modpack(
instance_id: String,
file_id: u32,
) -> Result<theseus::install::InstallJobSnapshot> {
Ok(theseus::install::runner::update_managed_curseforge_modpack(
instance_id,
file_id,
)
.await?)
}

View File

@ -0,0 +1,102 @@
use crate::api::Result;
use either::Either;
use std::path::PathBuf;
use tauri::{AppHandle, Manager, Runtime};
use theseus::worlds::{World, WorldDatapack, WorldWithDatapacks};
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("datapacks")
.invoke_handler(tauri::generate_handler![
list_datapacks,
delete_datapack,
set_datapack_enabled
])
.build()
}
#[tauri::command]
pub async fn list_datapacks<R: Runtime>(
app_handle: AppHandle<R>,
instance_id: &str,
) -> Result<Vec<WorldWithDatapacks>> {
let mut result = theseus::worlds::list_world_datapacks(instance_id).await?;
for world in &mut result {
adapt_world_icon(&app_handle, &mut world.world);
for datapack in &mut world.datapacks {
adapt_datapack_icon(&app_handle, datapack);
}
}
Ok(result)
}
#[tauri::command]
pub async fn delete_datapack(
instance_id: String,
world_path: String,
file_name: String,
) -> Result<()> {
theseus::worlds::delete_world_datapack(
&instance_id,
&world_path,
&file_name,
)
.await?;
Ok(())
}
#[tauri::command]
pub async fn set_datapack_enabled(
instance_id: String,
world_path: String,
file_name: String,
enabled: bool,
) -> Result<()> {
theseus::worlds::set_world_datapack_enabled(
&instance_id,
&world_path,
&file_name,
enabled,
)
.await?;
Ok(())
}
fn adapt_world_icon<R: Runtime>(app_handle: &AppHandle<R>, world: &mut World) {
adapt_icon_field(app_handle, &mut world.icon, &world.name);
}
fn adapt_datapack_icon<R: Runtime>(
app_handle: &AppHandle<R>,
datapack: &mut WorldDatapack,
) {
adapt_icon_field(app_handle, &mut datapack.icon, &datapack.display_name);
}
fn adapt_icon_field<R: Runtime>(
app_handle: &AppHandle<R>,
icon: &mut Option<Either<PathBuf, url::Url>>,
label: &str,
) {
if let Some(Either::Left(icon_path)) = icon {
let icon_path = icon_path.clone();
if let Ok(new_url) = super::utils::tauri_convert_file_src(&icon_path) {
*icon = Some(Either::Right(new_url));
if let Err(error) =
app_handle.asset_protocol_scope().allow_file(&icon_path)
{
tracing::warn!(
"Failed to allow file access for icon {}: {}",
icon_path.display(),
error
);
}
} else {
tracing::warn!(
"Encountered invalid icon path for {}: {}",
label,
icon_path.display()
);
*icon = None;
}
}
}

552
apps/app/src/api/drop.rs Normal file
View File

@ -0,0 +1,552 @@
use serde::{Deserialize, Serialize};
use tauri::Emitter;
use theseus::drop_classifier::{
DroppedCandidate, DroppedItemType, ModrinthLookupResult,
classify_dropped_item_with_candidates, classify_zip_with_extraction,
lookup_mod_hash,
};
use theseus::pack::import::{ImportLauncherType, get_importable_instances};
use theseus::{LockingProcess, get_locking_processes};
use tracing::{debug, info, warn};
/// A scanned importable instance: name plus the resolved filesystem path.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ScannedInstance {
pub name: String,
pub path: String,
#[serde(default)]
pub compatible_mode: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version_path: Option<String>,
}
/// One candidate inside a multi-candidate classification result.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CandidateResult {
pub item_type: String,
pub file_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inner_base: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub candidates: Vec<String>,
}
/// Serializable classification result mapped from `DroppedItemType`.
///
/// All `PathBuf` fields are converted to `String` via `to_string_lossy()`.
/// The JSON representation uses an `item_type` tag (via `#[serde(tag = "item_type")]`)
/// so the frontend can discriminate variants with a string switch.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "item_type")]
pub enum ClassificationResult {
#[serde(rename = "launcher")]
Launcher {
launcher_type: String,
base_path: String,
#[serde(
rename = "innerBase",
default,
skip_serializing_if = "Option::is_none"
)]
inner_base: Option<String>,
},
#[serde(rename = "hmcl_launcher")]
HmclLauncher {
launcher_dir: String,
data_dir: String,
},
#[serde(rename = "mod")]
Mod { file_path: String },
#[serde(rename = "litematic")]
Litematic { file_path: String },
#[serde(rename = "resource_pack")]
ResourcePack {
file_path: String,
candidates: Vec<String>,
#[serde(
rename = "innerBase",
default,
skip_serializing_if = "Option::is_none"
)]
inner_base: Option<String>,
},
#[serde(rename = "shader_pack")]
ShaderPack {
file_path: String,
#[serde(
rename = "innerBase",
default,
skip_serializing_if = "Option::is_none"
)]
inner_base: Option<String>,
},
#[serde(rename = "world_save")]
WorldSave {
file_path: String,
#[serde(
rename = "innerBase",
default,
skip_serializing_if = "Option::is_none"
)]
inner_base: Option<String>,
},
#[serde(rename = "modpack")]
Modpack { file_path: String },
#[serde(rename = "multiple")]
Multiple {
file_path: String,
candidates: Vec<CandidateResult>,
},
#[serde(rename = "shortcut_resolved")]
ShortcutResolved {
original: String,
resolved_to: Box<ClassificationResult>,
},
#[serde(rename = "unknown")]
Unknown { reason: String },
}
impl From<DroppedCandidate> for CandidateResult {
fn from(candidate: DroppedCandidate) -> Self {
CandidateResult {
item_type: candidate.item_type,
file_path: candidate.file_path.to_string_lossy().to_string(),
inner_base: candidate.inner_base,
candidates: candidate.candidates,
}
}
}
impl From<DroppedItemType> for ClassificationResult {
fn from(item: DroppedItemType) -> Self {
match item {
DroppedItemType::Launcher {
launcher_type,
base_path,
inner_base,
} => ClassificationResult::Launcher {
launcher_type: launcher_type.to_string(),
base_path: base_path.to_string_lossy().to_string(),
inner_base,
},
DroppedItemType::HmclLauncher {
launcher_dir,
data_dir,
} => ClassificationResult::HmclLauncher {
launcher_dir: launcher_dir.to_string_lossy().to_string(),
data_dir: data_dir.to_string_lossy().to_string(),
},
DroppedItemType::Mod { file_path } => ClassificationResult::Mod {
file_path: file_path.to_string_lossy().to_string(),
},
DroppedItemType::Litematic { file_path } => {
ClassificationResult::Litematic {
file_path: file_path.to_string_lossy().to_string(),
}
}
DroppedItemType::ResourcePack {
file_path,
candidates,
inner_base,
} => ClassificationResult::ResourcePack {
file_path: file_path.to_string_lossy().to_string(),
candidates,
inner_base,
},
DroppedItemType::ShaderPack {
file_path,
inner_base,
} => ClassificationResult::ShaderPack {
file_path: file_path.to_string_lossy().to_string(),
inner_base,
},
DroppedItemType::WorldSave {
file_path,
inner_base,
} => ClassificationResult::WorldSave {
file_path: file_path.to_string_lossy().to_string(),
inner_base,
},
DroppedItemType::ShortcutResolved {
original,
resolved_to,
} => ClassificationResult::ShortcutResolved {
original: original.to_string_lossy().to_string(),
resolved_to: Box::new(ClassificationResult::from(*resolved_to)),
},
DroppedItemType::Modpack { file_path } => {
ClassificationResult::Modpack {
file_path: file_path.to_string_lossy().to_string(),
}
}
DroppedItemType::Multiple {
file_path,
candidates,
} => ClassificationResult::Multiple {
file_path: file_path.to_string_lossy().to_string(),
candidates: candidates.into_iter().map(Into::into).collect(),
},
DroppedItemType::Unknown { reason } => {
ClassificationResult::Unknown { reason }
}
}
}
}
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("drop")
.invoke_handler(tauri::generate_handler![
drop_classify,
drop_classify_extract,
drop_extract_zip_to_temp,
drop_scan_launcher_instances,
drop_remove_temp_dir,
drop_detect_file_lock,
drop_extract_mod_metadata,
drop_lookup_mod_hash,
])
.build()
}
/// Classify a dropped file or folder path.
///
/// Returns a `ClassificationResult` with an `item_type` tag that the frontend
/// can use to decide what UI to show (confirm dialog, error, etc.).
#[tauri::command]
pub async fn drop_classify<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
path: String,
allow_nested_extraction: Option<bool>,
) -> Result<ClassificationResult, String> {
debug!("Drop event received: {}", path);
let path = std::path::PathBuf::from(&path);
let path_label = path.to_string_lossy().to_string();
let _ = app.emit(
"drop_classify_progress",
serde_json::json!({
"phase": "classify",
"currentItem": path_label,
"processed": 0,
"total": null,
}),
);
// The first pass never unpacks nested archives; when one would be needed
// the classification reports the total nested size so the frontend can
// confirm the potentially slow unpack with the user before retrying.
// Batch drops classify several files concurrently, so the classifier must
// run on a blocking thread instead of occupying the async runtime.
let result = tokio::task::spawn_blocking(move || {
if allow_nested_extraction.unwrap_or(false) {
classify_dropped_item_with_candidates(&path, true)
} else {
classify_dropped_item_with_candidates(&path, false)
}
})
.await
.map_err(|e| format!("Classification task panicked: {e}"))?;
let _ = app.emit(
"drop_classify_progress",
serde_json::json!({
"phase": "done",
"currentItem": path_label,
"processed": 1,
"total": 1,
}),
);
let classification = ClassificationResult::from(result);
info!("Classification result: {:?}", classification);
Ok(classification)
}
/// Classify ZIP
#[tauri::command]
pub async fn drop_classify_extract<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
path: String,
) -> Result<ClassificationResult, String> {
debug!("Drop classify with extraction: {}", path);
let path = std::path::PathBuf::from(&path);
let path_label = path.to_string_lossy().to_string();
let _ = app.emit(
"drop_classify_progress",
serde_json::json!({
"phase": "extract",
"currentItem": &path_label,
"processed": 0,
"total": null,
}),
);
let result = tokio::task::spawn_blocking(move || {
classify_zip_with_extraction(&path)
})
.await
.map_err(|e| format!("Extraction task panicked: {e}"))?;
let _ = app.emit(
"drop_classify_progress",
serde_json::json!({
"phase": "done",
"currentItem": &path_label,
"processed": 1,
"total": 1,
}),
);
let classification = ClassificationResult::from(result);
info!(
"Classification result (with extraction): {:?}",
classification
);
Ok(classification)
}
/// Root directory under the system temp where compressed launcher folders
/// are extracted for scanning and importing. Entries are removed with
/// `drop_remove_temp_dir` once the frontend flow ends.
fn launcher_import_temp_base() -> std::path::PathBuf {
std::env::temp_dir().join("axolotl-launcher-import")
}
/// Remove `drop-*` directories under `base` whose contents are older than
/// one day. The frontend cleans up after every flow, but a crashed process
/// would otherwise leave stale extractions behind forever.
fn sweep_stale_launcher_import_dirs(base: &std::path::Path) {
let Ok(entries) = std::fs::read_dir(base) else {
return;
};
let cutoff = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(24 * 60 * 60));
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.starts_with("drop-") {
continue;
}
let stale = match path.metadata().and_then(|m| m.modified()) {
Ok(modified) => cutoff.is_none_or(|cutoff| modified < cutoff),
Err(_) => false,
};
if stale {
tracing::debug!(
"Removing stale launcher import temp dir: {}",
path.display()
);
let _ = std::fs::remove_dir_all(&path);
}
}
}
/// Extract a ZIP archive into a fresh temporary directory and return its
/// path. The frontend scans and imports instances from the extraction, then
/// calls [`drop_remove_temp_dir`] to clean it up — the archive is unpacked
/// exactly once.
#[tauri::command]
pub async fn drop_extract_zip_to_temp<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
zip_path: String,
) -> Result<String, String> {
let zip_path = std::path::PathBuf::from(&zip_path);
info!("Extracting launcher ZIP to temp: {}", zip_path.display());
let _ = app.emit(
"drop_classify_progress",
serde_json::json!({
"phase": "extract",
"currentItem": zip_path.to_string_lossy(),
"processed": 0,
"total": null,
}),
);
let base = launcher_import_temp_base();
let zip_path_label = zip_path.to_string_lossy().to_string();
let extracted =
tokio::task::spawn_blocking(move || -> Result<String, String> {
std::fs::create_dir_all(&base).map_err(|e| {
format!("Failed to create temp base '{}': {e}", base.display())
})?;
sweep_stale_launcher_import_dirs(&base);
let dir = base.join(format!(
"drop-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
std::fs::create_dir(&dir)
.map_err(|e| format!("Failed to create temp directory: {e}"))?;
theseus::drop_classifier::extract_zip_to_dir(&zip_path, &dir)
.map_err(|e| {
let _ = std::fs::remove_dir_all(&dir);
tracing::warn!(
"Launcher ZIP extraction failed for '{}': {e}",
zip_path.display()
);
e
})?;
Ok(dir.to_string_lossy().to_string())
})
.await
.map_err(|e| {
tracing::warn!("Launcher ZIP extraction task panicked: {e}");
format!("Extraction task panicked: {e}")
})??;
info!("Extracted launcher ZIP to: {extracted}");
let _ = app.emit(
"drop_classify_progress",
serde_json::json!({
"phase": "done",
"currentItem": zip_path_label,
"processed": 1,
"total": 1,
}),
);
Ok(extracted)
}
/// Remove a temporary directory created by [`drop_extract_zip_to_temp`].
/// Only paths inside the launcher import temp root are accepted.
#[tauri::command]
pub async fn drop_remove_temp_dir(path: String) -> Result<(), String> {
let base = launcher_import_temp_base();
let base = std::fs::canonicalize(&base)
.map_err(|e| format!("Launcher import temp base missing: {e}"))?;
let target = std::fs::canonicalize(&path)
.map_err(|e| format!("Temp path missing: {e}"))?;
if !target.starts_with(&base) {
return Err(format!(
"Refusing to remove '{}': not inside the launcher import temp root",
target.display()
));
}
if !target.is_dir() {
return Err(format!(
"Refusing to remove '{}': not a directory",
target.display()
));
}
tokio::task::spawn_blocking(move || {
std::fs::remove_dir_all(&target).map_err(|e| {
format!("Failed to remove temp dir '{}': {e}", target.display())
})
})
.await
.map_err(|e| format!("Cleanup task panicked: {e}"))?
}
/// Scan for importable instances in a launcher's data directory.
///
/// `launcher_type` must be one of the `ImportLauncherType` variant names
/// (e.g. `"MultiMC"`, `"PrismLauncher"`, `"HMCL"`).
#[tauri::command]
pub async fn drop_scan_launcher_instances<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
launcher_type: String,
base_path: String,
) -> Result<Vec<ScannedInstance>, String> {
info!(
"Scanning launcher instances — type: {launcher_type}, path: {base_path}"
);
let base_path_label = base_path.clone();
let _ = app.emit(
"drop_classify_progress",
serde_json::json!({
"phase": "scan",
"currentItem": &base_path_label,
"processed": 0,
"total": null,
}),
);
let lt: ImportLauncherType =
serde_json::from_str(&format!("\"{launcher_type}\"")).map_err(|e| {
format!("Invalid launcher type '{launcher_type}': {e}")
})?;
let base = std::path::PathBuf::from(&base_path);
let instances = get_importable_instances(lt, base)
.await
.map_err(|e| e.to_string())?;
info!("Scan complete — found {} instance(s)", instances.len());
for inst in &instances {
debug!(
"Scanned instance: name={:?} path={:?} compatible_mode={}",
inst.name, inst.path, inst.compatible_mode
);
}
let _ = app.emit(
"drop_classify_progress",
serde_json::json!({
"phase": "done",
"currentItem": &base_path_label,
"processed": 1,
"total": 1,
}),
);
Ok(instances
.into_iter()
.map(|i| ScannedInstance {
name: i.name,
path: i.path,
compatible_mode: i.compatible_mode,
version_path: i.version_path,
})
.collect())
}
/// Detect processes holding a file lock on the given path.
///
/// Returns an empty list when detection is unavailable on the current platform
/// or the required tools are not installed.
#[tauri::command]
pub async fn drop_detect_file_lock(
path: String,
) -> Result<Vec<LockingProcess>, String> {
let path = std::path::PathBuf::from(&path);
info!("Detecting file lock for: {}", path.display());
let processes = get_locking_processes(&path);
if !processes.is_empty() {
warn!("File locked by {} process(es)", processes.len());
}
Ok(processes)
}
/// Extract mod metadata from a JAR file without installing it.
///
/// Reads the JAR bytes, extracts embedded mod metadata (fabric.mod.json,
/// quilt.mod.json, META-INF/mods.toml, etc.), and returns the parsed
/// `LocalModMetadata` as a JSON string.
#[tauri::command]
pub async fn drop_extract_mod_metadata(path: String) -> Result<String, String> {
let path = std::path::PathBuf::from(&path);
let meta = tokio::task::spawn_blocking(move || {
let file_bytes = std::fs::read(&path)
.map_err(|e| format!("Failed to read file: {e}"))?;
let bytes = bytes::Bytes::from(file_bytes);
theseus::mod_metadata::extract_mod_metadata(&bytes)
.ok_or_else(|| "No mod metadata found in file".to_string())
})
.await
.map_err(|e| format!("Metadata extraction task panicked: {e}"))??;
serde_json::to_string(&meta)
.map_err(|e| format!("Failed to serialize metadata: {e}"))
}
/// Look up a mod file by SHA1 hash to find matching Modrinth project and version.
///
/// Computes the SHA1 hash of the given file and queries the Modrinth API
/// to find matching versions. Returns project and version information if found.
#[tauri::command]
pub async fn drop_lookup_mod_hash(
path: String,
) -> Result<Option<ModrinthLookupResult>, String> {
let path = std::path::PathBuf::from(&path);
info!("Looking up mod hash for: {}", path.display());
lookup_mod_hash(&path)
.await
.map_err(|e| format!("Failed to lookup mod hash: {e}"))
}

614
apps/app/src/api/files.rs Normal file
View File

@ -0,0 +1,614 @@
use crate::api::Result;
use async_zip::base::read::seek::ZipFileReader;
use image::ImageEncoder;
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use serde::Serialize;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{Emitter, Runtime};
use tauri_plugin_dialog::DialogExt;
use theseus::instance::get_full_path;
const STUDIO_FILES_CHANGED_EVENT: &str = "studio-files-changed";
pub(crate) fn ensure_browsable<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
root: &std::path::Path,
) {
use tauri_plugin_fs::FsExt;
if let Err(error) = app.fs_scope().allow_directory(root, true) {
tracing::warn!(%error, path = %root.display(), "Failed to grant webview access to external instance");
}
}
#[derive(Default)]
pub struct StudioWatchers {
watchers: Mutex<HashMap<String, StudioWatcher>>,
}
struct StudioWatcher {
registration_id: String,
root: PathBuf,
watcher: RecommendedWatcher,
}
impl Drop for StudioWatcher {
fn drop(&mut self) {
if let Err(error) = self.watcher.unwatch(&self.root) {
tracing::warn!(%error, "Failed to stop Studio file watcher");
}
}
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct StudioFilesChangedEvent {
instance_id: String,
registration_id: String,
paths: Vec<String>,
}
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("files")
.invoke_handler(tauri::generate_handler![
file_extract_zip,
file_save_as,
file_read_dragged_file,
screenshot_thumbnail,
instance_icon_thumbnail,
studio_read_text,
studio_read_binary,
studio_write_binary,
studio_trash,
studio_watch_register,
studio_watch_unregister,
])
.build()
}
#[tauri::command]
pub async fn studio_trash(instance_id: &str, file_path: &str) -> Result<()> {
let base = get_full_path(instance_id).await?;
let source = tokio::fs::canonicalize(base.join(file_path)).await?;
let canonical_base = tokio::fs::canonicalize(&base).await?;
if !source.starts_with(&canonical_base) {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"file_path escapes the instance directory".to_string(),
))
.into());
}
tokio::task::spawn_blocking(move || trash::delete(source))
.await
.map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to send file to trash: {error}"
)))
})?
.map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to send file to trash: {error}"
)))
})?;
Ok(())
}
#[tauri::command]
pub async fn studio_read_text(
instance_id: &str,
file_path: &str,
) -> Result<String> {
let base = get_full_path(instance_id).await?;
let source = tokio::fs::canonicalize(base.join(file_path)).await?;
let canonical_base = tokio::fs::canonicalize(&base).await?;
if !source.starts_with(&canonical_base) {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"file_path escapes the instance directory".to_string(),
))
.into());
}
let bytes = tokio::fs::read(source).await?;
if bytes.contains(&0) {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"File is not a text file".to_string(),
))
.into());
}
String::from_utf8(bytes).map_err(|_| {
theseus::Error::from(theseus::ErrorKind::OtherError(
"File is not a UTF-8 text file".to_string(),
))
.into()
})
}
async fn studio_file_path(
instance_id: &str,
file_path: &str,
) -> Result<PathBuf> {
let base = get_full_path(instance_id).await?;
let source = tokio::fs::canonicalize(base.join(file_path)).await?;
let canonical_base = tokio::fs::canonicalize(&base).await?;
if !source.starts_with(&canonical_base) {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"file_path escapes the instance directory".to_string(),
))
.into());
}
Ok(source)
}
#[tauri::command]
pub async fn studio_read_binary(
instance_id: &str,
file_path: &str,
) -> Result<tauri::ipc::Response> {
Ok(tauri::ipc::Response::new(
tokio::fs::read(studio_file_path(instance_id, file_path).await?)
.await?,
))
}
#[tauri::command]
pub async fn studio_write_binary(
instance_id: &str,
file_path: &str,
bytes: Vec<u8>,
) -> Result<()> {
tokio::fs::write(studio_file_path(instance_id, file_path).await?, bytes)
.await?;
Ok(())
}
#[tauri::command]
pub async fn studio_watch_register<R: Runtime>(
app: tauri::AppHandle<R>,
state: tauri::State<'_, StudioWatchers>,
instance_id: String,
) -> Result<String> {
let root = get_full_path(&instance_id).await?;
if !root.is_dir() {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"Instance directory does not exist".to_string(),
))
.into());
}
let registration_id = uuid::Uuid::new_v4().to_string();
let event_instance_id = instance_id.clone();
let event_registration_id = registration_id.clone();
let event_root = root.clone();
let mut watcher = notify::recommended_watcher(
move |result: notify::Result<notify::Event>| match result {
Ok(event) => {
let mut paths = event
.paths
.into_iter()
.filter_map(|path| {
path.strip_prefix(&event_root)
.ok()
.filter(|relative| !relative.as_os_str().is_empty())
.map(|relative| {
relative.to_string_lossy().replace('\\', "/")
})
})
.collect::<Vec<_>>();
paths.sort();
paths.dedup();
if paths.is_empty() {
return;
}
if let Err(error) = app.emit(
STUDIO_FILES_CHANGED_EVENT,
StudioFilesChangedEvent {
instance_id: event_instance_id.clone(),
registration_id: event_registration_id.clone(),
paths,
},
) {
tracing::warn!(%error, "Failed to emit Studio file watcher event");
}
}
Err(error) => {
tracing::warn!(%error, "Studio file watcher failed");
}
},
)
.map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to create Studio file watcher: {error}"
)))
})?;
watcher
.watch(&root, RecursiveMode::Recursive)
.map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to watch instance directory: {error}"
)))
})?;
let mut watchers = state.watchers.lock().map_err(|_| {
theseus::Error::from(theseus::ErrorKind::OtherError(
"Studio watcher state is unavailable".to_string(),
))
})?;
watchers.insert(
instance_id,
StudioWatcher {
registration_id: registration_id.clone(),
root,
watcher,
},
);
Ok(registration_id)
}
#[tauri::command]
pub fn studio_watch_unregister(
state: tauri::State<'_, StudioWatchers>,
instance_id: String,
registration_id: String,
) -> Result<()> {
let mut watchers = state.watchers.lock().map_err(|_| {
theseus::Error::from(theseus::ErrorKind::OtherError(
"Studio watcher state is unavailable".to_string(),
))
})?;
if watchers
.get(&instance_id)
.is_some_and(|watcher| watcher.registration_id == registration_id)
{
watchers.remove(&instance_id);
}
Ok(())
}
#[derive(Serialize)]
pub struct ExtractDryRunResult {
modpack_name: Option<String>,
conflicting_files: Vec<String>,
}
#[tauri::command]
pub async fn file_read_dragged_file(
path: String,
) -> Result<tauri::ipc::Response> {
let metadata = tokio::fs::metadata(&path).await?;
if !metadata.is_file() {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"Dropped path is not a file".to_string(),
))
.into());
}
// Raw binary payload: the frontend receives an ArrayBuffer instead of a
// JSON number array, which would balloon multi-hundred-MB files to
// gigabytes of transient memory on both sides of the IPC boundary.
Ok(tauri::ipc::Response::new(tokio::fs::read(path).await?))
}
/// Decodes a screenshot and returns a downscaled thumbnail as raw image bytes,
/// so the webview never decodes high-resolution originals in the screenshots grid.
/// Files that already fit within `max_dimension` are returned unchanged.
#[tauri::command]
pub async fn screenshot_thumbnail(
instance_id: &str,
file_path: &str,
max_dimension: u32,
) -> Result<tauri::ipc::Response> {
let base = get_full_path(instance_id).await?;
let canonical_source =
tokio::fs::canonicalize(base.join(file_path)).await?;
let canonical_base = tokio::fs::canonicalize(&base).await?;
if !canonical_source.starts_with(&canonical_base) {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"file_path escapes the instance directory".to_string(),
))
.into());
}
let bytes = tokio::fs::read(&canonical_source).await?;
let max_dimension = max_dimension.max(1);
let thumbnail = tokio::task::spawn_blocking(move || -> Result<Vec<u8>> {
let (width, height) =
image::ImageReader::new(Cursor::new(bytes.as_slice()))
.with_guessed_format()
.map_err(|error| thumbnail_error(error.into()))?
.into_dimensions()
.map_err(thumbnail_error)?;
if width <= max_dimension && height <= max_dimension {
return Ok(bytes);
}
let decoded = image::ImageReader::new(Cursor::new(bytes.as_slice()))
.with_guessed_format()
.map_err(|error| thumbnail_error(error.into()))?
.decode()
.map_err(thumbnail_error)?;
let thumbnail = decoded.thumbnail(max_dimension, max_dimension);
let mut output = Vec::new();
if thumbnail.color().has_alpha() {
let rgba = thumbnail.to_rgba8();
image::codecs::png::PngEncoder::new(&mut output)
.write_image(
rgba.as_raw(),
rgba.width(),
rgba.height(),
image::ExtendedColorType::Rgba8,
)
.map_err(thumbnail_error)?;
} else {
let rgb = thumbnail.to_rgb8();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 85)
.write_image(
rgb.as_raw(),
rgb.width(),
rgb.height(),
image::ExtendedColorType::Rgb8,
)
.map_err(thumbnail_error)?;
}
Ok(output)
})
.await
.map_err(|e| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Screenshot thumbnail task failed: {e}"
)))
})??;
Ok(tauri::ipc::Response::new(thumbnail))
}
pub(crate) async fn local_instance_icon_path(
instance_id: &str,
max_dimension: u32,
) -> Result<Option<String>> {
const LOCAL_ICON_NAMES: [&str; 4] =
["icon.png", "icon.jpg", "icon.jpeg", "icon.webp"];
const MAX_LOCAL_ICON_BYTES: usize = 2 * 1024 * 1024;
let base = get_full_path(instance_id).await?;
let max_dimension = max_dimension.max(1);
for file_name in LOCAL_ICON_NAMES {
let source = base.join(file_name);
if !source.is_file() {
continue;
}
let candidate: Result<Option<String>> = async {
let bytes = tokio::fs::read(&source).await?;
let (processed, cache_name) = tokio::task::spawn_blocking(
move || -> Result<(Vec<u8>, &'static str)> {
let (width, height) =
image::ImageReader::new(Cursor::new(bytes.as_slice()))
.with_guessed_format()
.map_err(|error| thumbnail_error(error.into()))?
.into_dimensions()
.map_err(|error| thumbnail_error(error.into()))?;
if width <= max_dimension
&& height <= max_dimension
&& bytes.len() <= MAX_LOCAL_ICON_BYTES
{
return Ok((bytes, file_name));
}
let decoded =
image::ImageReader::new(Cursor::new(bytes.as_slice()))
.with_guessed_format()
.map_err(|error| thumbnail_error(error.into()))?
.decode()
.map_err(|error| thumbnail_error(error.into()))?;
let thumbnail =
decoded.thumbnail(max_dimension, max_dimension);
let mut output = Vec::new();
if thumbnail.color().has_alpha() {
let rgba = thumbnail.to_rgba8();
image::codecs::png::PngEncoder::new(&mut output)
.write_image(
rgba.as_raw(),
rgba.width(),
rgba.height(),
image::ExtendedColorType::Rgba8,
)
.map_err(|error| thumbnail_error(error.into()))?;
Ok((output, "icon.png"))
} else {
let rgb = thumbnail.to_rgb8();
image::codecs::jpeg::JpegEncoder::new_with_quality(
&mut output,
85,
)
.write_image(
rgb.as_raw(),
rgb.width(),
rgb.height(),
image::ExtendedColorType::Rgb8,
)
.map_err(|error| thumbnail_error(error.into()))?;
Ok((output, "icon.jpg"))
}
},
)
.await
.map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Instance icon thumbnail task failed: {error}"
)))
})??;
let cached_path =
theseus::instance::cache_icon(cache_name, processed).await?;
Ok(Some(cached_path))
}
.await;
if let Ok(Some(path)) = candidate {
return Ok(Some(path));
}
}
Ok(None)
}
#[tauri::command]
pub async fn instance_icon_thumbnail(
instance_id: &str,
max_dimension: u32,
) -> Result<Option<String>> {
local_instance_icon_path(instance_id, max_dimension).await
}
fn thumbnail_error(error: image::ImageError) -> theseus::Error {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to process screenshot: {error}"
)))
}
#[tauri::command]
pub async fn file_extract_zip(
instance_id: &str,
file_path: &str,
override_conflicts: bool,
dry_run: bool,
) -> Result<Option<ExtractDryRunResult>> {
let base = get_full_path(instance_id).await?;
let zip_path = base.join(file_path);
let canonical_zip = tokio::fs::canonicalize(&zip_path).await?;
let canonical_base = tokio::fs::canonicalize(&base).await?;
if !canonical_zip.starts_with(&canonical_base) {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"file_path escapes the instance directory".to_string(),
))
.into());
}
let extract_dir = zip_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| base.clone());
let file_bytes = tokio::fs::read(&zip_path).await?;
let reader = Cursor::new(file_bytes);
let zip_reader = ZipFileReader::with_tokio(reader).await.map_err(|e| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to read zip file: {e}"
)))
})?;
let entries: Vec<(usize, String)> = zip_reader
.file()
.entries()
.iter()
.enumerate()
.filter_map(|(i, entry)| {
let name = entry.filename().as_str().ok()?.to_string();
if name.ends_with('/') {
None
} else {
Some((i, name))
}
})
.collect();
if dry_run {
let mut conflicting_files = Vec::new();
let canonical_extract = tokio::fs::canonicalize(&extract_dir).await?;
for (_, name) in &entries {
let target = extract_dir.join(name);
if let Some(parent) = target.parent() {
let normalized = parent
.canonicalize()
.unwrap_or_else(|_| extract_dir.join(parent));
if !normalized.starts_with(&canonical_extract) {
continue;
}
}
if target.exists() {
conflicting_files.push(name.clone());
}
}
return Ok(Some(ExtractDryRunResult {
modpack_name: None,
conflicting_files,
}));
}
let canonical_extract_dir = tokio::fs::canonicalize(&extract_dir).await?;
let mut zip_reader = zip_reader;
for (index, name) in &entries {
let target = extract_dir.join(name);
if !override_conflicts && target.exists() {
continue;
}
if let Some(parent) = target.parent() {
tokio::fs::create_dir_all(parent).await?;
let canonical_parent = tokio::fs::canonicalize(parent).await?;
if !canonical_parent.starts_with(&canonical_extract_dir) {
continue;
}
}
let mut file_bytes = Vec::new();
let mut entry_reader =
zip_reader.reader_with_entry(*index).await.map_err(|e| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to read zip entry: {e}"
)))
})?;
entry_reader
.read_to_end_checked(&mut file_bytes)
.await
.map_err(|e| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to extract zip entry: {e}"
)))
})?;
tokio::fs::write(&target, &file_bytes).await?;
}
Ok(None)
}
#[tauri::command]
pub async fn file_save_as<R: Runtime>(
app: tauri::AppHandle<R>,
instance_id: &str,
file_path: &str,
) -> Result<()> {
let base = get_full_path(instance_id).await?;
let source = base.join(file_path);
let file_name = source
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.set_file_name(&file_name)
.save_file(|path| {
let _ = tx.send(path);
});
if let Some(dest) = rx.await.unwrap_or(None) {
let dest_path = std::path::PathBuf::try_from(dest).map_err(|e| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Invalid save path: {e}"
)))
})?;
tokio::fs::copy(&source, &dest_path).await?;
}
Ok(())
}

View File

@ -0,0 +1,33 @@
use tauri::plugin::TauriPlugin;
use theseus::prelude::{UserFriend, UserStatus};
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("friends")
.invoke_handler(tauri::generate_handler![
friends,
friend_statuses,
add_friend,
remove_friend
])
.build()
}
#[tauri::command]
pub async fn friends() -> crate::api::Result<Vec<UserFriend>> {
Ok(theseus::friends::friends().await?)
}
#[tauri::command]
pub async fn friend_statuses() -> crate::api::Result<Vec<UserStatus>> {
Ok(theseus::friends::friend_statuses().await?)
}
#[tauri::command]
pub async fn add_friend(user_id: &str) -> crate::api::Result<()> {
Ok(theseus::friends::add_friend(user_id).await?)
}
#[tauri::command]
pub async fn remove_friend(user_id: &str) -> crate::api::Result<()> {
Ok(theseus::friends::remove_friend(user_id).await?)
}

View File

@ -0,0 +1,48 @@
use std::path::PathBuf;
use crate::api::Result;
use theseus::pack::import::{ImportLauncherType, ImportableInstance};
use theseus::pack::import;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("import")
.invoke_handler(tauri::generate_handler![
get_importable_instances,
is_valid_importable_instance,
get_default_launcher_path,
])
.build()
}
/// Gets a list of importable instances from a launcher type and base path.
/// Each entry includes the display name and the resolved filesystem path.
#[tauri::command]
pub async fn get_importable_instances(
launcher_type: ImportLauncherType,
base_path: PathBuf,
) -> Result<Vec<ImportableInstance>> {
Ok(import::get_importable_instances(launcher_type, base_path).await?)
}
/// Checks if this instance is valid for importing, given a certain launcher type
/// eg: is_valid_importable_instance(PathBuf::from("C:/MultiMC/Instance 1"), ImportLauncherType::MultiMC)
#[tauri::command]
pub async fn is_valid_importable_instance(
instance_folder: PathBuf,
launcher_type: ImportLauncherType,
) -> Result<bool> {
Ok(
import::is_valid_importable_instance(instance_folder, launcher_type)
.await,
)
}
/// Returns the default path for the given launcher type
/// None if it can't be found or doesn't exist
#[tauri::command]
pub async fn get_default_launcher_path(
launcher_type: ImportLauncherType,
) -> Result<Option<PathBuf>> {
Ok(import::get_default_launcher_path(launcher_type))
}

405
apps/app/src/api/install.rs Normal file
View File

@ -0,0 +1,405 @@
use crate::api::Result;
use crate::api::instance::InstanceLink;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use theseus::data::ModLoader;
use theseus::install::{
ImportPlanRequest, InstallJobSnapshot, InstallModpackPreview,
InstallPostInstallEdit,
};
use theseus::pack::import::ImportLauncherType;
use theseus::pack::install_from::CreatePackLocation;
use uuid::Uuid;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("install")
.invoke_handler(tauri::generate_handler![
install_get_modpack_preview,
install_create_instance,
install_create_modpack_instance,
install_import_instance,
install_start_import_plan,
install_cancel_import_plan,
install_duplicate_instance,
install_existing_instance,
install_pack_to_existing_instance,
install_job_list,
install_job_get,
install_job_retry,
install_job_repair_cache_and_retry,
install_job_resume,
install_job_skip_missing_content,
install_job_missing_files,
install_job_scan_missing_files,
install_job_retry_missing_file,
install_job_import_missing_file,
install_job_cancel,
install_job_dismiss,
install_job_support_details,
download_job_list,
download_job_get,
download_job_retry,
download_job_resume,
download_job_cancel,
download_job_delete,
download_history_clear,
download_job_support_details,
])
.build()
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallCreateInstanceRequest {
pub name: String,
pub game_version: String,
pub loader: ModLoader,
pub loader_version: Option<String>,
#[serde(default)]
pub adjuncts: Vec<theseus::data::LoaderComponent>,
pub icon_path: Option<String>,
pub link: Option<InstanceLink>,
#[serde(default)]
pub game_dir_override: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallPostInstallEditRequest {
pub name: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_with::rust::double_option"
)]
pub icon_path: Option<Option<String>>,
pub link: Option<InstanceLink>,
}
impl InstallPostInstallEditRequest {
fn into_core(self) -> Result<InstallPostInstallEdit> {
Ok(InstallPostInstallEdit {
name: self.name,
icon_path: self.icon_path,
link: self.link.map(|link| link.into_core()).transpose()?,
})
}
}
#[tauri::command]
pub async fn install_get_modpack_preview(
location: CreatePackLocation,
) -> Result<InstallModpackPreview> {
Ok(theseus::pack::install_from::get_instance_from_pack(location).await?)
}
#[tauri::command]
pub async fn install_create_instance(
request: InstallCreateInstanceRequest,
) -> Result<InstallJobSnapshot> {
Ok(theseus::install::create_instance_with_adjuncts(
request.name.trim().to_string(),
request.game_version,
request.loader,
request.loader_version,
request.adjuncts,
request.icon_path,
match request.link {
Some(link) => link.into_core()?,
None => theseus::data::InstanceLink::Unmanaged,
},
request.game_dir_override,
)
.await?)
}
#[tauri::command]
pub async fn install_create_modpack_instance(
location: CreatePackLocation,
post_install_edit: Option<InstallPostInstallEditRequest>,
) -> Result<InstallJobSnapshot> {
Ok(theseus::install::create_modpack_instance(
location,
post_install_edit.map(|edit| edit.into_core()).transpose()?,
)
.await?)
}
#[tauri::command]
pub async fn install_import_instance(
launcher_type: ImportLauncherType,
base_path: PathBuf,
instance_folder: String,
instance_path: Option<String>,
symlink: bool,
game_version: Option<String>,
loader: Option<ModLoader>,
loader_version: Option<String>,
game_dir_override: Option<String>,
) -> Result<InstallJobSnapshot> {
tracing::debug!(
"install_import_instance called: launcher_type={launcher_type:?} base_path={} instance_folder={} instance_path={:?} symlink={symlink} game_version={game_version:?} loader={loader:?} loader_version={loader_version:?} game_dir_override={game_dir_override:?}",
base_path.display(),
instance_folder,
instance_path,
);
// Extracted launcher archives live in a temporary folder that is removed
// after the import; a symlink into it would dangle immediately.
if symlink
&& base_path
.starts_with(std::env::temp_dir().join("axolotl-launcher-import"))
{
return Err(theseus::Error::from(theseus::ErrorKind::InputError(
"Symbolic-link import is unavailable for extracted archives: \
the temporary folder is deleted after the import completes. \
Choose copy instead."
.to_string(),
))
.into());
}
Ok(theseus::install::import_instance_with_plan(
launcher_type,
base_path,
instance_folder,
instance_path,
symlink,
game_version,
loader,
loader_version,
game_dir_override,
)
.await?)
}
#[tauri::command]
pub async fn install_start_import_plan(
request: ImportPlanRequest,
) -> Result<String> {
Ok(theseus::install::start_import_plan(request)?)
}
#[tauri::command]
pub async fn install_cancel_import_plan(request_id: String) -> Result<()> {
Ok(theseus::install::cancel_import_plan(&request_id).await?)
}
#[tauri::command]
pub async fn install_duplicate_instance(
source_instance_id: String,
) -> Result<InstallJobSnapshot> {
Ok(theseus::install::duplicate_instance(source_instance_id).await?)
}
#[tauri::command]
pub async fn install_existing_instance(
instance_id: String,
force: bool,
) -> Result<InstallJobSnapshot> {
Ok(theseus::install::install_existing_instance(instance_id, force).await?)
}
#[tauri::command]
pub async fn install_pack_to_existing_instance(
instance_id: String,
location: CreatePackLocation,
post_install_edit: Option<InstallPostInstallEditRequest>,
) -> Result<InstallJobSnapshot> {
Ok(theseus::install::install_pack_to_existing_instance(
instance_id,
location,
post_install_edit.map(|edit| edit.into_core()).transpose()?,
)
.await?)
}
#[tauri::command]
pub async fn install_job_list(
include_finished: bool,
) -> Result<Vec<InstallJobSnapshot>> {
Ok(theseus::install::list_jobs(include_finished).await?)
}
#[tauri::command]
pub async fn install_job_get(job_id: Uuid) -> Result<InstallJobSnapshot> {
Ok(theseus::install::get_job(job_id).await?)
}
#[tauri::command]
pub async fn install_job_retry(job_id: Uuid) -> Result<InstallJobSnapshot> {
Ok(theseus::install::retry_job(job_id).await?)
}
#[tauri::command]
pub async fn install_job_repair_cache_and_retry(
job_id: Uuid,
) -> Result<InstallJobSnapshot> {
Ok(theseus::install::repair_cache_and_retry_job(job_id).await?)
}
#[tauri::command]
pub async fn install_job_resume(job_id: Uuid) -> Result<InstallJobSnapshot> {
Ok(theseus::install::resume_job(job_id).await?)
}
#[tauri::command]
pub async fn install_job_skip_missing_content(
job_id: Uuid,
) -> Result<InstallJobSnapshot> {
Ok(theseus::install::skip_missing_content_and_resume_job(job_id).await?)
}
#[tauri::command]
pub async fn install_job_missing_files(
job_id: Uuid,
) -> Result<theseus::install::MissingModpackContentView> {
Ok(theseus::install::list_missing_modpack_files(job_id).await?)
}
#[tauri::command]
pub async fn install_job_scan_missing_files(
job_id: Uuid,
scan_directory: Option<PathBuf>,
) -> Result<theseus::install::MissingModpackScanResult> {
Ok(
theseus::install::scan_missing_modpack_files(job_id, scan_directory)
.await?,
)
}
#[tauri::command]
pub async fn install_job_retry_missing_file(
job_id: Uuid,
item_id: String,
) -> Result<InstallJobSnapshot> {
Ok(theseus::install::retry_missing_modpack_file(job_id, item_id).await?)
}
#[tauri::command]
pub async fn install_job_import_missing_file(
job_id: Uuid,
item_id: String,
selected_file_path: PathBuf,
) -> Result<InstallJobSnapshot> {
Ok(theseus::install::import_missing_modpack_file(
job_id,
item_id,
selected_file_path,
)
.await?)
}
#[tauri::command]
pub async fn install_job_cancel(job_id: Uuid) -> Result<InstallJobSnapshot> {
Ok(theseus::install::cancel_job(job_id).await?)
}
#[tauri::command]
pub async fn install_job_dismiss(job_id: Uuid) -> Result<()> {
Ok(theseus::install::dismiss_job(job_id).await?)
}
#[tauri::command]
pub async fn install_job_support_details(job_id: String) -> Result<String> {
let Ok(uuid) = Uuid::parse_str(&job_id) else {
// Synthetic, frontend-tracked jobs (e.g. server downloads) use non-UUID
// identifiers and do not expose backend diagnostic details.
return Ok(String::new());
};
Ok(theseus::install::job_support_details(uuid).await?)
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct DownloadJobListRequest {
pub status: Option<String>,
pub provider: Option<String>,
pub query: Option<String>,
pub cursor: Option<String>,
pub limit: Option<usize>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DownloadJobPage {
pub jobs: Vec<InstallJobSnapshot>,
pub next_cursor: Option<String>,
}
#[tauri::command]
pub async fn download_job_list(
request: Option<DownloadJobListRequest>,
) -> Result<DownloadJobPage> {
let request = request.unwrap_or_default();
let mut jobs = theseus::install::list_jobs(true).await?;
if let Some(status) = request.status.as_deref() {
jobs.retain(|job| job.status.as_str() == status);
}
if let Some(provider) = request.provider.as_deref() {
jobs.retain(|job| {
format!("{:?}", job.provider)
.to_ascii_lowercase()
.replace('_', "")
== provider.to_ascii_lowercase().replace('_', "")
});
}
if let Some(query) = request.query.as_deref() {
let query = query.trim().to_ascii_lowercase();
if !query.is_empty() {
jobs.retain(|job| {
job.display.as_ref().is_some_and(|display| {
display.title.to_ascii_lowercase().contains(&query)
}) || job.job_id.to_string().contains(&query)
});
}
}
jobs.sort_by(|a, b| b.created.cmp(&a.created));
let offset = request
.cursor
.as_deref()
.and_then(|cursor| cursor.parse::<usize>().ok())
.unwrap_or(0);
let limit = request.limit.unwrap_or(100).clamp(1, 250);
let jobs = jobs
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
let next_cursor =
(jobs.len() == limit).then(|| (offset + limit).to_string());
Ok(DownloadJobPage { jobs, next_cursor })
}
#[tauri::command]
pub async fn download_job_get(job_id: Uuid) -> Result<InstallJobSnapshot> {
install_job_get(job_id).await
}
#[tauri::command]
pub async fn download_job_retry(job_id: Uuid) -> Result<InstallJobSnapshot> {
Ok(theseus::install::retry_job_as_new(job_id).await?)
}
#[tauri::command]
pub async fn download_job_resume(job_id: Uuid) -> Result<InstallJobSnapshot> {
install_job_resume(job_id).await
}
#[tauri::command]
pub async fn download_job_cancel(job_id: Uuid) -> Result<InstallJobSnapshot> {
install_job_cancel(job_id).await
}
#[tauri::command]
pub async fn download_job_delete(job_id: Uuid) -> Result<()> {
install_job_dismiss(job_id).await
}
#[tauri::command]
pub async fn download_history_clear() -> Result<u64> {
Ok(theseus::install::clear_job_history().await?)
}
#[tauri::command]
pub async fn download_job_support_details(job_id: String) -> Result<String> {
install_job_support_details(job_id).await
}

1496
apps/app/src/api/instance.rs Normal file

File diff suppressed because it is too large Load Diff

170
apps/app/src/api/jre.rs Normal file
View File

@ -0,0 +1,170 @@
use crate::api::Result;
use std::path::PathBuf;
use tauri::plugin::TauriPlugin;
use theseus::prelude::*;
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("jre")
.invoke_handler(tauri::generate_handler![
get_java_versions,
get_java_default_versions,
set_java_version,
set_java_default_version,
remove_java_default_version,
remove_java_version,
jre_find_filtered_jres,
jre_get_jre,
jre_test_jre,
jre_auto_install_java,
jre_respond_to_download_confirmation,
jre_get_max_memory,
jre_get_memory_status,
jre_optimize_memory,
list_java_distribution_versions,
list_java_feed_vendors,
list_java_feed_versions,
download_java_from_feed,
download_java,
])
.build()
}
#[tauri::command]
pub async fn get_java_versions() -> Result<Vec<JavaVersion>> {
Ok(jre::get_java_versions().await?)
}
#[tauri::command]
pub async fn get_java_default_versions() -> Result<Vec<JavaVersion>> {
Ok(jre::get_java_default_versions().await?)
}
#[tauri::command]
pub async fn set_java_version(java_version: JavaVersion) -> Result<()> {
jre::set_java_version(java_version).await?;
Ok(())
}
#[tauri::command]
pub async fn set_java_default_version(
major_version: u32,
path: String,
) -> Result<JavaVersion> {
Ok(jre::set_java_default_version(major_version, path).await?)
}
#[tauri::command]
pub async fn remove_java_default_version(major_version: u32) -> Result<()> {
jre::remove_java_default_version(major_version).await?;
Ok(())
}
#[tauri::command]
pub async fn remove_java_version(path: String) -> Result<()> {
jre::remove_java_version(path).await?;
Ok(())
}
// Finds the installation of Java 8, if it exists
#[tauri::command]
pub async fn jre_find_filtered_jres(
version: Option<u32>,
full_scan: bool,
force_fresh: bool,
exhaustive: bool,
) -> Result<Vec<JavaVersion>> {
Ok(
jre::find_filtered_jres(version, full_scan, force_fresh, exhaustive)
.await?,
)
}
// Validates JRE at a given path
// Returns None if the path is not a valid JRE
#[tauri::command]
pub async fn jre_get_jre(path: PathBuf) -> Result<JavaVersion> {
Ok(jre::check_jre(path).await?)
}
// Tests JRE of a certain version
#[tauri::command]
pub async fn jre_test_jre(path: PathBuf, major_version: u32) -> Result<bool> {
Ok(jre::test_jre(path, major_version).await?)
}
// Auto installs java for the given java version
#[tauri::command]
pub async fn jre_auto_install_java(
java_version: u32,
) -> Result<Option<PathBuf>> {
Ok(jre::auto_install_java(java_version).await?)
}
#[tauri::command]
pub fn jre_respond_to_download_confirmation(
request_id: uuid::Uuid,
approved: bool,
) -> bool {
jre::respond_to_java_download_confirmation(request_id, approved)
}
#[tauri::command]
pub async fn list_java_distribution_versions(
distribution: String,
) -> Result<Vec<u32>> {
Ok(jre::list_java_distribution_versions(distribution).await?)
}
// Gets the maximum memory a system has available.
#[tauri::command]
pub async fn jre_get_max_memory() -> Result<u64> {
Ok(jre::get_max_memory().await?)
}
#[tauri::command]
pub async fn jre_get_memory_status(
instance_id: Option<String>,
requested_memory_mb: u32,
automatic: bool,
) -> Result<jre::MemoryStatus> {
Ok(jre::get_memory_status(
instance_id.as_deref(),
requested_memory_mb,
automatic,
)
.await?)
}
#[tauri::command]
pub async fn jre_optimize_memory()
-> Result<theseus::memory::MemoryOptimizationResult> {
Ok(theseus::memory::optimize().await?)
}
#[tauri::command]
pub async fn list_java_feed_vendors() -> Result<Vec<String>> {
Ok(jre::list_java_feed_vendors().await?)
}
#[tauri::command]
pub async fn list_java_feed_versions(
vendor: String,
) -> Result<Vec<JdkVersionInfo>> {
Ok(jre::list_java_feed_versions(&vendor).await?)
}
#[tauri::command]
pub async fn download_java_from_feed(
vendor: String,
jdk_version_major: u32,
) -> Result<PathBuf> {
Ok(jre::download_java_from_feed(&vendor, jdk_version_major).await?)
}
#[tauri::command]
pub async fn download_java(
vendor: String,
version: u32,
) -> Result<theseus::install::InstallJobSnapshot> {
Ok(theseus::install::download_java(vendor, version).await?)
}

187
apps/app/src/api/logs.rs Normal file
View File

@ -0,0 +1,187 @@
use crate::api::Result;
use async_zip::tokio::write::ZipFileWriter;
use std::path::PathBuf;
use theseus::logs::LogType;
use theseus::logs::{
self, CensoredString, CrashAnalysis, CrashAnalysisAiExplanation,
CrashAnalysisAiSettings, LatestLogCursor, Logs,
};
/*
A log is a struct containing the filename string, stdout, and stderr, as follows:
pub struct Logs {
pub filename: String,
pub stdout: String,
pub stderr: String,
}
*/
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("logs")
.invoke_handler(tauri::generate_handler![
logs_get_logs,
logs_get_logs_by_filename,
logs_get_output_by_filename,
logs_delete_logs,
logs_delete_logs_by_filename,
logs_get_latest_log_cursor,
logs_get_minecraft_latest_log_cursor,
logs_get_live_log_buffer,
logs_clear_live_log_buffer,
logs_analyze_crash,
logs_get_crash_analysis_ai_settings,
logs_update_crash_analysis_ai_settings,
logs_explain_crash_with_ai,
logs_undo_added_mod,
logs_export_crash_context,
])
.build()
}
/// Get all logs for an instance, sorted by filename.
#[tauri::command]
pub async fn logs_get_logs(
instance_id: &str,
clear_contents: Option<bool>,
) -> Result<Vec<Logs>> {
let val = logs::get_logs(instance_id, clear_contents).await?;
Ok(val)
}
/// Get a log struct for an instance by filename.
#[tauri::command]
pub async fn logs_get_logs_by_filename(
instance_id: &str,
log_type: LogType,
filename: String,
) -> Result<Logs> {
Ok(logs::get_logs_by_filename(instance_id, log_type, filename).await?)
}
/// Get the output for an instance by filename.
#[tauri::command]
pub async fn logs_get_output_by_filename(
instance_id: &str,
log_type: LogType,
filename: String,
) -> Result<CensoredString> {
Ok(logs::get_output_by_filename(instance_id, log_type, &filename).await?)
}
/// Delete all logs for an instance.
#[tauri::command]
pub async fn logs_delete_logs(instance_id: &str) -> Result<()> {
Ok(logs::delete_logs(instance_id).await?)
}
/// Delete a log for an instance by filename.
#[tauri::command]
pub async fn logs_delete_logs_by_filename(
instance_id: &str,
log_type: LogType,
filename: String,
) -> Result<()> {
Ok(logs::delete_logs_by_filename(instance_id, log_type, &filename).await?)
}
/// Get live log from a cursor
#[tauri::command]
pub async fn logs_get_latest_log_cursor(
instance_id: &str,
cursor: u64, // 0 to start at beginning of file
) -> Result<LatestLogCursor> {
Ok(logs::get_latest_log_cursor(instance_id, cursor).await?)
}
/// Get Minecraft's logs/latest.log from a cursor.
#[tauri::command]
pub async fn logs_get_minecraft_latest_log_cursor(
instance_id: &str,
cursor: u64, // 0 to start at beginning of file
) -> Result<LatestLogCursor> {
Ok(
logs::get_generic_live_log_cursor(instance_id, "latest.log", cursor)
.await?,
)
}
/// Get all buffered live log lines for an instance.
#[tauri::command]
pub async fn logs_get_live_log_buffer(
instance_id: &str,
) -> Result<CensoredString> {
Ok(logs::get_live_log_buffer(instance_id).await?)
}
/// Clear the live log buffer for an instance.
#[tauri::command]
pub async fn logs_clear_live_log_buffer(instance_id: &str) -> Result<()> {
logs::clear_live_log_buffer(instance_id);
Ok(())
}
/// Collect and locally analyze the files produced by the instance's latest run.
#[tauri::command]
pub async fn logs_analyze_crash(instance_id: &str) -> Result<CrashAnalysis> {
Ok(logs::analyze_crash(instance_id).await?)
}
#[tauri::command]
pub async fn logs_get_crash_analysis_ai_settings()
-> Result<CrashAnalysisAiSettings> {
Ok(logs::get_crash_analysis_ai_settings().await?)
}
#[tauri::command]
pub async fn logs_update_crash_analysis_ai_settings(
settings: CrashAnalysisAiSettings,
) -> Result<()> {
Ok(logs::update_crash_analysis_ai_settings(settings).await?)
}
#[tauri::command]
pub async fn logs_explain_crash_with_ai(
instance_id: &str,
) -> Result<CrashAnalysisAiExplanation> {
Ok(logs::explain_crash_with_ai(instance_id).await?)
}
#[tauri::command]
pub async fn logs_undo_added_mod(
instance_id: &str,
filename: &str,
expected_hash: &str,
) -> Result<()> {
Ok(logs::undo_added_mod(instance_id, filename, expected_hash).await?)
}
/// Export the latest run's censored diagnostic context as a ZIP archive.
#[tauri::command]
pub async fn logs_export_crash_context(
instance_id: &str,
output_path: PathBuf,
) -> Result<()> {
let analysis = logs::analyze_crash(instance_id).await?;
let archive = tokio::fs::File::create(&output_path).await?;
let mut writer = ZipFileWriter::with_tokio(archive);
let report = serde_json::to_vec_pretty(&analysis).map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to serialize crash analysis: {error}"
)))
})?;
crate::api::utils::write_zip_entry(&mut writer, "analysis.json", &report)
.await?;
for source in &analysis.sources {
let safe_name = source.filename.replace(['/', '\\'], "_");
crate::api::utils::write_zip_entry(
&mut writer,
&format!("logs/{safe_name}"),
source.content.as_str().as_bytes(),
)
.await?;
}
writer.close().await.map_err(crate::api::utils::zip_error)?;
Ok(())
}

View File

@ -0,0 +1,48 @@
use crate::api::Result;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("mcarchive")
.invoke_handler(tauri::generate_handler![
mcarchive_get_game_versions,
mcarchive_search_mods,
mcarchive_get_mod_by_slug,
mcarchive_get_file_by_filename,
mcarchive_get_file_by_sha256,
])
.build()
}
#[tauri::command]
pub async fn mcarchive_get_game_versions()
-> Result<Vec<theseus::mcarchive::McArchiveGameVersion>> {
Ok(theseus::mcarchive::get_game_versions().await?)
}
#[tauri::command]
pub async fn mcarchive_search_mods(
keyword: &str,
game_version: Option<&str>,
) -> Result<Vec<theseus::mcarchive::McArchiveMod>> {
Ok(theseus::mcarchive::search_mods(keyword, game_version).await?)
}
#[tauri::command]
pub async fn mcarchive_get_mod_by_slug(
slug: &str,
) -> Result<theseus::mcarchive::McArchiveMod> {
Ok(theseus::mcarchive::get_mod_by_slug(slug).await?)
}
#[tauri::command]
pub async fn mcarchive_get_file_by_filename(
filename: &str,
) -> Result<Option<theseus::mcarchive::McArchiveFile>> {
Ok(theseus::mcarchive::get_file_by_filename(filename).await?)
}
#[tauri::command]
pub async fn mcarchive_get_file_by_sha256(
sha256: &str,
) -> Result<Option<theseus::mcarchive::McArchiveFile>> {
Ok(theseus::mcarchive::get_file_by_sha256(sha256).await?)
}

View File

@ -0,0 +1,35 @@
use crate::api::Result;
use daedalus::minecraft::VersionManifest;
use daedalus::modded::Manifest;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("metadata")
.invoke_handler(tauri::generate_handler![
metadata_get_game_versions,
metadata_get_loader_versions,
])
.build()
}
/// Gets the game versions from daedalus
#[tauri::command]
pub async fn metadata_get_game_versions() -> Result<VersionManifest> {
Ok(theseus::metadata::get_minecraft_versions().await?)
}
/// Gets the fabric versions from daedalus
#[tauri::command]
pub async fn metadata_get_loader_versions(
loader: &str,
game_version: Option<&str>,
) -> Result<Manifest> {
if let Some(game_version) = game_version {
Ok(theseus::metadata::get_loader_versions_for_game(
loader,
game_version,
)
.await?)
} else {
Ok(theseus::metadata::get_loader_versions(loader).await?)
}
}

View File

@ -0,0 +1,147 @@
use crate::api::Result;
use std::path::Path;
use theseus::minecraft_skins::{
self, Bytes, Cape, MinecraftSkinVariant, Skin, UrlOrBlob,
};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("minecraft-skins")
.invoke_handler(tauri::generate_handler![
get_available_capes,
get_available_skins,
add_and_equip_custom_skin,
equip_skin,
remove_custom_skin,
save_custom_skin,
set_custom_skin_order,
unequip_skin,
flush_pending_skin_change,
flush_pending_skin_change_for_profile,
normalize_skin_texture,
get_dragged_skin_data,
])
.build()
}
/// `invoke('plugin:minecraft-skins|get_available_capes')`
///
/// See also: [minecraft_skins::get_available_capes]
#[tauri::command]
pub async fn get_available_capes() -> Result<Vec<Cape>> {
Ok(minecraft_skins::get_available_capes().await?)
}
/// `invoke('plugin:minecraft-skins|get_available_skins')`
///
/// See also: [minecraft_skins::get_available_skins]
#[tauri::command]
pub async fn get_available_skins() -> Result<Vec<Skin>> {
Ok(minecraft_skins::get_available_skins().await?)
}
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape)`
///
/// See also: [minecraft_skins::add_and_equip_custom_skin]
#[tauri::command]
pub async fn add_and_equip_custom_skin(
texture_blob: Bytes,
variant: MinecraftSkinVariant,
cape: Option<Cape>,
) -> Result<Skin> {
Ok(
minecraft_skins::add_and_equip_custom_skin(texture_blob, variant, cape)
.await?,
)
}
/// `invoke('plugin:minecraft-skins|equip_skin', skin)`
///
/// See also: [minecraft_skins::equip_skin]
#[tauri::command]
pub async fn equip_skin(skin: Skin) -> Result<()> {
Ok(minecraft_skins::equip_skin(skin).await?)
}
/// `invoke('plugin:minecraft-skins|remove_custom_skin', skin)`
///
/// See also: [minecraft_skins::remove_custom_skin]
#[tauri::command]
pub async fn remove_custom_skin(skin: Skin) -> Result<()> {
Ok(minecraft_skins::remove_custom_skin(skin).await?)
}
/// `invoke('plugin:minecraft-skins|save_custom_skin', skin, texture_blob, variant, cape, replace_texture)`
///
/// See also: [minecraft_skins::save_custom_skin]
#[tauri::command]
pub async fn save_custom_skin(
skin: Skin,
texture_blob: Bytes,
variant: MinecraftSkinVariant,
cape: Option<Cape>,
replace_texture: bool,
) -> Result<Skin> {
Ok(minecraft_skins::save_custom_skin(
skin,
texture_blob,
variant,
cape,
replace_texture,
)
.await?)
}
/// `invoke('plugin:minecraft-skins|set_custom_skin_order', texture_keys)`
///
/// See also: [minecraft_skins::set_custom_skin_order]
#[tauri::command]
pub async fn set_custom_skin_order(texture_keys: Vec<String>) -> Result<()> {
Ok(minecraft_skins::set_custom_skin_order(texture_keys).await?)
}
/// `invoke('plugin:minecraft-skins|unequip_skin')`
///
/// See also: [minecraft_skins::unequip_skin]
#[tauri::command]
pub async fn unequip_skin() -> Result<()> {
Ok(minecraft_skins::unequip_skin().await?)
}
/// `invoke('plugin:minecraft-skins|flush_pending_skin_change')`
///
/// See also: [minecraft_skins::flush_pending_skin_change]
#[tauri::command]
pub async fn flush_pending_skin_change() -> Result<()> {
Ok(minecraft_skins::flush_pending_skin_change().await?)
}
/// `invoke('plugin:minecraft-skins|flush_pending_skin_change_for_profile', profile_id)`
///
/// See also: [minecraft_skins::flush_pending_skin_change_for_profile]
#[tauri::command]
pub async fn flush_pending_skin_change_for_profile(
profile_id: uuid::Uuid,
) -> Result<()> {
Ok(
minecraft_skins::flush_pending_skin_change_for_profile(profile_id)
.await?,
)
}
/// `invoke('plugin:minecraft-skins|normalize_skin_texture')`
///
/// See also: [minecraft_skins::normalize_skin_texture]
#[tauri::command]
pub async fn normalize_skin_texture(texture: UrlOrBlob) -> Result<Bytes> {
Ok(minecraft_skins::normalize_skin_texture(&texture).await?)
}
/// `invoke('plugin:minecraft-skins|get_dragged_skin_data', path)`
///
/// See also: [minecraft_skins::get_dragged_skin_data]
#[tauri::command]
pub async fn get_dragged_skin_data(path: String) -> Result<Bytes> {
let path = Path::new(&path);
Ok(minecraft_skins::get_dragged_skin_data(path).await?)
}

142
apps/app/src/api/mod.rs Normal file
View File

@ -0,0 +1,142 @@
use serde::ser::SerializeStruct;
use serde::{Serialize, Serializer};
use thiserror::Error;
pub mod ai;
pub mod auth;
pub mod import;
pub mod install;
pub mod instance;
pub mod jre;
pub mod logs;
pub mod mcarchive;
pub mod metadata;
pub mod minecraft_skins;
pub mod mod_translation;
pub mod mr_auth;
pub mod multiplayer;
pub mod planet_minecraft;
pub mod process;
pub mod schematic_preview;
pub mod seed_map;
pub mod servers;
pub mod settings;
pub mod shortcuts;
pub mod storage;
pub mod system_accent;
pub mod tags;
pub mod telemetry;
pub mod terracotta;
pub mod translation;
pub mod utils;
pub mod cache;
pub mod content_favorites;
pub mod content_search;
pub mod curseforge;
pub mod datapacks;
pub mod drop;
pub mod files;
pub mod friends;
pub mod worlds;
mod oauth_utils;
mod search_cancellation;
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
// // Main returnable Theseus GUI error
// // Needs to be Serializable to be returned to the JavaScript side
// #[derive(Error, Debug, Serialize)]
// pub enum TheseusGuiError {
// #[error(transparent)]
// Serializable(),
// }
// Serializable error intermediary, so TheseusGuiError can be Serializable (eg: so that we can return theseus::Errors in Tauri directly)
#[derive(Error, Debug)]
pub enum TheseusSerializableError {
#[error("{0}")]
Theseus(#[from] theseus::Error),
#[error("IO error: {0}")]
IO(#[from] std::io::Error),
#[error("Tauri error: {0}")]
Tauri(#[from] tauri::Error),
#[cfg(feature = "updater")]
#[error("Updater error: {0}")]
Updater(#[from] tauri_plugin_updater::Error),
#[cfg(feature = "updater")]
#[error("HTTP error: {0}")]
Http(#[from] tauri_plugin_http::reqwest::Error),
#[error("Search request cancelled: {0}")]
SearchCancelled(String),
}
// Generic implementation of From<T> for ErrorTypeA
// impl<T> From<T> for TheseusGuiError
// where
// TheseusSerializableError: From<T>,
// {
// fn from(error: T) -> Self {
// TheseusGuiError::Serializable(TheseusSerializableError::from(error))
// }
// }
// This is a very simple macro that implements a very basic Serializable for each variant of TheseusSerializableError,
// where the field is the string. (This allows easy extension to errors without many match arms)
macro_rules! impl_serialize {
($($variant:ident),* $(,)?) => {
impl Serialize for TheseusSerializableError {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
// For the Theseus variant, we add a special display for the error,
// to view the spans if subscribed to them (which is information that is lost when serializing)
TheseusSerializableError::Theseus(theseus_error) => {
$crate::error::display_tracing_error(theseus_error);
let mut state = serializer.serialize_struct("Theseus", 2)?;
state.serialize_field("field_name", "Theseus")?;
state.serialize_field(
"message",
&theseus_error.user_facing_message(),
)?;
state.end()
}
$(
TheseusSerializableError::$variant(message) => {
let mut state = serializer.serialize_struct(stringify!($variant), 2)?;
state.serialize_field("field_name", stringify!($variant))?;
state.serialize_field("message", &message.to_string())?;
state.end()
},
)*
}
}
}
};
}
// Use the macro to implement Serialize for TheseusSerializableError
#[cfg(not(feature = "updater"))]
impl_serialize! {
IO,
Tauri,
SearchCancelled,
}
#[cfg(feature = "updater")]
impl_serialize! {
IO,
Tauri,
Updater,
Http,
SearchCancelled,
}

View File

@ -0,0 +1,754 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tauri::{Emitter, Runtime};
use crate::mod_translation;
use crate::mod_translation::analyze::{AnalysisSummary, JarInspection};
use crate::mod_translation::error::TranslateErrorCode;
use crate::mod_translation::repair::{RepairActivity, RepairEmitter};
use crate::mod_translation::translate::{
PreparedTranslationWorkspace, TranslationReport, TranslationSample,
};
const WORKSPACE_SUBDIR: &str = "mod-translation";
const TASK_EVENT: &str = "mod-translation-task-event";
const ANALYSIS_TTL: Duration = Duration::from_secs(30 * 60);
const MAX_SNAPSHOT_EVENTS: usize = 400;
struct TaskHandle {
cancel: Arc<AtomicBool>,
sequence: AtomicU64,
event_gate: Mutex<()>,
snapshot: Mutex<TaskSnapshot>,
lock_keys: Vec<String>,
}
struct PreparedAnalysis {
created_at: SystemTime,
input_path: PathBuf,
input_hash: String,
workspace: PathBuf,
inspection: JarInspection,
}
static TASKS: OnceLock<Mutex<HashMap<String, Arc<TaskHandle>>>> =
OnceLock::new();
static ACTIVE_LOCKS: OnceLock<Mutex<HashMap<String, String>>> = OnceLock::new();
static ANALYSES: OnceLock<Mutex<HashMap<String, PreparedAnalysis>>> =
OnceLock::new();
fn tasks() -> &'static Mutex<HashMap<String, Arc<TaskHandle>>> {
TASKS.get_or_init(|| Mutex::new(HashMap::new()))
}
fn active_locks() -> &'static Mutex<HashMap<String, String>> {
ACTIVE_LOCKS.get_or_init(|| Mutex::new(HashMap::new()))
}
fn analyses() -> &'static Mutex<HashMap<String, PreparedAnalysis>> {
ANALYSES.get_or_init(|| Mutex::new(HashMap::new()))
}
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("mod-translation")
.invoke_handler(tauri::generate_handler![
mod_translation_analyze,
mod_translation_translate,
mod_translation_cancel,
mod_translation_list_tasks,
mod_translation_get_task,
mod_translation_dismiss_task,
])
.build()
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModTranslationOptions {
#[serde(default = "default_batch_size")]
pub batch_size: usize,
#[serde(default = "default_deep_batch_size")]
pub deep_batch_size: usize,
#[serde(default)]
pub generate_mod_name: bool,
#[serde(default = "default_repair_enabled")]
pub repair_enabled: bool,
#[serde(default)]
pub class_text_enabled: bool,
#[serde(default = "default_class_batch")]
pub max_class_batch: usize,
}
impl Default for ModTranslationOptions {
fn default() -> Self {
Self {
batch_size: default_batch_size(),
deep_batch_size: default_deep_batch_size(),
generate_mod_name: false,
repair_enabled: default_repair_enabled(),
class_text_enabled: false,
max_class_batch: default_class_batch(),
}
}
}
fn default_batch_size() -> usize {
40
}
fn default_deep_batch_size() -> usize {
24
}
fn default_repair_enabled() -> bool {
true
}
fn default_class_batch() -> usize {
16
}
impl From<ModTranslationOptions>
for mod_translation::translate::TranslateOptions
{
fn from(value: ModTranslationOptions) -> Self {
Self {
batch_size: value.batch_size.clamp(10, 80),
deep_batch_size: value.deep_batch_size.clamp(4, 40),
generate_mod_name: value.generate_mod_name,
repair_enabled: value.repair_enabled,
class_text_enabled: value.class_text_enabled,
max_class_batch: value.max_class_batch.clamp(4, 20),
..Default::default()
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModTranslationAnalysis {
pub analysis_id: String,
pub input_hash: String,
#[serde(flatten)]
pub summary: AnalysisSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskFailure {
pub code: String,
pub message: String,
pub details: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModTranslationTaskEvent {
pub event_id: String,
pub task_id: String,
pub sequence: u64,
pub occurred_at: String,
pub event_type: String,
pub status: String,
pub progress: Option<TranslationSample>,
pub activity: Option<RepairActivity>,
pub report: Option<TranslationReport>,
pub error: Option<TaskFailure>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskSnapshot {
pub task_id: String,
pub input_path: PathBuf,
pub output_path: PathBuf,
pub input_hash: String,
pub started_at: String,
pub updated_at: String,
pub status: String,
pub sequence: u64,
pub progress: Option<TranslationSample>,
pub activities: Vec<RepairActivity>,
pub report: Option<TranslationReport>,
pub error: Option<TaskFailure>,
pub events: Vec<ModTranslationTaskEvent>,
}
async fn workspace_root() -> crate::api::Result<PathBuf> {
let state = theseus::State::get().await?;
Ok(state.directories.caches_dir().join(WORKSPACE_SUBDIR))
}
fn to_command_error(error: mod_translation::error::TranslateError) -> String {
tracing::error!(code = ?error.code, message = %error.message, "mod translation command failed");
error.user_message()
}
#[tauri::command]
pub async fn mod_translation_analyze(
input_path: String,
) -> Result<ModTranslationAnalysis, String> {
let root = workspace_root().await.map_err(|error| error.to_string())?;
let input = PathBuf::from(&input_path);
if !input.is_file() {
return Err("找不到输入 JAR 文件".to_string());
}
let input_for_analysis = input.clone();
let (directory, inspection, input_hash) =
tauri::async_runtime::spawn_blocking(move || {
let input_hash =
mod_translation::analyze::input_file_hash(&input_for_analysis)
.map_err(to_command_error)?;
let (directory, inspection) =
mod_translation::analyze::extract_and_inspect(
&input_for_analysis,
&root,
)
.map_err(to_command_error)?;
Ok::<_, String>((directory, inspection, input_hash))
})
.await
.map_err(|error| error.to_string())??;
let analysis_id = format!("ANALYSIS-{}", uuid::Uuid::new_v4());
let summary = mod_translation::translate::analysis_summary(&inspection);
let mut registry = analyses()
.lock()
.map_err(|_| "分析注册表不可用".to_string())?;
prune_analyses(&mut registry);
registry.insert(
analysis_id.clone(),
PreparedAnalysis {
created_at: SystemTime::now(),
input_path: input,
input_hash: input_hash.clone(),
workspace: directory,
inspection,
},
);
Ok(ModTranslationAnalysis {
analysis_id,
input_hash,
summary,
})
}
async fn validate_ai_config(
provider_id: &str,
model_id: &str,
) -> Result<(), String> {
let state = theseus::ai::get_state()
.await
.map_err(|error| error.to_string())?;
if !state.settings.enabled {
return Err(format!(
"{}: 请在 AI 设置中启用 AI 功能后再试",
TranslateErrorCode::AiDisabled.as_str()
));
}
let Some(provider) = state
.providers
.iter()
.find(|provider| provider.provider_id == provider_id)
else {
return Err(format!(
"{}: 未找到已配置的 AI 提供商 {provider_id},请先在 AI 设置中添加",
TranslateErrorCode::AiProviderDisabled.as_str()
));
};
if !provider.enabled {
return Err(format!(
"{}: AI 提供商 {provider_id} 当前已禁用,请在 AI 设置中启用",
TranslateErrorCode::AiProviderDisabled.as_str()
));
}
if model_id.trim().is_empty()
|| !provider.models.iter().any(|model| model.id == model_id)
{
return Err(format!(
"{}: 请先在翻译设置或 AI 设置中选择模型",
TranslateErrorCode::AiModelNotSelected.as_str()
));
}
Ok(())
}
#[tauri::command]
pub async fn mod_translation_translate<R: Runtime>(
app: tauri::AppHandle<R>,
input_path: String,
output_path: String,
provider_id: String,
model_id: String,
analysis_id: Option<String>,
input_hash: Option<String>,
options: Option<ModTranslationOptions>,
) -> Result<TaskSnapshot, String> {
validate_ai_config(&provider_id, &model_id).await?;
let input = PathBuf::from(&input_path);
let output = PathBuf::from(&output_path);
if !input.is_file() {
return Err("找不到输入 JAR 文件".to_string());
}
if !mod_translation::jar::is_clean_absolute_path(&output) {
return Err("输出路径必须是合法的绝对路径".to_string());
}
if output.exists() {
return Err(format!("输出文件已存在:{}", output.display()));
}
let actual_hash = {
let input = input.clone();
tauri::async_runtime::spawn_blocking(move || {
mod_translation::analyze::input_file_hash(&input)
})
.await
.map_err(|error| error.to_string())?
.map_err(to_command_error)?
};
let root = workspace_root().await.map_err(|error| error.to_string())?;
let task_id = new_task_id();
let lock_keys = vec![
format!("output:{}", normalized_path_lock_key(&output)),
format!("checkpoint:{actual_hash}"),
];
claim_locks(&task_id, &lock_keys)?;
let prepared = match take_prepared_analysis(
analysis_id.as_deref(),
input_hash.as_deref(),
&actual_hash,
&input,
) {
Ok(prepared) => prepared,
Err(error) => {
release_lock_keys(&task_id, &lock_keys);
return Err(error);
}
};
let now = iso_now();
let snapshot = TaskSnapshot {
task_id: task_id.clone(),
input_path: input.clone(),
output_path: output.clone(),
input_hash: actual_hash,
started_at: now.clone(),
updated_at: now,
status: "running".to_string(),
sequence: 0,
progress: None,
activities: Vec::new(),
report: None,
error: None,
events: Vec::new(),
};
let handle = Arc::new(TaskHandle {
cancel: Arc::new(AtomicBool::new(false)),
sequence: AtomicU64::new(0),
event_gate: Mutex::new(()),
snapshot: Mutex::new(snapshot.clone()),
lock_keys,
});
match tasks().lock() {
Ok(mut registry) => {
registry.insert(task_id.clone(), handle.clone());
}
Err(_) => {
release_lock_keys(&task_id, &handle.lock_keys);
if let Some(prepared) = &prepared {
let _ = std::fs::remove_dir_all(&prepared.workspace);
}
return Err("任务注册表不可用".to_string());
}
}
let mut translate_options: mod_translation::translate::TranslateOptions =
options.unwrap_or_default().into();
translate_options.provider_id = provider_id;
translate_options.model_id = model_id;
let emitter = make_progress_emitter(app.clone(), handle.clone());
let repair_emitter = make_repair_emitter(app.clone(), handle.clone());
let cancel = handle.cancel.clone();
tauri::async_runtime::spawn(async move {
let result = mod_translation::translate::run_translation_task(
task_id.clone(),
input,
output,
translate_options,
root,
prepared,
cancel,
emitter,
repair_emitter,
)
.await;
if let Err(error) = result {
ensure_terminal_error(&app, &handle, &error);
}
release_locks(&handle);
});
Ok(snapshot)
}
#[tauri::command]
pub fn mod_translation_cancel(task_id: String) -> Result<(), String> {
if let Some(handle) = tasks()
.lock()
.map_err(|_| "任务注册表不可用".to_string())?
.get(&task_id)
{
handle.cancel.store(true, Ordering::Relaxed);
}
Ok(())
}
#[tauri::command]
pub fn mod_translation_list_tasks() -> Result<Vec<TaskSnapshot>, String> {
let registry =
tasks().lock().map_err(|_| "任务注册表不可用".to_string())?;
let mut snapshots = registry
.values()
.filter_map(|handle| {
handle.snapshot.lock().ok().map(|snapshot| snapshot.clone())
})
.collect::<Vec<_>>();
snapshots.sort_by(|left, right| right.started_at.cmp(&left.started_at));
Ok(snapshots)
}
#[tauri::command]
pub fn mod_translation_get_task(
task_id: String,
) -> Result<Option<TaskSnapshot>, String> {
let registry =
tasks().lock().map_err(|_| "任务注册表不可用".to_string())?;
Ok(registry.get(&task_id).and_then(|handle| {
handle.snapshot.lock().ok().map(|snapshot| snapshot.clone())
}))
}
#[tauri::command]
pub fn mod_translation_dismiss_task(task_id: String) -> Result<(), String> {
let mut registry =
tasks().lock().map_err(|_| "任务注册表不可用".to_string())?;
let Some(handle) = registry.get(&task_id) else {
return Ok(());
};
let running = handle
.snapshot
.lock()
.map_err(|_| "任务快照不可用".to_string())?
.status
== "running";
if running {
return Err("运行中的任务不能移除,请先取消".to_string());
}
registry.remove(&task_id);
Ok(())
}
fn make_progress_emitter<R: Runtime>(
app: tauri::AppHandle<R>,
handle: Arc<TaskHandle>,
) -> mod_translation::translate::EventEmitter {
Arc::new(move |progress: TranslationSample| {
let report = progress.report.as_deref().and_then(|value| {
serde_json::from_str::<TranslationReport>(value).ok()
});
let error = if progress.finished && !progress.ok {
Some(parse_failure(
progress.report.as_deref().unwrap_or(&progress.message),
))
} else {
None
};
emit_task_event(
&app,
&handle,
"progress",
Some(progress),
None,
report,
error,
);
})
}
fn make_repair_emitter<R: Runtime>(
app: tauri::AppHandle<R>,
handle: Arc<TaskHandle>,
) -> RepairEmitter {
Arc::new(move |activity: RepairActivity| {
emit_task_event(
&app,
&handle,
"activity",
None,
Some(activity),
None,
None,
);
})
}
fn emit_task_event<R: Runtime>(
app: &tauri::AppHandle<R>,
handle: &Arc<TaskHandle>,
event_type: &str,
progress: Option<TranslationSample>,
activity: Option<RepairActivity>,
report: Option<TranslationReport>,
error: Option<TaskFailure>,
) {
let Ok(_event_guard) = handle.event_gate.lock() else {
return;
};
let event = {
let Ok(mut snapshot) = handle.snapshot.lock() else {
return;
};
let sequence = handle.sequence.fetch_add(1, Ordering::SeqCst) + 1;
let occurred_at = iso_now();
if let Some(progress) = &progress {
snapshot.progress = Some(progress.clone());
if progress.finished {
snapshot.status =
if progress.ok { "completed" } else { "failed" }
.to_string();
}
}
if let Some(activity) = &activity {
snapshot.activities.push(activity.clone());
if snapshot.activities.len() > MAX_SNAPSHOT_EVENTS {
snapshot.activities.remove(0);
}
}
if report.is_some() {
snapshot.report = report.clone();
}
if error.is_some() {
snapshot.error = error.clone();
snapshot.status = "failed".to_string();
}
snapshot.sequence = sequence;
snapshot.updated_at = occurred_at.clone();
let event = ModTranslationTaskEvent {
event_id: format!("EVENT-{}", uuid::Uuid::new_v4()),
task_id: snapshot.task_id.clone(),
sequence,
occurred_at,
event_type: event_type.to_string(),
status: snapshot.status.clone(),
progress,
activity,
report,
error,
};
snapshot.events.push(event.clone());
if snapshot.events.len() > MAX_SNAPSHOT_EVENTS {
snapshot.events.remove(0);
}
event
};
if let Err(error) = app.emit(TASK_EVENT, event) {
tracing::warn!(error = %error, "mod translation task event emit failed");
}
}
fn ensure_terminal_error<R: Runtime>(
app: &tauri::AppHandle<R>,
handle: &Arc<TaskHandle>,
error: &mod_translation::error::TranslateError,
) {
let already_terminal = handle
.snapshot
.lock()
.map(|snapshot| {
snapshot.status != "running" && snapshot.error.is_some()
})
.unwrap_or(false);
if already_terminal {
return;
}
emit_task_event(
app,
handle,
"finished",
None,
None,
None,
Some(TaskFailure {
code: error.code.as_str().to_string(),
message: error.message.clone(),
details: None,
}),
);
}
fn parse_failure(value: &str) -> TaskFailure {
let (code, message) = value
.split_once(": ")
.map(|(code, message)| (code.to_string(), message.to_string()))
.unwrap_or_else(|| ("UNKNOWN".to_string(), value.to_string()));
let details = message
.rsplit_once('')
.and_then(|(_, json)| serde_json::from_str::<Value>(json).ok());
TaskFailure {
code,
message,
details,
}
}
fn take_prepared_analysis(
analysis_id: Option<&str>,
requested_hash: Option<&str>,
actual_hash: &str,
input_path: &PathBuf,
) -> Result<Option<PreparedTranslationWorkspace>, String> {
let Some(analysis_id) = analysis_id else {
return Ok(None);
};
let mut registry = analyses()
.lock()
.map_err(|_| "分析注册表不可用".to_string())?;
prune_analyses(&mut registry);
let Some(prepared) = registry.remove(analysis_id) else {
return Ok(None);
};
let matches = requested_hash == Some(prepared.input_hash.as_str())
&& prepared.input_hash == actual_hash
&& prepared.input_path == *input_path
&& prepared.workspace.is_dir();
if !matches {
let _ = std::fs::remove_dir_all(prepared.workspace);
return Ok(None);
}
Ok(Some(PreparedTranslationWorkspace {
workspace: prepared.workspace,
inspection: prepared.inspection,
input_hash: prepared.input_hash,
}))
}
fn prune_analyses(registry: &mut HashMap<String, PreparedAnalysis>) {
let now = SystemTime::now();
let expired = registry
.iter()
.filter_map(|(id, prepared)| {
now.duration_since(prepared.created_at)
.ok()
.filter(|age| *age > ANALYSIS_TTL)
.map(|_| id.clone())
})
.collect::<Vec<_>>();
for id in expired {
if let Some(prepared) = registry.remove(&id) {
let _ = std::fs::remove_dir_all(prepared.workspace);
}
}
}
fn claim_locks(task_id: &str, keys: &[String]) -> Result<(), String> {
let mut locks = active_locks()
.lock()
.map_err(|_| "任务锁不可用".to_string())?;
if let Some(key) = keys.iter().find(|key| locks.contains_key(*key)) {
return Err(format!("该输出或检查点已有运行中的任务:{key}"));
}
for key in keys {
locks.insert(key.clone(), task_id.to_string());
}
Ok(())
}
fn normalized_path_lock_key(path: &std::path::Path) -> String {
let normalized = path.to_string_lossy().replace('\\', "/");
if cfg!(windows) {
normalized.to_lowercase()
} else {
normalized
}
}
fn release_locks(handle: &TaskHandle) {
let task_id = handle
.snapshot
.lock()
.ok()
.map(|snapshot| snapshot.task_id.clone())
.unwrap_or_default();
release_lock_keys(&task_id, &handle.lock_keys);
}
fn release_lock_keys(task_id: &str, keys: &[String]) {
if let Ok(mut locks) = active_locks().lock() {
for key in keys {
if locks.get(key).is_some_and(|owner| owner == task_id) {
locks.remove(key);
}
}
}
}
fn new_task_id() -> String {
let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%S").to_string();
format!("TASK-{timestamp}-{}", uuid::Uuid::new_v4().simple())
}
fn iso_now() -> String {
chrono::Utc::now().to_rfc3339()
}
#[cfg(test)]
mod tests {
use super::*;
fn handle(task_id: &str, lock_keys: Vec<String>) -> TaskHandle {
let now = iso_now();
TaskHandle {
cancel: Arc::new(AtomicBool::new(false)),
sequence: AtomicU64::new(0),
event_gate: Mutex::new(()),
snapshot: Mutex::new(TaskSnapshot {
task_id: task_id.to_string(),
input_path: PathBuf::from("C:/mods/demo.jar"),
output_path: PathBuf::from("C:/mods/demo-zh_cn.jar"),
input_hash: "hash".to_string(),
started_at: now.clone(),
updated_at: now,
status: "running".to_string(),
sequence: 0,
progress: None,
activities: Vec::new(),
report: None,
error: None,
events: Vec::new(),
}),
lock_keys,
}
}
#[test]
fn duplicate_output_or_checkpoint_locks_are_rejected_until_release() {
let key = format!("output:test-{}", uuid::Uuid::new_v4());
claim_locks("TASK-a", std::slice::from_ref(&key)).unwrap();
assert!(claim_locks("TASK-b", std::slice::from_ref(&key)).is_err());
let first = handle("TASK-a", vec![key.clone()]);
release_locks(&first);
claim_locks("TASK-b", std::slice::from_ref(&key)).unwrap();
let second = handle("TASK-b", vec![key]);
release_locks(&second);
}
#[test]
fn task_failures_preserve_the_backend_error_code() {
let failure =
parse_failure("UNSUPPORTED_RESOURCE: assets/demo/data.txt");
assert_eq!(failure.code, "UNSUPPORTED_RESOURCE");
assert_eq!(failure.message, "assets/demo/data.txt");
}
}

View File

@ -0,0 +1,84 @@
use crate::api::Result;
use crate::api::TheseusSerializableError;
use crate::api::oauth_utils;
use tauri::Manager;
use tauri::Runtime;
use tauri::plugin::TauriPlugin;
use tauri_plugin_opener::OpenerExt;
use theseus::prelude::*;
use tokio::sync::oneshot;
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
tauri::plugin::Builder::new("mr-auth")
.invoke_handler(tauri::generate_handler![
modrinth_login,
logout,
get,
cancel_modrinth_login,
])
.build()
}
#[tauri::command]
pub async fn modrinth_login<R: Runtime>(
app: tauri::AppHandle<R>,
) -> Result<ModrinthCredentials> {
let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
auth_code_recv_socket_tx,
));
let auth_code_recv_socket = auth_code_recv_socket.await.unwrap()?;
let auth_request_uri = format!(
"{}?launcher=true&ipver={}&port={}",
mr_auth::authenticate_begin_flow(),
if auth_code_recv_socket.is_ipv4() {
"4"
} else {
"6"
},
auth_code_recv_socket.port()
);
app.opener()
.open_url(auth_request_uri, None::<&str>)
.map_err(|e| {
TheseusSerializableError::Theseus(
theseus::ErrorKind::OtherError(format!(
"Failed to open auth request URI: {e}"
))
.into(),
)
})?;
let Some(auth_reply) = auth_code.await.unwrap()? else {
return Err(TheseusSerializableError::Theseus(
theseus::ErrorKind::OtherError("Login canceled".into()).into(),
));
};
let credentials =
mr_auth::authenticate_finish_flow(&auth_reply.code).await?;
if let Some(main_window) = app.get_window("main") {
main_window.set_focus().ok();
}
Ok(credentials)
}
#[tauri::command]
pub async fn logout() -> Result<()> {
Ok(theseus::mr_auth::logout().await?)
}
#[tauri::command]
pub async fn get() -> Result<Option<ModrinthCredentials>> {
Ok(theseus::mr_auth::get_credentials().await?)
}
#[tauri::command]
pub fn cancel_modrinth_login() {
oauth_utils::auth_code_reply::stop_listeners();
}

View File

@ -0,0 +1,110 @@
use crate::api::Result;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("multiplayer")
.invoke_handler(tauri::generate_handler![
multiplayer_get_state,
multiplayer_get_nodes,
multiplayer_get_detected_ports,
multiplayer_download_hongshi,
multiplayer_switch_provider,
multiplayer_prepare_terracotta,
multiplayer_host,
multiplayer_join,
multiplayer_stop,
multiplayer_reset,
multiplayer_get_player_name,
multiplayer_open_hongshi_logs,
])
.build()
}
#[tauri::command]
pub async fn multiplayer_download_hongshi() -> Result<()> {
Ok(theseus::hongshi::download()
.await
.map_err(theseus::Error::from)?)
}
#[tauri::command]
pub async fn multiplayer_get_state()
-> Result<theseus::multiplayer::MultiplayerState> {
Ok(theseus::multiplayer::get_state().await)
}
#[tauri::command]
pub async fn multiplayer_get_nodes(
force_refresh: Option<bool>,
) -> Result<Vec<theseus::hongshi::HongshiNode>> {
Ok(theseus::hongshi::get_nodes(force_refresh.unwrap_or(false))
.await
.map_err(theseus::Error::from)?)
}
#[tauri::command]
pub async fn multiplayer_get_detected_ports()
-> Result<Vec<theseus::hongshi::DetectedLanPort>> {
Ok(theseus::hongshi::get_detected_ports().await)
}
#[tauri::command]
pub async fn multiplayer_switch_provider(
provider: theseus::multiplayer::MultiplayerProvider,
) -> Result<()> {
Ok(theseus::multiplayer::switch_provider(provider)
.await
.map_err(theseus::Error::from)?)
}
#[tauri::command]
pub async fn multiplayer_prepare_terracotta() -> Result<()> {
Ok(theseus::multiplayer::prepare_terracotta()
.await
.map_err(theseus::Error::from)?)
}
#[tauri::command]
pub async fn multiplayer_host(
request: theseus::multiplayer::MultiplayerHostRequest,
) -> Result<()> {
Ok(theseus::multiplayer::host(request)
.await
.map_err(theseus::Error::from)?)
}
#[tauri::command]
pub async fn multiplayer_join(
request: theseus::multiplayer::MultiplayerJoinRequest,
) -> Result<()> {
Ok(theseus::multiplayer::join(request)
.await
.map_err(theseus::Error::from)?)
}
#[tauri::command]
pub async fn multiplayer_stop() -> Result<()> {
Ok(theseus::multiplayer::stop()
.await
.map_err(theseus::Error::from)?)
}
#[tauri::command]
pub async fn multiplayer_reset() -> Result<()> {
Ok(theseus::multiplayer::reset()
.await
.map_err(theseus::Error::from)?)
}
#[tauri::command]
pub async fn multiplayer_get_player_name() -> Result<String> {
Ok(theseus::terracotta::get_player_name().await)
}
#[tauri::command]
pub async fn multiplayer_open_hongshi_logs<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<()> {
tokio::fs::create_dir_all(theseus::hongshi::logs_dir()).await?;
crate::api::utils::open_path(app, theseus::hongshi::logs_dir()).await;
Ok(())
}

View File

@ -0,0 +1,219 @@
//! A minimal OAuth 2.0 authorization code grant flow redirection/reply loopback URI HTTP
//! server implementation, compliant with [RFC 6749]'s authorization code grant flow and
//! [RFC 8252]'s best current practices for OAuth 2.0 in native apps.
//!
//! This server is needed for the step 4 of the OAuth authentication dance represented in
//! figure 1 of [RFC 8252].
//!
//! Further reading: https://www.oauth.com/oauth2-servers/oauth-native-apps/redirect-urls-for-native-apps/
//!
//! [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749
//! [RFC 8252]: https://datatracker.ietf.org/doc/html/rfc8252
use std::{
net::SocketAddr,
sync::{LazyLock, Mutex},
time::Duration,
};
use hyper::body::Incoming;
use hyper_util::rt::{TokioIo, TokioTimer};
use theseus::ErrorKind;
use theseus::prelude::tcp_listen_any_loopback;
use tokio::sync::{broadcast, oneshot};
static SERVER_SHUTDOWN: LazyLock<broadcast::Sender<()>> =
LazyLock::new(|| broadcast::channel(1024).0);
/// Starts a temporary HTTP server to receive OAuth 2.0 authorization code grant flow redirects
/// on a loopback interface with an ephemeral port. The caller can know the bound socket address
/// by listening on the counterpart channel for `listen_socket_tx`.
///
/// If the server is stopped before receiving an authorization code, `Ok(None)` is returned.
pub async fn listen(
listen_socket_tx: oneshot::Sender<Result<SocketAddr, theseus::Error>>,
) -> Result<Option<AuthorizationCodeReply>, theseus::Error> {
let listener = tcp_listen_any_loopback().await;
listen_with_listener(listener, listen_socket_tx).await
}
/// Starts a temporary HTTP server on a registered loopback callback address.
pub async fn listen_fixed(
address: SocketAddr,
listen_socket_tx: oneshot::Sender<Result<SocketAddr, theseus::Error>>,
) -> Result<Option<AuthorizationCodeReply>, theseus::Error> {
let listener = tokio::net::TcpListener::bind(address).await;
listen_with_listener(listener, listen_socket_tx).await
}
async fn listen_with_listener(
listener: Result<tokio::net::TcpListener, std::io::Error>,
listen_socket_tx: oneshot::Sender<Result<SocketAddr, theseus::Error>>,
) -> Result<Option<AuthorizationCodeReply>, theseus::Error> {
let listener = match listener {
Ok(listener) => {
listen_socket_tx
.send(listener.local_addr().map_err(|e| {
ErrorKind::OtherError(format!(
"Failed to get auth code reply socket address: {e}"
))
.into()
}))
.ok();
listener
}
Err(e) => {
let error_msg =
format!("Failed to bind auth code reply socket: {e}");
listen_socket_tx
.send(Err(ErrorKind::OtherError(error_msg.clone()).into()))
.ok();
return Err(ErrorKind::OtherError(error_msg).into());
}
};
let mut auth_code = Mutex::new(None);
let mut shutdown_notification = SERVER_SHUTDOWN.subscribe();
while auth_code.get_mut().unwrap().is_none() {
let client_socket = tokio::select! {
biased;
_ = shutdown_notification.recv() => {
break;
}
conn_accept_result = listener.accept() => {
match conn_accept_result {
Ok((socket, _)) => socket,
Err(e) => {
tracing::warn!("Failed to accept auth code reply: {e}");
continue;
}
}
}
};
if let Err(e) = hyper::server::conn::http1::Builder::new()
.keep_alive(false)
.header_read_timeout(Duration::from_secs(5))
.timer(TokioTimer::new())
.auto_date_header(false)
.serve_connection(
TokioIo::new(client_socket),
hyper::service::service_fn(|req| handle_reply(req, &auth_code)),
)
.await
{
tracing::warn!("Failed to handle auth code reply: {e}");
}
}
Ok(auth_code.into_inner().unwrap())
}
/// Stops any active OAuth 2.0 authorization code grant flow reply listening HTTP servers.
pub fn stop_listeners() {
SERVER_SHUTDOWN.send(()).ok();
}
pub struct AuthorizationCodeReply {
pub code: String,
pub state: Option<String>,
}
struct ReplyPageCopy {
language: &'static str,
success_title: &'static str,
success_message: &'static str,
error_title: &'static str,
error_message: &'static str,
}
fn reply_page_copy(
accept_language: Option<&hyper::header::HeaderValue>,
) -> ReplyPageCopy {
let is_chinese = accept_language
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(',')
.any(|language| language.trim_start().starts_with("zh"))
});
if is_chinese {
ReplyPageCopy {
language: "zh-CN",
success_title: "登录成功",
success_message: "你已成功登录!现在可以关闭此页面。",
error_title: "发生错误",
error_message: "未找到授权代码。请重新尝试登录。",
}
} else {
ReplyPageCopy {
language: "en",
success_title: "Success",
success_message: "You have successfully signed in! You can close this page now.",
error_title: "Error",
error_message: "Authorization code not found. Please try signing in again.",
}
}
}
fn render_reply_page(language: &str, title: &str, message: &str) -> String {
include_str!("auth_code_reply/page.html")
.replace("lang=en", &format!("lang={language}"))
.replace("{{title}}", title)
.replace("{{message}}", message)
}
async fn handle_reply(
req: hyper::Request<Incoming>,
auth_code_out: &Mutex<Option<AuthorizationCodeReply>>,
) -> Result<hyper::Response<String>, hyper::http::Error> {
if req.method() != hyper::Method::GET {
return hyper::Response::builder()
.status(hyper::StatusCode::METHOD_NOT_ALLOWED)
.header("Allow", "GET")
.body("".into());
}
let copy =
reply_page_copy(req.headers().get(hyper::header::ACCEPT_LANGUAGE));
// The authorization code is guaranteed to be sent as a "code" query parameter
// in the request URI query string as per RFC 6749 § 4.1.2
let auth_code = req.uri().query().and_then(|query_string| {
let params: std::collections::HashMap<_, _> =
url::form_urlencoded::parse(query_string.as_bytes()).collect();
Some(AuthorizationCodeReply {
code: params.get("code")?.to_string(),
state: params.get("state").map(ToString::to_string),
})
});
let response = if let Some(auth_code) = auth_code {
*auth_code_out.lock().unwrap() = Some(auth_code);
hyper::Response::builder()
.status(hyper::StatusCode::OK)
.header("Content-Type", "text/html;charset=utf-8")
.body(render_reply_page(
copy.language,
copy.success_title,
copy.success_message,
))
} else {
hyper::Response::builder()
.status(hyper::StatusCode::BAD_REQUEST)
.header("Content-Type", "text/html;charset=utf-8")
.body(render_reply_page(
copy.language,
copy.error_title,
copy.error_message,
))
}?;
Ok(response)
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
//! Assorted utilities for OAuth 2.0 authorization flows.
pub mod auth_code_reply;

View File

@ -0,0 +1,31 @@
use crate::api::Result;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("planet-minecraft")
.invoke_handler(tauri::generate_handler![
planet_minecraft_connector_available,
planet_minecraft_search_projects,
planet_minecraft_get_project,
])
.build()
}
#[tauri::command]
pub fn planet_minecraft_connector_available() -> bool {
theseus::planet_minecraft::connector_base_url().is_ok()
}
#[tauri::command]
pub async fn planet_minecraft_search_projects(
query: &str,
game_version: Option<&str>,
) -> Result<Vec<theseus::planet_minecraft::PlanetMinecraftProject>> {
Ok(theseus::planet_minecraft::search_projects(query, game_version).await?)
}
#[tauri::command]
pub async fn planet_minecraft_get_project(
id: &str,
) -> Result<theseus::planet_minecraft::PlanetMinecraftProject> {
Ok(theseus::planet_minecraft::get_project(id).await?)
}

View File

@ -0,0 +1,36 @@
use crate::api::Result;
use theseus::prelude::*;
use uuid::Uuid;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("process")
.invoke_handler(tauri::generate_handler![
process_get_all,
process_get_by_instance_id,
process_kill,
process_wait_for,
])
.build()
}
#[tauri::command]
pub async fn process_get_all() -> Result<Vec<ProcessMetadata>> {
Ok(process::get_all().await?)
}
#[tauri::command]
pub async fn process_get_by_instance_id(
instance_id: &str,
) -> Result<Vec<ProcessMetadata>> {
Ok(process::get_by_instance_id(instance_id).await?)
}
#[tauri::command]
pub async fn process_kill(uuid: Uuid) -> Result<()> {
Ok(process::kill(uuid).await?)
}
#[tauri::command]
pub async fn process_wait_for(uuid: Uuid) -> Result<()> {
Ok(process::wait_for(uuid).await?)
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,151 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use tokio_util::sync::CancellationToken;
static SEARCH_CANCELLATIONS: OnceLock<
Mutex<HashMap<String, Arc<CancellationToken>>>,
> = OnceLock::new();
fn cancellations() -> &'static Mutex<HashMap<String, Arc<CancellationToken>>> {
SEARCH_CANCELLATIONS.get_or_init(|| Mutex::new(HashMap::new()))
}
pub struct SearchCancellation {
request_id: String,
token: Arc<CancellationToken>,
}
pub struct PendingCancellation {
request_id: String,
token: Arc<CancellationToken>,
}
impl PendingCancellation {
pub fn expire(self) {
let mut requests = cancellations()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let is_still_pending = requests
.get(&self.request_id)
.is_some_and(|token| Arc::ptr_eq(token, &self.token));
if is_still_pending {
requests.remove(&self.request_id);
}
}
}
impl SearchCancellation {
pub fn register(request_id: String) -> Self {
let mut requests = cancellations()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let token = match requests.remove(&request_id) {
Some(token) if token.is_cancelled() => token,
Some(token) => {
token.cancel();
Arc::new(CancellationToken::new())
}
None => Arc::new(CancellationToken::new()),
};
requests.insert(request_id.clone(), Arc::clone(&token));
Self { request_id, token }
}
pub async fn cancelled(&self) {
self.token.cancelled().await;
}
}
impl Drop for SearchCancellation {
fn drop(&mut self) {
let mut requests = cancellations()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let owns_registration = requests
.get(&self.request_id)
.is_some_and(|token| Arc::ptr_eq(token, &self.token));
if owns_registration {
requests.remove(&self.request_id);
}
}
}
pub fn cancel(request_id: &str) -> Option<PendingCancellation> {
let mut requests = cancellations()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(token) = requests.get(request_id) {
token.cancel();
return None;
}
let token = Arc::new(CancellationToken::new());
token.cancel();
requests.insert(request_id.to_string(), Arc::clone(&token));
Some(PendingCancellation {
request_id: request_id.to_string(),
token,
})
}
#[cfg(test)]
mod tests {
use super::{SearchCancellation, cancel, cancellations};
#[test]
fn cancellation_before_registration_is_preserved() {
let request_id = "search-cancelled-before-registration";
let _pending = cancel(request_id);
let cancellation = SearchCancellation::register(request_id.to_string());
assert!(cancellation.token.is_cancelled());
drop(cancellation);
assert!(
!cancellations()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.contains_key(request_id)
);
}
#[test]
fn stale_guard_does_not_remove_a_new_registration() {
let request_id = "search-registration-replacement";
let stale = SearchCancellation::register(request_id.to_string());
let current = SearchCancellation::register(request_id.to_string());
drop(stale);
let _ = cancel(request_id);
assert!(current.token.is_cancelled());
drop(current);
assert!(
!cancellations()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.contains_key(request_id)
);
}
#[test]
fn unused_pending_cancellation_can_expire() {
let request_id = "search-cancellation-expiry";
let pending =
cancel(request_id).expect("a new cancellation should be pending");
pending.expire();
assert!(
!cancellations()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.contains_key(request_id)
);
}
}

View File

@ -0,0 +1,97 @@
use tauri::Runtime;
use crate::{api::Result, seed_map};
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("seed-map")
.invoke_handler(tauri::generate_handler![
seed_map_profiles,
seed_map_render_tile,
seed_map_find_features,
seed_map_spawn,
seed_map_biome_at,
seed_map_scan_ores,
seed_map_read_level_dat,
])
.build()
}
#[tauri::command]
pub fn seed_map_profiles() -> Vec<seed_map::VersionProfile> {
seed_map::version_profiles()
}
#[tauri::command]
pub async fn seed_map_render_tile(
request: seed_map::TileRequest,
) -> Result<tauri::ipc::Response> {
let pixels = tauri::async_runtime::spawn_blocking(move || {
seed_map::render_tile(request)
})
.await
.map_err(std::io::Error::other)??;
Ok(tauri::ipc::Response::new(pixels))
}
#[tauri::command]
pub async fn seed_map_find_features(
query: seed_map::FeatureQuery,
) -> Result<Vec<seed_map::MapFeature>> {
Ok(tauri::async_runtime::spawn_blocking(move || {
seed_map::find_features(query)
})
.await
.map_err(std::io::Error::other)??)
}
#[tauri::command]
pub async fn seed_map_spawn(
seed: String,
edition: seed_map::Edition,
version: String,
) -> Result<seed_map::SpawnPoint> {
Ok(tauri::async_runtime::spawn_blocking(move || {
seed_map::spawn(seed, edition, version)
})
.await
.map_err(std::io::Error::other)??)
}
#[tauri::command]
pub async fn seed_map_biome_at(
seed: String,
edition: seed_map::Edition,
version: String,
dimension: seed_map::Dimension,
x: i32,
y: i32,
z: i32,
) -> Result<i32> {
Ok(tauri::async_runtime::spawn_blocking(move || {
seed_map::biome_at(seed, edition, version, dimension, x, y, z)
})
.await
.map_err(std::io::Error::other)??)
}
#[tauri::command]
pub async fn seed_map_scan_ores(
request: seed_map::ores::OreScanRequest,
) -> Result<Vec<seed_map::ores::OreChunkResult>> {
Ok(tauri::async_runtime::spawn_blocking(move || {
seed_map::ores::scan_ores(request)
})
.await
.map_err(std::io::Error::other)??)
}
#[tauri::command]
pub async fn seed_map_read_level_dat(
path: String,
) -> Result<seed_map::LevelDatInfo> {
Ok(tauri::async_runtime::spawn_blocking(move || {
seed_map::read_level_dat(path)
})
.await
.map_err(std::io::Error::other)??)
}

218
apps/app/src/api/servers.rs Normal file
View File

@ -0,0 +1,218 @@
use crate::api::Result;
use theseus::prelude::*;
use theseus::servers::ServerInfo;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("servers")
.invoke_handler(tauri::generate_handler![
servers_list,
servers_get,
servers_create,
servers_update_settings,
servers_set_icon,
servers_delete,
servers_read_file,
servers_write_file,
servers_download_file,
servers_install_forge,
servers_install_modpack,
servers_start,
servers_send_command,
servers_send_console_input,
servers_resize_console,
servers_stop,
servers_kill,
servers_kill_port_process,
servers_port_process,
servers_get_log_buffer,
servers_clear_log,
])
.build()
}
#[tauri::command]
pub async fn servers_list() -> Result<Vec<ServerInfo>> {
Ok(servers::list().await?)
}
#[tauri::command]
pub async fn servers_get(server_id: &str) -> Result<ServerInfo> {
Ok(servers::get(server_id).await?)
}
#[tauri::command]
pub async fn servers_create(
name: &str,
server_type: &str,
game_version: &str,
loader_version: Option<String>,
java_path: Option<String>,
memory_mb: Option<u32>,
) -> Result<servers::ServerManifest> {
Ok(servers::create(
name,
server_type,
game_version,
loader_version,
java_path,
memory_mb,
)
.await?)
}
#[tauri::command]
pub async fn servers_update_settings(
server_id: &str,
name: Option<String>,
java_path: Option<String>,
memory_mb: Option<u32>,
jvm_args: Option<Vec<String>>,
) -> Result<servers::ServerManifest> {
Ok(servers::update_settings(
server_id, name, java_path, memory_mb, jvm_args,
)
.await?)
}
#[tauri::command]
pub async fn servers_set_icon(
server_id: &str,
icon_path: Option<String>,
) -> Result<servers::ServerManifest> {
Ok(servers::set_icon(server_id, icon_path).await?)
}
#[tauri::command]
pub async fn servers_delete(server_id: &str) -> Result<()> {
Ok(servers::delete(server_id).await?)
}
#[tauri::command]
pub async fn servers_read_file(server_id: &str, file: &str) -> Result<String> {
Ok(servers::read_file(server_id, file).await?)
}
#[tauri::command]
pub async fn servers_write_file(
server_id: &str,
file: &str,
contents: &str,
) -> Result<()> {
Ok(servers::write_file(server_id, file, contents).await?)
}
#[tauri::command]
pub async fn servers_download_file(
server_id: &str,
url: &str,
filename: &str,
expected_sha1: Option<String>,
) -> Result<()> {
Ok(servers::download_file(server_id, url, filename, expected_sha1).await?)
}
#[tauri::command]
pub async fn servers_install_forge(
server_id: &str,
mc_version: &str,
build: &str,
java_path: Option<String>,
) -> Result<()> {
Ok(servers::install_forge(server_id, mc_version, build, java_path).await?)
}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn servers_install_modpack(
server_id: &str,
mrpack_url: &str,
mrpack_sha1: Option<String>,
jar_url: &str,
jar_filename: &str,
jar_sha1: Option<String>,
modpack_project_id: Option<String>,
modpack_version_id: Option<String>,
modpack_title: Option<String>,
modpack_icon_url: Option<String>,
) -> Result<()> {
Ok(servers::install_modpack(
server_id,
mrpack_url,
mrpack_sha1,
jar_url,
jar_filename,
jar_sha1,
modpack_project_id,
modpack_version_id,
modpack_title,
modpack_icon_url,
)
.await?)
}
#[tauri::command]
pub async fn servers_start(
server_id: &str,
java_path: Option<String>,
memory_mb: Option<u32>,
jvm_args: Option<Vec<String>>,
) -> Result<()> {
Ok(servers::start(server_id, java_path, memory_mb, jvm_args).await?)
}
#[tauri::command]
pub async fn servers_send_command(
server_id: &str,
command: &str,
) -> Result<()> {
Ok(servers::send_command(server_id, command).await?)
}
#[tauri::command]
pub async fn servers_send_console_input(
server_id: &str,
data: &str,
) -> Result<()> {
Ok(servers::send_console_input(server_id, data).await?)
}
#[tauri::command]
pub async fn servers_resize_console(
server_id: &str,
cols: u16,
rows: u16,
) -> Result<()> {
Ok(servers::resize_console(server_id, cols, rows).await?)
}
#[tauri::command]
pub async fn servers_stop(server_id: &str) -> Result<()> {
Ok(servers::stop(server_id).await?)
}
#[tauri::command]
pub async fn servers_kill(server_id: &str) -> Result<()> {
Ok(servers::kill(server_id).await?)
}
#[tauri::command]
pub async fn servers_kill_port_process(port: u16) -> Result<()> {
Ok(servers::kill_port_process(port).await?)
}
#[tauri::command]
pub async fn servers_port_process(
port: u16,
) -> Result<Option<servers::PortProcessInfo>> {
Ok(servers::port_process(port).await?)
}
#[tauri::command]
pub async fn servers_get_log_buffer(server_id: &str) -> Result<Vec<String>> {
Ok(servers::get_log_buffer(server_id).await?)
}
#[tauri::command]
pub async fn servers_clear_log(server_id: &str) -> Result<()> {
Ok(servers::clear_log(server_id).await?)
}

View File

@ -0,0 +1,133 @@
use crate::api::Result;
use tauri::{Emitter, Runtime};
use theseus::prelude::*;
use theseus::{ProxyConfig, ProxyTestResult};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("settings")
.invoke_handler(tauri::generate_handler![
settings_get,
settings_set,
privacy_get,
privacy_set,
telemetry_set,
discord_rpc_set,
download_engine_set,
cancel_directory_change,
proxy_get,
proxy_set,
proxy_test
])
.build()
}
// Get full settings
// invoke('plugin:settings|settings_get')
#[tauri::command]
pub async fn settings_get() -> Result<Settings> {
let res = settings::get().await?;
Ok(res)
}
// Set full settings
// invoke('plugin:settings|settings_set', settings)
#[tauri::command]
pub async fn settings_set(
app: tauri::AppHandle<impl Runtime>,
settings: Settings,
) -> Result<()> {
settings::set(settings).await?;
let _ = app.emit("settings", ());
Ok(())
}
#[tauri::command]
pub async fn privacy_get() -> Result<PrivacySettings> {
Ok(settings::get_privacy().await?)
}
#[tauri::command]
pub async fn privacy_set(privacy: PrivacySettings) -> Result<PrivacySettings> {
Ok(settings::set_privacy(privacy).await?)
}
#[tauri::command]
pub async fn telemetry_set(enabled: bool) -> Result<PrivacySettings> {
Ok(settings::set_telemetry(enabled).await?)
}
#[tauri::command]
pub async fn discord_rpc_set(enabled: bool) -> Result<PrivacySettings> {
Ok(settings::set_discord_rpc(enabled).await?)
}
#[tauri::command]
pub async fn download_engine_set(
engine: settings::DownloadEngine,
) -> Result<()> {
settings::set_download_engine(engine).await?;
Ok(())
}
#[tauri::command]
pub async fn cancel_directory_change<R: Runtime>(
app: tauri::AppHandle<R>,
) -> Result<()> {
let identifier = &app.config().identifier;
settings::cancel_directory_change(identifier).await?;
Ok(())
}
#[tauri::command]
pub async fn proxy_get() -> Result<ProxyConfig> {
let state = State::get().await?;
Ok(state.proxy_config().await?)
}
#[tauri::command]
pub async fn proxy_set(config: ProxyConfig) -> Result<()> {
let state = State::get().await?;
config.validate()?;
state.update_proxy_config(&config).await?;
Ok(())
}
#[tauri::command]
pub async fn proxy_test(config: ProxyConfig) -> Result<ProxyTestResult> {
if let Err(e) = config.validate() {
return Ok(ProxyTestResult {
success: false,
latency_ms: None,
message: e.to_string(),
});
}
let client = theseus::build_proxied_client(&config);
let started = std::time::Instant::now();
match client
.get("http://connect.rom.miui.com/generate_204")
.send()
.await
{
Ok(response) if response.status().is_success() => {
let latency_ms = started.elapsed().as_millis() as u64;
Ok(ProxyTestResult {
success: true,
latency_ms: Some(latency_ms),
message: format!("Connection successful ({latency_ms} ms)"),
})
}
Ok(response) => {
let latency_ms = started.elapsed().as_millis() as u64;
Ok(ProxyTestResult {
success: false,
latency_ms: Some(latency_ms),
message: format!("HTTP {}", response.status()),
})
}
Err(e) => Ok(ProxyTestResult {
success: false,
latency_ms: None,
message: format!("{e}"),
}),
}
}

View File

@ -0,0 +1,55 @@
use crate::api::Result;
use std::path::Path;
use url::Url;
pub(super) const SHORTCUT_EXTENSION: &str = "desktop";
pub(super) async fn create_shortcut(
profile_name: &str,
launch_url: &Url,
output_path: &Path,
) -> Result<()> {
let target_path = std::env::current_exe()?;
tokio::fs::write(
output_path,
format!(
"[Desktop Entry]\n\
Type=Application\n\
Name={}\n\
Exec={} {}\n\
Icon=AxolotlLauncher\n\
Terminal=false\n\
Categories=Game;\n",
escape_desktop_entry_value(&format!("Launch {profile_name}")),
quote_desktop_exec_arg(&target_path.to_string_lossy()),
quote_desktop_exec_arg(launch_url.as_str()),
),
)
.await?;
use std::os::unix::fs::PermissionsExt;
let mut permissions = tokio::fs::metadata(output_path).await?.permissions();
permissions.set_mode(0o755);
tokio::fs::set_permissions(output_path, permissions).await?;
Ok(())
}
fn escape_desktop_entry_value(input: &str) -> String {
input
.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace('\r', "")
}
fn quote_desktop_exec_arg(input: &str) -> String {
format!(
"\"{}\"",
input
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('$', "\\$")
.replace('`', "\\`")
)
}

View File

@ -0,0 +1,90 @@
use crate::api::Result;
use std::{
hash::{DefaultHasher, Hash, Hasher},
path::Path,
};
use url::Url;
pub(super) const SHORTCUT_EXTENSION: &str = "app";
pub(super) async fn create_shortcut(
profile_name: &str,
launch_url: &Url,
output_path: &Path,
) -> Result<()> {
let contents_dir = output_path.join("Contents");
let macos_dir = contents_dir.join("MacOS");
let resources_dir = contents_dir.join("Resources");
tokio::fs::create_dir_all(&macos_dir).await?;
tokio::fs::create_dir_all(&resources_dir).await?;
let executable_path = macos_dir.join("launch");
tokio::fs::write(
&executable_path,
format!(
"#!/bin/sh\nexec /usr/bin/open {}\n",
shell_quote(launch_url.as_str()),
),
)
.await?;
tokio::fs::write(
resources_dir.join("icon.icns"),
include_bytes!("../../../icons/icon.icns"),
)
.await?;
tokio::fs::write(
contents_dir.join("Info.plist"),
format!(r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>launch</string>
<key>CFBundleIdentifier</key>
<string>{}</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>CFBundleName</key>
<string>{}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
</dict>
</plist>
"#,
macos_shortcut_identifier(launch_url.as_str()),
escape_xml(&format!("Launch {profile_name}")),
),
)
.await?;
use std::os::unix::fs::PermissionsExt;
let mut permissions =
tokio::fs::metadata(&executable_path).await?.permissions();
permissions.set_mode(0o755);
tokio::fs::set_permissions(&executable_path, permissions).await?;
Ok(())
}
fn macos_shortcut_identifier(launch_url: &str) -> String {
let mut hasher = DefaultHasher::new();
launch_url.hash(&mut hasher);
format!("com.modrinth.instance-shortcut.{:x}", hasher.finish())
}
fn shell_quote(input: &str) -> String {
format!("'{}'", input.replace('\'', "'\\''"))
}
fn escape_xml(input: &str) -> String {
input
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}

View File

@ -0,0 +1,114 @@
use crate::api::Result;
use std::path::{Path, PathBuf};
use tauri::Runtime;
use url::Url;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "linux")]
use linux::{SHORTCUT_EXTENSION, create_shortcut};
#[cfg(target_os = "macos")]
use macos::{SHORTCUT_EXTENSION, create_shortcut};
#[cfg(target_os = "windows")]
use windows::{SHORTCUT_EXTENSION, create_shortcut};
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("shortcuts")
.invoke_handler(tauri::generate_handler![create_instance_shortcut])
.build()
}
#[tauri::command]
pub async fn create_instance_shortcut(
instance_name: String,
instance_id: String,
output_path: PathBuf,
server: Option<String>,
singleplayer_world: Option<String>,
) -> Result<PathBuf> {
if server.is_some() && singleplayer_world.is_some() {
return Err(std::io::Error::other(
"shortcut cannot launch both a server and a singleplayer world",
)
.into());
}
let launch_url =
instance_launch_url(instance_id, server, singleplayer_world);
let output_path = shortcut_path_with_extension(output_path);
let output_path_existed =
tokio::fs::try_exists(&output_path).await.unwrap_or(false);
if let Err(error) =
create_shortcut(&instance_name, &launch_url, &output_path).await
{
cleanup_shortcut_artifact(&output_path, output_path_existed).await;
return Err(error);
}
Ok(output_path)
}
fn instance_launch_url(
instance_id: String,
server: Option<String>,
singleplayer_world: Option<String>,
) -> Url {
let mut launch_url = Url::parse("axolotl://launch/instance")
.expect("static launch URL should parse");
launch_url
.path_segments_mut()
.expect("launch URL should support path segments")
.push(&instance_id);
if let Some(server) = server {
launch_url.query_pairs_mut().append_pair("server", &server);
} else if let Some(singleplayer_world) = singleplayer_world {
launch_url
.query_pairs_mut()
.append_pair("singleplayer_world", &singleplayer_world);
}
launch_url
}
fn shortcut_path_with_extension(mut path: PathBuf) -> PathBuf {
if path
.extension()
.is_none_or(|current_extension| current_extension != SHORTCUT_EXTENSION)
{
path.set_extension(SHORTCUT_EXTENSION);
}
path
}
async fn cleanup_shortcut_artifact(path: &Path, existed: bool) {
if existed {
return;
}
let result = match tokio::fs::metadata(path).await {
Ok(metadata) if metadata.is_dir() => {
tokio::fs::remove_dir_all(path).await
}
_ => tokio::fs::remove_file(path).await,
};
if let Err(error) = result
&& error.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(
"failed to clean up shortcut artifact {}: {}",
path.display(),
error
);
}
}

View File

@ -0,0 +1,125 @@
use crate::api::Result;
use std::{
os::windows::ffi::OsStrExt,
path::{Path, PathBuf},
};
use url::Url;
use windows::{
Win32::{
System::Com::{
CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
COINIT_DISABLE_OLE1DDE, CoCreateInstance, CoInitializeEx,
CoUninitialize, IPersistFile,
},
UI::Shell::{IShellLinkW, ShellLink},
},
core::{Interface, PCWSTR},
};
pub(super) const SHORTCUT_EXTENSION: &str = "lnk";
pub(super) async fn create_shortcut(
_profile_name: &str,
launch_url: &Url,
output_path: &Path,
) -> Result<()> {
let target_path = std::env::current_exe()?;
let working_dir = target_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let output_path = output_path.to_path_buf();
let launch_url = launch_url.to_string();
tokio::task::spawn_blocking(move || {
create_windows_shortcut(
output_path,
target_path,
working_dir,
launch_url,
)
})
.await
.map_err(|error| {
std::io::Error::other(format!(
"failed to join shortcut creation task: {error}"
))
})??;
Ok(())
}
fn create_windows_shortcut(
output_path: PathBuf,
target_path: PathBuf,
working_dir: PathBuf,
launch_url: String,
) -> std::io::Result<()> {
let output_path = windows_wide_path(&output_path);
let target_path = windows_wide_path(&target_path);
let working_dir = windows_wide_path(&working_dir);
let launch_url = windows_wide_string(&launch_url);
// SAFETY:
// - COM is initialized for this blocking thread before any COM object is created.
// - `_com` is declared before the COM interface values, so it is dropped
// after them and calls `CoUninitialize` only once they are released.
// - Every PCWSTR points to a NUL-terminated UTF-16 buffer that lives until
// each call using it has returned.
unsafe {
let init_result = CoInitializeEx(
None,
COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE,
);
windows_result(init_result.ok())?;
let _com = WindowsComGuard;
let shortcut: IShellLinkW = windows_result(CoCreateInstance(
&ShellLink,
None,
CLSCTX_INPROC_SERVER,
))?;
windows_result(shortcut.SetPath(windows_pcwstr(&target_path)))?;
windows_result(shortcut.SetArguments(windows_pcwstr(&launch_url)))?;
windows_result(
shortcut.SetWorkingDirectory(windows_pcwstr(&working_dir)),
)?;
windows_result(
shortcut.SetIconLocation(windows_pcwstr(&target_path), 0),
)?;
let persist_file: IPersistFile = windows_result(shortcut.cast())?;
windows_result(persist_file.Save(windows_pcwstr(&output_path), true))?;
}
Ok(())
}
fn windows_result<T>(result: windows::core::Result<T>) -> std::io::Result<T> {
result.map_err(std::io::Error::other)
}
struct WindowsComGuard;
impl Drop for WindowsComGuard {
fn drop(&mut self) {
unsafe {
CoUninitialize();
}
}
}
fn windows_wide_path(path: &Path) -> Vec<u16> {
path.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
fn windows_wide_string(value: &str) -> Vec<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
}
fn windows_pcwstr(value: &[u16]) -> PCWSTR {
PCWSTR::from_raw(value.as_ptr())
}

173
apps/app/src/api/storage.rs Normal file
View File

@ -0,0 +1,173 @@
use serde::Serialize;
use std::path::PathBuf;
use tauri::{Emitter, Runtime};
use tauri_plugin_opener::OpenerExt;
use theseus::storage::{
StorageNode, StoragePath, StoragePathKind, StorageSize, StorageTree,
assemble_storage_tree, load_storage_cache, save_storage_cache,
scan_cache_category, scan_database_category, scan_instances_category,
scan_meta_category, scan_root_other,
};
use crate::api::Result;
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("storage")
.invoke_handler(tauri::generate_handler![
storage_scan_start,
storage_open_paths,
])
.build()
}
#[derive(Clone, Serialize)]
#[serde(tag = "kind", content = "payload", rename_all = "snake_case")]
enum StorageScanEvent {
Started,
Category { category: StorageNode },
Complete { tree: StorageTree },
Error { message: String },
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct StorageOpenResult {
pub opened: Vec<String>,
pub failed: Vec<StorageOpenFailure>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct StorageOpenFailure {
pub path: String,
pub reason: String,
}
#[tauri::command]
pub async fn storage_scan_start<R: Runtime>(
app: tauri::AppHandle<R>,
force: bool,
) -> Result<()> {
tauri::async_runtime::spawn(async move {
if let Err(error) = run_scan(&app, force).await {
let _ = app.emit(
"storage-scan",
StorageScanEvent::Error {
message: error.to_string(),
},
);
}
});
Ok(())
}
async fn run_scan<R: Runtime>(
app: &tauri::AppHandle<R>,
force: bool,
) -> crate::api::Result<()> {
if !force {
if let Some(tree) = load_storage_cache().await {
let _ =
app.emit("storage-scan", StorageScanEvent::Complete { tree });
return Ok(());
}
}
let _ = app.emit("storage-scan", StorageScanEvent::Started);
let mut categories = Vec::new();
let mut known = StorageSize::default();
if let Some(node) = scan_instances_category().await? {
known += node.size;
categories.push(node.clone());
let _ = app.emit(
"storage-scan",
StorageScanEvent::Category { category: node },
);
}
if let Some(node) = scan_cache_category().await? {
known += node.size;
categories.push(node.clone());
let _ = app.emit(
"storage-scan",
StorageScanEvent::Category { category: node },
);
}
if let Some(node) = scan_meta_category().await? {
known += node.size;
categories.push(node.clone());
let _ = app.emit(
"storage-scan",
StorageScanEvent::Category { category: node },
);
}
if let Some(node) = scan_database_category().await? {
known += node.size;
categories.push(node.clone());
let _ = app.emit(
"storage-scan",
StorageScanEvent::Category { category: node },
);
}
let root_other = scan_root_other(known).await?;
if let Some(node) = &root_other {
let _ = app.emit(
"storage-scan",
StorageScanEvent::Category {
category: node.clone(),
},
);
}
let tree = assemble_storage_tree(categories, root_other);
if let Err(error) = save_storage_cache(&tree).await {
tracing::warn!(
error = %error,
"Failed to persist launcher storage cache; continuing without cache"
);
}
let _ = app.emit("storage-scan", StorageScanEvent::Complete { tree });
Ok(())
}
#[tauri::command]
pub async fn storage_open_paths<R: Runtime>(
app: tauri::AppHandle<R>,
paths: Vec<StoragePath>,
) -> StorageOpenResult {
let mut opened = Vec::new();
let mut failed = Vec::new();
for storage_path in paths {
let path = PathBuf::from(&storage_path.path);
let result = match storage_path.kind {
StoragePathKind::File => {
app.opener().reveal_item_in_dir(path.clone()).map(|_| ())
}
StoragePathKind::Directory => app
.opener()
.open_path(path.to_string_lossy(), None::<&str>)
.map(|_| ()),
};
match result {
Ok(()) => opened.push(storage_path.path),
Err(error) => {
tracing::warn!(
path = %storage_path.path,
error = %error,
"Failed to open storage path"
);
failed.push(StorageOpenFailure {
path: storage_path.path,
reason: error.to_string(),
});
}
}
}
StorageOpenResult { opened, failed }
}

View File

@ -0,0 +1,269 @@
use serde::Serialize;
use tauri::{AppHandle, Emitter, Runtime};
const ACCENT_COLOR_CHANGED: &str = "system-accent-color-changed";
#[cfg(target_os = "macos")]
thread_local! {
static STOP_ACCENT_COLOR_WATCHER: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
const { std::cell::RefCell::new(None) };
}
#[derive(Debug, Clone, Serialize)]
pub struct AccentColor {
hex: String,
r: u8,
g: u8,
b: u8,
}
impl AccentColor {
fn new(r: u8, g: u8, b: u8) -> Self {
Self {
hex: format!("#{r:02X}{g:02X}{b:02X}"),
r,
g,
b,
}
}
}
#[cfg(any(target_os = "macos", target_os = "linux", test))]
fn unit_to_u8(value: f64) -> Result<u8, String> {
if !value.is_finite() || !(0.0..=1.0).contains(&value) {
return Err(format!("Invalid RGB component: {value}"));
}
Ok((value * 255.0).round() as u8)
}
fn emit_accent_color<R: Runtime>(app: &AppHandle<R>, color: AccentColor) {
if let Err(error) = app.emit(ACCENT_COLOR_CHANGED, color) {
tracing::warn!("Failed to emit system accent color: {error}");
}
}
#[cfg(target_os = "windows")]
fn read_accent_color() -> Result<AccentColor, String> {
use windows::UI::ViewManagement::{UIColorType, UISettings};
let settings = UISettings::new()
.map_err(|error| format!("Failed to initialize UISettings: {error}"))?;
let color =
settings
.GetColorValue(UIColorType::Accent)
.map_err(|error| {
format!("Failed to read Windows accent color: {error}")
})?;
Ok(AccentColor::new(color.R, color.G, color.B))
}
#[cfg(target_os = "windows")]
fn start_accent_color_watcher<R: Runtime>(app: AppHandle<R>) {
std::thread::spawn(move || {
use windows::{
Foundation::TypedEventHandler, UI::ViewManagement::UISettings,
core::IInspectable,
};
let settings = match UISettings::new() {
Ok(settings) => settings,
Err(error) => {
tracing::warn!(
"Failed to initialize system accent color watcher: {error}"
);
return;
}
};
let app_handle = app.clone();
let handler = TypedEventHandler::<UISettings, IInspectable>::new(
move |_sender, _args| {
if let Ok(color) = read_accent_color() {
emit_accent_color(&app_handle, color);
}
Ok(())
},
);
let token = match settings.ColorValuesChanged(&handler) {
Ok(token) => token,
Err(error) => {
tracing::warn!(
"Failed to listen for Windows accent color changes: {error}"
);
return;
}
};
std::thread::park();
let _ = settings.RemoveColorValuesChanged(token);
});
}
#[cfg(target_os = "macos")]
fn read_accent_color() -> Result<AccentColor, String> {
use objc2_app_kit::{NSColor, NSColorSpace};
let color = NSColor::controlAccentColor();
let srgb = NSColorSpace::sRGBColorSpace();
let color = color.colorUsingColorSpace(&srgb).ok_or_else(|| {
"Unable to convert the macOS accent color to sRGB".to_string()
})?;
Ok(AccentColor::new(
unit_to_u8(color.redComponent() as f64)?,
unit_to_u8(color.greenComponent() as f64)?,
unit_to_u8(color.blueComponent() as f64)?,
))
}
#[cfg(target_os = "macos")]
fn start_accent_color_watcher<R: Runtime>(app: AppHandle<R>) {
use std::ptr::NonNull;
use block2::RcBlock;
use objc2_app_kit::NSSystemColorsDidChangeNotification;
use objc2_foundation::{
NSNotification, NSNotificationCenter, NSOperationQueue,
};
let center = NSNotificationCenter::defaultCenter();
let queue = NSOperationQueue::mainQueue();
let block: RcBlock<dyn Fn(NonNull<NSNotification>)> =
RcBlock::new(move |_notification| {
if let Ok(color) = read_accent_color() {
emit_accent_color(&app, color);
}
});
let observer = unsafe {
center.addObserverForName_object_queue_usingBlock(
Some(NSSystemColorsDidChangeNotification),
None,
Some(&queue),
&block,
)
};
let stop = Box::new(move || unsafe {
center.removeObserver((&*observer).as_ref());
});
STOP_ACCENT_COLOR_WATCHER.with(|watcher| {
if let Some(stop) = watcher.borrow_mut().replace(stop) {
stop();
}
});
}
#[cfg(target_os = "macos")]
fn stop_accent_color_watcher() {
STOP_ACCENT_COLOR_WATCHER.with(|watcher| {
if let Some(stop) = watcher.borrow_mut().take() {
stop();
}
});
}
#[cfg(not(target_os = "macos"))]
fn stop_accent_color_watcher() {}
#[cfg(target_os = "linux")]
async fn read_accent_color() -> Result<AccentColor, String> {
use ashpd::desktop::settings::Settings;
let settings = Settings::new().await.map_err(|error| {
format!("Unable to connect to the XDG portal: {error}")
})?;
let color = settings.accent_color().await.map_err(|error| {
format!("XDG portal does not provide accent-color: {error}")
})?;
Ok(AccentColor::new(
unit_to_u8(color.red())?,
unit_to_u8(color.green())?,
unit_to_u8(color.blue())?,
))
}
#[cfg(target_os = "linux")]
fn start_accent_color_watcher<R: Runtime>(app: AppHandle<R>) {
tauri::async_runtime::spawn(async move {
use ashpd::desktop::settings::Settings;
use futures::StreamExt;
let settings = match Settings::new().await {
Ok(settings) => settings,
Err(error) => {
tracing::warn!("Unable to connect to the XDG portal: {error}");
return;
}
};
let stream = match settings.receive_accent_color_changed().await {
Ok(stream) => stream,
Err(error) => {
tracing::warn!(
"Unable to listen for XDG accent-color changes: {error}"
);
return;
}
};
futures::pin_mut!(stream);
while let Some(color) = stream.next().await {
let color = match (
unit_to_u8(color.red()),
unit_to_u8(color.green()),
unit_to_u8(color.blue()),
) {
(Ok(r), Ok(g), Ok(b)) => AccentColor::new(r, g, b),
_ => continue,
};
emit_accent_color(&app, color);
}
});
}
#[tauri::command]
async fn system_accent_color() -> Result<AccentColor, String> {
#[cfg(target_os = "windows")]
return read_accent_color();
#[cfg(target_os = "macos")]
return read_accent_color();
#[cfg(target_os = "linux")]
return read_accent_color().await;
#[allow(unreachable_code)]
Err("Unsupported operating system".to_string())
}
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("system-accent")
.setup(|app, _api| {
start_accent_color_watcher(app.clone());
Ok(())
})
.on_drop(|_| stop_accent_color_watcher())
.invoke_handler(tauri::generate_handler![system_accent_color])
.build()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalized_rgb_components_are_rounded() {
assert_eq!(unit_to_u8(0.0).unwrap(), 0);
assert_eq!(unit_to_u8(0.5).unwrap(), 128);
assert_eq!(unit_to_u8(1.0).unwrap(), 255);
}
#[test]
fn invalid_normalized_rgb_components_are_rejected() {
for value in [-0.1, 1.1, f64::NAN, f64::INFINITY] {
assert!(unit_to_u8(value).is_err());
}
}
}

44
apps/app/src/api/tags.rs Normal file
View File

@ -0,0 +1,44 @@
use crate::api::Result;
use theseus::tags::{Category, DonationPlatform, GameVersion, Loader};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("tags")
.invoke_handler(tauri::generate_handler![
tags_get_categories,
tags_get_report_types,
tags_get_loaders,
tags_get_game_versions,
tags_get_donation_platforms,
])
.build()
}
/// Gets cached category tags from the database
#[tauri::command]
pub async fn tags_get_categories() -> Result<Vec<Category>> {
Ok(theseus::tags::get_category_tags().await?)
}
/// Gets cached report type tags from the database
#[tauri::command]
pub async fn tags_get_report_types() -> Result<Vec<String>> {
Ok(theseus::tags::get_report_type_tags().await?)
}
/// Gets cached loader tags from the database
#[tauri::command]
pub async fn tags_get_loaders() -> Result<Vec<Loader>> {
Ok(theseus::tags::get_loader_tags().await?)
}
/// Gets cached game version tags from the database
#[tauri::command]
pub async fn tags_get_game_versions() -> Result<Vec<GameVersion>> {
Ok(theseus::tags::get_game_version_tags().await?)
}
/// Gets cached donation platform tags from the database
#[tauri::command]
pub async fn tags_get_donation_platforms() -> Result<Vec<DonationPlatform>> {
Ok(theseus::tags::get_donation_platform_tags().await?)
}

View File

@ -0,0 +1,10 @@
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("telemetry")
.invoke_handler(tauri::generate_handler![notify_online,])
.build()
}
#[tauri::command]
pub fn notify_online() {
theseus::telemetry::notify_online();
}

View File

@ -0,0 +1,180 @@
use crate::api::Result;
use serde::Serialize;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("terracotta")
.invoke_handler(tauri::generate_handler![
terracotta_get_state,
terracotta_get_meta,
terracotta_start,
terracotta_stop,
terracotta_host,
terracotta_join,
terracotta_reset,
terracotta_get_platform_key,
terracotta_check_for_update,
terracotta_download,
terracotta_update,
terracotta_get_player_name,
terracotta_get_diagnostic_report,
])
.build()
}
#[tauri::command]
pub async fn terracotta_get_state()
-> Result<theseus::terracotta::TerracottaState> {
Ok(theseus::terracotta::get_state().await)
}
#[derive(Serialize)]
pub struct TerracottaMetaResponse {
pub version: String,
pub compile_timestamp: String,
pub easytier_version: String,
pub yggdrasil_port: u16,
pub target_tuple: String,
pub target_os: String,
}
#[tauri::command]
pub async fn terracotta_get_meta() -> Result<TerracottaMetaResponse> {
let meta = theseus::terracotta::get_meta()
.await
.map_err(theseus::Error::from)?;
Ok(TerracottaMetaResponse {
version: meta.version,
compile_timestamp: meta.compile_timestamp,
easytier_version: meta.easytier_version,
yggdrasil_port: meta.yggdrasil_port,
target_tuple: meta.target_tuple,
target_os: meta.target_os,
})
}
#[tauri::command]
pub async fn terracotta_start(
binary_path: Option<String>,
auto_download: Option<bool>,
) -> Result<()> {
theseus::multiplayer::prepare_terracotta_with_options(
binary_path,
auto_download.unwrap_or(true),
)
.await
.map_err(|error| {
tracing::error!(target: "theseus::terracotta", action = "start", error = %error);
theseus::Error::from(error)
})?;
Ok(())
}
#[tauri::command]
pub async fn terracotta_stop() -> Result<()> {
theseus::multiplayer::stop_terracotta_compat()
.await
.map_err(|error| {
tracing::error!(target: "theseus::terracotta", action = "stop", error = %error);
theseus::Error::from(error)
})?;
Ok(())
}
#[tauri::command]
pub async fn terracotta_host(
room_code: Option<String>,
player_name: String,
) -> Result<()> {
theseus::multiplayer::host(
theseus::multiplayer::MultiplayerHostRequest::Terracotta {
room_code,
player_name,
},
)
.await
.map_err(|error| {
tracing::error!(target: "theseus::terracotta", action = "host", error = %error);
theseus::Error::from(error)
})?;
Ok(())
}
#[tauri::command]
pub async fn terracotta_join(
room_code: String,
player_name: String,
) -> Result<()> {
theseus::multiplayer::join(theseus::multiplayer::MultiplayerJoinRequest {
provider: theseus::multiplayer::MultiplayerProvider::Terracotta,
room_code,
player_name,
})
.await
.map_err(|error| {
tracing::error!(target: "theseus::terracotta", action = "join", error = %error);
theseus::Error::from(error)
})?;
Ok(())
}
#[tauri::command]
pub async fn terracotta_reset() -> Result<()> {
theseus::multiplayer::reset_terracotta_compat()
.await
.map_err(|error| {
tracing::error!(target: "theseus::terracotta", action = "reset", error = %error);
theseus::Error::from(error)
})?;
Ok(())
}
#[tauri::command]
pub async fn terracotta_get_platform_key() -> Result<String> {
Ok(theseus::terracotta::terracotta_platform_key().to_string())
}
#[tauri::command]
pub async fn terracotta_check_for_update()
-> Result<theseus::terracotta::TerracottaUpdate> {
Ok(theseus::terracotta::check_for_update()
.await
.map_err(theseus::Error::from)?)
}
#[tauri::command]
pub async fn terracotta_download(version: Option<String>) -> Result<()> {
theseus::terracotta::download_terracotta(version)
.await
.map_err(|error| {
tracing::error!(target: "theseus::terracotta", action = "download", error = %error);
theseus::Error::from(error)
})?;
Ok(())
}
#[tauri::command]
pub async fn terracotta_update() -> Result<theseus::terracotta::TerracottaUpdate>
{
Ok(theseus::terracotta::update_terracotta()
.await
.map_err(|error| {
tracing::error!(target: "theseus::terracotta", action = "update", error = %error);
theseus::Error::from(error)
})?)
}
#[tauri::command]
pub async fn terracotta_get_player_name() -> Result<String> {
let name = theseus::terracotta::get_player_name().await;
Ok(name)
}
#[tauri::command]
pub async fn terracotta_get_diagnostic_report() -> Result<String> {
Ok(theseus::terracotta::get_diagnostic_report()
.await
.map_err(|error| {
tracing::error!(target: "theseus::terracotta", action = "diagnostic_report", error = %error);
theseus::Error::from(error)
})?)
}

View File

@ -0,0 +1,55 @@
use crate::api::Result;
use theseus::google_ip;
use theseus::translation::{
self, TranslationProvider, TranslationRequest, TranslationResponse,
TranslationSettings,
};
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("translation")
.invoke_handler(tauri::generate_handler![
translation_get_settings,
translation_update_settings,
translation_test_provider,
translation_translate,
translation_clear_cache,
translation_google_ip_pool_size,
])
.build()
}
#[tauri::command]
pub async fn translation_get_settings() -> Result<TranslationSettings> {
Ok(translation::get_settings().await?)
}
#[tauri::command]
pub async fn translation_update_settings(
settings: TranslationSettings,
) -> Result<()> {
Ok(translation::update_settings(settings).await?)
}
#[tauri::command]
pub async fn translation_test_provider(
provider: TranslationProvider,
) -> Result<String> {
Ok(translation::test_provider(provider).await?)
}
#[tauri::command]
pub async fn translation_translate(
request: TranslationRequest,
) -> Result<TranslationResponse> {
Ok(translation::translate(request).await?)
}
#[tauri::command]
pub async fn translation_clear_cache() -> Result<()> {
Ok(translation::clear_cache().await?)
}
#[tauri::command]
pub async fn translation_google_ip_pool_size() -> usize {
google_ip::ip_pool_size().await
}

309
apps/app/src/api/utils.rs Normal file
View File

@ -0,0 +1,309 @@
use serde::{Deserialize, Serialize};
use tauri::Runtime;
use tauri_plugin_opener::OpenerExt;
use theseus::{
handler,
prelude::{CommandPayload, DirectoryInfo, app_db_backup_dir},
};
use crate::api::{Result, TheseusSerializableError};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use dashmap::DashMap;
use std::path::{Path, PathBuf};
use theseus::prelude::canonicalize;
use tokio_util::compat::FuturesAsyncWriteCompatExt;
use url::Url;
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("utils")
.invoke_handler(tauri::generate_handler![
get_os,
is_network_metered,
should_disable_mouseover,
highlight_in_folder,
open_path,
show_launcher_logs_folder,
export_error_logs,
show_app_db_backups_folder,
progress_bars_list,
get_opening_command,
get_minecraft_news
])
.build()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::enum_variant_names)]
pub enum OS {
Windows,
Linux,
MacOS,
}
/// Gets OS
#[tauri::command]
pub fn get_os() -> OS {
#[cfg(target_os = "windows")]
let os = OS::Windows;
#[cfg(target_os = "linux")]
let os = OS::Linux;
#[cfg(target_os = "macos")]
let os = OS::MacOS;
os
}
#[tauri::command]
pub async fn is_network_metered() -> Result<bool> {
Ok(theseus::prelude::is_network_metered().await?)
}
// Lists active progress bars
// Create a new HashMap with the same keys
// Values provided should not be used directly, as they are not guaranteed to be up-to-date
#[tauri::command]
pub async fn progress_bars_list()
-> Result<DashMap<uuid::Uuid, theseus::LoadingBar>> {
let res = theseus::EventState::list_progress_bars().await?;
Ok(res)
}
// disables mouseover and fixes a random crash error only fixed by recent versions of macos
#[tauri::command]
pub async fn should_disable_mouseover() -> bool {
if cfg!(target_os = "macos") {
// We try to match version to 12.2 or higher. If unrecognizable to pattern or lower, we default to the css with disabled mouseover for safety
if let tauri_plugin_os::Version::Semantic(major, minor, _) =
tauri_plugin_os::version()
&& major >= 12
&& minor >= 3
{
// Mac os version is 12.3 or higher, we allow mouseover
return false;
}
true
} else {
// Not macos, we allow mouseover
false
}
}
#[tauri::command]
pub async fn highlight_in_folder<R: Runtime>(
app: tauri::AppHandle<R>,
path: PathBuf,
) {
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) = app.opener().reveal_item_in_dir(path) {
tracing::error!("Failed to highlight file in folder: {}", e);
}
})
.await
.ok();
}
#[tauri::command]
pub async fn open_path<R: Runtime>(app: tauri::AppHandle<R>, path: PathBuf) {
tauri::async_runtime::spawn_blocking(move || {
if let Err(e) =
app.opener().open_path(path.to_string_lossy(), None::<&str>)
{
tracing::error!("Failed to open path: {}", e);
}
})
.await
.ok();
}
#[tauri::command]
pub async fn show_launcher_logs_folder<R: Runtime>(app: tauri::AppHandle<R>) {
if let Some(d) = DirectoryInfo::global_handle_if_ready() {
let path = d.launcher_logs_dir().unwrap_or_default();
// failure to get folder just opens filesystem
// (ie: if in debug mode only and launcher_logs never created)
open_path(app, path).await;
}
}
#[tauri::command]
pub async fn export_error_logs(
output_path: PathBuf,
error_message: String,
) -> Result<()> {
let archive = tokio::fs::File::create(&output_path).await?;
let mut writer = ZipFileWriter::with_tokio(archive);
let report = format!(
"Axolotl Launcher error report\nExported at: {}\n\nError:\n{}\n",
chrono::Local::now().to_rfc3339(),
error_message
);
write_zip_entry(&mut writer, "error.txt", report.as_bytes()).await?;
if let Some(directories) = DirectoryInfo::global_handle_if_ready()
&& let Some(logs_dir) = directories.launcher_logs_dir()
&& tokio::fs::try_exists(&logs_dir).await?
{
let mut entries = tokio::fs::read_dir(&logs_dir).await?;
while let Some(entry) = entries.next_entry().await? {
if !entry.file_type().await?.is_file() {
continue;
}
let file_name = entry.file_name().to_string_lossy().to_string();
write_zip_file(
&mut writer,
&format!("launcher_logs/{file_name}"),
&entry.path(),
)
.await?;
}
}
writer.close().await.map_err(zip_error)?;
Ok(())
}
async fn write_zip_file(
writer: &mut ZipFileWriter<tokio::fs::File>,
filename: &str,
path: &Path,
) -> Result<()> {
let mut stream = writer
.write_entry_stream(
ZipEntryBuilder::new(
filename.to_string().into(),
Compression::Deflate,
)
.build(),
)
.await
.map_err(zip_error)?
.compat_write();
let mut source = tokio::fs::File::open(path).await?;
tokio::io::copy(&mut source, &mut stream).await?;
stream.into_inner().close().await.map_err(zip_error)?;
Ok(())
}
pub(crate) async fn write_zip_entry(
writer: &mut ZipFileWriter<tokio::fs::File>,
filename: &str,
contents: &[u8],
) -> Result<()> {
writer
.write_entry_whole(
ZipEntryBuilder::new(
filename.to_string().into(),
Compression::Deflate,
),
contents,
)
.await
.map_err(zip_error)?;
Ok(())
}
pub(crate) fn zip_error(
error: async_zip::error::ZipError,
) -> TheseusSerializableError {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to create ZIP archive: {error}"
)))
.into()
}
#[tauri::command]
pub async fn show_app_db_backups_folder<R: Runtime>(
app: tauri::AppHandle<R>,
) -> Result<()> {
let path = app_db_backup_dir()?;
tokio::fs::create_dir_all(&path).await?;
open_path(app, path).await;
Ok(())
}
// Get opening command
// For example, if a user clicks on an .mrpack to open the app.
// This should be called once and only when the app is done booting up and ready to receive a command
// Returns a Command struct- see events.js
#[tauri::command]
#[cfg(target_os = "macos")]
pub async fn get_opening_command(
state: tauri::State<'_, crate::macos::deep_link::InitialPayload>,
) -> Result<Option<CommandPayload>> {
let payload = state.payload.lock().await;
let cmd_arg = std::env::args_os()
.nth(1)
.map(|path| path.to_string_lossy().to_string());
return if let Some(payload) = payload.as_ref() {
tracing::info!("opening command {payload}");
Ok(Some(handler::parse_command(payload).await?))
} else if let Some(cmd_arg) = cmd_arg {
tracing::info!("opening command {cmd_arg:?}");
Ok(Some(handler::parse_command(&cmd_arg).await?))
} else {
Ok(None)
};
}
#[tauri::command]
#[cfg(not(target_os = "macos"))]
pub async fn get_opening_command() -> Result<Option<CommandPayload>> {
// Tauri is not CLI, we use arguments as path to file to call
let cmd_arg = std::env::args_os().nth(1);
tracing::info!("opening command {cmd_arg:?}");
let cmd_arg = cmd_arg.map(|path| path.to_string_lossy().to_string());
if let Some(cmd) = cmd_arg {
tracing::debug!("Opening command: {:?}", cmd);
return Ok(Some(handler::parse_command(&cmd).await?));
}
Ok(None)
}
#[tauri::command]
pub async fn get_minecraft_news(
limit: Option<usize>,
) -> Result<Vec<theseus::minecraft_news::MinecraftNewsItem>> {
Ok(
theseus::minecraft_news::get_minecraft_news(limit.unwrap_or(12))
.await?,
)
}
// helper function called when redirected by a weblink (ie: modrith://do-something) or when redirected by a .mrpack file (in which case its a filepath)
// We hijack the deep link library (which also contains functionality for instance-checking)
pub async fn handle_command(command: String) -> Result<()> {
tracing::info!("handle command: {command}");
Ok(theseus::handler::parse_and_emit_command(&command).await?)
}
// Remove when (and if) https://github.com/tauri-apps/tauri/issues/12022 is implemented
pub(crate) fn tauri_convert_file_src(path: &Path) -> Result<Url> {
#[cfg(any(windows, target_os = "android"))]
const BASE: &str = "http://asset.localhost/";
#[cfg(not(any(windows, target_os = "android")))]
const BASE: &str = "asset://localhost/";
macro_rules! theseus_try {
($test:expr) => {
match $test {
Ok(val) => val,
Err(e) => {
return Err(TheseusSerializableError::Theseus(e.into()))
}
}
};
}
let path = theseus_try!(canonicalize(path));
let path = path.to_string_lossy();
let encoded = urlencoding::encode(&path);
Ok(theseus_try!(Url::parse(&format!("{BASE}{encoded}"))))
}

294
apps/app/src/api/worlds.rs Normal file
View File

@ -0,0 +1,294 @@
use crate::api::Result;
use crate::api::instance::InstanceRunResult;
use either::Either;
use enumset::EnumSet;
use tauri::{AppHandle, Manager, Runtime};
use theseus::instance::{self, GcLaunchIntent, QuickPlayType, get_full_path};
use theseus::server_address::ServerAddress;
use theseus::worlds;
use theseus::worlds::{
DisplayStatus, ProtocolVersion, ServerPackStatus, ServerStatus, World,
WorldLevelData, WorldSettingsPatch, WorldType, WorldWithInstance,
};
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("worlds")
.invoke_handler(tauri::generate_handler![
get_recent_worlds,
get_favorite_worlds,
get_instance_worlds,
get_singleplayer_world,
set_world_display_status,
get_world_level_data,
update_world_settings,
rename_world,
reset_world_icon,
backup_world,
delete_world,
add_server_to_instance,
edit_server_in_instance,
remove_server_from_instance,
get_instance_protocol_version,
get_server_status,
start_join_singleplayer_world,
start_join_server,
])
.build()
}
#[tauri::command]
pub async fn get_recent_worlds<R: Runtime>(
app_handle: AppHandle<R>,
limit: usize,
display_statuses: Option<EnumSet<DisplayStatus>>,
) -> Result<Vec<WorldWithInstance>> {
let mut result = worlds::get_recent_worlds(
limit,
display_statuses.unwrap_or(EnumSet::all()),
)
.await?;
for world in &mut result {
adapt_world_icon(&app_handle, &mut world.world);
}
Ok(result)
}
#[tauri::command]
pub async fn get_favorite_worlds<R: Runtime>(
app_handle: AppHandle<R>,
) -> Result<Vec<WorldWithInstance>> {
let mut result = worlds::get_favorite_worlds().await?;
for world in &mut result {
adapt_world_icon(&app_handle, &mut world.world);
}
Ok(result)
}
#[tauri::command]
pub async fn get_instance_worlds<R: Runtime>(
app_handle: AppHandle<R>,
instance_id: &str,
) -> Result<Vec<World>> {
let mut result = worlds::get_instance_worlds(instance_id).await?;
for world in &mut result {
adapt_world_icon(&app_handle, world);
}
Ok(result)
}
#[tauri::command]
pub async fn get_singleplayer_world<R: Runtime>(
app_handle: AppHandle<R>,
instance: &str,
world: &str,
) -> Result<World> {
let mut world = worlds::get_singleplayer_world(instance, world).await?;
adapt_world_icon(&app_handle, &mut world);
Ok(world)
}
fn adapt_world_icon<R: Runtime>(app_handle: &AppHandle<R>, world: &mut World) {
adapt_icon_field(app_handle, &mut world.icon, &world.name);
}
fn adapt_icon_field<R: Runtime>(
app_handle: &AppHandle<R>,
icon: &mut Option<Either<std::path::PathBuf, url::Url>>,
world_name: &str,
) {
if let Some(Either::Left(icon_path)) = icon {
let icon_path = icon_path.clone();
if let Ok(new_url) = super::utils::tauri_convert_file_src(&icon_path) {
*icon = Some(Either::Right(new_url));
if let Err(e) =
app_handle.asset_protocol_scope().allow_file(&icon_path)
{
tracing::warn!(
"Failed to allow file access for icon {}: {}",
icon_path.display(),
e
);
}
} else {
tracing::warn!(
"Encountered invalid icon path for world {}: {}",
world_name,
icon_path.display()
);
*icon = None;
}
}
}
#[tauri::command]
pub async fn get_world_level_data<R: Runtime>(
app_handle: AppHandle<R>,
instance: &str,
world: &str,
) -> Result<WorldLevelData> {
let mut data = worlds::get_world_level_data(instance, world).await?;
let name = data.name.clone();
adapt_icon_field(&app_handle, &mut data.icon, &name);
Ok(data)
}
#[tauri::command]
pub async fn update_world_settings(
instance: &str,
world: &str,
patch: WorldSettingsPatch,
) -> Result<()> {
let instance = get_full_path(instance).await?;
worlds::update_world_settings(&instance, world, patch).await?;
Ok(())
}
#[tauri::command]
pub async fn set_world_display_status(
instance: &str,
world_type: WorldType,
world_id: &str,
display_status: DisplayStatus,
) -> Result<()> {
Ok(worlds::set_world_display_status(
instance,
world_type,
world_id,
display_status,
)
.await?)
}
#[tauri::command]
pub async fn rename_world(
instance: &str,
world: &str,
new_name: &str,
) -> Result<()> {
let instance = get_full_path(instance).await?;
worlds::rename_world(&instance, world, new_name).await?;
Ok(())
}
#[tauri::command]
pub async fn reset_world_icon(instance: &str, world: &str) -> Result<()> {
let instance = get_full_path(instance).await?;
worlds::reset_world_icon(&instance, world).await?;
Ok(())
}
#[tauri::command]
pub async fn backup_world(instance: &str, world: &str) -> Result<u64> {
let instance = get_full_path(instance).await?;
Ok(worlds::backup_world(&instance, world).await?)
}
#[tauri::command]
pub async fn delete_world(instance: &str, world: &str) -> Result<()> {
let instance = get_full_path(instance).await?;
worlds::delete_world(&instance, world).await?;
Ok(())
}
#[tauri::command]
pub async fn add_server_to_instance(
instance_id: &str,
name: String,
address: String,
pack_status: ServerPackStatus,
project_id: Option<String>,
content_kind: Option<String>,
) -> Result<usize> {
Ok(worlds::add_server_to_instance(
instance_id,
name,
address,
pack_status,
project_id,
content_kind,
)
.await?)
}
#[tauri::command]
pub async fn edit_server_in_instance(
instance_id: &str,
index: usize,
name: String,
address: String,
pack_status: ServerPackStatus,
) -> Result<()> {
worlds::edit_server_in_instance(
instance_id,
index,
name,
address,
pack_status,
)
.await?;
Ok(())
}
#[tauri::command]
pub async fn remove_server_from_instance(
instance_id: &str,
index: usize,
) -> Result<()> {
worlds::remove_server_from_instance(instance_id, index).await?;
Ok(())
}
#[tauri::command]
pub async fn get_instance_protocol_version(
instance_id: &str,
) -> Result<Option<ProtocolVersion>> {
Ok(worlds::get_instance_protocol_version(instance_id).await?)
}
#[tauri::command]
pub async fn get_server_status(
address: &str,
protocol_version: Option<ProtocolVersion>,
) -> Result<ServerStatus> {
Ok(worlds::get_server_status(address, protocol_version).await?)
}
#[tauri::command]
pub async fn start_join_singleplayer_world(
instance_id: &str,
world: String,
offline_mode: bool,
extra_launch_args: Option<Vec<String>>,
gc_intent: Option<GcLaunchIntent>,
) -> Result<InstanceRunResult> {
let (process, gc_notice) = instance::run_with_extra_launch_args_with_gc(
instance_id,
QuickPlayType::Singleplayer(world),
offline_mode,
extra_launch_args,
gc_intent,
)
.await?;
Ok(InstanceRunResult { process, gc_notice })
}
#[tauri::command]
pub async fn start_join_server(
instance_id: &str,
address: &str,
offline_mode: bool,
extra_launch_args: Option<Vec<String>>,
gc_intent: Option<GcLaunchIntent>,
) -> Result<InstanceRunResult> {
let (process, gc_notice) = instance::run_with_extra_launch_args_with_gc(
instance_id,
QuickPlayType::Server(ServerAddress::Unresolved(address.to_owned())),
offline_mode,
extra_launch_args,
gc_intent,
)
.await?;
Ok(InstanceRunResult { process, gc_notice })
}