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 })
}

18
apps/app/src/error.rs Normal file
View File

@ -0,0 +1,18 @@
use tracing_error::ExtractSpanTrace;
pub fn display_tracing_error(err: &theseus::Error) {
match get_span_trace(err) {
Some(span_trace) => {
tracing::error!(error = %err, span_trace = %span_trace);
}
None => {
tracing::error!(error = %err);
}
}
}
pub fn get_span_trace<'a>(
error: &'a (dyn std::error::Error + 'static),
) -> Option<&'a tracing_error::SpanTrace> {
error.source().and_then(|e| e.span_trace())
}

View File

@ -0,0 +1,751 @@
use serde::Serialize;
use std::{
collections::{HashSet, VecDeque},
sync::Mutex,
};
use tauri::{
AppHandle, Emitter, Listener, Manager, WebviewUrl, WebviewWindowBuilder,
menu::{
CheckMenuItem, IsMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu,
},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
window::WindowBuilder,
};
use theseus::prelude::CommandPayload;
const MAIN_WINDOW_LABEL: &str = "main";
const LIGHTWEIGHT_HOST_WINDOW_LABEL: &str = "lightweight-host";
const TRAY_ID: &str = "main";
const LAUNCH_INSTANCE_PREFIX: &str = "launch-instance:";
struct TrayLabels {
show_launcher: &'static str,
launch_instance: &'static str,
lightweight_mode: &'static str,
quit: &'static str,
running_prefix: &'static str,
}
fn tray_labels(locale: &str) -> TrayLabels {
if locale.eq_ignore_ascii_case("zh-CN") {
TrayLabels {
show_launcher: "显示 Axolotl 启动器",
launch_instance: "启动实例",
lightweight_mode: "轻量模式",
quit: "退出",
running_prefix: "正在运行:",
}
} else if locale.eq_ignore_ascii_case("zh-TW") {
TrayLabels {
show_launcher: "顯示 Axolotl 啟動器",
launch_instance: "啟動實例",
lightweight_mode: "輕量模式",
quit: "結束",
running_prefix: "正在執行:",
}
} else {
TrayLabels {
show_launcher: "Show Axolotl Launcher",
launch_instance: "Launch instance",
lightweight_mode: "Lightweight mode",
quit: "Quit",
running_prefix: "Running: ",
}
}
}
struct LightweightModeState {
active: bool,
route: String,
running_processes: usize,
pending_crashes: VecDeque<PendingCrash>,
pending_commands: VecDeque<CommandPayload>,
frontend_ready: bool,
restoring: bool,
running_instance_ids: HashSet<String>,
tray_update_generation: u64,
}
impl Default for LightweightModeState {
fn default() -> Self {
Self {
active: false,
route: "/".to_string(),
running_processes: 0,
pending_crashes: VecDeque::new(),
pending_commands: VecDeque::new(),
frontend_ready: false,
restoring: false,
running_instance_ids: HashSet::new(),
tray_update_generation: 0,
}
}
}
#[derive(Serialize)]
pub struct PendingCrash {
pub instance_id: String,
pub uuid: String,
}
#[derive(Default)]
pub struct LightweightMode(Mutex<LightweightModeState>);
impl LightweightMode {
fn enter(&self, app: &AppHandle) -> Result<(), String> {
self.enter_internal(app, true)
}
pub fn enter_for_close(&self, app: &AppHandle) -> Result<(), String> {
self.enter_internal(app, false)
}
fn enter_internal(
&self,
app: &AppHandle,
require_running: bool,
) -> Result<(), String> {
let state = self.0.lock().map_err(|error| error.to_string())?;
if state.active {
return Ok(());
}
if require_running && state.running_processes == 0 {
return Err(
"Lightweight mode requires a running Minecraft instance"
.to_string(),
);
}
drop(state);
create_lightweight_host_window(app)?;
let mut state = self.0.lock().map_err(|error| error.to_string())?;
if state.active {
drop(state);
destroy_lightweight_host_window(app);
return Ok(());
}
state.active = true;
state.frontend_ready = false;
drop(state);
if let Err(error) = destroy_main_window(app) {
if let Ok(mut state) = self.0.lock() {
state.active = false;
}
destroy_lightweight_host_window(app);
return Err(error);
}
schedule_tray_menu_update(app);
Ok(())
}
pub fn exit(&self, app: &AppHandle) -> Result<(), String> {
let state = self.0.lock().map_err(|error| error.to_string())?;
if !state.active {
return show_main_window(app);
}
let route = state.route.clone();
drop(state);
if let Ok(mut state) = self.0.lock() {
state.frontend_ready = false;
state.restoring = true;
}
if let Err(error) = create_main_window(app, &route) {
if let Ok(mut state) = self.0.lock() {
state.restoring = false;
}
return Err(error);
}
destroy_lightweight_host_window(app);
if let Ok(mut state) = self.0.lock() {
state.active = false;
}
schedule_tray_menu_update(app);
Ok(())
}
fn is_active(&self) -> bool {
self.0.lock().map(|state| state.active).unwrap_or(false)
}
fn process_event(&self, app: &AppHandle, payload: ProcessEventPayload) {
if payload.lightweight_replay {
return;
}
let restore_window = {
let mut state = match self.0.lock() {
Ok(state) => state,
Err(error) => {
tracing::error!(
"Failed to lock lightweight mode state: {error}"
);
return;
}
};
match payload.event.as_str() {
"launched" => {
state.running_processes += 1;
state.running_instance_ids.insert(payload.instance_id);
None
}
"finished" => {
state.running_processes =
state.running_processes.saturating_sub(1);
state.running_instance_ids.remove(&payload.instance_id);
let crashed = payload.crashed == Some(true);
if state.active && crashed {
state.pending_crashes.push_back(PendingCrash {
instance_id: payload.instance_id,
uuid: payload.uuid,
});
}
let should_restore = if state.active {
crashed || state.running_processes == 0
} else {
state.running_processes == 0
};
if should_restore {
let was_lightweight = state.active;
state.active = false;
if was_lightweight {
state.frontend_ready = false;
state.restoring = true;
}
Some((state.route.clone(), was_lightweight))
} else {
None
}
}
_ => None,
}
};
schedule_tray_menu_update(app);
match restore_window {
Some((route, was_lightweight)) => {
let app = app.clone();
tauri::async_runtime::spawn(async move {
let result = if was_lightweight {
create_main_window(&app, &route).map(|()| {
destroy_lightweight_host_window(&app);
})
} else {
show_main_window(&app)
};
if let Err(error) = result {
if was_lightweight {
if let Ok(mut state) =
app.state::<LightweightMode>().0.lock()
{
state.restoring = false;
}
}
tracing::error!(
"Failed to restore launcher after Minecraft exited: {error}"
);
}
});
}
None if payload.event == "launched" => {
let app = app.clone();
tauri::async_runtime::spawn(async move {
if payload.maximize_window {
maximize_minecraft_window(payload.pid).await;
}
let settings = match theseus::settings::get().await {
Ok(settings) => settings,
Err(error) => {
tracing::warn!(
"Failed to read lightweight mode setting: {error}"
);
return;
}
};
if settings.enter_lightweight_mode_on_game_launch {
let state = app.state::<LightweightMode>();
if let Err(error) = state.enter(&app) {
tracing::warn!(
"Failed to enter lightweight mode: {error}"
);
}
} else if settings.hide_on_process_start
&& let Some(window) =
app.get_webview_window(MAIN_WINDOW_LABEL)
&& let Err(error) = window.minimize()
{
tracing::warn!(
"Failed to minimize launcher after Minecraft started: {error}"
);
}
});
}
None => {}
}
}
fn set_route(&self, route: String) {
if route.starts_with('/') {
if let Ok(mut state) = self.0.lock() {
state.route = route;
}
}
}
fn mark_frontend_ready(&self) -> (Vec<PendingCrash>, Vec<CommandPayload>) {
self.0
.lock()
.map(|mut state| {
state.frontend_ready = true;
state.restoring = false;
(
state.pending_crashes.drain(..).collect(),
state.pending_commands.drain(..).collect(),
)
})
.unwrap_or_else(|_| (Vec::new(), Vec::new()))
}
fn queue_command(&self, command: CommandPayload) {
if let Ok(mut state) = self.0.lock() {
state.pending_commands.push_back(command);
}
}
fn is_frontend_ready(&self) -> bool {
self.0
.lock()
.map(|state| state.frontend_ready)
.unwrap_or(false)
}
fn is_restoring(&self) -> bool {
self.0.lock().map(|state| state.restoring).unwrap_or(false)
}
}
#[derive(serde::Deserialize)]
struct ProcessEventPayload {
instance_id: String,
uuid: String,
#[serde(default)]
pid: u32,
#[serde(default)]
maximize_window: bool,
event: String,
crashed: Option<bool>,
#[serde(default)]
lightweight_replay: bool,
}
#[cfg(target_os = "windows")]
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
#[cfg(target_os = "windows")]
static MAXIMIZE_PROCESS_ID: AtomicU32 = AtomicU32::new(0);
#[cfg(target_os = "windows")]
static MAXIMIZE_WINDOW_FOUND: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "windows")]
static MAXIMIZE_WINDOW_ENUMERATION: Mutex<()> = Mutex::new(());
#[cfg(target_os = "windows")]
unsafe extern "system" fn maximize_if_owned_by_process(
hwnd: windows::Win32::Foundation::HWND,
_: windows::Win32::Foundation::LPARAM,
) -> windows::core::BOOL {
use windows::Win32::UI::WindowsAndMessaging::{
GetWindowThreadProcessId, IsWindowVisible, SW_MAXIMIZE, ShowWindow,
};
use windows::core::BOOL;
let mut window_pid = 0;
unsafe { GetWindowThreadProcessId(hwnd, Some(&mut window_pid)) };
if window_pid == MAXIMIZE_PROCESS_ID.load(Ordering::Relaxed)
&& unsafe { IsWindowVisible(hwnd).as_bool() }
{
let _ = unsafe { ShowWindow(hwnd, SW_MAXIMIZE) };
MAXIMIZE_WINDOW_FOUND.store(true, Ordering::Relaxed);
return BOOL(0);
}
BOOL(1)
}
#[cfg(target_os = "windows")]
async fn maximize_minecraft_window(pid: u32) {
if pid == 0 {
return;
}
for _ in 0..20 {
let found = {
let _guard = MAXIMIZE_WINDOW_ENUMERATION.lock();
MAXIMIZE_PROCESS_ID.store(pid, Ordering::Relaxed);
MAXIMIZE_WINDOW_FOUND.store(false, Ordering::Relaxed);
unsafe {
use windows::Win32::Foundation::LPARAM;
use windows::Win32::UI::WindowsAndMessaging::EnumWindows;
let _ =
EnumWindows(Some(maximize_if_owned_by_process), LPARAM(0));
}
MAXIMIZE_WINDOW_FOUND.load(Ordering::Relaxed)
};
if found {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
}
#[cfg(not(target_os = "windows"))]
async fn maximize_minecraft_window(_pid: u32) {}
#[tauri::command]
pub fn lightweight_mode_frontend_ready(
app: AppHandle,
route: String,
) -> Result<FrontendReadyPayload, String> {
let state = app.state::<LightweightMode>();
state.set_route(route);
let (pending_crashes, pending_commands) = state.mark_frontend_ready();
schedule_tray_menu_update(&app);
Ok(FrontendReadyPayload {
pending_crashes,
pending_commands,
})
}
#[derive(Serialize)]
pub struct FrontendReadyPayload {
pub pending_crashes: Vec<PendingCrash>,
pub pending_commands: Vec<CommandPayload>,
}
#[tauri::command]
pub fn lightweight_mode_set_route(app: AppHandle, route: String) {
app.state::<LightweightMode>().set_route(route);
}
#[tauri::command]
pub fn lightweight_mode_enter(app: AppHandle) -> Result<(), String> {
// Schedule the window destruction after returning from the IPC command.
// Destroying the webview synchronously would leave the invoke caller
// waiting for a response from a window that is already being destroyed.
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
if let Err(error) = app.state::<LightweightMode>().enter_for_close(&app)
{
tracing::warn!(
"Failed to enter lightweight mode on close: {error}"
);
let _ = app.emit("lightweight-mode-error", error);
}
});
Ok(())
}
fn destroy_main_window(app: &AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
window.destroy().map_err(|error| error.to_string())?;
}
Ok(())
}
fn create_lightweight_host_window(app: &AppHandle) -> Result<(), String> {
if app.get_window(LIGHTWEIGHT_HOST_WINDOW_LABEL).is_none() {
WindowBuilder::new(app, LIGHTWEIGHT_HOST_WINDOW_LABEL)
.title("Axolotl Launcher")
.visible(false)
.focused(false)
.focusable(false)
.skip_taskbar(true)
.build()
.map_err(|error| error.to_string())?;
}
Ok(())
}
fn destroy_lightweight_host_window(app: &AppHandle) {
if let Some(window) = app.get_window(LIGHTWEIGHT_HOST_WINDOW_LABEL)
&& let Err(error) = window.destroy()
{
tracing::warn!("Failed to destroy lightweight host window: {error}");
}
}
fn create_main_window(app: &AppHandle, route: &str) -> Result<(), String> {
if app.get_webview_window(MAIN_WINDOW_LABEL).is_none() {
// Only reassigned on non-macOS platforms to strip window decorations.
#[cfg_attr(target_os = "macos", allow(unused_mut))]
let mut builder = WebviewWindowBuilder::new(
app,
MAIN_WINDOW_LABEL,
WebviewUrl::App(route.into()),
)
.title("Axolotl Launcher")
.inner_size(1280.0, 800.0)
.min_inner_size(1100.0, 700.0)
.resizable(true)
.transparent(true)
.zoom_hotkeys_enabled(false)
.visible(false);
#[cfg(not(target_os = "macos"))]
{
builder = builder.decorations(false);
}
builder.build().map_err(|error| error.to_string())?;
}
Ok(())
}
fn show_main_window(app: &AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
window.show().map_err(|error| error.to_string())?;
window.unminimize().map_err(|error| error.to_string())?;
window.set_focus().map_err(|error| error.to_string())?;
}
Ok(())
}
fn schedule_tray_menu_update(app: &AppHandle) {
let generation = {
let state = app.state::<LightweightMode>();
let Ok(mut state) = state.0.lock() else {
return;
};
state.tray_update_generation =
state.tray_update_generation.wrapping_add(1);
state.tray_update_generation
};
let app = app.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let (active, has_running_processes, running_instance_ids) = {
let state = app.state::<LightweightMode>();
let Ok(state) = state.0.lock() else {
return;
};
if state.tray_update_generation != generation {
return;
}
(
state.active,
state.running_processes > 0,
state.running_instance_ids.clone(),
)
};
if let Err(error) = rebuild_tray_menu(
&app,
active,
has_running_processes,
&running_instance_ids,
)
.await
{
tracing::warn!("Failed to update tray menu: {error}");
}
});
}
async fn rebuild_tray_menu(
app: &AppHandle,
active: bool,
has_running_processes: bool,
running_instance_ids: &HashSet<String>,
) -> Result<(), String> {
let locale = theseus::settings::get()
.await
.map(|settings| settings.locale)
.unwrap_or_default();
let labels = tray_labels(&locale);
let show_launcher = MenuItem::with_id(
app,
"show-launcher",
labels.show_launcher,
true,
None::<&str>,
)
.map_err(|error| error.to_string())?;
let lightweight_mode = CheckMenuItem::with_id(
app,
"lightweight-mode",
labels.lightweight_mode,
has_running_processes,
active,
None::<&str>,
)
.map_err(|error| error.to_string())?;
let quit = MenuItem::with_id(app, "quit", labels.quit, true, None::<&str>)
.map_err(|error| error.to_string())?;
let first_separator = PredefinedMenuItem::separator(app)
.map_err(|error| error.to_string())?;
let second_separator = PredefinedMenuItem::separator(app)
.map_err(|error| error.to_string())?;
let instances = theseus::instance::list().await.unwrap_or_else(|error| {
tracing::debug!("Tray instance list is unavailable: {error}");
Vec::new()
});
let mut instance_items = Vec::with_capacity(instances.len());
for instance in instances {
let running = running_instance_ids.contains(&instance.instance.id);
instance_items.push(
MenuItem::with_id(
app,
format!("{LAUNCH_INSTANCE_PREFIX}{}", instance.instance.id),
if running {
format!(
"{}{}",
labels.running_prefix, instance.instance.name
)
} else {
instance.instance.name.clone()
},
!running,
None::<&str>,
)
.map_err(|error| error.to_string())?,
);
}
let instance_references: Vec<&dyn IsMenuItem<_>> = instance_items
.iter()
.map(|item| item as &dyn IsMenuItem<_>)
.collect();
let launch_instances = Submenu::with_items(
app,
labels.launch_instance,
true,
&instance_references,
)
.map_err(|error| error.to_string())?;
let menu = Menu::with_items(
app,
&[
&show_launcher,
&first_separator,
&launch_instances,
&lightweight_mode,
&second_separator,
&quit,
],
)
.map_err(|error| error.to_string())?;
app.tray_by_id(TRAY_ID)
.ok_or_else(|| "Tray icon is unavailable".to_string())?
.set_menu(Some(menu))
.map_err(|error| error.to_string())
}
fn handle_menu_event(app: &AppHandle, id: &str) {
match id {
"show-launcher" => {
let app = app.clone();
tauri::async_runtime::spawn(async move {
let _ = app.state::<LightweightMode>().exit(&app);
});
}
"lightweight-mode" => {
let app = app.clone();
tauri::async_runtime::spawn(async move {
let state = app.state::<LightweightMode>();
if state.is_active() {
let _ = state.exit(&app);
} else if let Err(error) = state.enter(&app) {
tracing::debug!(
"Lightweight mode was not entered from tray: {error}"
);
}
});
}
"quit" => app.exit(0),
instance_id if instance_id.starts_with(LAUNCH_INSTANCE_PREFIX) => {
let instance_id = instance_id
.trim_start_matches(LAUNCH_INSTANCE_PREFIX)
.to_string();
tauri::async_runtime::spawn(async move {
if let Err(error) = theseus::instance::run(
&instance_id,
theseus::instance::QuickPlayType::None,
false,
)
.await
{
tracing::error!(
"Failed to launch tray instance {instance_id}: {error}"
);
}
});
}
_ => {}
}
}
pub fn init(app: &AppHandle) {
app.manage(LightweightMode::default());
let tray = TrayIconBuilder::with_id(TRAY_ID)
.icon(
app.default_window_icon()
.expect("missing default app icon")
.clone(),
)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| handle_menu_event(app, event.id.as_ref()))
.on_tray_icon_event(|tray, event| {
if matches!(
event,
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
}
) {
let app = tray.app_handle().clone();
tauri::async_runtime::spawn(async move {
let _ = app.state::<LightweightMode>().exit(&app);
});
}
})
.build(app)
.expect("failed to create system tray");
let _tray = tray;
schedule_tray_menu_update(app);
let app_handle = app.clone();
app.listen("process", move |event| {
let Ok(payload) =
serde_json::from_str::<ProcessEventPayload>(event.payload())
else {
return;
};
app_handle
.state::<LightweightMode>()
.process_event(&app_handle, payload);
});
let app_handle = app.clone();
app.listen("instance", move |_| {
schedule_tray_menu_update(&app_handle);
});
let app_handle = app.clone();
app.listen("settings", move |_| {
schedule_tray_menu_update(&app_handle);
});
let app_handle = app.clone();
app.listen("command", move |event| {
let Ok(command) =
serde_json::from_str::<CommandPayload>(event.payload())
else {
return;
};
let state = app_handle.state::<LightweightMode>();
if state.is_frontend_ready()
|| (!state.is_active() && !state.is_restoring())
{
return;
}
state.queue_command(command);
if !state.is_active() {
return;
}
let app = app_handle.clone();
tauri::async_runtime::spawn(async move {
let _ = app.state::<LightweightMode>().exit(&app);
});
});
}

View File

@ -0,0 +1,27 @@
use std::sync::Arc;
use tauri::{Manager, Runtime};
use tokio::sync::Mutex;
#[derive(Debug, Clone)]
pub struct InitialPayload {
pub payload: Arc<Mutex<Option<String>>>,
}
pub fn get_or_init_payload<R: Runtime, M: Manager<R>>(
manager: &M,
) -> InitialPayload {
let initial_payload = manager.try_state::<InitialPayload>();
if let Some(initial_payload) = initial_payload {
initial_payload.inner().clone()
} else {
tracing::info!("No initial payload found, creating new");
let payload = InitialPayload {
payload: Arc::new(Mutex::new(None)),
};
manager.manage(payload.clone());
payload
}
}

View File

@ -0,0 +1 @@
pub mod deep_link;

1060
apps/app/src/main.rs Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,157 @@
use serde::{Deserialize, Serialize};
use std::fmt;
use thiserror::Error;
/// 抛给前端的稳定错误码。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TranslateErrorCode {
UnsafeArchivePath,
SignedModRefused,
InvalidArchive,
MissingApiKey,
ModelNotFound,
EmptyModelResponse,
InvalidModelResponse,
PlaceholderMismatch,
WritebackVerificationFailed,
QualityHardErrors,
WorkGraphNoExit,
UnsupportedResource,
SessionHandoff,
Cancelled,
Io,
Config,
AiDisabled,
AiProviderDisabled,
AiModelNotSelected,
AiRequestFailed,
}
impl TranslateErrorCode {
pub fn as_str(self) -> &'static str {
match self {
Self::UnsafeArchivePath => "UNSAFE_ARCHIVE_PATH",
Self::SignedModRefused => "SIGNED_MOD_REFUSED",
Self::InvalidArchive => "INVALID_ARCHIVE",
Self::MissingApiKey => "MISSING_API_KEY",
Self::ModelNotFound => "MODEL_NOT_FOUND",
Self::EmptyModelResponse => "EMPTY_MODEL_RESPONSE",
Self::InvalidModelResponse => "INVALID_MODEL_RESPONSE",
Self::PlaceholderMismatch => "PLACEHOLDER_MISMATCH",
Self::WritebackVerificationFailed => {
"WRITEBACK_VERIFICATION_FAILED"
}
Self::QualityHardErrors => "QUALITY_HARD_ERRORS",
Self::WorkGraphNoExit => "WORK_GRAPH_NO_EXIT",
Self::UnsupportedResource => "UNSUPPORTED_RESOURCE",
Self::SessionHandoff => "SESSION_HANDOFF",
Self::Cancelled => "CANCELLED",
Self::Io => "IO_ERROR",
Self::Config => "CONFIG_ERROR",
Self::AiDisabled => "AI_DISABLED",
Self::AiProviderDisabled => "AI_PROVIDER_DISABLED",
Self::AiModelNotSelected => "AI_MODEL_NOT_SELECTED",
Self::AiRequestFailed => "AI_REQUEST_FAILED",
}
}
}
impl fmt::Display for TranslateErrorCode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Error)]
#[error("{code}: {message}")]
pub struct TranslateError {
pub code: TranslateErrorCode,
pub message: String,
#[source]
pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
}
impl TranslateError {
pub fn new(code: TranslateErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
source: None,
}
}
pub fn with_source(
code: TranslateErrorCode,
message: impl Into<String>,
source: impl std::error::Error + Send + Sync + 'static,
) -> Self {
Self {
code,
message: message.into(),
source: Some(Box::new(source)),
}
}
pub fn io(context: impl Into<String>, source: std::io::Error) -> Self {
Self::with_source(TranslateErrorCode::Io, context, source)
}
pub fn config(message: impl Into<String>) -> Self {
Self::new(TranslateErrorCode::Config, message)
}
/// 过 Tauri 边界时用的消息code + 可读文本。
pub fn user_message(&self) -> String {
format!("{}: {}", self.code.as_str(), self.detail_message())
}
pub fn detail_message(&self) -> String {
match self.source.as_ref() {
Some(source) => format!("{}: {source}", self.message),
None => self.message.clone(),
}
}
}
impl From<std::io::Error> for TranslateError {
fn from(error: std::io::Error) -> Self {
Self::io("mod translation I/O error", error)
}
}
impl From<theseus::Error> for TranslateError {
fn from(error: theseus::Error) -> Self {
use theseus::ErrorKind;
let message = error.to_string();
let code = match error.raw.as_ref() {
ErrorKind::InputError(text)
if text.contains("AI features are disabled") =>
{
TranslateErrorCode::AiDisabled
}
ErrorKind::InputError(text) if text.contains("is disabled") => {
TranslateErrorCode::AiProviderDisabled
}
ErrorKind::InputError(text)
if text.contains("Select an AI model") =>
{
TranslateErrorCode::AiModelNotSelected
}
ErrorKind::InputError(_) => TranslateErrorCode::Config,
ErrorKind::StdIOError(_)
| ErrorKind::IOError(_)
| ErrorKind::FSError(_)
| ErrorKind::Sqlx(_) => TranslateErrorCode::Io,
_ => TranslateErrorCode::AiRequestFailed,
};
Self {
code,
message,
source: None,
}
}
}
pub type Result<T> = std::result::Result<T, TranslateError>;

View File

@ -0,0 +1,651 @@
use std::collections::HashSet;
use std::fs::File;
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use zip::ZipArchive;
use zip::write::SimpleFileOptions;
use crate::mod_translation::error::{
Result, TranslateError, TranslateErrorCode,
};
pub const ARCHIVE_MANIFEST: &str = ".mod-translator-archive-manifest.json";
pub const ARCHIVE_FILES_DIRECTORY: &str = ".mod-translator-archive-files";
const WINDOWS_RESERVED_SEGMENT_RE: &str =
r"^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$";
/// 解包时的资源上限,防止恶意压缩包把磁盘/内存打爆。
#[derive(Debug, Clone)]
pub struct ExtractionLimits {
pub max_archive_bytes: u64,
pub max_entries: usize,
pub max_entry_bytes: u64,
pub max_uncompressed_bytes: u64,
pub max_compression_ratio: f64,
}
impl Default for ExtractionLimits {
fn default() -> Self {
Self {
max_archive_bytes: 1_200 * 1024 * 1024,
max_entries: 200_000,
max_entry_bytes: 256 * 1024 * 1024,
max_uncompressed_bytes: 2_000 * 1024 * 1024,
max_compression_ratio: 200.0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchiveManifest {
pub version: u32,
pub entries: Vec<ArchiveEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchiveEntry {
pub archive_path: String,
pub workspace_path: String,
}
impl ArchiveManifest {
pub fn read(directory: &Path) -> Option<Self> {
let path = directory.join(ARCHIVE_MANIFEST);
let content = std::fs::read_to_string(path).ok()?;
let parsed: ArchiveManifest = serde_json::from_str(&content).ok()?;
if parsed.version != 1 {
return None;
}
Some(parsed)
}
pub fn write(&self, directory: &Path) -> Result<()> {
let content = format!(
"{}\n",
serde_json::to_string_pretty(self).map_err(|error| {
TranslateError::config(format!(
"manifest serialization: {error}"
))
})?
);
std::fs::write(directory.join(ARCHIVE_MANIFEST), content).map_err(
|error| {
TranslateError::io("unable to write archive manifest", error)
},
)
}
}
/// 归档路径的安全策略,集中一处方便单测。
pub struct PathPolicy;
impl PathPolicy {
/// 规范化条目名并拒绝一切可能逃出工作区的写法。
pub fn safe_entry_name(raw: &str) -> Result<String> {
let name = raw.replace('\\', "/");
if name.contains('\0')
|| name.starts_with('/')
|| name.starts_with("//")
|| looks_like_drive_prefix(&name)
|| name.split('/').any(|segment| segment == "..")
{
return Err(TranslateError::new(
TranslateErrorCode::UnsafeArchivePath,
format!("archive contains an unsafe path: {raw}"),
));
}
Ok(name)
}
#[cfg(test)]
pub fn is_symlink(unix_mode: u32) -> bool {
(unix_mode & 0o170000) == 0o120000
}
pub fn is_signature_file(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower.starts_with("meta-inf/")
&& lower
.rsplit_once('/')
.map(|(_, tail)| {
tail.ends_with(".sf")
|| tail.ends_with(".rsa")
|| tail.ends_with(".dsa")
|| tail.ends_with(".ec")
})
.unwrap_or(false)
}
/// Windows 上不可移植的段:非法字符、结尾点/空格、保留设备名。
pub fn requires_portable_mapping(name: &str) -> bool {
let reserved = regex::Regex::new(WINDOWS_RESERVED_SEGMENT_RE).unwrap();
name.split('/').any(|segment| {
segment.is_empty()
|| segment.chars().any(|character| {
matches!(
character,
'<' | '>' | ':' | '"' | '|' | '?' | '*' | '\0'
..='\u{001f}'
)
})
|| segment.ends_with('.')
|| segment.ends_with(' ')
|| reserved.is_match(segment)
})
}
pub fn portable_path_key(name: &str) -> String {
name.split('/')
.map(|segment| {
segment.trim_end_matches(['.', ' ']).to_ascii_uppercase()
})
.collect::<Vec<_>>()
.join("/")
}
pub fn mapped_workspace_path(index: usize, archive_path: &str) -> String {
let extension = Path::new(archive_path)
.extension()
.and_then(|value| value.to_str())
.unwrap_or("")
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.take(16)
.collect::<String>();
format!("{ARCHIVE_FILES_DIRECTORY}/{index:06}{extension}")
}
/// 解析工作区相对路径并保证不越界。
pub fn workspace_path(
workspace: &Path,
requested: &str,
) -> Result<PathBuf> {
let normalized = requested.replace('\\', "/");
let mut result = workspace.to_path_buf();
for segment in normalized.split('/') {
if segment.is_empty() || segment == "." {
continue;
}
if segment == ".." {
return Err(TranslateError::new(
TranslateErrorCode::UnsafeArchivePath,
"workspace path escapes the task workspace",
));
}
result.push(segment);
}
if result != workspace && !result.starts_with(workspace) {
return Err(TranslateError::new(
TranslateErrorCode::UnsafeArchivePath,
"workspace path escapes the task workspace",
));
}
Ok(result)
}
}
fn looks_like_drive_prefix(name: &str) -> bool {
let bytes = name.as_bytes();
bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
}
#[derive(Debug)]
pub struct ExtractionResult {
pub signed: bool,
pub total_entries: u64,
pub uncompressed_bytes: u64,
}
/// 把 input_path 里每个条目解到 workspace目录需已存在
pub fn extract_archive(
input_path: &Path,
workspace: &Path,
limits: &ExtractionLimits,
) -> Result<ExtractionResult> {
let metadata = std::fs::metadata(input_path).map_err(|error| {
TranslateError::io("unable to stat input JAR", error)
})?;
if !metadata.is_file() {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"input path is not a file",
));
}
if metadata.len() > limits.max_archive_bytes {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JAR file exceeds the safe size limit",
));
}
let file = File::open(input_path).map_err(|error| {
TranslateError::io("unable to open input JAR", error)
})?;
let mut archive = ZipArchive::new(file).map_err(|error| {
TranslateError::with_source(
TranslateErrorCode::InvalidArchive,
"the file is not a valid JAR/ZIP archive",
error,
)
})?;
let mut signed = false;
let mut total_entries = 0u64;
let mut uncompressed_bytes = 0u64;
let mut manifest = ArchiveManifest {
version: 1,
entries: Vec::with_capacity(archive.len().min(100_000)),
};
let mut seen: HashSet<String> = HashSet::new();
let mut portable_seen: HashSet<String> = HashSet::new();
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(|error| {
TranslateError::with_source(
TranslateErrorCode::InvalidArchive,
format!("unable to read archive entry {index}"),
error,
)
})?;
let raw_name = entry.name().to_string();
let name = PathPolicy::safe_entry_name(&raw_name)?;
if !seen.insert(name.clone()) {
return Err(TranslateError::new(
TranslateErrorCode::UnsafeArchivePath,
format!("archive contains a duplicate entry: {name}"),
));
}
total_entries += 1;
if total_entries > limits.max_entries as u64 {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JAR entry count exceeds the safety limit",
));
}
let entry_size = entry.size();
uncompressed_bytes = uncompressed_bytes.saturating_add(entry_size);
if entry_size > limits.max_entry_bytes {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
format!("JAR entry is too large: {name}"),
));
}
if uncompressed_bytes > limits.max_uncompressed_bytes {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JAR expands beyond the safety limit",
));
}
if entry.compressed_size() > 0
&& entry_size as f64 / entry.compressed_size() as f64
> limits.max_compression_ratio
{
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
format!("JAR entry has an abnormal compression ratio: {name}"),
));
}
if entry.is_symlink() {
return Err(TranslateError::new(
TranslateErrorCode::UnsafeArchivePath,
format!("JAR contains a symbolic link: {name}"),
));
}
if PathPolicy::is_signature_file(&name) {
signed = true;
}
if name.ends_with('/') {
continue;
}
let portable_key = PathPolicy::portable_path_key(&name);
let must_map = PathPolicy::requires_portable_mapping(&name)
|| portable_seen.contains(&portable_key);
portable_seen.insert(portable_key);
let workspace_name = if must_map {
PathPolicy::mapped_workspace_path(manifest.entries.len(), &name)
} else {
name.clone()
};
let output_path =
PathPolicy::workspace_path(workspace, &workspace_name)?;
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
TranslateError::io(
format!("unable to create workspace directory for {name}"),
error,
)
})?;
}
let mut output = File::create(&output_path).map_err(|error| {
TranslateError::io(
format!("unable to create workspace file for {name}"),
error,
)
})?;
std::io::copy(&mut entry, &mut output).map_err(|error| {
TranslateError::io(format!("unable to extract {name}"), error)
})?;
manifest.entries.push(ArchiveEntry {
archive_path: name,
workspace_path: workspace_name,
});
}
manifest.write(workspace)?;
Ok(ExtractionResult {
signed,
total_entries,
uncompressed_bytes,
})
}
/// 递归收集工作区相对路径。
pub fn collect_files(root: &Path) -> Result<Vec<String>> {
let mut result = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(directory) = pending.pop() {
let entries = std::fs::read_dir(&directory).map_err(|error| {
TranslateError::io(
format!("unable to read workspace directory {directory:?}"),
error,
)
})?;
for entry in entries {
let entry = entry.map_err(|error| {
TranslateError::io(
format!("unable to read workspace entry in {directory:?}"),
error,
)
})?;
let path = entry.path();
let file_type = entry.file_type().map_err(|error| {
TranslateError::io(
format!("unable to stat {}", path.display()),
error,
)
})?;
if file_type.is_dir() {
pending.push(path);
continue;
}
if file_type.is_file() {
let relative = path.strip_prefix(root).map_err(|_| {
TranslateError::config("workspace path prefix error")
})?;
result.push(relative.to_string_lossy().replace('\\', "/"));
}
}
}
result.sort();
Ok(result)
}
const INTERNAL_FILES: &[&str] = &[
".mod-translator-archive-manifest.json",
".mod-translator-checkpoint.json",
".mod-translator-resume.json",
".mod-translator-performance.json",
".mod-translator-events.ndjson",
".mod-translator-resource-coverage.json",
".mod-translator-agent-findings.json",
".mod-translator-agent-report.json",
];
fn is_internal_file(name: &str) -> bool {
INTERNAL_FILES.iter().any(|internal| {
name == *internal || name.starts_with(".mod-translator-")
})
}
/// 把工作区重新打成 JAR用 manifest 还原原始条目名;新生成的文件按相对路径加进去,
/// `.mod-translator-*` 内部文件一律不打进去。
pub fn package_archive(
workspace: &Path,
output_path: &Path,
manifest: &ArchiveManifest,
) -> Result<()> {
if let Some(parent) = output_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|error| {
TranslateError::io("unable to create output directory", error)
})?;
}
if output_path.exists() {
return Err(TranslateError::new(
TranslateErrorCode::Config,
format!("output file already exists: {}", output_path.display()),
));
}
let temporary = output_path.with_extension("jar.partial");
let _ = std::fs::remove_file(&temporary);
let file = File::create(&temporary).map_err(|error| {
TranslateError::io("unable to create temporary output JAR", error)
})?;
let mut writer = zip::ZipWriter::new(file);
let options = SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for entry in &manifest.entries {
let source =
PathPolicy::workspace_path(workspace, &entry.workspace_path)?;
if !source.is_file() {
continue;
}
let bytes = std::fs::read(&source).map_err(|error| {
TranslateError::io(
format!("unable to read workspace file {}", source.display()),
error,
)
})?;
writer
.start_file(&entry.archive_path, options)
.map_err(|error| {
TranslateError::with_source(
TranslateErrorCode::Io,
format!(
"unable to write archive entry {}",
entry.archive_path
),
error,
)
})?;
writer.write_all(&bytes).map_err(|error| {
TranslateError::io(
format!("unable to write archive entry {}", entry.archive_path),
error,
)
})?;
}
let mapped_files: HashSet<String> = manifest
.entries
.iter()
.map(|entry| entry.workspace_path.to_ascii_lowercase())
.collect();
for relative in collect_files(workspace)? {
if relative == ARCHIVE_MANIFEST
|| mapped_files.contains(&relative.to_ascii_lowercase())
|| is_internal_file(&relative)
{
continue;
}
let bytes =
std::fs::read(workspace.join(&relative)).map_err(|error| {
TranslateError::io(
format!("unable to read workspace file {relative}"),
error,
)
})?;
writer.start_file(&relative, options).map_err(|error| {
TranslateError::with_source(
TranslateErrorCode::Io,
format!("unable to write archive entry {relative}"),
error,
)
})?;
writer.write_all(&bytes).map_err(|error| {
TranslateError::io(
format!("unable to write archive entry {relative}"),
error,
)
})?;
}
let mut writer = writer.finish().map_err(|error| {
TranslateError::with_source(
TranslateErrorCode::Io,
"unable to finalize output JAR",
error,
)
})?;
writer.flush().map_err(|error| {
TranslateError::io("unable to flush output JAR", error)
})?;
drop(writer);
std::fs::rename(&temporary, output_path).map_err(|error| {
TranslateError::io("unable to move temporary JAR into place", error)
})?;
Ok(())
}
/// 校验路径只有普通段(前端给的输出路径先过这一关)。
pub fn is_clean_absolute_path(path: &Path) -> bool {
path.is_absolute()
&& path.components().all(|component| {
matches!(
component,
Component::Normal(_)
| Component::RootDir
| Component::Prefix(_)
)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn build_zip(
dir: &Path,
name: &str,
entries: &[(&str, &[u8], Option<u32>)],
) -> PathBuf {
let path = dir.join(name);
let file = File::create(&path).unwrap();
let mut writer = zip::ZipWriter::new(file);
for (name, data, mode) in entries {
let mut options = SimpleFileOptions::default();
if let Some(mode) = mode {
options = options.unix_permissions(*mode);
}
writer.start_file(*name, options).unwrap();
writer.write_all(data).unwrap();
}
writer.finish().unwrap();
path
}
#[test]
fn unsafe_paths_are_rejected() {
let dir = tempfile::tempdir().unwrap();
let input = build_zip(
dir.path(),
"malicious.jar",
&[
("../evil.txt", b"evil", None),
("C:/evil.txt", b"evil", None),
("/absolute.txt", b"evil", None),
],
);
let workspace = dir.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let error =
extract_archive(&input, &workspace, &ExtractionLimits::default())
.expect_err("malicious archive must be rejected");
assert_eq!(error.code, TranslateErrorCode::UnsafeArchivePath);
}
#[test]
fn nul_bytes_are_rejected_by_path_policy() {
assert!(PathPolicy::safe_entry_name("a\0b").is_err());
assert!(PathPolicy::safe_entry_name("..\\..\\escape").is_err());
assert_eq!(PathPolicy::safe_entry_name("a\\b.txt").unwrap(), "a/b.txt");
assert!(PathPolicy::safe_entry_name("normal.txt").is_ok());
}
#[test]
fn symlink_mode_bits_are_detected() {
assert!(PathPolicy::is_symlink(0o120777));
assert!(!PathPolicy::is_symlink(0o100644));
assert!(!PathPolicy::is_symlink(0o040755));
}
#[test]
fn signature_files_flag_signed_archives() {
let dir = tempfile::tempdir().unwrap();
let input = build_zip(
dir.path(),
"signed.jar",
&[
("assets/x/lang/en_us.json", b"{}", None),
("META-INF/MOD.SF", b"META-INF/MANIFEST.MF", None),
("META-INF/MOD.RSA", b"sig", None),
],
);
let workspace = dir.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let result =
extract_archive(&input, &workspace, &ExtractionLimits::default())
.unwrap();
assert!(result.signed);
// 打包本身不拒绝签名模组拒绝发生在编排层SignedModRefused
let manifest = ArchiveManifest::read(&workspace).unwrap();
assert_eq!(manifest.entries.len(), 3);
}
#[test]
fn package_restores_original_names_and_skips_internal_files() {
let dir = tempfile::tempdir().unwrap();
let input = build_zip(
dir.path(),
"plain.jar",
&[
("assets/x/lang/en_us.json", b"{\"a\":\"A\"}", None),
("META-INF/mods.toml", b"modId = \"x\"", None),
],
);
let workspace = dir.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let result =
extract_archive(&input, &workspace, &ExtractionLimits::default())
.unwrap();
let _ = result;
// 模拟翻译产物 + 内部文件
std::fs::write(
workspace.join("assets/x/lang/zh_cn.json"),
"{\"a\":\"\"}\n",
)
.unwrap();
std::fs::write(workspace.join(".mod-translator-checkpoint.json"), "{}")
.unwrap();
let output = dir.path().join("out-zh_cn.jar");
let manifest = ArchiveManifest::read(&workspace).unwrap();
package_archive(&workspace, &output, &manifest).unwrap();
let file = File::open(&output).unwrap();
let mut archive = ZipArchive::new(file).unwrap();
assert!(archive.by_name("assets/x/lang/en_us.json").is_ok());
assert!(archive.by_name("assets/x/lang/zh_cn.json").is_ok());
assert!(archive.by_name("META-INF/mods.toml").is_ok());
assert!(archive.by_name(".mod-translator-checkpoint.json").is_err());
}
}

View File

@ -0,0 +1,428 @@
//! 翻译工作图、任务记忆与 class 处置账本。
use std::collections::{BTreeMap, HashMap};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::mod_translation::analyze;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WorkKind {
Language,
VisibleText,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WorkStatus {
Pending,
Claimed,
Submitted,
Verified,
Superseded,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attempt {
pub action: String,
pub outcome: String,
#[serde(default)]
pub failure_class: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkItem {
pub id: String,
pub kind: WorkKind,
pub goal: String,
pub source: String,
pub weight: f64,
#[serde(default)]
pub attempts: Vec<Attempt>,
pub status: WorkStatus,
pub version: u64,
}
impl WorkItem {
pub fn model_attempt_count(&self) -> usize {
self.attempts
.iter()
.filter(|attempt| {
matches!(
attempt.action.as_str(),
"fast_translate"
| "deep_translate"
| "deep_quality"
| "agent"
)
})
.count()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkGraphSnapshot {
pub task_id: String,
pub revision: u64,
pub items: Vec<WorkItem>,
}
#[derive(Debug, Clone)]
pub struct WorkGraph {
task_id: String,
items: BTreeMap<String, WorkItem>,
revision: u64,
}
#[allow(dead_code)]
impl WorkGraph {
pub fn new(task_id: String) -> Self {
Self {
task_id,
items: BTreeMap::new(),
revision: 0,
}
}
pub fn from_snapshot(snapshot: WorkGraphSnapshot) -> Self {
Self {
task_id: snapshot.task_id,
items: snapshot
.items
.into_iter()
.map(|item| (item.id.clone(), item))
.collect(),
revision: snapshot.revision,
}
}
pub fn snapshot(&self) -> WorkGraphSnapshot {
WorkGraphSnapshot {
task_id: self.task_id.clone(),
revision: self.revision,
items: self.items.values().cloned().collect(),
}
}
/// 新增或更新一个工作项(按 source 去重)。
pub fn upsert(
&mut self,
kind: WorkKind,
goal: &str,
source: &str,
weight: f64,
) -> String {
let id = work_item_id(&self.task_id, kind, source);
match self.items.get_mut(&id) {
Some(item) => {
item.weight = weight;
item.goal = goal.to_string();
}
None => {
self.items.insert(
id.clone(),
WorkItem {
id: id.clone(),
kind,
goal: goal.to_string(),
source: source.to_string(),
weight,
attempts: Vec::new(),
status: WorkStatus::Pending,
version: 1,
},
);
self.revision += 1;
}
}
id
}
pub fn all(&self) -> Vec<WorkItem> {
self.items.values().cloned().collect()
}
pub fn pending(&self) -> Vec<WorkItem> {
self.items
.values()
.filter(|item| {
!matches!(
item.status,
WorkStatus::Verified | WorkStatus::Superseded
)
})
.cloned()
.collect()
}
pub fn by_source(&self, kind: WorkKind, source: &str) -> Option<WorkItem> {
let id = work_item_id(&self.task_id, kind, source);
self.items.get(&id).cloned()
}
pub fn item(&self, id: &str) -> Option<WorkItem> {
self.items.get(id).cloned()
}
pub fn record_attempt(
&mut self,
id: &str,
action: &str,
outcome: &str,
failure_class: Option<&str>,
) {
if let Some(item) = self.items.get_mut(id) {
item.attempts.push(Attempt {
action: action.to_string(),
outcome: outcome.to_string(),
failure_class: failure_class.map(str::to_string),
});
self.revision += 1;
}
}
pub fn reconcile(&mut self, id: &str, accepted: bool, reason: &str) {
if let Some(item) = self.items.get_mut(id) {
if accepted {
item.status = WorkStatus::Verified;
} else if item.status != WorkStatus::Superseded {
item.status = WorkStatus::Pending;
}
item.version += 1;
self.revision += 1;
let _ = reason;
}
}
pub fn reset_for_retry(&mut self, id: &str) {
if let Some(item) = self.items.get_mut(id) {
if item.status != WorkStatus::Superseded {
item.status = WorkStatus::Pending;
}
item.attempts.retain(|attempt| {
!matches!(
attempt.action.as_str(),
"fast_translate"
| "deep_translate"
| "deep_quality"
| "agent"
)
});
item.version += 1;
self.revision += 1;
}
}
pub fn supersede(&mut self, id: &str, reason: &str) {
if let Some(item) = self.items.get_mut(id) {
item.status = WorkStatus::Superseded;
item.version += 1;
self.revision += 1;
let _ = reason;
}
}
pub fn progress(&self) -> (f64, f64) {
let total = self.items.values().map(|item| item.weight).sum::<f64>();
let verified = self
.items
.values()
.filter(|item| {
matches!(
item.status,
WorkStatus::Verified | WorkStatus::Superseded
)
})
.map(|item| item.weight)
.sum::<f64>();
(verified, total)
}
pub fn revision(&self) -> u64 {
self.revision
}
}
fn work_item_id(task_id: &str, kind: WorkKind, source: &str) -> String {
let kind_str = match kind {
WorkKind::Language => "language",
WorkKind::VisibleText => "visible_text",
};
let mut hasher = Sha256::new();
hasher.update(task_id.as_bytes());
hasher.update(b"\0");
hasher.update(kind_str.as_bytes());
hasher.update(b"\0");
hasher.update(source.as_bytes());
format!("{:x}", hasher.finalize())[..24].to_string()
}
/// 任务记忆:跨会话继承,各字段有界。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TaskMemory {
#[serde(default)]
pub recommended_name: Option<String>,
#[serde(default)]
pub summary: String,
#[serde(default)]
pub glossary: HashMap<String, String>,
#[serde(default)]
pub decisions: Vec<Value>,
#[serde(default)]
pub uncertainties: Vec<String>,
#[serde(default)]
pub discovered_targets: Vec<String>,
#[serde(default)]
pub coverage: Option<Value>,
}
impl TaskMemory {
pub fn update(&mut self, update: Value) {
if let Some(name) =
update.get("recommendedName").and_then(Value::as_str)
{
self.recommended_name = Some(name.chars().take(80).collect());
}
if let Some(summary) = update.get("summary").and_then(Value::as_str) {
self.summary = truncate(summary, 4_000);
}
if let Some(glossary) = update.get("glossary").and_then(Value::as_array)
{
for entry in glossary {
if let (Some(source), Some(translation)) = (
entry.get("source").and_then(Value::as_str),
entry.get("translation").and_then(Value::as_str),
) {
self.glossary.insert(
truncate(source, 120),
truncate(translation, 120),
);
}
}
while self.glossary.len() > 500 {
if let Some(key) = self.glossary.keys().next().cloned() {
self.glossary.remove(&key);
}
}
}
if let Some(decisions) =
update.get("decisions").and_then(Value::as_array)
{
self.decisions.extend(decisions.iter().take(50).cloned());
while self.decisions.len() > 200 {
self.decisions.remove(0);
}
}
if let Some(uncertainties) =
update.get("uncertainties").and_then(Value::as_array)
{
for value in uncertainties.iter().filter_map(Value::as_str) {
self.uncertainties.push(truncate(value, 500));
}
while self.uncertainties.len() > 100 {
self.uncertainties.remove(0);
}
}
if let Some(targets) =
update.get("discoveredTargets").and_then(Value::as_array)
{
for value in targets.iter().filter_map(Value::as_str) {
self.discovered_targets.push(truncate(value, 500));
}
while self.discovered_targets.len() > 500 {
self.discovered_targets.remove(0);
}
}
if let Some(coverage) = update.get("coverage") {
self.coverage = Some(coverage.clone());
}
}
}
fn truncate(value: &str, maximum: usize) -> String {
let mut out = value
.chars()
.filter(|character| !matches!(*character, '\u{0000}'..='\u{0008}' | '\u{000b}' | '\u{000c}' | '\u{000e}'..='\u{001f}'))
.collect::<String>();
out = out.trim().to_string();
if out.chars().count() > maximum {
out = out.chars().take(maximum).collect();
}
out
}
/// class 候选处置账本translate.rs 与 ledger.rs 共用)。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ClassDecisionLedger {
pub decisions: HashMap<String, ClassDecision>,
pub replaced_files: Vec<String>,
pub replacement_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassDecision {
pub action: String,
pub translation: Option<String>,
pub reason: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ResolutionLedger {
pub work_graph: WorkGraph,
pub class_ledger: ClassDecisionLedger,
}
impl ClassDecisionLedger {
#[allow(dead_code)]
pub fn is_resolved(&self, id: &str) -> bool {
self.decisions.contains_key(id)
}
pub fn unresolved(
&self,
candidates: &[analyze::ClassCandidate],
) -> Vec<analyze::ClassCandidate> {
candidates
.iter()
.filter(|candidate| !self.decisions.contains_key(&candidate.id))
.cloned()
.collect()
}
pub fn snapshot_exclusions(&self) -> Vec<String> {
self.decisions
.iter()
.filter(|(_, decision)| decision.action == "exclude")
.map(|(id, _)| id.clone())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resumed_required_work_is_reopened_with_a_fresh_model_budget() {
let mut graph = WorkGraph::new("TASK-test".to_string());
let id = graph.upsert(
WorkKind::Language,
"translate",
"assets/demo/lang/zh_cn.json#demo.key",
1.0,
);
graph.record_attempt(&id, "fast_translate", "bad", Some("partial"));
graph.record_attempt(&id, "quality_audit", "bad", Some("quality"));
graph.reconcile(&id, true, "old checkpoint");
graph.reset_for_retry(&id);
let item = graph.item(&id).unwrap();
assert_eq!(item.status, WorkStatus::Pending);
assert_eq!(item.model_attempt_count(), 0);
assert_eq!(item.attempts.len(), 1);
assert_eq!(item.attempts[0].action, "quality_audit");
}
}

View File

@ -0,0 +1,181 @@
//! 翻译记忆跨模组缓存同源文本JSON 文件落盘 + LRU 淘汰。
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::mod_translation::error::{Result, TranslateError};
use crate::mod_translation::quality::{has_chinese, validate_protected_tokens};
const MAX_ENTRIES: usize = 100_000;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MemoryEntry {
translation: String,
updated_at: u64,
hits: u64,
}
#[derive(Debug, Serialize, Deserialize)]
struct MemoryFile {
version: u32,
entries: HashMap<String, MemoryEntry>,
}
#[derive(Debug)]
pub struct TranslationMemory {
path: PathBuf,
entries: HashMap<String, MemoryEntry>,
dirty: bool,
}
impl TranslationMemory {
pub async fn load(path: PathBuf) -> Self {
let read_path = path.clone();
let entries = tokio::task::spawn_blocking(move || {
let Ok(content) = std::fs::read_to_string(&read_path) else {
return HashMap::new();
};
serde_json::from_str::<MemoryFile>(&content)
.map(|file| file.entries)
.unwrap_or_default()
})
.await
.unwrap_or_default();
Self {
path,
entries,
dirty: false,
}
}
pub fn memory_key(
mod_ids: &[String],
namespace: &str,
source: &str,
) -> String {
let mut ids = mod_ids.to_vec();
ids.sort();
ids.dedup();
let mut hasher = Sha256::new();
hasher.update(serde_json::to_string(&ids).unwrap_or_default());
hasher.update(namespace.to_ascii_lowercase().as_bytes());
hasher.update(source.as_bytes());
format!("{:x}", hasher.finalize())
}
/// 命中必须再过占位符 + 中文校验,不过就当 miss。
pub fn lookup(
&mut self,
mod_ids: &[String],
namespace: &str,
source: &str,
) -> Option<String> {
let key = Self::memory_key(mod_ids, namespace, source);
let entry = self.entries.get_mut(&key)?;
let translation = entry.translation.trim().to_string();
if translation.is_empty()
|| !has_chinese(&translation)
|| validate_protected_tokens(source, &translation).is_some()
{
return None;
}
entry.hits += 1;
entry.updated_at = now_seconds();
self.dirty = true;
Some(translation)
}
pub fn record(
&mut self,
mod_ids: &[String],
namespace: &str,
source: &str,
translation: &str,
) {
let key = Self::memory_key(mod_ids, namespace, source);
self.entries.insert(
key,
MemoryEntry {
translation: translation.to_string(),
updated_at: now_seconds(),
hits: 0,
},
);
self.dirty = true;
}
pub async fn flush(&mut self) -> Result<()> {
if !self.dirty {
return Ok(());
}
if self.entries.len() > MAX_ENTRIES {
let mut entries = self.entries.drain().collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.1
.updated_at
.cmp(&left.1.updated_at)
.then_with(|| right.1.hits.cmp(&left.1.hits))
});
entries.truncate(MAX_ENTRIES);
self.entries = entries.into_iter().collect();
}
let entries = self.entries.clone();
let path = self.path.clone();
tokio::task::spawn_blocking(move || {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| {
TranslateError::io(
"unable to create translation memory directory",
error,
)
})?;
}
let file = MemoryFile {
version: 1,
entries,
};
let content = format!(
"{}\n",
serde_json::to_string(&file).map_err(|error| {
TranslateError::config(format!(
"translation memory serialization: {error}"
))
})?
);
let temporary = path.with_extension("json.tmp");
std::fs::write(&temporary, content).map_err(|error| {
TranslateError::io("unable to write translation memory", error)
})?;
std::fs::rename(&temporary, &path).map_err(|error| {
TranslateError::io(
"unable to move translation memory into place",
error,
)
})
})
.await
.map_err(|error| {
TranslateError::config(format!(
"translation memory flush task: {error}"
))
})??;
self.dirty = false;
Ok(())
}
}
fn now_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|value| value.as_secs())
.unwrap_or(0)
}
pub fn memory_path(workspace_root: &Path) -> PathBuf {
workspace_root.join("translation-memory-v1.json")
}

View File

@ -0,0 +1,11 @@
pub mod analyze;
pub mod error;
pub mod jar;
pub mod ledger;
pub mod memory;
pub mod mod_name;
pub mod quality;
pub mod repair;
pub mod resume;
pub mod translate;
pub mod writeback;

View File

@ -0,0 +1,319 @@
//! 模组名分层解析:内嵌 → 已知表 → 生成 → 直译 → 文件名 → 显示名 → modId。
use std::collections::HashMap;
use std::path::Path;
use std::sync::LazyLock;
use regex::Regex;
use serde::{Deserialize, Serialize};
const GENERIC_NAMES_RE: &str = r"^(?:(?:中文|魔法|冒险|科技|装饰|工具|未知|未命名)?模组|未命名|未知|有趣的冒险|中文扩展|未命名扩展)$";
const KNOWN_NAMES: &[(&str, &str)] = &[
("macaw'swindows", "Macaw 的窗户"),
("mcwwindows", "Macaw 的窗户"),
("iron'sspells'nspellbooks", "铁魔法与法术书"),
("irons_spellbooks", "铁魔法与法术书"),
("farmer'sdelight", "农夫乐事"),
("farmersdelight", "农夫乐事"),
("alex'scaves", "Alex 的洞穴"),
("alexscaves", "Alex 的洞穴"),
("mekanism", "通用机械"),
("lootmate", "战利品助手"),
];
const WORD_TRANSLATIONS: &[(&str, &str)] = &[
("window", "窗户"),
("windows", "窗户"),
("cave", "洞穴"),
("caves", "洞穴"),
("spell", "法术"),
("spells", "法术"),
("spellbook", "法术书"),
("spellbooks", "法术书"),
("loot", "战利品"),
("mate", "助手"),
("farmer", "农夫"),
("farmers", "农夫"),
("delight", "乐事"),
("magic", "魔法"),
("iron", ""),
("tools", "工具"),
("tool", "工具"),
("doors", ""),
("door", ""),
];
static GENERIC_NAMES: LazyLock<Regex> =
LazyLock::new(|| Regex::new(GENERIC_NAMES_RE).unwrap());
static WORD_MAP: LazyLock<HashMap<&'static str, &'static str>> =
LazyLock::new(|| WORD_TRANSLATIONS.iter().copied().collect());
static KNOWN_MAP: LazyLock<HashMap<&'static str, &'static str>> =
LazyLock::new(|| KNOWN_NAMES.iter().copied().collect());
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModNameResult {
pub name: String,
pub source: String,
}
fn identity_key(value: &str) -> String {
value
.to_ascii_lowercase()
.chars()
.filter(|character| {
character.is_ascii_alphanumeric()
|| *character == '_'
|| *character == '\''
})
.collect()
}
pub fn usable_chinese_mod_name(value: &str) -> bool {
let normalized = value.trim();
normalized.chars().count() >= 2
&& normalized
.chars()
.any(|character| matches!(character, '\u{3400}'..='\u{9fff}'))
&& !GENERIC_NAMES.is_match(normalized)
}
fn original_project_label(original_name: &str) -> Option<String> {
let stem = Path::new(original_name)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or(original_name)
.trim()
.to_string();
if stem.is_empty()
|| Regex::new(r"^(?:download|mod|file|unknown)(?:[-_ ]?\d+)?$")
.unwrap()
.is_match(&stem)
{
return None;
}
let without_version = Regex::new(
r"[-_ ]+(?:mc)?\d+\.\d+(?:\.\d+)?(?:[-+._][A-Za-z0-9.]+)*.*$",
)
.unwrap()
.replace_all(&stem, "")
.trim()
.to_string();
let label = (if without_version.is_empty() {
stem
} else {
without_version
})
.replace('_', " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if label.is_empty() { None } else { Some(label) }
}
fn known_chinese_mod_name(
project_names: &[String],
mod_ids: &[String],
) -> Option<String> {
mod_ids
.iter()
.chain(project_names.iter())
.filter_map(|value| KNOWN_MAP.get(identity_key(value).as_str()))
.map(|value| value.to_string())
.next()
}
fn translate_english_label(value: &str) -> Option<String> {
let clean = value
.replace(['_', '-'], " ")
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if let Some(captures) =
Regex::new(r"^(.+?)[']s\s+(.+)$").unwrap().captures(&clean)
{
let tail = captures[2]
.split_whitespace()
.map(|word| {
WORD_MAP
.get(word.to_ascii_lowercase().as_str())
.copied()
.unwrap_or(word)
})
.collect::<String>();
if tail
.chars()
.any(|character| matches!(character, '\u{3400}'..='\u{9fff}'))
{
return Some(format!("{}{}", &captures[1], tail));
}
}
let mut translated = false;
let parts = clean
.split_whitespace()
.filter(|word| {
!Regex::new(r"^(?:mc|forge|fabric|neoforge|mod)$")
.unwrap()
.is_match(word)
})
.map(|word| {
if let Some(replacement) =
WORD_MAP.get(word.to_ascii_lowercase().as_str())
{
translated = true;
replacement.to_string()
} else {
word.to_string()
}
})
.collect::<Vec<_>>()
.join("");
if translated { Some(parts) } else { None }
}
fn translated_fallback(
project_names: &[String],
original_name: &str,
) -> Option<ModNameResult> {
for display_name in project_names {
if let Some(translated) = translate_english_label(display_name)
&& usable_chinese_mod_name(&translated)
{
return Some(ModNameResult {
name: translated.chars().take(64).collect(),
source: "translated_display_name".to_string(),
});
}
}
let original = original_project_label(original_name)?;
let translated = translate_english_label(&original)?;
if usable_chinese_mod_name(&translated) {
Some(ModNameResult {
name: translated.chars().take(64).collect(),
source: "translated_filename".to_string(),
})
} else {
None
}
}
/// 决策链主入口。
pub fn resolve_mod_name(
project_names: &[String],
mod_ids: &[String],
original_name: &str,
recommended_name: Option<String>,
recommended_source: Option<String>,
) -> ModNameResult {
if let Some(embedded) = project_names
.iter()
.find(|name| usable_chinese_mod_name(name))
{
return ModNameResult {
name: embedded.trim().chars().take(64).collect(),
source: "embedded_chinese".to_string(),
};
}
if let Some(name) = recommended_name
&& usable_chinese_mod_name(&name)
{
return ModNameResult {
name: name.trim().chars().take(64).collect(),
source: recommended_source.unwrap_or_else(|| {
"researched_or_generated_chinese".to_string()
}),
};
}
if let Some(known) = known_chinese_mod_name(project_names, mod_ids) {
return ModNameResult {
name: known.chars().take(64).collect(),
source: "known_chinese".to_string(),
};
}
if let Some(translated) = translated_fallback(project_names, original_name)
{
return translated;
}
if let Some(original) = original_project_label(original_name) {
return ModNameResult {
name: original.chars().take(64).collect(),
source: "original_filename".to_string(),
};
}
if let Some(display_name) = project_names
.iter()
.map(|name| name.trim())
.find(|name| !name.is_empty() && !GENERIC_NAMES.is_match(name))
{
return ModNameResult {
name: display_name.chars().take(64).collect(),
source: "display_name".to_string(),
};
}
let mod_id = mod_ids.iter().map(|id| id.trim()).find(|id| !id.is_empty());
ModNameResult {
name: mod_id.unwrap_or("mod").chars().take(64).collect(),
source: "mod_id".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_chinese_wins() {
let result = resolve_mod_name(
&["Macaw 的窗户".to_string()],
&["mcwwindows".to_string()],
"mcwwindows-1.0.jar",
None,
None,
);
assert_eq!(result.source, "embedded_chinese");
assert_eq!(result.name, "Macaw 的窗户");
}
#[test]
fn known_names_are_used() {
let result = resolve_mod_name(
&[],
&["mekanism".to_string()],
"mekanism-1.20.1.jar",
None,
None,
);
assert_eq!(result.source, "known_chinese");
assert_eq!(result.name, "通用机械");
}
#[test]
fn possessive_names_translate() {
let result =
resolve_mod_name(&[], &[], "Alex's Caves-1.2.jar", None, None);
assert_eq!(result.source, "translated_filename");
assert_eq!(result.name, "Alex 的洞穴");
}
#[test]
fn generic_names_fall_through_to_mod_id() {
let result = resolve_mod_name(
&[],
&["weird_mod_id".to_string()],
"download.jar",
None,
None,
);
assert_eq!(result.source, "mod_id");
assert_eq!(result.name, "weird_mod_id");
}
#[test]
fn version_suffixes_are_stripped() {
assert_eq!(
original_project_label("some-mod-1.20.1.jar").as_deref(),
Some("some-mod")
);
}
}

View File

@ -0,0 +1,731 @@
//! 占位符保护、机械/语义审计、术语表、待译判定、工作量权重。
use std::collections::BTreeMap;
use std::sync::LazyLock;
use regex::Regex;
/// 占位符提取正则。Rust regex 不支持 lookbehind所以用 `(?:^|[^X])` + 捕获组
/// 等价实现,取捕获组 1 作为 token。
const PLACEHOLDER_PATTERNS: &[&str] = &[
r"(?m)^[\t ]*(/[A-Za-z0-9_.:-]+(?:[\t ]+(?:[A-Za-z0-9_.:-]+|\{[^{}\s]+\}|<[^<>\s]+>|\[[^\[\]\s]+\]))*)",
r"(%(?:\d+\$)?[-#+ 0,(]*\d*(?:\.\d+)?[a-zA-Z%])",
r"\$\{[A-Za-z_][A-Za-z0-9_.-]*\}",
r"(?:^|[^$])(\{(?:\d+|[A-Za-z_][A-Za-z0-9_.-]*)\})",
r"§[0-9A-FK-ORa-fk-or]",
r"\\[nrt]",
r"[\u{0001}-\u{0008}\u{000b}\u{000c}\u{000e}-\u{001f}]",
];
static COMPILED: LazyLock<Vec<Regex>> = LazyLock::new(|| {
PLACEHOLDER_PATTERNS
.iter()
.map(|pattern| {
Regex::new(pattern).expect("placeholder pattern should compile")
})
.collect()
});
pub fn extract_protected_tokens(text: &str) -> Vec<String> {
let mut tokens = Vec::new();
for regex in COMPILED.iter() {
for captures in regex.captures_iter(text) {
if let Some(capture) = captures.get(1) {
tokens.push(capture.as_str().to_string());
} else if let Some(full) = captures.get(0) {
tokens.push(full.as_str().to_string());
}
}
}
tokens.sort();
tokens
}
pub fn validate_protected_tokens(
source: &str,
translation: &str,
) -> Option<String> {
let expected = extract_protected_tokens(source);
let actual = extract_protected_tokens(translation);
if expected == actual {
None
} else {
Some(format!(
"占位符不一致:期望 [{}],实际 [{}]",
expected.join(", "),
actual.join(", ")
))
}
}
pub fn normalize_model_translation(source: &str, translation: &str) -> String {
let mut normalized = translation.to_string();
for (escaped, control) in [("\\n", "\n"), ("\\r", "\r"), ("\\t", "\t")] {
if source.contains(escaped) {
if !normalized.contains(escaped) {
normalized = normalized.replace(control, escaped);
}
} else {
normalized = normalized.replace(escaped, control);
}
}
normalized
}
pub fn has_chinese(text: &str) -> bool {
text.chars()
.any(|character| matches!(character, '\u{3400}'..='\u{9fff}' | '\u{3000}'..='\u{303f}' | '\u{ff00}'..='\u{ffef}'))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuditSeverity {
Error,
Warning,
}
#[derive(Debug, Clone)]
pub struct AuditIssue {
pub severity: AuditSeverity,
pub key: String,
pub message: String,
}
/// 机械不变量审计:缺译、占位符、多余键、已有中文被改写。
pub fn audit_invariants(
source: &BTreeMap<String, String>,
target: &BTreeMap<String, String>,
) -> Vec<AuditIssue> {
let mut issues = Vec::new();
for (key, english) in source {
match target.get(key) {
Some(chinese) if !chinese.trim().is_empty() => {
if let Some(error) = validate_protected_tokens(english, chinese)
{
issues.push(AuditIssue {
severity: AuditSeverity::Error,
key: key.clone(),
message: error,
});
}
if requires_work(key, english, Some(chinese))
&& !has_chinese(chinese)
{
issues.push(AuditIssue {
severity: AuditSeverity::Error,
key: key.clone(),
message: "译文不含简体中文;若必须保留原文,需要显式标记 keep-source".to_string(),
});
}
}
_ => issues.push(AuditIssue {
severity: AuditSeverity::Error,
key: key.clone(),
message: "缺少中文译文".to_string(),
}),
}
}
for key in target.keys() {
if !source.contains_key(key) {
issues.push(AuditIssue {
severity: AuditSeverity::Warning,
key: key.clone(),
message: "中文文件包含源语言中不存在的额外条目".to_string(),
});
}
}
issues
}
/// 官方术语规则:英文命中时必须含指定中文,否则 error。
pub struct TermRule {
pub source: &'static str,
pub chinese: &'static str,
pub label: &'static str,
}
pub const OFFICIAL_TERMS: &[TermRule] = &[
TermRule {
source: r"\bMacaw(?:'s)?\b",
chinese: "Macaw",
label: "模组品牌 Macaw 应保留原文,不应擅自音译",
},
TermRule {
source: r"\bAdditions\b",
chinese: "扩展",
label: "物品组标题中的 Additions 应译为自然的“扩展”",
},
TermRule {
source: r"\bIron Bars\b",
chinese: "铁栏杆",
label: "Iron Bars 应沿用 Minecraft 官方简中术语“铁栏杆”",
},
TermRule {
source: r"\bResizeable\b",
chinese: "可变形",
label: "Resizeable 在可改变外形的窗户语境中应译为自然的“可变形”",
},
TermRule {
source: r"\bEnd(?:er)? Brick\b",
chinese: "末地石砖",
label: "End Brick 应沿用 Minecraft 官方材料名“末地石砖”",
},
TermRule {
source: r"\bCrimson\b",
chinese: "绯红",
label: "Crimson 应沿用“绯红”",
},
TermRule {
source: r"\bWarped\b",
chinese: "诡异",
label: "Warped 应沿用“诡异”",
},
TermRule {
source: r"\bPale Oak\b",
chinese: "苍白橡木",
label: "Pale Oak 应沿用“苍白橡木”",
},
TermRule {
source: r"\bDark Oak\b",
chinese: "深色橡木",
label: "Dark Oak 应沿用“深色橡木”",
},
TermRule {
source: r"\bOak Planks\b",
chinese: "橡木木板",
label: "Oak Planks 应沿用完整材料名“橡木木板”",
},
TermRule {
source: r"\bDark Oak Planks\b",
chinese: "深色橡木木板",
label: "Dark Oak Planks 应沿用完整材料名“深色橡木木板”",
},
TermRule {
source: r"\bSpruce Planks\b",
chinese: "云杉木板",
label: "Spruce Planks 应沿用“云杉木板”",
},
TermRule {
source: r"\bBirch Planks\b",
chinese: "白桦木板",
label: "Birch Planks 应沿用“白桦木板”",
},
TermRule {
source: r"\bJungle Planks\b",
chinese: "丛林木板",
label: "Jungle Planks 应沿用“丛林木板”",
},
TermRule {
source: r"\bAcacia Planks\b",
chinese: "金合欢木板",
label: "Acacia Planks 应沿用“金合欢木板”",
},
TermRule {
source: r"\bCherry Planks\b",
chinese: "樱花木板",
label: "Cherry Planks 应沿用“樱花木板”",
},
TermRule {
source: r"\bMangrove Planks\b",
chinese: "红树木板",
label: "Mangrove Planks 应沿用“红树木板”",
},
TermRule {
source: r"\bCrimson Planks\b",
chinese: "绯红木板",
label: "Crimson Planks 应沿用“绯红木板”",
},
TermRule {
source: r"\bWarped Planks\b",
chinese: "诡异木板",
label: "Warped Planks 应沿用“诡异木板”",
},
];
const MATERIAL_ORDER: &[(&str, &str)] = &[
(r"\bDark Oak\b", "深色橡木"),
(r"\bPale Oak\b", "苍白橡木"),
(r"\bOak\b", "橡木"),
(r"\bSpruce\b", "云杉"),
(r"\bBirch\b", "白桦"),
(r"\bJungle\b", "丛林"),
(r"\bAcacia\b", "金合欢"),
(r"\bCherry\b", "樱花"),
(r"\bMangrove\b", "红树"),
];
/// 语义审计:官方术语 + 材料族 + 动作区分 + 同原文同译法。
pub fn audit_semantic(
source: &BTreeMap<String, String>,
target: &BTreeMap<String, String>,
) -> Vec<AuditIssue> {
let mut issues = Vec::new();
let mut by_english: BTreeMap<&str, Vec<(&String, &String)>> =
BTreeMap::new();
for (key, english) in source {
let Some(chinese) = target.get(key) else {
continue;
};
if chinese.trim().is_empty() {
continue;
}
for term in OFFICIAL_TERMS {
let regex = Regex::new(term.source).unwrap();
if regex.is_match(english) && !chinese.contains(term.chinese) {
issues.push(AuditIssue {
severity: AuditSeverity::Error,
key: key.clone(),
message: term.label.to_string(),
});
}
}
if Regex::new(r"\bPlanks\b").unwrap().is_match(english)
&& !chinese.contains("木板")
{
issues.push(AuditIssue {
severity: AuditSeverity::Error,
key: key.clone(),
message: "Planks 必须保留“木板”材料含义".to_string(),
});
}
if Regex::new(r"\bStem\b").unwrap().is_match(english)
&& !chinese.contains("菌柄")
{
issues.push(AuditIssue {
severity: AuditSeverity::Error,
key: key.clone(),
message: "Stem 必须保留“菌柄”材料含义".to_string(),
});
}
let log_key = Regex::new(r"(?:^|_)log(?:_|$)").unwrap().is_match(key);
let plank_sibling = log_key
&& source.keys().any(|candidate| {
candidate
.replace("_log", "_plank")
.replace(".log", ".plank")
== *key
|| candidate == &key.replace("_log", "_plank")
});
let source_means_timber = Regex::new(r"\b(?:Log|Stem|Timber|Wood)\b")
.unwrap()
.is_match(english)
&& !Regex::new(r"\b(?:Journal|Logbook|Research Log|Data Log)\b")
.unwrap()
.is_match(english);
if log_key
&& (plank_sibling || source_means_timber)
&& !chinese.contains("原木")
{
issues.push(AuditIssue {
severity: AuditSeverity::Error,
key: key.clone(),
message: "键名和本地结构表明是原木版本,译名必须保留“原木”"
.to_string(),
});
}
let pane_sibling = key
.rsplit_once('.')
.map(|(prefix, tail)| {
tail.strip_suffix("_pane_window")
.map(|stem| (prefix.to_string(), stem.to_string()))
})
.flatten();
if let Some((prefix, stem)) = pane_sibling
&& !stem.contains("plank")
&& source.keys().any(|candidate| {
candidate == &format!("{prefix}.{stem}_plank_pane_window")
})
&& !Regex::new(r"(?:原木|菌柄)").unwrap().is_match(chinese)
{
issues.push(AuditIssue {
severity: AuditSeverity::Error,
key: key.clone(),
message: "存在对应木板玻璃板窗兄弟键,普通版本必须保留原木或菌柄材质差异".to_string(),
});
}
let window_sibling = key
.rsplit_once('.')
.map(|(prefix, tail)| {
tail.strip_suffix("_window")
.map(|stem| (prefix.to_string(), stem.to_string()))
})
.flatten();
if let Some((prefix, stem)) = window_sibling
&& !stem.contains("plank")
&& source.keys().any(|candidate| {
candidate == &format!("{prefix}.{stem}_plank_window")
})
&& !Regex::new(r"(?:原木|菌柄)").unwrap().is_match(chinese)
{
issues.push(AuditIssue {
severity: AuditSeverity::Error,
key: key.clone(),
message:
"存在对应木板窗兄弟键,普通版本必须保留原木或菌柄材质差异"
.to_string(),
});
}
if Regex::new(r"(?:^|[._])open$").unwrap().is_match(key)
&& !Regex::new(r"(?:打开|开启)").unwrap().is_match(chinese)
{
issues.push(AuditIssue {
severity: AuditSeverity::Error,
key: key.clone(),
message: "键名表示打开动作,译文必须与关闭动作明确区分"
.to_string(),
});
}
if Regex::new(r"(?:^|[._])close$").unwrap().is_match(key)
&& !Regex::new(r"(?:关闭|合上)").unwrap().is_match(chinese)
{
issues.push(AuditIssue {
severity: AuditSeverity::Error,
key: key.clone(),
message: "键名表示关闭动作,译文必须与打开动作明确区分"
.to_string(),
});
}
if Regex::new(r"\b(?:Four )?Pane Window\b")
.unwrap()
.is_match(english)
&& !chinese.contains("玻璃板")
&& !Regex::new(r"Four Pane").unwrap().is_match(english)
{
issues.push(AuditIssue {
severity: AuditSeverity::Warning,
key: key.clone(),
message: "Pane Window 通常应体现“玻璃板”;请结合模型确认"
.to_string(),
});
}
if Regex::new(r"\bPane Window\b").unwrap().is_match(english)
&& chinese.contains("玻璃板")
{
for (pattern, material) in MATERIAL_ORDER {
if Regex::new(pattern).unwrap().is_match(english)
&& chinese.find(material).unwrap_or(usize::MAX)
> chinese.find("玻璃板").unwrap_or(usize::MAX)
{
issues.push(AuditIssue {
severity: AuditSeverity::Error,
key: key.clone(),
message: "物品名应采用“材质 + 玻璃板 + 窗”的自然语序"
.to_string(),
});
break;
}
}
}
if Regex::new(r"\bWindow (?:Four |Half )?Pane Base\b|\bWindow Base\b")
.unwrap()
.is_match(english)
&& !chinese.contains("底座")
{
issues.push(AuditIssue {
severity: AuditSeverity::Warning,
key: key.clone(),
message: "作为合成组件的 Base 通常译为“底座”比“基础”自然"
.to_string(),
});
}
by_english
.entry(english.as_str())
.or_default()
.push((key, chinese));
}
for (_, group) in by_english {
if group.len() <= 1 {
continue;
}
let variants: std::collections::HashSet<&String> =
group.iter().map(|(_, chinese)| *chinese).collect();
let distinguishes_action = group.iter().any(|(key, _)| {
Regex::new(r"(?:^|[._])open$").unwrap().is_match(key)
}) && group.iter().any(|(key, _)| {
Regex::new(r"(?:^|[._])close$").unwrap().is_match(key)
});
if variants.len() > 1 && !distinguishes_action {
for (key, _) in group {
issues.push(AuditIssue {
severity: AuditSeverity::Warning,
key: key.clone(),
message: format!(
"相同英文原文出现 {} 种译法,请确认是否需要统一",
variants.len()
),
});
}
}
}
issues
}
/// 待翻译条目判定。
pub fn requires_work(
key: &str,
source_text: &str,
existing_target: Option<&str>,
) -> bool {
let original = source_text.trim();
if original.is_empty() {
return false;
}
let target = existing_target.map(str::trim).unwrap_or("");
let protected_only = is_passthrough_entry(key, original);
let credited_work = (key.contains("music_disc")
|| key.contains("soundtrack")
|| key.contains("credit")
|| key.contains("author")
|| key.contains("artist"))
&& Regex::new(r"^.{2,80}\s[-–—]\s.{2,120}$")
.unwrap()
.is_match(original);
let identical_needs_review = !target.is_empty()
&& target == original
&& !protected_only
&& !credited_work;
if !target.is_empty() && has_chinese(target) {
// 已有中文且非"原文==译文"的复核场景 → 不动
if !identical_needs_review {
return false;
}
}
if protected_only || credited_work {
return false;
}
if target.is_empty() {
return true;
}
identical_needs_review || !has_chinese(target)
}
pub fn is_passthrough_entry(key: &str, source_text: &str) -> bool {
let original = source_text.trim();
let protected_only = Regex::new(
r"^(?:https?://\S+|\/[a-z0-9_.:-]+(?:\s+[a-z0-9_.:<>{}\[\]-]+)*|[a-z0-9_.-]+:[a-z0-9_./-]+)$",
)
.unwrap()
.is_match(original);
let credited_work = (key.contains("music_disc")
|| key.contains("soundtrack")
|| key.contains("credit")
|| key.contains("author")
|| key.contains("artist"))
&& Regex::new(r"^.{2,80}\s[-–—]\s.{2,120}$")
.unwrap()
.is_match(original);
protected_only || credited_work
}
/// 语言条目权重(用于排序/进度,非定价)。
pub fn language_work_weight(text: &str) -> f64 {
let characters = text.chars().count();
let protected =
(Regex::new(r"%\d*\$?[a-z]|\{\w+\}|\$\{[^}]+\}|§[0-9a-fk-or]")
.unwrap()
.find_iter(text))
.count();
(1.0 + (characters as f64 / 80.0).min(3.0) + protected as f64 * 0.35)
.round() as f64
}
/// class 文本权重。
pub fn visible_text_work_weight(text: &str) -> f64 {
(2.0 + (text.chars().count() as f64 / 60.0).min(4.0)).round() as f64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn printf_placeholders_must_survive() {
assert!(
validate_protected_tokens("Spawn %d zombies", "生成 %d 只僵尸")
.is_none()
);
assert!(
validate_protected_tokens("Spawn %d zombies", "生成 %s 只僵尸")
.is_some()
);
assert!(
validate_protected_tokens(
"%1$s took %2$d damage",
"%1$s 受到 %2$d 点伤害"
)
.is_none()
);
}
#[test]
fn named_and_dollar_placeholders_must_survive() {
assert!(
validate_protected_tokens("{count} items", "{count} 个物品")
.is_none()
);
assert!(
validate_protected_tokens("${path} not found", "${path} 未找到")
.is_none()
);
assert!(
validate_protected_tokens("${path} not found", "路径未找到")
.is_some()
);
}
#[test]
fn format_codes_and_escapes_must_survive() {
assert!(
validate_protected_tokens("§aGreen text", "§a绿色文字").is_none()
);
assert!(
validate_protected_tokens("line\\nbreak", "换行\\n测试").is_none()
);
assert!(
validate_protected_tokens("line\\nbreak", "换行测试").is_some()
);
}
#[test]
fn model_control_escapes_are_normalized_to_runtime_text() {
assert_eq!(
normalize_model_translation(
"Rendering disabled: %s",
"渲染已禁用:%s\\n请重新启用"
),
"渲染已禁用:%s\n请重新启用"
);
assert_eq!(
normalize_model_translation("line\\nbreak", "第一行\n第二行"),
"第一行\\n第二行"
);
}
#[test]
fn commands_are_protected_as_whole() {
assert!(
validate_protected_tokens(
"/give @p minecraft:diamond",
"/give @p minecraft:diamond 给予"
)
.is_none()
);
}
#[test]
fn prose_after_a_slash_is_not_treated_as_a_command() {
let source = "Total Blocks: §f%s§r / Volume: §f%s§r";
let translation = "总方块数§f%s§r / 体积§f%s§r";
assert!(validate_protected_tokens(source, translation).is_none());
assert_eq!(
extract_protected_tokens(source),
vec!["%s", "%s", "§f", "§f", "§r", "§r"]
);
}
#[test]
fn requires_work_skips_urls_and_ids() {
assert!(!requires_work("k", "https://example.com", None));
assert!(!requires_work("k", "minecraft:iron_ingot", None));
assert!(!requires_work("k", "", None));
assert!(requires_work("k", "Iron Ingot", None));
assert!(!requires_work(
"item.music_disc.cat.desc",
"C418 - Cat",
Some("C418 - Cat")
));
}
#[test]
fn latin_only_targets_require_an_explicit_keep_source_decision() {
assert!(requires_work("button.reload", "Reload", Some("Reload")));
let source = BTreeMap::from([(
"button.reload".to_string(),
"Reload".to_string(),
)]);
let target = BTreeMap::from([(
"button.reload".to_string(),
"Reload".to_string(),
)]);
assert!(
audit_invariants(&source, &target)
.iter()
.any(|issue| issue.severity == AuditSeverity::Error)
);
}
#[test]
fn deterministic_passthrough_is_valid_after_copying_the_source() {
let source = BTreeMap::from([
("homepage".to_string(), "https://example.com".to_string()),
("item.id".to_string(), "minecraft:iron_ingot".to_string()),
]);
assert!(
audit_invariants(&source, &source)
.iter()
.all(|issue| issue.severity != AuditSeverity::Error)
);
}
#[test]
fn audit_catches_missing_and_placeholder_breaks() {
let source =
BTreeMap::from([("a".to_string(), "Spawn %d".to_string())]);
let target = BTreeMap::from([("a".to_string(), "生成".to_string())]);
let issues = audit_invariants(&source, &target);
assert!(
issues
.iter()
.any(|issue| issue.severity == AuditSeverity::Error
&& issue.key == "a")
);
}
#[test]
fn official_terms_are_enforced() {
let source =
BTreeMap::from([("k".to_string(), "Iron Bars".to_string())]);
let target = BTreeMap::from([("k".to_string(), "铁条".to_string())]);
let issues = audit_semantic(&source, &target);
assert!(
issues
.iter()
.any(|issue| issue.severity == AuditSeverity::Error
&& issue.message.contains("铁栏杆"))
);
}
#[test]
fn plank_family_rule_requires_木板() {
let source =
BTreeMap::from([("k".to_string(), "Oak Planks".to_string())]);
let target = BTreeMap::from([("k".to_string(), "橡树板".to_string())]);
let issues = audit_semantic(&source, &target);
assert!(
issues
.iter()
.any(|issue| issue.severity == AuditSeverity::Error
&& issue.message.contains("木板"))
);
}
#[test]
fn same_source_must_share_translation() {
let mut source = BTreeMap::new();
source.insert("a".to_string(), "Wrench".to_string());
source.insert("b".to_string(), "Wrench".to_string());
let mut target = BTreeMap::new();
target.insert("a".to_string(), "扳手".to_string());
target.insert("b".to_string(), "螺丝刀".to_string());
let issues = audit_semantic(&source, &target);
assert!(issues.iter().any(|issue| issue.message.contains("种译法")));
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,395 @@
//! 断点续传:工作区匹配 + 检查点读写,原子写。
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::mod_translation::analyze::{
JarInspection, reload_structured_templates,
};
use crate::mod_translation::error::{Result, TranslateError};
pub const RESUME_FILE: &str = ".mod-translator-resume.json";
pub const CHECKPOINT_FILE: &str = ".mod-translator-checkpoint.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResumeMarker {
pub version: u32,
pub input_hash: String,
pub resume_identity: String,
pub created_at: String,
pub inspection: JarInspection,
}
/// 在工作区根目录找匹配 input_hash + resume_identity 的 job 目录。
pub fn find_resumable_workspace(
workspace_root: &Path,
input_hash: &str,
identity: &str,
) -> Result<Option<PathBuf>> {
let entries = std::fs::read_dir(workspace_root).map_err(|error| {
TranslateError::io("unable to read workspace root", error)
})?;
let mut matches = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
if !name.starts_with("job-") {
continue;
}
let marker_path = path.join(RESUME_FILE);
let Ok(content) = std::fs::read_to_string(&marker_path) else {
continue;
};
let Ok(marker) = serde_json::from_str::<ResumeMarker>(&content) else {
continue;
};
if marker.input_hash == input_hash && marker.resume_identity == identity
{
matches.push((marker.created_at, path));
}
}
matches.sort_by(|left, right| right.0.cmp(&left.0));
Ok(matches.into_iter().next().map(|(_, path)| path))
}
pub fn read_resume_marker(directory: &Path) -> Option<ResumeMarker> {
let content = std::fs::read_to_string(directory.join(RESUME_FILE)).ok()?;
serde_json::from_str(&content).ok()
}
pub fn write_resume_marker(
directory: &Path,
marker: &ResumeMarker,
) -> Result<()> {
let content = format!(
"{}\n",
serde_json::to_string(marker).map_err(|error| {
TranslateError::config(format!(
"resume marker serialization: {error}"
))
})?
);
std::fs::write(directory.join(RESUME_FILE), content).map_err(|error| {
TranslateError::io("unable to write resume marker", error)
})
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Checkpoint {
pub version: u32,
pub task_id: String,
pub research_completed: bool,
pub research_summary: String,
pub completed_language_batches: Vec<String>,
pub class_exclusions: Vec<String>,
pub class_replacement_count: usize,
pub class_changed_files: Vec<String>,
#[serde(default)]
pub work_graph: Option<serde_json::Value>,
#[serde(default)]
pub event_cursor: u64,
#[serde(default)]
pub transport_handoffs: u32,
#[serde(default)]
pub last_verified_weight: f64,
#[serde(default)]
pub stage: String,
#[serde(default)]
pub harness: Option<serde_json::Value>,
}
impl Checkpoint {
pub fn fresh(task_id: String) -> Self {
Self {
version: 5,
task_id,
stage: "autonomous".to_string(),
..Default::default()
}
}
}
pub fn read_checkpoint(directory: &Path) -> Option<Checkpoint> {
let content =
std::fs::read_to_string(directory.join(CHECKPOINT_FILE)).ok()?;
serde_json::from_str::<Checkpoint>(&content)
.ok()
.filter(|checkpoint| (1..=5).contains(&checkpoint.version))
}
pub fn save_checkpoint(
directory: &Path,
checkpoint: &Checkpoint,
) -> Result<()> {
let content = format!(
"{}\n",
serde_json::to_string(checkpoint).map_err(|error| {
TranslateError::config(format!("checkpoint serialization: {error}"))
})?
);
let path = directory.join(CHECKPOINT_FILE);
let temporary = path.with_extension("json.tmp");
std::fs::write(&temporary, content).map_err(|error| {
TranslateError::io("unable to write checkpoint", error)
})?;
std::fs::rename(&temporary, &path).map_err(|error| {
TranslateError::io("unable to move checkpoint into place", error)
})
}
/// 把当前工作图/账本/记忆写进检查点(同步,供自主循环每轮落盘)。
pub fn update_checkpoint_from_state(
checkpoint: &mut Checkpoint,
work_graph: &crate::mod_translation::ledger::WorkGraph,
class_ledger: &crate::mod_translation::ledger::ClassDecisionLedger,
memory: &crate::mod_translation::ledger::TaskMemory,
stage: &str,
) {
checkpoint.stage = stage.to_string();
checkpoint.class_exclusions = class_ledger.snapshot_exclusions();
checkpoint.class_replacement_count = class_ledger.replacement_count;
checkpoint.class_changed_files = class_ledger.replaced_files.clone();
checkpoint.work_graph = Some(
serde_json::to_value(work_graph.snapshot())
.unwrap_or(serde_json::Value::Null),
);
checkpoint.harness =
Some(serde_json::to_value(memory).unwrap_or(serde_json::Value::Null));
}
/// 恢复时把工作区里已有的翻译成果合并回检查点语义。
pub fn prepare_resumed_inspection(
workspace: &Path,
inspection: &mut JarInspection,
) -> Result<()> {
reload_structured_templates(workspace, &mut inspection.language_sources);
// 从磁盘读回已写出的目标文件,合并进 existing_target避免恢复后重复翻译。
for source in &mut inspection.language_sources {
let target = workspace.join(&source.target_path);
if !target.is_file() {
continue;
}
let content = std::fs::read_to_string(&target).unwrap_or_default();
let current = crate::mod_translation::writeback::read_language_target(
&content, source,
);
source.existing_target = current;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mod_translation::analyze::{LanguageKind, LanguageSource};
use crate::mod_translation::ledger::{
ClassDecisionLedger, WorkGraph, WorkKind,
};
#[test]
fn resume_marker_matches_by_hash_and_identity() {
let dir = tempfile::tempdir().unwrap();
let job = dir.path().join("job-test");
std::fs::create_dir_all(&job).unwrap();
let inspection = JarInspection {
input_path: PathBuf::from("C:/mods/demo.jar"),
original_filename: "demo.jar".to_string(),
loader: crate::mod_translation::analyze::Loader::Fabric,
mod_ids: vec!["demo".to_string()],
project_names: vec!["Demo".to_string()],
mod_version: Some("1.0.0".to_string()),
minecraft_version_range: None,
contained_mods: Vec::new(),
signed: false,
total_entries: 1,
uncompressed_bytes: 10,
language_sources: Vec::new(),
class_candidates: Vec::new(),
resource_coverage: Vec::new(),
warnings: Vec::new(),
};
let marker = ResumeMarker {
version: 2,
input_hash: "abc123".to_string(),
resume_identity: "mod-translator-v2".to_string(),
created_at: "now".to_string(),
inspection,
};
write_resume_marker(&job, &marker).unwrap();
let found =
find_resumable_workspace(dir.path(), "abc123", "mod-translator-v2")
.unwrap()
.expect("matching workspace should be found");
assert_eq!(found, job);
assert!(
find_resumable_workspace(dir.path(), "other", "mod-translator-v2")
.unwrap()
.is_none()
);
}
#[test]
fn newest_matching_workspace_is_selected() {
let dir = tempfile::tempdir().unwrap();
for (name, created_at) in [
("job-old", "2026-08-07T01:00:00Z"),
("job-new", "2026-08-07T02:00:00Z"),
] {
let job = dir.path().join(name);
std::fs::create_dir_all(&job).unwrap();
write_resume_marker(
&job,
&ResumeMarker {
version: 3,
input_hash: "abc123".to_string(),
resume_identity: "mod-translator-v3".to_string(),
created_at: created_at.to_string(),
inspection: JarInspection {
input_path: PathBuf::from("C:/mods/demo.jar"),
original_filename: "demo.jar".to_string(),
loader: crate::mod_translation::analyze::Loader::Fabric,
mod_ids: vec!["demo".to_string()],
project_names: Vec::new(),
mod_version: None,
minecraft_version_range: None,
contained_mods: Vec::new(),
signed: false,
total_entries: 0,
uncompressed_bytes: 0,
language_sources: Vec::new(),
class_candidates: Vec::new(),
resource_coverage: Vec::new(),
warnings: Vec::new(),
},
},
)
.unwrap();
}
assert_eq!(
find_resumable_workspace(dir.path(), "abc123", "mod-translator-v3")
.unwrap(),
Some(dir.path().join("job-new"))
);
}
#[test]
fn checkpoint_round_trips_and_restores_state() {
let dir = tempfile::tempdir().unwrap();
let mut checkpoint = Checkpoint::fresh("TASK-test".to_string());
let mut graph = WorkGraph::new("TASK-test".to_string());
graph.upsert(
WorkKind::Language,
"goal",
"assets/x/lang/zh_cn.json#block.x",
2.0,
);
graph.reconcile(
&graph
.by_source(
WorkKind::Language,
"assets/x/lang/zh_cn.json#block.x",
)
.unwrap()
.id,
true,
"done",
);
let ledger = ClassDecisionLedger::default();
let memory = crate::mod_translation::ledger::TaskMemory::default();
update_checkpoint_from_state(
&mut checkpoint,
&graph,
&ledger,
&memory,
"test",
);
save_checkpoint(dir.path(), &checkpoint).unwrap();
let restored =
read_checkpoint(dir.path()).expect("checkpoint should restore");
assert_eq!(restored.version, 5);
assert_eq!(restored.stage, "test");
let restored_graph = serde_json::from_value::<
crate::mod_translation::ledger::WorkGraphSnapshot,
>(restored.work_graph.unwrap())
.unwrap();
let graph = WorkGraph::from_snapshot(restored_graph);
let item = graph
.by_source(WorkKind::Language, "assets/x/lang/zh_cn.json#block.x")
.unwrap();
assert_eq!(
item.status,
crate::mod_translation::ledger::WorkStatus::Verified
);
}
#[test]
fn resumed_inspection_merges_existing_target_from_disk() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("assets/x/lang")).unwrap();
std::fs::write(
dir.path().join("assets/x/lang/en_us.json"),
"{\"a\":\"Iron\",\"b\":\"Gold\"}",
)
.unwrap();
// 恢复时磁盘上已存在 zh_cn.json上次中断前只写好了 a
std::fs::write(
dir.path().join("assets/x/lang/zh_cn.json"),
"{\"a\":\"\"}",
)
.unwrap();
let mut inspection = JarInspection {
input_path: PathBuf::from("C:/mods/demo.jar"),
original_filename: "demo.jar".to_string(),
loader: crate::mod_translation::analyze::Loader::Fabric,
mod_ids: vec!["demo".to_string()],
project_names: vec![],
mod_version: None,
minecraft_version_range: None,
contained_mods: Vec::new(),
signed: false,
total_entries: 1,
uncompressed_bytes: 1,
language_sources: vec![LanguageSource {
kind: LanguageKind::Json,
namespace: "x".to_string(),
source_path: "assets/x/lang/en_us.json".to_string(),
target_path: "assets/x/lang/zh_cn.json".to_string(),
entries: {
let mut map = std::collections::BTreeMap::new();
map.insert("a".to_string(), "Iron".to_string());
map.insert("b".to_string(), "Gold".to_string());
map
},
existing_target: {
let mut map = std::collections::BTreeMap::new();
map.insert("a".to_string(), "".to_string());
map
},
structured_template: None,
localized_layout: None,
}],
class_candidates: Vec::new(),
resource_coverage: Vec::new(),
warnings: Vec::new(),
};
prepare_resumed_inspection(dir.path(), &mut inspection).unwrap();
// b 没有译文 → 仍需翻译a 已有中文 → 不再要求
let required = inspection.language_sources[0].required_keys();
assert_eq!(required, vec!["b"]);
assert_eq!(
inspection.language_sources[0]
.existing_target
.get("a")
.map(String::as_str),
Some("")
);
assert!(inspection.language_sources[0].structured_template.is_none());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,898 @@
use std::collections::BTreeMap;
use crate::mod_translation::error::{
Result, TranslateError, TranslateErrorCode,
};
/// 自由文本按行对齐用的内部键:/lines/000000。
pub fn localized_line_key(index: usize) -> String {
format!("/lines/{index:06}")
}
pub fn localized_line_index(key: &str) -> Option<usize> {
let rest = key.strip_prefix("/lines/")?;
rest.parse::<usize>()
.ok()
.filter(|index| *index < 10_000_000)
}
/// 自由文本文件的字节级布局快照。
#[derive(Debug, Clone)]
pub struct FreeTextSnapshot {
pub bom: bool,
pub eol: String,
pub trailing_newline: bool,
pub lines: Vec<String>,
}
impl FreeTextSnapshot {
pub fn parse(content: &str) -> Self {
let body = content.strip_prefix('\u{feff}').unwrap_or(content);
let bom = body.len() != content.len();
let eol = if body.contains("\r\n") { "\r\n" } else { "\n" };
let trailing_newline = body.ends_with("\r\n") || body.ends_with('\n');
let mut lines: Vec<String> = body
.split("\r\n")
.flat_map(|part| part.split('\n'))
.map(str::to_string)
.collect();
if trailing_newline {
lines.pop();
}
Self {
bom,
eol: eol.to_string(),
trailing_newline,
lines,
}
}
pub fn render(&self) -> String {
let mut out = String::new();
if self.bom {
out.push('\u{feff}');
}
out.push_str(&self.lines.join(&self.eol));
if self.trailing_newline {
out.push_str(&self.eol);
}
out
}
}
/// 保序 JSON 值结构化资源写回时用serde_json 默认 Map 是排序的,会打乱键序)。
#[derive(Debug, Clone)]
pub enum JsonValue {
Object(Vec<(String, JsonValue)>),
Array(Vec<JsonValue>),
String(String),
Number(f64),
Bool(bool),
Null,
}
impl JsonValue {
pub fn parse(content: &str) -> Result<Self> {
let mut parser = JsonParser {
bytes: content.as_bytes(),
position: 0,
};
let value = parser.parse_value()?;
parser.skip_whitespace();
if parser.position != content.len() {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON document has trailing content",
));
}
Ok(value)
}
/// 按 JSON pointer 定位并写入,路径不存在就报错。
pub fn set_pointer(
&mut self,
pointer: &str,
translation: String,
) -> Result<()> {
if !pointer.starts_with('/') {
return Err(TranslateError::new(
TranslateErrorCode::Config,
format!("structured pointer must start with '/': {pointer}"),
));
}
if pointer == "/" {
if let JsonValue::String(value) = self {
*value = translation;
return Ok(());
}
return Err(TranslateError::new(
TranslateErrorCode::Config,
"structured pointer targets a non-string root",
));
}
let segments = pointer
.split('/')
.skip(1)
.map(decode_json_pointer_segment)
.collect::<Vec<_>>();
let mut cursor = self;
for (index, segment) in segments.iter().enumerate() {
let last = index == segments.len() - 1;
let child = match cursor {
JsonValue::Object(entries) => {
if let Some(position) =
entries.iter().position(|(key, _)| key == segment)
{
if last {
entries[position].1 =
JsonValue::String(translation.clone());
return Ok(());
}
&mut entries[position].1
} else {
return Err(TranslateError::new(
TranslateErrorCode::Config,
format!(
"structured pointer segment not found: {segment}"
),
));
}
}
JsonValue::Array(items) => {
let position = segment.parse::<usize>().map_err(|_| {
TranslateError::new(
TranslateErrorCode::Config,
format!("structured pointer segment is not an index: {segment}"),
)
})?;
if position >= items.len() {
return Err(TranslateError::new(
TranslateErrorCode::Config,
format!(
"structured pointer index out of range: {segment}"
),
));
}
if last {
items[position] =
JsonValue::String(translation.clone());
return Ok(());
}
&mut items[position]
}
_ => {
return Err(TranslateError::new(
TranslateErrorCode::Config,
format!(
"structured pointer traverses a scalar: {segment}"
),
));
}
};
cursor = child;
}
Err(TranslateError::new(
TranslateErrorCode::Config,
format!(
"structured pointer did not resolve to a string: {pointer}"
),
))
}
/// 两空格缩进序列化。
pub fn render_pretty(&self) -> String {
let mut out = String::new();
self.write_pretty(&mut out, 0);
out.push('\n');
out
}
fn write_pretty(&self, out: &mut String, indent: usize) {
match self {
JsonValue::Object(entries) => {
if entries.is_empty() {
out.push_str("{}");
return;
}
out.push_str("{\n");
for (index, (key, value)) in entries.iter().enumerate() {
out.push_str(&" ".repeat(indent + 1));
out.push_str(&json_escape(key));
out.push_str(": ");
value.write_pretty(out, indent + 1);
if index + 1 < entries.len() {
out.push(',');
}
out.push('\n');
}
out.push_str(&" ".repeat(indent));
out.push('}');
}
JsonValue::Array(items) => {
if items.is_empty() {
out.push_str("[]");
return;
}
out.push_str("[\n");
for (index, value) in items.iter().enumerate() {
out.push_str(&" ".repeat(indent + 1));
value.write_pretty(out, indent + 1);
if index + 1 < items.len() {
out.push(',');
}
out.push('\n');
}
out.push_str(&" ".repeat(indent));
out.push(']');
}
JsonValue::String(value) => out.push_str(&json_escape(value)),
JsonValue::Number(value) => {
if value.fract() == 0.0 && value.abs() < 9_007_199_254_740_992.0
{
out.push_str(&format!("{}", *value as i64));
} else {
out.push_str(&value.to_string());
}
}
JsonValue::Bool(value) => {
out.push_str(if *value { "true" } else { "false" })
}
JsonValue::Null => out.push_str("null"),
}
}
}
fn decode_json_pointer_segment(value: &str) -> String {
value.replace("~1", "/").replace("~0", "~")
}
fn json_escape(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for character in value.chars() {
match character {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
character if (character as u32) < 0x20 => {
out.push_str(&format!("\\u{:04x}", character as u32));
}
character => out.push(character),
}
}
out.push('"');
out
}
struct JsonParser<'a> {
bytes: &'a [u8],
position: usize,
}
impl<'a> JsonParser<'a> {
fn skip_whitespace(&mut self) {
while self.position < self.bytes.len()
&& matches!(self.bytes[self.position], b' ' | b'\t' | b'\n' | b'\r')
{
self.position += 1;
}
}
fn peek(&self) -> Option<u8> {
self.bytes.get(self.position).copied()
}
fn parse_value(&mut self) -> Result<JsonValue> {
self.skip_whitespace();
match self.peek() {
Some(b'{') => self.parse_object(),
Some(b'[') => self.parse_array(),
Some(b'"') => Ok(JsonValue::String(self.parse_string()?)),
Some(b't') => {
self.expect_literal("true")?;
Ok(JsonValue::Bool(true))
}
Some(b'f') => {
self.expect_literal("false")?;
Ok(JsonValue::Bool(false))
}
Some(b'n') => {
self.expect_literal("null")?;
Ok(JsonValue::Null)
}
Some(_) => self.parse_number(),
None => Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON document ended unexpectedly",
)),
}
}
fn parse_object(&mut self) -> Result<JsonValue> {
self.position += 1; // {
let mut entries = Vec::new();
self.skip_whitespace();
if self.peek() == Some(b'}') {
self.position += 1;
return Ok(JsonValue::Object(entries));
}
loop {
self.skip_whitespace();
if self.peek() != Some(b'"') {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON object key must be a string",
));
}
let key = self.parse_string()?;
self.skip_whitespace();
if self.peek() != Some(b':') {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON object key is missing a colon",
));
}
self.position += 1;
let value = self.parse_value()?;
entries.push((key, value));
self.skip_whitespace();
match self.peek() {
Some(b',') => {
self.position += 1;
}
Some(b'}') => {
self.position += 1;
return Ok(JsonValue::Object(entries));
}
_ => {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON object is missing a closing brace",
));
}
}
}
}
fn parse_array(&mut self) -> Result<JsonValue> {
self.position += 1; // [
let mut items = Vec::new();
self.skip_whitespace();
if self.peek() == Some(b']') {
self.position += 1;
return Ok(JsonValue::Array(items));
}
loop {
items.push(self.parse_value()?);
self.skip_whitespace();
match self.peek() {
Some(b',') => {
self.position += 1;
}
Some(b']') => {
self.position += 1;
return Ok(JsonValue::Array(items));
}
_ => {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON array is missing a closing bracket",
));
}
}
}
}
fn parse_string(&mut self) -> Result<String> {
self.skip_whitespace();
if self.peek() != Some(b'"') {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON string is missing an opening quote",
));
}
self.position += 1;
let mut out = String::new();
loop {
let Some(byte) = self.bytes.get(self.position).copied() else {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON string is unterminated",
));
};
self.position += 1;
match byte {
b'"' => return Ok(out),
b'\\' => {
let Some(escape) = self.bytes.get(self.position).copied()
else {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON string escape is unterminated",
));
};
self.position += 1;
match escape {
b'"' => out.push('"'),
b'\\' => out.push('\\'),
b'/' => out.push('/'),
b'b' => out.push('\u{0008}'),
b'f' => out.push('\u{000c}'),
b'n' => out.push('\n'),
b'r' => out.push('\r'),
b't' => out.push('\t'),
b'u' => {
if self.position + 4 > self.bytes.len() {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON unicode escape is truncated",
));
}
let hex = std::str::from_utf8(
&self.bytes[self.position..self.position + 4],
)
.map_err(|_| {
TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON unicode escape is invalid",
)
})?;
let code =
u16::from_str_radix(hex, 16).map_err(|_| {
TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON unicode escape is invalid",
)
})?;
self.position += 4;
out.push(
char::from_u32(code as u32)
.unwrap_or('\u{fffd}'),
);
}
_ => {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
format!(
"JSON string contains an unknown escape: \\{}",
escape as char
),
));
}
}
}
byte if byte < 0x20 => {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON string contains a control character",
));
}
_ => {
// Preserve UTF-8 sequences byte by byte.
let length = utf8_sequence_length(byte);
if self.position + length - 1 > self.bytes.len() {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON string contains truncated UTF-8",
));
}
let slice = &self.bytes
[self.position - 1..self.position - 1 + length];
let text = std::str::from_utf8(slice).map_err(|_| {
TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON string contains invalid UTF-8",
)
})?;
out.push_str(text);
self.position += length - 1;
}
}
}
}
fn parse_number(&mut self) -> Result<JsonValue> {
let start = self.position;
while self.position < self.bytes.len()
&& matches!(
self.bytes[self.position],
b'0'..=b'9' | b'-' | b'+' | b'.' | b'e' | b'E'
)
{
self.position += 1;
}
if self.position == start {
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
"JSON contains an invalid token",
));
}
let text = std::str::from_utf8(&self.bytes[start..self.position])
.map_err(|_| {
TranslateError::new(
TranslateErrorCode::InvalidArchive,
"invalid JSON number",
)
})?;
let value = text.parse::<f64>().map_err(|_| {
TranslateError::new(
TranslateErrorCode::InvalidArchive,
"invalid JSON number",
)
})?;
Ok(JsonValue::Number(value))
}
fn expect_literal(&mut self, literal: &str) -> Result<()> {
if self.position + literal.len() > self.bytes.len()
|| &self.bytes[self.position..self.position + literal.len()]
!= literal.as_bytes()
{
return Err(TranslateError::new(
TranslateErrorCode::InvalidArchive,
format!("JSON expected `{literal}`"),
));
}
self.position += literal.len();
Ok(())
}
}
fn utf8_sequence_length(first: u8) -> usize {
if first < 0x80 {
1
} else if first >> 5 == 0b110 {
2
} else if first >> 4 == 0b1110 {
3
} else if first >> 3 == 0b11110 {
4
} else {
1
}
}
/// Reads the current target entries for a language source from raw content.
pub fn read_language_target(
content: &str,
source: &crate::mod_translation::analyze::LanguageSource,
) -> BTreeMap<String, String> {
use crate::mod_translation::analyze::LanguageKind;
match source.kind {
LanguageKind::Json if source.is_structured_json() => {
read_json_structured(content)
}
LanguageKind::Json => read_json_flat(content),
LanguageKind::KeyValue => read_key_value(content),
LanguageKind::FreeText => {
if let Some(layout) = &source.localized_layout {
let snapshot = FreeTextSnapshot::parse(content);
read_localized_target_entries(&snapshot, layout)
} else {
let mut map = BTreeMap::new();
if content.trim().is_empty() {
map
} else {
map.insert("/".to_string(), content.to_string());
map
}
}
}
}
}
fn read_json_structured(content: &str) -> BTreeMap<String, String> {
let mut entries = BTreeMap::new();
if let Ok(root) = JsonValue::parse(content) {
flatten_json_strings(&root, "", &mut entries);
}
entries
}
fn flatten_json_strings(
value: &JsonValue,
pointer: &str,
out: &mut BTreeMap<String, String>,
) {
match value {
JsonValue::String(text) => {
out.insert(
if pointer.is_empty() { "/" } else { pointer }.to_string(),
text.clone(),
);
}
JsonValue::Object(entries) => {
for (key, value) in entries {
let escaped = key.replace('~', "~0").replace('/', "~1");
flatten_json_strings(
value,
&format!("{pointer}/{escaped}"),
out,
);
}
}
JsonValue::Array(items) => {
for (index, value) in items.iter().enumerate() {
flatten_json_strings(value, &format!("{pointer}/{index}"), out);
}
}
JsonValue::Number(_) | JsonValue::Bool(_) | JsonValue::Null => {}
}
}
pub fn read_json_flat(content: &str) -> BTreeMap<String, String> {
match JsonValue::parse(content) {
Ok(JsonValue::Object(entries)) => entries
.into_iter()
.filter_map(|(key, value)| match value {
JsonValue::String(text) => Some((key, text)),
_ => None,
})
.collect(),
_ => BTreeMap::new(),
}
}
pub fn read_key_value(content: &str) -> BTreeMap<String, String> {
let mut result = BTreeMap::new();
for raw_line in content.split("\r\n").flat_map(|part| part.split('\n')) {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
continue;
}
let separator = find_unquoted_separator(line);
let Some(separator) = separator else { continue };
if separator < 1 {
continue;
}
let key = line[..separator].trim().to_string();
let value = line[separator + 1..].trim().to_string();
if !key.is_empty() {
result.insert(key, value);
}
}
result
}
/// Finds the first `=` or `:` that is not escaped by a backslash.
fn find_unquoted_separator(line: &str) -> Option<usize> {
let mut previous_escape = false;
for (index, character) in line.char_indices() {
if character == '\\' {
previous_escape = !previous_escape;
continue;
}
if (character == '=' || character == ':') && !previous_escape {
return Some(index);
}
previous_escape = false;
}
None
}
/// Serialises language target content for a source .
pub fn serialize_language_target(
source: &crate::mod_translation::analyze::LanguageSource,
entries: &BTreeMap<String, String>,
) -> Result<String> {
use crate::mod_translation::analyze::LanguageKind;
match source.kind {
LanguageKind::FreeText => {
Ok(serialize_localized_target(entries, source))
}
LanguageKind::Json if source.is_structured_json() => {
let template =
source.structured_template.as_deref().ok_or_else(|| {
TranslateError::new(
TranslateErrorCode::Config,
format!(
"structured template is unavailable for {}",
source.source_path
),
)
})?;
let mut root = JsonValue::parse(template)?;
for (pointer, translation) in entries {
root.set_pointer(pointer, translation.clone())?;
}
Ok(root.render_pretty())
}
LanguageKind::Json => Ok(serialize_json_flat(entries)),
LanguageKind::KeyValue => {
let mut out = String::new();
for (key, value) in entries {
out.push_str(key);
out.push('=');
out.push_str(value);
out.push('\n');
}
Ok(out)
}
}
}
pub fn serialize_json_flat(entries: &BTreeMap<String, String>) -> String {
let mut out = String::from("{\n");
let entries = entries.iter().collect::<Vec<_>>();
for (index, (key, value)) in entries.iter().enumerate() {
out.push_str(" ");
out.push_str(&json_escape(key));
out.push_str(": ");
out.push_str(&json_escape(value));
if index + 1 < entries.len() {
out.push(',');
}
out.push('\n');
}
out.push_str("}\n");
out
}
/// Merge ordering: existing target keys first, then source keys, then the
/// current batch, preserving already-written values.
pub fn ordered_language_target(
source: &crate::mod_translation::analyze::LanguageSource,
current: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
if source.kind == crate::mod_translation::analyze::LanguageKind::FreeText {
return current.clone();
}
let mut keys: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
for key in source
.existing_target
.keys()
.chain(source.entries.keys())
.chain(current.keys())
{
if seen.insert(key.clone()) {
keys.push(key.clone());
}
}
keys.retain(|key| current.contains_key(key));
keys.into_iter()
.filter_map(|key| current.get(&key).cloned().map(|value| (key, value)))
.collect()
}
fn read_localized_target_entries(
snapshot: &FreeTextSnapshot,
layout: &crate::mod_translation::analyze::LocalizedLayout,
) -> BTreeMap<String, String> {
let mut result = BTreeMap::new();
for (index, line) in layout.source_lines.iter().enumerate() {
if !line.trim().is_empty()
&& let Some(translated) = snapshot.lines.get(index)
&& !translated.trim().is_empty()
{
result.insert(localized_line_key(index), translated.clone());
}
}
result
}
pub fn serialize_localized_target(
entries: &BTreeMap<String, String>,
source: &crate::mod_translation::analyze::LanguageSource,
) -> String {
let Some(layout) = &source.localized_layout else {
return entries.get("/").cloned().unwrap_or_default();
};
let base = if let Some(existing) = &layout.existing_target_lines {
existing.clone()
} else {
vec![String::new(); layout.source_lines.len()]
};
let mut lines = base;
let length = layout.source_lines.len().max(lines.len());
lines.resize(length, String::new());
for (key, value) in entries {
if let Some(index) = localized_line_index(key)
&& index < layout.source_lines.len()
{
lines[index] = value.clone();
}
}
let snapshot = FreeTextSnapshot {
bom: layout.bom,
eol: layout.eol.clone(),
trailing_newline: layout.trailing_newline,
lines,
};
snapshot.render()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ordered_json_round_trips_keep_key_order() {
let content =
"{\n \"z\": \"1\",\n \"a\": \"2\",\n \"m\": \"3\"\n}\n";
let root = JsonValue::parse(content).unwrap();
let rendered = root.render_pretty();
let z = rendered.find("\"z\"").unwrap();
let a = rendered.find("\"a\"").unwrap();
let m = rendered.find("\"m\"").unwrap();
assert!(z < a && a < m, "key order must be preserved: {rendered}");
}
#[test]
fn pointers_set_nested_values() {
let content = r#"{"a":{"b":[{"c":"old"}]}}"#;
let mut root = JsonValue::parse(content).unwrap();
root.set_pointer("/a/b/0/c", "new".to_string()).unwrap();
assert_eq!(root.render_pretty().trim(), "{\n \"a\": {\n \"b\": [\n {\n \"c\": \"new\"\n }\n ]\n }\n}".trim());
}
#[test]
fn flat_language_key_is_not_accepted_as_a_json_pointer() {
let mut root = JsonValue::parse(r#"{"demo.hello":"Hello"}"#).unwrap();
let error = root
.set_pointer("demo.hello", "你好".to_string())
.unwrap_err();
assert_eq!(error.code, TranslateErrorCode::Config);
assert_eq!(
root.render_pretty().trim(),
"{\n \"demo.hello\": \"Hello\"\n}"
);
}
#[test]
fn structured_language_serialization_round_trips_pointer_values() {
let source = crate::mod_translation::analyze::LanguageSource {
kind: crate::mod_translation::analyze::LanguageKind::Json,
namespace: "demo".to_string(),
source_path: "assets/demo/en_us/menu.json".to_string(),
target_path: "assets/demo/zh_cn/menu.json".to_string(),
entries: BTreeMap::from([(
"/menu/title".to_string(),
"Hello".to_string(),
)]),
existing_target: BTreeMap::new(),
structured_template: Some(
r#"{"menu":{"title":"Hello","width":10}}"#.to_string(),
),
localized_layout: None,
};
let translated =
BTreeMap::from([("/menu/title".to_string(), "你好".to_string())]);
let serialized =
serialize_language_target(&source, &translated).unwrap();
let reread = read_language_target(&serialized, &source);
assert_eq!(reread.get("/menu/title").map(String::as_str), Some("你好"));
assert!(serialized.contains("\"width\": 10"));
}
#[test]
fn key_value_parser_skips_comments_and_escaped_separators() {
let content = "a=1\n# comment\nb:two\nc\\=x=3\n! bang\n";
let map = read_key_value(content);
assert_eq!(map.get("a").map(String::as_str), Some("1"));
assert_eq!(map.get("b").map(String::as_str), Some("two"));
assert_eq!(map.get("c\\=x").map(String::as_str), Some("3"));
assert!(!map.contains_key("comment"));
}
#[test]
fn free_text_snapshot_preserves_bom_eol_and_trailing_newline() {
let content = "\u{feff}line1\r\nline2\r\n";
let snapshot = FreeTextSnapshot::parse(content);
assert!(snapshot.bom);
assert_eq!(snapshot.eol, "\r\n");
assert!(snapshot.trailing_newline);
assert_eq!(snapshot.lines, vec!["line1", "line2"]);
assert_eq!(snapshot.render(), content);
}
#[test]
fn local_line_keys_round_trip() {
assert_eq!(localized_line_key(0), "/lines/000000");
assert_eq!(localized_line_key(12), "/lines/000012");
assert_eq!(localized_line_index("/lines/000042"), Some(42));
assert_eq!(localized_line_index("/other"), None);
}
}

74
apps/app/src/portable.rs Normal file
View File

@ -0,0 +1,74 @@
use std::sync::atomic::{AtomicBool, Ordering};
const PORTABLE_DIR_NAME: &str = ".Axolotl";
static PORTABLE_MODE: AtomicBool = AtomicBool::new(false);
/// 在启动时初始化便携模式
/// 检查 `.Axolotl` 文件夹是否存在且可写
/// 如果存在且可写,将 `THESEUS_CONFIG_DIR` 环境变量设置为该路径
/// 返回 `true` 如果便携模式已启用,否则返回 `false`
///
/// 必须在 main() 开头、任何其他线程(包括 tokio runtime启动之前调用。
/// 此函数内部会调用 `std::env::set_var`,该函数在 Rust 中不是线程安全的。
pub unsafe fn init_portable_mode() -> bool {
let exe_path = match std::env::current_exe() {
Ok(p) => p,
Err(_) => return false,
};
let Some(app_dir) = exe_path.parent() else {
return false;
};
let portable_dir = app_dir.join(PORTABLE_DIR_NAME);
if !portable_dir.is_dir() {
return false;
}
if !try_write_test(&portable_dir) {
return false;
}
// SAFETY: 调用者保证此时没有其他线程访问环境变量
unsafe {
std::env::set_var("THESEUS_CONFIG_DIR", &portable_dir);
}
PORTABLE_MODE.store(true, Ordering::Relaxed);
tracing::info!(
"Portable mode enabled: THESEUS_CONFIG_DIR={}",
portable_dir.display()
);
true
}
/// 尝试在目标目录中创建并删除临时文件以验证可写性。
/// 先删除可能残留的 `.write_test`,避免已有的只读文件导致 `File::create` 失败。
fn try_write_test(dir: &std::path::Path) -> bool {
let test_file = dir.join(".write_test");
let _ = std::fs::remove_file(&test_file);
match std::fs::File::create(&test_file) {
Ok(_) => {
let _ = std::fs::remove_file(&test_file);
true
}
Err(e) => {
tracing::warn!(
"Portable directory {} exists but is not writable: {}",
dir.display(),
e
);
false
}
}
}
/// Tauri 命令:检查应用程序是否运行在便携模式下
#[tauri::command]
pub fn is_portable_mode() -> bool {
PORTABLE_MODE.load(Ordering::Relaxed)
}

View File

@ -0,0 +1,826 @@
#include "cubiomes_bridge.h"
#include "biomenoise.h"
#include "finders.h"
#include "generator.h"
#include "noise.h"
#include "rng.h"
#include "util.h"
#include <math.h>
#include <stdlib.h>
#include <string.h>
static float axolotl_clamp(float value, float low, float high);
/* Sample coarse Overworld pixels at the centre of their block cell while
* retaining the same 3D Voronoi surface lookup used by scale=1. Without
* this, scale=16+ reads the raw noise point directly and can select a
* different vertical biome layer at tile boundaries. */
static void axolotl_gen_surface_biomes(
int *out,
const Generator *generator,
int32_t x,
int32_t z,
int32_t scale,
int32_t width,
int32_t height,
int32_t elevation
) {
const int32_t y = elevation;
for (int row = 0; row < height; row++) {
for (int column = 0; column < width; column++) {
const int block_x = x * scale + column * scale + scale / 2;
const int block_z = z * scale + row * scale + scale / 2;
int x4, y4, z4;
voronoiAccess3D(generator->sha, block_x, y, block_z, &x4, &y4, &z4);
out[(size_t)row * width + column] =
sampleBiomeNoise(&generator->bn, NULL, x4, y4, z4, NULL, 0);
}
}
}
static int axolotl_map_approx_height(
float *heights,
const Generator *generator,
const SurfaceNoise *surface_noise,
int x,
int z,
int width,
int height,
int sparse
) {
if (generator->dim != DIM_OVERWORLD || generator->mc < MC_1_18 ||
(generator->bn.nptype != -1 && generator->bn.nptype != NP_DEPTH)) {
return mapApproxHeight(
heights, NULL, generator, surface_noise, x, z, width, height);
}
const uint32_t flags = SAMPLE_NO_BIOME | (sparse ? SAMPLE_NO_SHIFT : 0);
for (int row = 0; row < height; row++) {
for (int column = 0; column < width; column++) {
int64_t noise_parameters[6];
sampleBiomeNoise(
&generator->bn, noise_parameters, x + column, 0, z + row, NULL, flags);
heights[(size_t)row * width + column] =
(float)noise_parameters[NP_DEPTH] / 76.0f;
}
}
return 0;
}
enum {
AXOLOTL_FEATURE_VILLAGE = 1 << 0,
AXOLOTL_FEATURE_OUTPOST = 1 << 1,
AXOLOTL_FEATURE_SHIPWRECK = 1 << 2,
AXOLOTL_FEATURE_MONUMENT = 1 << 3,
AXOLOTL_FEATURE_MANSION = 1 << 4,
AXOLOTL_FEATURE_ANCIENT_CITY = 1 << 5,
AXOLOTL_FEATURE_TRAIL_RUINS = 1 << 6,
AXOLOTL_FEATURE_TRIAL_CHAMBERS = 1 << 7,
AXOLOTL_FEATURE_RUINED_PORTAL = 1 << 8,
AXOLOTL_FEATURE_STRONGHOLD = 1 << 9,
AXOLOTL_FEATURE_SLIME_CHUNK = 1 << 10,
AXOLOTL_FEATURE_DESERT_PYRAMID = 1 << 11,
AXOLOTL_FEATURE_JUNGLE_TEMPLE = 1 << 12,
AXOLOTL_FEATURE_SWAMP_HUT = 1 << 13,
AXOLOTL_FEATURE_IGLOO = 1 << 14,
AXOLOTL_FEATURE_OCEAN_RUIN = 1 << 15,
AXOLOTL_FEATURE_BURIED_TREASURE = 1 << 16,
AXOLOTL_FEATURE_MINESHAFT = 1 << 17,
AXOLOTL_FEATURE_DESERT_WELL = 1 << 18,
AXOLOTL_FEATURE_GEODE = 1 << 19,
AXOLOTL_FEATURE_FORTRESS = 1 << 20,
AXOLOTL_FEATURE_BASTION = 1 << 21,
AXOLOTL_FEATURE_END_CITY = 1 << 22,
AXOLOTL_FEATURE_END_GATEWAY = 1 << 23,
};
typedef struct AxolotlStructureDefinition {
int type;
uint32_t flag;
} AxolotlStructureDefinition;
static const AxolotlStructureDefinition AXOLOTL_STRUCTURES[] = {
{Village, AXOLOTL_FEATURE_VILLAGE},
{Outpost, AXOLOTL_FEATURE_OUTPOST},
{Shipwreck, AXOLOTL_FEATURE_SHIPWRECK},
{Monument, AXOLOTL_FEATURE_MONUMENT},
{Mansion, AXOLOTL_FEATURE_MANSION},
{Ancient_City, AXOLOTL_FEATURE_ANCIENT_CITY},
{Trail_Ruins, AXOLOTL_FEATURE_TRAIL_RUINS},
{Trial_Chambers, AXOLOTL_FEATURE_TRIAL_CHAMBERS},
{Ruined_Portal, AXOLOTL_FEATURE_RUINED_PORTAL},
{Ruined_Portal_N, AXOLOTL_FEATURE_RUINED_PORTAL},
{Desert_Pyramid, AXOLOTL_FEATURE_DESERT_PYRAMID},
{Jungle_Temple, AXOLOTL_FEATURE_JUNGLE_TEMPLE},
{Swamp_Hut, AXOLOTL_FEATURE_SWAMP_HUT},
{Igloo, AXOLOTL_FEATURE_IGLOO},
{Ocean_Ruin, AXOLOTL_FEATURE_OCEAN_RUIN},
{Treasure, AXOLOTL_FEATURE_BURIED_TREASURE},
{Mineshaft, AXOLOTL_FEATURE_MINESHAFT},
{Desert_Well, AXOLOTL_FEATURE_DESERT_WELL},
{Geode, AXOLOTL_FEATURE_GEODE},
{Fortress, AXOLOTL_FEATURE_FORTRESS},
{Bastion, AXOLOTL_FEATURE_BASTION},
{End_City, AXOLOTL_FEATURE_END_CITY},
{End_Gateway, AXOLOTL_FEATURE_END_GATEWAY},
};
int32_t axolotl_seed_map_java_version(int32_t version) {
switch (version) {
/*
* Every release since the 1.21 winter drop shares its Overworld,
* Nether, and End generation, so 26.x and the late 1.21.x patches all
* map to the newest bundled engine.
*/
case 260200:
case 260102:
case 260100:
case 12109:
case 12106:
case 12105:
case 12104: return MC_NEWEST;
case 12103: return MC_1_21_3;
case 12101: return MC_1_21_1;
case 12000: return MC_1_20;
case 11904: return MC_1_19;
case 11902: return MC_1_19_2;
case 11800: return MC_1_18;
case 11700: return MC_1_17;
case 11600: return MC_1_16;
case 11500: return MC_1_15;
case 11400: return MC_1_14;
case 11300: return MC_1_13;
case 11200: return MC_1_12;
case 11100: return MC_1_11;
case 11000: return MC_1_10;
case 10900: return MC_1_9;
case 10800: return MC_1_8;
case 10700: return MC_1_7;
case 10600: return MC_1_6;
case 10500: return MC_1_5;
case 10400: return MC_1_4;
case 10300: return MC_1_3;
case 10200: return MC_1_2;
case 10100: return MC_1_1;
case 10000: return MC_1_0;
default: return MC_UNDEF;
}
}
static const unsigned char AXOLOTL_END_VOID[3] = {0x0D, 0x0D, 0x16};
static int axolotl_smooth_height_grid(
float *grid,
int width,
int height,
int passes
) {
float *scratch = malloc((size_t)width * (size_t)height * sizeof(float));
if (!scratch) return -1;
for (int pass = 0; pass < passes; pass++) {
for (int row = 0; row < height; row++) {
const int offset = row * width;
for (int column = 0; column < width; column++) {
const float west = grid[offset + (column > 0 ? column - 1 : 0)];
const float east = grid[offset + (column < width - 1 ? column + 1 : width - 1)];
scratch[offset + column] =
(west + 2.0f * grid[offset + column] + east) * 0.25f;
}
}
for (int row = 0; row < height; row++) {
const int north = (row > 0 ? row - 1 : 0) * width;
const int offset = row * width;
const int south = (row < height - 1 ? row + 1 : height - 1) * width;
for (int column = 0; column < width; column++) {
grid[offset + column] =
(scratch[north + column] + 2.0f * scratch[offset + column] +
scratch[south + column]) *
0.25f;
}
}
}
free(scratch);
return 0;
}
/*
* The End uses the game's density noise to distinguish real island surface
* from void. Like the reference map, the height grid is smoothed before slope
* lighting so surface noise does not make the broad, flat islands look hilly.
*/
static void axolotl_render_end(
uint8_t *rgb,
const Generator *generator,
const SurfaceNoise *surface_noise,
int32_t x,
int32_t z,
int32_t scale,
int32_t width,
int32_t height,
int32_t contours
) {
if (scale > 4) return;
const int passes = scale == 1 ? 3 : 1;
const int padding = passes + 1;
const int grid_width = width + 2 * padding;
const int grid_height = height + 2 * padding;
float *heights = malloc((size_t)grid_width * (size_t)grid_height * sizeof(float));
if (!heights) return;
uint8_t *land = malloc((size_t)width * (size_t)height);
if (!land) {
free(heights);
return;
}
if (mapEndSurfaceHeight(
heights, &generator->en, surface_noise,
x - padding, z - padding, grid_width, grid_height, scale, 0) != 0) {
free(land);
free(heights);
return;
}
for (int row = 0; row < height; row++) {
for (int column = 0; column < width; column++) {
land[(size_t)row * width + column] =
heights[(row + padding) * grid_width + column + padding] > 4.0f;
}
}
if (axolotl_smooth_height_grid(heights, grid_width, grid_height, passes) != 0) {
free(land);
free(heights);
return;
}
for (int row = 0; row < height; row++) {
for (int column = 0; column < width; column++) {
const size_t index = (size_t)row * (size_t)width + (size_t)column;
uint8_t *pixel = rgb + index * 3;
if (!land[index]) {
memcpy(pixel, AXOLOTL_END_VOID, 3);
continue;
}
const int grid_row = row + padding;
const int grid_column = column + padding;
const int surface = (int)floorf(heights[grid_row * grid_width + grid_column]);
const int west = (int)floorf(heights[grid_row * grid_width + grid_column - 1]);
const int north = (int)floorf(heights[(grid_row - 1) * grid_width + grid_column]);
const int northwest =
(int)floorf(heights[(grid_row - 1) * grid_width + grid_column - 1]);
float factor = 1.0f;
if (contours && (surface / 16 != west / 16 || surface / 16 != north / 16)) {
factor = 0.15f;
} else {
const int slope = 3 * surface - west - north - northwest;
if (slope != 0) {
const float delta = axolotl_clamp((float)slope / 16.0f, -0.3f, 0.3f);
factor = 1.0f + delta + (delta > 0.0f ? 0.1f : -0.1f);
if (factor < 0.72f) factor = 0.72f;
}
}
for (int channel = 0; channel < 3; channel++) {
pixel[channel] =
(uint8_t)axolotl_clamp((float)pixel[channel] * factor, 0.0f, 255.0f);
}
}
}
free(land);
free(heights);
}
static int axolotl_block_height(
const float *grid,
int grid_width,
int grid_height,
int column,
int row
) {
if (column < 0) column = 0;
if (row < 0) row = 0;
if (column > grid_width - 1) column = grid_width - 1;
if (row > grid_height - 1) row = grid_height - 1;
return (int)floorf(grid[row * grid_width + column]);
}
static float axolotl_clamp(float value, float low, float high) {
if (value < low) return low;
if (value > high) return high;
return value;
}
/*
* Minecraft maps compare each cell with its northwestern neighbours and use
* discrete brightness levels for the slope. The reference map applies the
* same rule to its cubiomes height grid, including 16-block contour bands.
*/
static void axolotl_shade_height_map(
uint8_t *rgb,
int32_t width,
int32_t height,
int32_t contours,
const float *grid,
int grid_width,
int grid_height,
int grid_origin_column,
int grid_origin_row,
int numerator,
int denominator
) {
for (int row = 0; row < height; row++) {
const int cell_row =
grid_origin_row + (int)(((int64_t)row * numerator) / denominator);
for (int column = 0; column < width; column++) {
const int cell_column =
grid_origin_column + (int)(((int64_t)column * numerator) / denominator);
const size_t index = (size_t)row * (size_t)width + (size_t)column;
const int here =
axolotl_block_height(grid, grid_width, grid_height, cell_column, cell_row);
const int west =
axolotl_block_height(grid, grid_width, grid_height, cell_column - 1, cell_row);
const int north =
axolotl_block_height(grid, grid_width, grid_height, cell_column, cell_row - 1);
const int northwest =
axolotl_block_height(grid, grid_width, grid_height, cell_column - 1, cell_row - 1);
float factor = 1.0f;
if (contours) {
const int band = here / 16;
if (band != west / 16 || band != north / 16) factor = 0.15f;
}
if (factor == 1.0f) {
const int slope = 3 * here - west - north - northwest;
if (slope != 0) {
const float delta = axolotl_clamp((float)slope / 16.0f, -0.3f, 0.3f);
factor = 1.0f + delta + (delta > 0.0f ? 0.1f : -0.1f);
if (factor < 0.72f) factor = 0.72f;
}
}
uint8_t *pixel = rgb + index * 3;
const int strongest = pixel[0] > pixel[1]
? (pixel[0] > pixel[2] ? pixel[0] : pixel[2])
: (pixel[1] > pixel[2] ? pixel[1] : pixel[2]);
if (factor < 1.0f && strongest > 25 && strongest - strongest * factor < 25)
factor = (strongest - 25.0f) / strongest;
for (int channel = 0; channel < 3; channel++) {
const float shaded = (float)pixel[channel] * factor;
pixel[channel] = (uint8_t)axolotl_clamp(shaded, 0.0f, 255.0f);
}
}
}
}
/*
* Far-zoom tiles cover too many blocks for the contiguous 1:4 height map, so
* relief comes from a sparse grid sampled point by point at each cell
* center. The grid stays coarse enough to keep far tiles interactive.
*/
static int axolotl_fill_sparse_height_grid(
const Generator *generator,
const SurfaceNoise *surface_noise,
int64_t min_block_x,
int64_t min_block_z,
int64_t span_blocks,
int grid,
float *out
) {
for (int row = 0; row < grid; row++) {
const int64_t block_z =
min_block_z + span_blocks * (2 * row + 1) / (2 * grid);
for (int column = 0; column < grid; column++) {
const int64_t block_x =
min_block_x + span_blocks * (2 * column + 1) / (2 * grid);
if (axolotl_map_approx_height(
out + (size_t)row * grid + column, generator, surface_noise,
(int)(block_x >> 2), (int)(block_z >> 2), 1, 1, 1) != 0) {
return -1;
}
}
}
return 0;
}
int axolotl_seed_map_render(
uint64_t seed,
int32_t minecraft_version,
int32_t generator_flags,
int32_t dimension,
int32_t x,
int32_t z,
int32_t scale,
int32_t width,
int32_t height,
int32_t elevation,
int32_t terrain,
int32_t contours,
const uint8_t *highlight_mask,
uint8_t *rgb,
size_t rgb_len
) {
if (!rgb || width <= 0 || height <= 0 || scale <= 0) return -1;
if (rgb_len < (size_t)width * (size_t)height * 3) return -2;
const int32_t render_elevation = elevation;
Generator generator;
setupGenerator(&generator, minecraft_version, generator_flags);
applySeed(&generator, dimension, seed);
Range range = {
.scale = scale,
.x = x,
.z = z,
.sx = width,
.sz = height,
.y = render_elevation / 4,
.sy = 1,
};
int *biomes = allocCache(&generator, range);
if (!biomes) return -3;
int result;
if (dimension == DIM_OVERWORLD && minecraft_version >= MC_1_18) {
/* Use the same surface/Voronoi path at every coarse scale. */
axolotl_gen_surface_biomes(
biomes, &generator, x, z, scale, width, height, render_elevation);
result = 0;
} else {
result = genBiomes(&generator, biomes, range);
}
if (result != 0) {
free(biomes);
return result;
}
unsigned char colors[256][3];
initBiomeColors(colors);
/* cubiomes' palette predates sulfur caves; keep this id consistent with
* the client picker instead of rendering it as black. */
colors[187][0] = 0xc8;
colors[187][1] = 0xc8;
colors[187][2] = 0x28;
biomesToImage(rgb, colors, biomes, width, height, 1, 2);
if (highlight_mask) {
for (int row = 0; row < height; row++) {
for (int column = 0; column < width; column++) {
size_t index = (size_t)row * (size_t)width + (size_t)column;
const int biome = biomes[index];
if (biome >= 0 && biome < 256 && highlight_mask[biome]) continue;
size_t pixel = index * 3;
if (dimension != DIM_OVERWORLD) {
rgb[pixel] = (uint8_t)(255 - (((255 - rgb[pixel]) * 51) >> 8));
rgb[pixel + 1] = (uint8_t)(255 - (((255 - rgb[pixel + 1]) * 51) >> 8));
rgb[pixel + 2] = (uint8_t)(255 - (((255 - rgb[pixel + 2]) * 51) >> 8));
} else {
rgb[pixel] = (uint8_t)((77 * rgb[pixel]) >> 8);
rgb[pixel + 1] = (uint8_t)((77 * rgb[pixel + 1]) >> 8);
rgb[pixel + 2] = (uint8_t)((77 * rgb[pixel + 2]) >> 8);
}
}
}
}
const int64_t min_block_x = (int64_t)x * scale;
const int64_t min_block_z = (int64_t)z * scale;
if (terrain && dimension == DIM_END) {
SurfaceNoise surface_noise;
initSurfaceNoise(&surface_noise, DIM_END, seed);
axolotl_render_end(
rgb, &generator, &surface_noise,
x, z, scale, width, height, contours);
}
if (terrain && dimension == DIM_OVERWORLD) {
SurfaceNoise surface_noise;
initSurfaceNoise(&surface_noise, DIM_OVERWORLD, seed);
if (scale <= 4) {
const int grid_x =
(int)(min_block_x >= 0 ? min_block_x / 4 : (min_block_x - 3) / 4) - 1;
const int grid_z =
(int)(min_block_z >= 0 ? min_block_z / 4 : (min_block_z - 3) / 4) - 1;
const int grid_width = (width * scale) / 4 + 3;
const int grid_height = (height * scale) / 4 + 3;
float *heights =
malloc((size_t)grid_width * (size_t)grid_height * sizeof(float));
if (heights) {
if (axolotl_map_approx_height(
heights, &generator, &surface_noise,
grid_x, grid_z, grid_width, grid_height, 0) == 0) {
axolotl_shade_height_map(
rgb, width, height, contours,
heights, grid_width, grid_height, 1, 1, scale, 4);
}
free(heights);
}
} else {
const int grid = scale == 16 ? 96 : 64;
float *heights = malloc((size_t)grid * (size_t)grid * sizeof(float));
if (heights) {
if (axolotl_fill_sparse_height_grid(
&generator, &surface_noise, min_block_x, min_block_z,
(int64_t)width * scale, grid, heights) == 0) {
axolotl_shade_height_map(
rgb, width, height, contours,
heights, grid, grid, 0, 0, grid, width);
}
free(heights);
}
}
}
free(biomes);
return 0;
}
static int add_feature(
AxolotlSeedMapFeature *out,
size_t out_len,
size_t *count,
int x,
int z,
uint32_t kind,
uint8_t approximate,
int8_t end_ship
) {
if (*count >= out_len) return 0;
out[*count].x = x;
out[*count].z = z;
out[*count].kind = kind;
out[*count].approximate = approximate;
out[*count].end_ship = end_ship;
*count += 1;
return 1;
}
static int8_t axolotl_end_city_has_ship(uint64_t seed, int block_x, int block_z) {
Piece pieces[END_CITY_PIECES_MAX];
const int count = getEndCityPieces(pieces, seed, block_x >> 4, block_z >> 4);
for (int index = 0; index < count; index++) {
if (pieces[index].type == END_SHIP) return 1;
}
return 0;
}
size_t axolotl_seed_map_find_features(
uint64_t seed,
int32_t minecraft_version,
int32_t generator_flags,
int32_t dimension,
int32_t min_x,
int32_t min_z,
int32_t max_x,
int32_t max_z,
uint32_t feature_mask,
AxolotlSeedMapFeature *out,
size_t out_len
) {
if (!out || out_len == 0) return 0;
Generator generator;
setupGenerator(&generator, minecraft_version, generator_flags);
applySeed(&generator, dimension, seed);
SurfaceNoise end_surface_noise;
if (dimension == DIM_END) initSurfaceNoise(&end_surface_noise, DIM_END, seed);
size_t count = 0;
for (size_t index = 0; index < sizeof(AXOLOTL_STRUCTURES) / sizeof(AXOLOTL_STRUCTURES[0]); index++) {
const AxolotlStructureDefinition definition = AXOLOTL_STRUCTURES[index];
if (!(feature_mask & definition.flag)) continue;
StructureConfig config;
if (!getStructureConfig(definition.type, minecraft_version, &config)) continue;
if (config.dim != dimension || config.regionSize <= 0) continue;
const int region_span = config.regionSize * 16;
const int min_region_x = min_x >= 0 ? min_x / region_span : (min_x - region_span + 1) / region_span;
const int min_region_z = min_z >= 0 ? min_z / region_span : (min_z - region_span + 1) / region_span;
const int max_region_x = max_x >= 0 ? max_x / region_span : (max_x - region_span + 1) / region_span;
const int max_region_z = max_z >= 0 ? max_z / region_span : (max_z - region_span + 1) / region_span;
for (int region_x = min_region_x; region_x <= max_region_x; region_x++) {
for (int region_z = min_region_z; region_z <= max_region_z; region_z++) {
Pos position;
if (!getStructurePos(definition.type, minecraft_version, seed, region_x, region_z, &position)) continue;
if (position.x < min_x || position.x > max_x || position.z < min_z || position.z > max_z) continue;
if (!isViableStructurePos(definition.type, &generator, position.x, position.z, 0)) continue;
if (definition.type == End_City &&
!isViableEndCityTerrain(&generator, &end_surface_noise, position.x, position.z))
continue;
if (dimension == DIM_OVERWORLD && minecraft_version >= MC_1_18 &&
!isViableStructureTerrain(definition.type, &generator, position.x, position.z))
continue;
const int8_t end_ship = definition.type == End_City
? axolotl_end_city_has_ship(seed, position.x, position.z)
: -1;
if (!add_feature(
out, out_len, &count, position.x, position.z,
definition.flag, 0, end_ship)) return count;
}
}
}
if (dimension == DIM_OVERWORLD && feature_mask & AXOLOTL_FEATURE_STRONGHOLD) {
StrongholdIter stronghold;
initFirstStronghold(&stronghold, minecraft_version, seed);
for (int index = 0; index < 32; index++) {
if (nextStronghold(&stronghold, &generator) <= 0) break;
if (stronghold.pos.x >= min_x && stronghold.pos.x <= max_x && stronghold.pos.z >= min_z && stronghold.pos.z <= max_z) {
if (!add_feature(
out, out_len, &count, stronghold.pos.x, stronghold.pos.z,
AXOLOTL_FEATURE_STRONGHOLD, 0, -1)) return count;
}
}
}
if (dimension == DIM_OVERWORLD && feature_mask & AXOLOTL_FEATURE_SLIME_CHUNK) {
const int min_chunk_x = min_x >= 0 ? min_x / 16 : (min_x - 15) / 16;
const int min_chunk_z = min_z >= 0 ? min_z / 16 : (min_z - 15) / 16;
const int max_chunk_x = max_x >= 0 ? max_x / 16 : (max_x - 15) / 16;
const int max_chunk_z = max_z >= 0 ? max_z / 16 : (max_z - 15) / 16;
for (int chunk_x = min_chunk_x; chunk_x <= max_chunk_x; chunk_x++) {
for (int chunk_z = min_chunk_z; chunk_z <= max_chunk_z; chunk_z++) {
if (!isSlimeChunk(seed, chunk_x, chunk_z)) continue;
if (!add_feature(
out, out_len, &count, chunk_x * 16 + 8, chunk_z * 16 + 8,
AXOLOTL_FEATURE_SLIME_CHUNK, 0, -1)) return count;
}
}
}
return count;
}
int axolotl_seed_map_get_spawn(
uint64_t seed,
int32_t minecraft_version,
int32_t generator_flags,
int32_t *x,
int32_t *z
) {
if (!x || !z) return -1;
Generator generator;
setupGenerator(&generator, minecraft_version, generator_flags);
applySeed(&generator, DIM_OVERWORLD, seed);
Pos position = estimateSpawn(&generator, NULL);
*x = position.x;
*z = position.z;
return 0;
}
int32_t axolotl_seed_map_biome_at(
uint64_t seed,
int32_t minecraft_version,
int32_t generator_flags,
int32_t dimension,
int32_t x,
int32_t y,
int32_t z
) {
Generator generator;
setupGenerator(&generator, minecraft_version, generator_flags);
applySeed(&generator, dimension, seed);
return getBiomeAt(&generator, 1, x, y, z);
}
typedef struct AxolotlVeinNoise {
DoublePerlinNoise toggle;
DoublePerlinNoise vein_a;
DoublePerlinNoise vein_b;
DoublePerlinNoise gap;
PerlinNoise octaves[8];
uint64_t ore_lo;
uint64_t ore_hi;
} AxolotlVeinNoise;
/*
* Seeds the Overworld vein noises the same way the game does: the world seed
* forks a positional random whose per-noise instances are XORed with the MD5
* halves of their resource ids.
*/
static void axolotl_init_vein_noise(AxolotlVeinNoise *vein_noise, uint64_t seed) {
static const double unit_amplitude[] = {1.0};
Xoroshiro base;
xSetSeed(&base, seed);
const uint64_t lo = xNextLong(&base);
const uint64_t hi = xNextLong(&base);
PerlinNoise *slot = vein_noise->octaves;
Xoroshiro xr;
xr.lo = lo ^ 0x6B86C7820A307171ULL; /* minecraft:ore_veininess */
xr.hi = hi ^ 0xD87FB0FEFD9C1624ULL;
slot += xDoublePerlinInit(&vein_noise->toggle, &xr, slot, unit_amplitude, -8, 1, -1);
xr.lo = lo ^ 0x4CD8D69B9A841649ULL; /* minecraft:ore_vein_a */
xr.hi = hi ^ 0xCDD63F17BFE8F5EDULL;
slot += xDoublePerlinInit(&vein_noise->vein_a, &xr, slot, unit_amplitude, -7, 1, -1);
xr.lo = lo ^ 0x6B26220B31F7C6C9ULL; /* minecraft:ore_vein_b */
xr.hi = hi ^ 0xAE077EDEBF6AAEC1ULL;
slot += xDoublePerlinInit(&vein_noise->vein_b, &xr, slot, unit_amplitude, -7, 1, -1);
xr.lo = lo ^ 0x9C4CC6B2FB0BE4BBULL; /* minecraft:ore_gap */
xr.hi = hi ^ 0xBD5964705573BB5EULL;
xDoublePerlinInit(&vein_noise->gap, &xr, slot, unit_amplitude, -5, 1, -1);
vein_noise->ore_lo = lo ^ 0x9B88124DE600116DULL; /* minecraft:ore */
vein_noise->ore_hi = hi ^ 0x2AE68055AA4A7761ULL;
}
static uint64_t axolotl_block_position_seed(int32_t x, int32_t y, int32_t z) {
int64_t l = (int64_t)(int32_t)((uint32_t)x * 3129871u) ^
((int64_t)z * 116129781LL) ^ (int64_t)y;
l = l * l * 42317861LL + l * 11LL;
return (uint64_t)(l >> 16);
}
static Xoroshiro axolotl_vein_block_rng(const AxolotlVeinNoise *vein_noise, int32_t x, int32_t y, int32_t z) {
Xoroshiro xr;
xr.lo = axolotl_block_position_seed(x, y, z) ^ vein_noise->ore_lo;
xr.hi = vein_noise->ore_hi;
if (xr.lo == 0 && xr.hi == 0) {
xr.lo = 0x9E3779B97F4A7C15ULL;
xr.hi = 0x6A09E667F3BCC909ULL;
}
return xr;
}
static double axolotl_clamped_map(double value, double from_min, double from_max, double to_min, double to_max) {
if (value <= from_min) return to_min;
if (value >= from_max) return to_max;
return to_min + (value - from_min) / (from_max - from_min) * (to_max - to_min);
}
/*
* Follows the game's vein rules on a 2-block sampling grid: the low frequency
* veininess noise selects copper (positive, Y 0..50) or iron (negative,
* Y -60..-8) with a 20-block edge fade towards a 0.4 threshold. A sampled
* position counts as ore when the 70% solidness roll passes, both ridge
* noises stay inside the 0.08 band, the richness roll (10%..30% by
* veininess) succeeds, and the gap noise is above -0.3 — the same checks and
* positional-random call order the game uses, evaluated without the noise
* cell interpolation, so results are close estimates.
*/
size_t axolotl_seed_map_scan_vein(
uint64_t seed,
int32_t chunk_x,
int32_t chunk_z,
int32_t vein_kind,
int32_t *out_xyz,
size_t out_cap
) {
if (!out_xyz || out_cap == 0) return 0;
AxolotlVeinNoise vein_noise;
axolotl_init_vein_noise(&vein_noise, seed);
const int min_y = vein_kind == 0 ? 0 : -60;
const int max_y = vein_kind == 0 ? 50 : -8;
const int block_x = chunk_x * 16;
const int block_z = chunk_z * 16;
uint8_t column_taken[64] = {0};
size_t count = 0;
for (int y = min_y; y <= max_y && count < out_cap; y += 2) {
const int edge = (max_y - y) < (y - min_y) ? (max_y - y) : (y - min_y);
const double edge_fade = edge >= 20 ? 0.0 : -0.2 + 0.01 * (double)edge;
const double coarse = sampleDoublePerlin(
&vein_noise.toggle,
(block_x + 8) * 1.5, y * 1.5, (block_z + 8) * 1.5);
const double coarse_signed = vein_kind == 0 ? coarse : -coarse;
if (coarse_signed + edge_fade < 0.34) continue;
for (int dx = 0; dx < 16 && count < out_cap; dx += 2) {
for (int dz = 0; dz < 16 && count < out_cap; dz += 2) {
const int column = (dx >> 1) * 8 + (dz >> 1);
if (column_taken[column]) continue;
const int x = block_x + dx;
const int z = block_z + dz;
const double toggle =
sampleDoublePerlin(&vein_noise.toggle, x * 1.5, y * 1.5, z * 1.5);
const double toggle_signed = vein_kind == 0 ? toggle : -toggle;
if (toggle_signed + edge_fade < 0.4) continue;
Xoroshiro block_rng = axolotl_vein_block_rng(&vein_noise, x, y, z);
if (xNextFloat(&block_rng) > 0.7f) continue;
if (fabs(sampleDoublePerlin(&vein_noise.vein_a, x * 4.0, y * 4.0, z * 4.0)) >= 0.08)
continue;
if (fabs(sampleDoublePerlin(&vein_noise.vein_b, x * 4.0, y * 4.0, z * 4.0)) >= 0.08)
continue;
const double richness =
axolotl_clamped_map(toggle_signed, 0.4, 0.6, 0.1, 0.3);
if (xNextFloat(&block_rng) >= richness) continue;
if (sampleDoublePerlin(&vein_noise.gap, x, y, z) <= -0.3) continue;
column_taken[column] = 1;
out_xyz[count * 3] = x;
out_xyz[count * 3 + 1] = y;
out_xyz[count * 3 + 2] = z;
count++;
}
}
}
return count;
}
int axolotl_seed_map_surface_heights(
uint64_t seed,
int32_t minecraft_version,
int32_t generator_flags,
int32_t x,
int32_t z,
int32_t width,
int32_t height,
float *out
) {
if (!out || width <= 0 || height <= 0) return -1;
Generator generator;
setupGenerator(&generator, minecraft_version, generator_flags);
applySeed(&generator, DIM_OVERWORLD, seed);
SurfaceNoise surface_noise;
initSurfaceNoise(&surface_noise, DIM_OVERWORLD, seed);
return mapApproxHeight(out, NULL, &generator, &surface_noise, x, z, width, height);
}

View File

@ -0,0 +1,115 @@
#ifndef AXOLOTL_CUBIOMES_BRIDGE_H
#define AXOLOTL_CUBIOMES_BRIDGE_H
#include <stddef.h>
#include <stdint.h>
typedef struct AxolotlSeedMapFeature {
int32_t x;
int32_t z;
uint32_t kind;
uint8_t approximate;
int8_t end_ship;
} AxolotlSeedMapFeature;
/*
* Maps an Axolotl version code (major * 10000 + minor * 100 + patch) to the
* matching cubiomes MCVersion, or 0 (MC_UNDEF) when unsupported.
*/
int32_t axolotl_seed_map_java_version(int32_t version);
/*
* Renders a biome tile into an RGB buffer (3 bytes per pixel).
*
* Coordinates are given in scaled units (block / scale). When `terrain` is
* non-zero, Overworld tiles use approximate surface heights and nearby End
* tiles use the dimension's density-derived surface height. `contours`
* additionally darkens height-band boundaries. `highlight_mask` may be NULL,
* or point to 256 bytes where a non-zero entry keeps that biome id at full
* color while all other biomes are faded.
*/
int axolotl_seed_map_render(
uint64_t seed,
int32_t minecraft_version,
int32_t generator_flags,
int32_t dimension,
int32_t x,
int32_t z,
int32_t scale,
int32_t width,
int32_t height,
int32_t elevation,
int32_t terrain,
int32_t contours,
const uint8_t *highlight_mask,
uint8_t *rgb,
size_t rgb_len
);
size_t axolotl_seed_map_find_features(
uint64_t seed,
int32_t minecraft_version,
int32_t generator_flags,
int32_t dimension,
int32_t min_x,
int32_t min_z,
int32_t max_x,
int32_t max_z,
uint32_t feature_mask,
AxolotlSeedMapFeature *out,
size_t out_len
);
int axolotl_seed_map_get_spawn(
uint64_t seed,
int32_t minecraft_version,
int32_t generator_flags,
int32_t *x,
int32_t *z
);
/*
* Returns the biome id at a block position (sampled at the given Y level),
* or -1 when the engine cannot resolve it.
*/
int32_t axolotl_seed_map_biome_at(
uint64_t seed,
int32_t minecraft_version,
int32_t generator_flags,
int32_t dimension,
int32_t x,
int32_t y,
int32_t z
);
/*
* Scans one chunk for large ore veins (`vein_kind`: 0 = copper, 1 = iron) by
* sampling the Overworld vein noises. Writes up to `out_cap` hits as
* (x, y, z) triples into `out_xyz` and returns the number of hits.
*/
size_t axolotl_seed_map_scan_vein(
uint64_t seed,
int32_t chunk_x,
int32_t chunk_z,
int32_t vein_kind,
int32_t *out_xyz,
size_t out_cap
);
/*
* Fills `out` (length `width * height`) with approximate Overworld surface
* heights in blocks. `x` and `z` are in 1:4 scale units and the grid has a
* stride of one unit (4 blocks). Returns 0 on success.
*/
int axolotl_seed_map_surface_heights(
uint64_t seed,
int32_t minecraft_version,
int32_t generator_flags,
int32_t x,
int32_t z,
int32_t width,
int32_t height,
float *out
);
#endif

1197
apps/app/src/seed_map/mod.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,873 @@
//! Seed-based ore prediction for Java 1.18+.
//!
//! Predicts scattered-ore placement attempts from the world seed by
//! replaying Minecraft's chunk population RNG (decoration seed, feature seed,
//! placement modifiers, and the ore blob's own RNG consumption). Terrain is
//! not simulated, so results are estimates: caves and surface exposure can
//! shift or remove blobs. Attempts are cross-checked against cubiomes'
//! approximate surface height to grade confidence.
use std::io;
use serde::{Deserialize, Serialize};
use super::rng::{Xoroshiro, decoration_seed, feature_rng, mc_sin};
use super::{Dimension, parse_seed, resolve_java_version};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OreKind {
Diamond,
Iron,
IronVein,
Copper,
CopperVein,
Gold,
Redstone,
Lapis,
Coal,
Netherite,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OreScanRequest {
pub seed: String,
pub version: String,
pub dimension: Dimension,
pub ores: Vec<OreKind>,
/// Interleaved chunk coordinates: `[cx0, cz0, cx1, cz1, ...]`.
pub chunks: Vec<i32>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OreHit {
pub ore: OreKind,
pub x: i32,
pub y: i32,
pub z: i32,
pub verified: bool,
pub y_min: i32,
pub y_max: i32,
pub precision: u8,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OreChunkResult {
pub cx: i32,
pub cz: i32,
pub hits: Vec<OreHit>,
}
#[derive(Clone, Copy, Debug)]
enum CountModifier {
Fixed(u32),
Uniform { min: u32, max: u32 },
Rarity(u32),
}
#[derive(Clone, Copy, Debug)]
enum HeightProvider {
Uniform { min: i32, max: i32 },
Trapezoid { min: i32, max: i32, plateau: i32 },
}
impl HeightProvider {
fn sample(self, rng: &mut Xoroshiro) -> i32 {
match self {
Self::Uniform { min, max } => {
rng.next_int_between_inclusive(min, max)
}
Self::Trapezoid { min, max, plateau } => {
let span = max - min;
if plateau >= span {
return rng.next_int_between_inclusive(min, max);
}
let lower = (span - plateau) / 2;
let upper = span - lower;
min + rng.next_int_between_inclusive(0, upper)
+ rng.next_int_between_inclusive(0, lower)
}
}
}
}
#[derive(Clone, Copy, Debug)]
struct PlacedOre {
feature_index: u32,
step: u32,
count: CountModifier,
height: HeightProvider,
blob_size: u32,
discard_on_air: f32,
precision: u8,
scattered: bool,
}
const UNDERGROUND_ORES_STEP: u32 = 6;
const UNDERGROUND_DECORATION_STEP: u32 = 7;
/*
* Global feature indices for the UNDERGROUND_ORES step, computed with
* Minecraft's FeatureSorter over the vanilla biome sets (validated against
* official generated data for 1.18.2 through 1.21.3): the base sequence
* starts at ore_coal_upper = 9 after the nine stone-patch features, and
* ore_copper sorts AFTER dripstone's ore_copper_large. `ore_diamond_medium`
* joined the base list in 1.20.5, shifting every later entry by one.
* Placement counts, height providers, blob sizes, and discard chances come
* from the vanilla OrePlacements/OreFeatures definitions.
*/
fn overworld_ore_features(kind: OreKind, mc_code: i32) -> Vec<PlacedOre> {
let has_medium_diamond = mc_code >= 12000;
let shift = u32::from(has_medium_diamond);
let ore = |feature_index: u32,
count: CountModifier,
height: HeightProvider,
blob_size: u32,
discard_on_air: f32,
precision: u8| PlacedOre {
feature_index,
step: UNDERGROUND_ORES_STEP,
count,
height,
blob_size,
discard_on_air,
precision,
scattered: false,
};
match kind {
OreKind::Coal => vec![
ore(
9,
CountModifier::Fixed(30),
HeightProvider::Uniform { min: 136, max: 319 },
17,
0.0,
88,
),
ore(
10,
CountModifier::Fixed(20),
HeightProvider::Trapezoid {
min: 0,
max: 192,
plateau: 0,
},
17,
0.5,
84,
),
],
OreKind::Iron => vec![
ore(
11,
CountModifier::Fixed(90),
HeightProvider::Trapezoid {
min: 80,
max: 384,
plateau: 0,
},
9,
0.0,
88,
),
ore(
12,
CountModifier::Fixed(10),
HeightProvider::Trapezoid {
min: -24,
max: 56,
plateau: 0,
},
9,
0.0,
90,
),
ore(
13,
CountModifier::Fixed(10),
HeightProvider::Uniform { min: -64, max: 72 },
4,
0.0,
90,
),
],
OreKind::Gold => vec![
ore(
14,
CountModifier::Fixed(4),
HeightProvider::Trapezoid {
min: -64,
max: 32,
plateau: 0,
},
9,
0.5,
87,
),
ore(
15,
CountModifier::Uniform { min: 0, max: 1 },
HeightProvider::Uniform { min: -64, max: -48 },
9,
0.5,
87,
),
],
OreKind::Redstone => vec![
ore(
16,
CountModifier::Fixed(4),
HeightProvider::Uniform { min: -64, max: 15 },
8,
0.0,
90,
),
ore(
17,
CountModifier::Fixed(8),
HeightProvider::Trapezoid {
min: -96,
max: -32,
plateau: 0,
},
8,
0.0,
90,
),
],
OreKind::Diamond => {
let mut features = vec![
ore(
18,
CountModifier::Fixed(7),
HeightProvider::Trapezoid {
min: -144,
max: 16,
plateau: 0,
},
4,
0.5,
88,
),
ore(
19 + shift,
CountModifier::Rarity(9),
HeightProvider::Trapezoid {
min: -144,
max: 16,
plateau: 0,
},
12,
0.7,
82,
),
ore(
20 + shift,
CountModifier::Fixed(4),
HeightProvider::Trapezoid {
min: -144,
max: 16,
plateau: 0,
},
8,
1.0,
92,
),
];
if has_medium_diamond {
features.push(ore(
19,
CountModifier::Fixed(2),
HeightProvider::Uniform { min: -64, max: -4 },
8,
0.5,
88,
));
}
features
}
OreKind::Lapis => vec![
ore(
21 + shift,
CountModifier::Fixed(2),
HeightProvider::Trapezoid {
min: -32,
max: 32,
plateau: 0,
},
7,
0.0,
88,
),
ore(
22 + shift,
CountModifier::Fixed(4),
HeightProvider::Uniform { min: -64, max: 64 },
7,
1.0,
92,
),
],
OreKind::Copper => vec![ore(
24 + shift,
CountModifier::Fixed(16),
HeightProvider::Trapezoid {
min: -16,
max: 112,
plateau: 0,
},
10,
0.0,
88,
)],
OreKind::IronVein | OreKind::CopperVein | OreKind::Netherite => vec![],
}
}
/*
* Large veins come from the Overworld vein noises rather than the population
* RNG, so they are scanned natively per chunk. Copper veins live at Y 0..50
* and iron veins at Y -60..-8.
*/
fn scan_vein_hits(
kind: OreKind,
world_seed: u64,
cx: i32,
cz: i32,
surface: Option<&SurfaceGrid>,
) -> Vec<OreHit> {
let vein_code = i32::from(kind != OreKind::CopperVein);
let mut buffer = [0_i32; 24];
let count = unsafe {
super::axolotl_seed_map_scan_vein(
world_seed,
cx,
cz,
vein_code,
buffer.as_mut_ptr(),
buffer.len() / 3,
)
};
buffer[..count * 3]
.chunks_exact(3)
.map(|triple| {
let (x, y, z) = (triple[0], triple[1], triple[2]);
let verified = surface
.map(|grid| (y as f32) < grid.height_at(x, z) - 6.0)
.unwrap_or(false);
OreHit {
ore: kind,
x,
y,
z,
verified,
y_min: y,
y_max: y,
precision: if verified { 93 } else { 78 },
}
})
.collect()
}
/*
* Ancient debris indices in the UNDERGROUND_DECORATION step are 21 and 22
* across every supported version (stable under all nether biome orderings).
* Both are single-attempt scattered-ore features that are always buried.
*/
fn nether_ore_features(kind: OreKind, _mc_code: i32) -> Vec<PlacedOre> {
if kind != OreKind::Netherite {
return vec![];
}
let ore = |feature_index: u32, height: HeightProvider, blob_size: u32| {
PlacedOre {
feature_index,
step: UNDERGROUND_DECORATION_STEP,
count: CountModifier::Fixed(1),
height,
blob_size,
discard_on_air: 1.0,
precision: 95,
scattered: true,
}
};
vec![
ore(
21,
HeightProvider::Trapezoid {
min: 8,
max: 24,
plateau: 0,
},
3,
),
ore(22, HeightProvider::Uniform { min: 8, max: 119 }, 2),
]
}
pub fn supports_ores(version_code: i32, dimension: Dimension) -> bool {
version_code >= 11800 && !matches!(dimension, Dimension::End)
}
pub fn scan_ores(request: OreScanRequest) -> io::Result<Vec<OreChunkResult>> {
let (mc_version, version_code) = resolve_java_version(&request.version)?;
if !supports_ores(version_code, request.dimension) {
return Err(io::Error::other(
"Ore prediction supports Java 1.18+ Overworld and Nether maps.",
));
}
if !request.chunks.len().is_multiple_of(2) || request.chunks.len() > 4_096 {
return Err(io::Error::other("The ore scan chunk list is invalid."));
}
let world_seed = parse_seed(&request.seed) as i64;
let chunk_pairs: Vec<(i32, i32)> = request
.chunks
.chunks_exact(2)
.map(|pair| (pair[0], pair[1]))
.collect();
let surface = match request.dimension {
Dimension::Overworld => {
SurfaceGrid::sample(world_seed as u64, mc_version, &chunk_pairs)
}
_ => None,
};
let (world_bottom, world_top) = match request.dimension {
Dimension::Overworld => (-64, 320),
_ => (0, 128),
};
let requested_kinds = dedupe_kinds(&request.ores);
let vein_kinds: Vec<OreKind> =
if matches!(request.dimension, Dimension::Overworld) {
requested_kinds
.iter()
.copied()
.filter(|kind| {
matches!(kind, OreKind::IronVein | OreKind::CopperVein)
})
.collect()
} else {
Vec::new()
};
let mut features: Vec<(OreKind, PlacedOre)> = Vec::new();
for kind in requested_kinds {
let kind_features = match request.dimension {
Dimension::Overworld => overworld_ore_features(kind, version_code),
Dimension::Nether => nether_ore_features(kind, version_code),
Dimension::End => vec![],
};
features
.extend(kind_features.into_iter().map(|feature| (kind, feature)));
}
let mut results = Vec::with_capacity(chunk_pairs.len());
for (cx, cz) in chunk_pairs {
let chunk_decoration_seed =
decoration_seed(world_seed, cx * 16, cz * 16);
let mut hits = Vec::new();
for &kind in &vein_kinds {
hits.extend(scan_vein_hits(
kind,
world_seed as u64,
cx,
cz,
surface.as_ref(),
));
}
for &(kind, feature) in &features {
let mut rng = feature_rng(
chunk_decoration_seed,
feature.feature_index,
feature.step,
);
let attempts = match feature.count {
CountModifier::Fixed(count) => count,
CountModifier::Uniform { min, max } => {
min + rng.next_int(max - min + 1)
}
CountModifier::Rarity(chance) => {
u32::from(rng.next_f32() < 1.0 / chance as f32)
}
};
for _ in 0..attempts {
let x = cx * 16 + rng.next_int(16) as i32;
let z = cz * 16 + rng.next_int(16) as i32;
let y = feature.height.sample(&mut rng);
let surface_height =
surface.as_ref().map(|grid| grid.height_at(x, z));
let blob = if feature.scattered {
Some((y, y))
} else {
simulate_blob(
&mut rng,
x,
y,
z,
feature.blob_size,
feature.discard_on_air,
surface_height,
world_bottom,
world_top,
)
};
let Some((blob_min_y, blob_max_y)) = blob else {
continue;
};
let verified = match surface_height {
Some(surface_y) => (blob_max_y as f32) < surface_y - 6.0,
None => feature.discard_on_air >= 1.0,
};
let precision = if verified {
feature.precision
} else {
feature.precision.saturating_sub(18).max(50)
};
hits.push(OreHit {
ore: kind,
x,
y,
z,
verified,
y_min: blob_min_y,
y_max: blob_max_y,
precision,
});
}
}
results.push(OreChunkResult { cx, cz, hits });
}
Ok(results)
}
fn dedupe_kinds(kinds: &[OreKind]) -> Vec<OreKind> {
let mut seen = Vec::new();
for &kind in kinds {
if !seen.contains(&kind) {
seen.push(kind);
}
}
seen
}
/*
* Replays the RNG consumption of `OreFeature.place`, assuming solid terrain:
* the blob angle, the two Y jitters, one radius roll per segment, and — for
* partially air-sensitive ores — one roll per candidate cell. Returns the
* simulated blob's Y extent, or None when the surface pre-check would skip
* placement entirely.
*/
#[allow(clippy::too_many_arguments)]
fn simulate_blob(
rng: &mut Xoroshiro,
x: i32,
y: i32,
z: i32,
size: u32,
discard_on_air: f32,
surface_height: Option<f32>,
world_bottom: i32,
world_top: i32,
) -> Option<(i32, i32)> {
let angle = rng.next_f32() * std::f32::consts::PI;
let spread = size as f32 / 8.0;
let margin = (((size as f32 / 16.0) * 2.0 + 1.0) / 2.0).ceil() as i32;
let x0 = f64::from(x) + f64::from(angle).sin() * f64::from(spread);
let x1 = f64::from(x) - f64::from(angle).sin() * f64::from(spread);
let z0 = f64::from(z) + f64::from(angle).cos() * f64::from(spread);
let z1 = f64::from(z) - f64::from(angle).cos() * f64::from(spread);
let y0 = f64::from(y + rng.next_int(3) as i32 - 2);
let y1 = f64::from(y + rng.next_int(3) as i32 - 2);
let box_min_x = x - (spread.ceil() as i32) - margin;
let box_min_y = y - 2 - margin;
let box_min_z = z - (spread.ceil() as i32) - margin;
if let Some(surface_y) = surface_height
&& box_min_y as f32 > surface_y
{
return None;
}
let segment_count = size as usize;
let mut segments = vec![[0.0_f64; 4]; segment_count];
for (index, segment) in segments.iter_mut().enumerate() {
let progress = index as f32 / segment_count as f32;
let center_x = lerp(f64::from(progress), x0, x1);
let center_y = lerp(f64::from(progress), y0, y1);
let center_z = lerp(f64::from(progress), z0, z1);
let radius_roll = rng.next_f64() * f64::from(size) / 16.0;
let radius = ((f64::from(mc_sin(std::f32::consts::PI * progress))
+ 1.0)
* radius_roll
+ 1.0)
/ 2.0;
*segment = [center_x, center_y, center_z, radius];
}
for first in 0..segment_count.saturating_sub(1) {
if segments[first][3] <= 0.0 {
continue;
}
for second in first + 1..segment_count {
if segments[second][3] <= 0.0 {
continue;
}
let dx = segments[first][0] - segments[second][0];
let dy = segments[first][1] - segments[second][1];
let dz = segments[first][2] - segments[second][2];
let dr = segments[first][3] - segments[second][3];
if dr * dr > dx * dx + dy * dy + dz * dz {
if dr > 0.0 {
segments[second][3] = -1.0;
} else {
segments[first][3] = -1.0;
}
}
}
}
let needs_cell_rolls = discard_on_air > 0.0 && discard_on_air < 1.0;
let mut placed = std::collections::HashSet::new();
let mut blob_min_y = i32::MAX;
let mut blob_max_y = i32::MIN;
for segment in &segments {
let radius = segment[3];
if radius < 0.0 {
continue;
}
let min_cell_x = ((segment[0] - radius).floor() as i32).max(box_min_x);
let min_cell_y = ((segment[1] - radius).floor() as i32).max(box_min_y);
let min_cell_z = ((segment[2] - radius).floor() as i32).max(box_min_z);
let max_cell_x = ((segment[0] + radius).floor() as i32).max(min_cell_x);
let max_cell_y = ((segment[1] + radius).floor() as i32).max(min_cell_y);
let max_cell_z = ((segment[2] + radius).floor() as i32).max(min_cell_z);
for cell_x in min_cell_x..=max_cell_x {
let dx = (f64::from(cell_x) + 0.5 - segment[0]) / radius;
if dx * dx >= 1.0 {
continue;
}
for cell_y in min_cell_y..=max_cell_y {
let dy = (f64::from(cell_y) + 0.5 - segment[1]) / radius;
if dx * dx + dy * dy >= 1.0 {
continue;
}
for cell_z in min_cell_z..=max_cell_z {
let dz = (f64::from(cell_z) + 0.5 - segment[2]) / radius;
if dx * dx + dy * dy + dz * dz >= 1.0 {
continue;
}
if cell_y < world_bottom || cell_y >= world_top {
continue;
}
if !placed.insert((cell_x, cell_y, cell_z)) {
continue;
}
if needs_cell_rolls {
rng.next_f32();
}
blob_min_y = blob_min_y.min(cell_y);
blob_max_y = blob_max_y.max(cell_y);
}
}
}
}
if blob_min_y > blob_max_y {
return Some((y, y));
}
Some((blob_min_y, blob_max_y))
}
fn lerp(progress: f64, from: f64, to: f64) -> f64 {
from + progress * (to - from)
}
/*
* A cached approximate surface-height grid at 1:4 scale covering the bounding
* box of the scanned chunks.
*/
struct SurfaceGrid {
origin_x: i32,
origin_z: i32,
width: i32,
height: i32,
values: Vec<f32>,
}
impl SurfaceGrid {
fn sample(
seed: u64,
mc_version: i32,
chunks: &[(i32, i32)],
) -> Option<Self> {
if chunks.is_empty() {
return None;
}
let min_cx = chunks.iter().map(|chunk| chunk.0).min()?;
let max_cx = chunks.iter().map(|chunk| chunk.0).max()?;
let min_cz = chunks.iter().map(|chunk| chunk.1).min()?;
let max_cz = chunks.iter().map(|chunk| chunk.1).max()?;
let origin_x = min_cx * 4;
let origin_z = min_cz * 4;
let width = (max_cx - min_cx + 1) * 4;
let height = (max_cz - min_cz + 1) * 4;
if width <= 0 || height <= 0 || width as i64 * height as i64 > 1 << 20 {
return None;
}
let mut values = vec![0.0_f32; (width * height) as usize];
let result = unsafe {
super::axolotl_seed_map_surface_heights(
seed,
mc_version,
0,
origin_x,
origin_z,
width,
height,
values.as_mut_ptr(),
)
};
if result != 0 {
return None;
}
Some(Self {
origin_x,
origin_z,
width,
height,
values,
})
}
fn height_at(&self, block_x: i32, block_z: i32) -> f32 {
let column =
(block_x.div_euclid(4) - self.origin_x).clamp(0, self.width - 1);
let row =
(block_z.div_euclid(4) - self.origin_z).clamp(0, self.height - 1);
self.values[(row * self.width + column) as usize]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scan(
seed: &str,
version: &str,
dimension: Dimension,
ores: Vec<OreKind>,
) -> Vec<OreChunkResult> {
scan_ores(OreScanRequest {
seed: seed.to_owned(),
version: version.to_owned(),
dimension,
ores,
chunks: vec![0, 0, 1, 0, -1, -1],
})
.expect("scan should succeed")
}
#[test]
fn ore_scans_are_deterministic() {
let first = scan(
"10292992",
"1.21.3",
Dimension::Overworld,
vec![OreKind::Diamond],
);
let second = scan(
"10292992",
"1.21.3",
Dimension::Overworld,
vec![OreKind::Diamond],
);
assert_eq!(first.len(), 3);
for (a, b) in first.iter().zip(second.iter()) {
assert_eq!(a.hits.len(), b.hits.len());
for (left, right) in a.hits.iter().zip(b.hits.iter()) {
assert_eq!(
(left.x, left.y, left.z),
(right.x, right.y, right.z)
);
}
}
}
#[test]
fn diamond_attempts_stay_in_distribution_range() {
for chunk in scan(
"axolotl",
"1.21.3",
Dimension::Overworld,
vec![OreKind::Diamond],
) {
for hit in chunk.hits {
assert!(
hit.y >= -144 && hit.y <= 16,
"y={} out of range",
hit.y
);
assert!(hit.x >= chunk.cx * 16 && hit.x < chunk.cx * 16 + 16);
assert!(hit.z >= chunk.cz * 16 && hit.z < chunk.cz * 16 + 16);
}
}
}
#[test]
fn netherite_scans_use_the_nether_layout() {
let results = scan(
"10292992",
"1.21.3",
Dimension::Nether,
vec![OreKind::Netherite],
);
let hits: Vec<_> =
results.iter().flat_map(|chunk| chunk.hits.iter()).collect();
assert!(!hits.is_empty());
for hit in hits {
assert!(hit.y >= 8 && hit.y <= 119);
assert!(hit.verified);
}
}
#[test]
fn vein_hits_stay_inside_their_bands_and_are_deterministic() {
let chunks: Vec<i32> = (0..8_i32)
.flat_map(|cx| (0..8_i32).flat_map(move |cz| [cx, cz]))
.collect();
let request = OreScanRequest {
seed: "10292992".to_owned(),
version: "26.2".to_owned(),
dimension: Dimension::Overworld,
ores: vec![OreKind::IronVein, OreKind::CopperVein],
chunks,
};
let first =
scan_ores(request.clone()).expect("vein scan should succeed");
let second = scan_ores(request).expect("vein scan should succeed");
let hits: Vec<_> =
first.iter().flat_map(|chunk| chunk.hits.iter()).collect();
for hit in &hits {
match hit.ore {
OreKind::IronVein => assert!(hit.y >= -60 && hit.y <= -8),
OreKind::CopperVein => assert!(hit.y >= 0 && hit.y <= 50),
other => panic!("unexpected ore kind {other:?}"),
}
}
let second_hits: Vec<_> =
second.iter().flat_map(|chunk| chunk.hits.iter()).collect();
assert_eq!(hits.len(), second_hits.len());
}
#[test]
fn old_versions_reject_ore_scans() {
let error = scan_ores(OreScanRequest {
seed: "1".to_owned(),
version: "1.16".to_owned(),
dimension: Dimension::Overworld,
ores: vec![OreKind::Iron],
chunks: vec![0, 0],
});
assert!(error.is_err());
}
}

View File

@ -0,0 +1,150 @@
//! Minecraft Java Edition worldgen RNG primitives (1.18+).
//!
//! Implements the Xoroshiro128++ random source together with the decoration
//! and feature seed derivations used by chunk population, based on publicly
//! documented game mechanics.
const SILVER_RATIO_64: u64 = 0x6A09E667F3BCC909;
const GOLDEN_RATIO_64: u64 = 0x9E3779B97F4A7C15;
fn mix_stafford_13(mut value: u64) -> u64 {
value = (value ^ (value >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
value = (value ^ (value >> 27)).wrapping_mul(0x94D049BB133111EB);
value ^ (value >> 31)
}
#[derive(Clone, Copy, Debug)]
pub struct Xoroshiro {
lo: u64,
hi: u64,
}
impl Xoroshiro {
pub fn from_seed(seed: u64) -> Self {
let lo = seed ^ SILVER_RATIO_64;
let hi = lo.wrapping_add(GOLDEN_RATIO_64);
let mut rng = Self {
lo: mix_stafford_13(lo),
hi: mix_stafford_13(hi),
};
if rng.lo == 0 && rng.hi == 0 {
rng.lo = GOLDEN_RATIO_64;
rng.hi = SILVER_RATIO_64;
}
rng
}
pub fn next_u64(&mut self) -> u64 {
let lo = self.lo;
let mut hi = self.hi;
let result = lo.wrapping_add(hi).rotate_left(17).wrapping_add(lo);
hi ^= lo;
self.lo = lo.rotate_left(49) ^ hi ^ (hi << 21);
self.hi = hi.rotate_left(28);
result
}
pub fn next_i64(&mut self) -> i64 {
self.next_u64() as i64
}
/// Java's `XoroshiroRandomSource.nextInt(bound)` (Lemire rejection).
pub fn next_int(&mut self, bound: u32) -> u32 {
debug_assert!(bound > 0);
let mut value = u64::from(self.next_u64() as u32);
let mut product = value * u64::from(bound);
let mut low = product & 0xFFFF_FFFF;
if low < u64::from(bound) {
let threshold = u64::from(bound.wrapping_neg() % bound);
while low < threshold {
value = u64::from(self.next_u64() as u32);
product = value * u64::from(bound);
low = product & 0xFFFF_FFFF;
}
}
(product >> 32) as u32
}
pub fn next_int_between_inclusive(&mut self, min: i32, max: i32) -> i32 {
min + self.next_int((max - min + 1) as u32) as i32
}
pub fn next_f32(&mut self) -> f32 {
(self.next_u64() >> 40) as f32 * 5.960_464_5e-8
}
pub fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 * 1.110_223_024_625_156_5e-16
}
}
/// `WorldgenRandom.setDecorationSeed(worldSeed, minBlockX, minBlockZ)`.
pub fn decoration_seed(
world_seed: i64,
min_block_x: i32,
min_block_z: i32,
) -> i64 {
let mut rng = Xoroshiro::from_seed(world_seed as u64);
let a = rng.next_i64() | 1;
let b = rng.next_i64() | 1;
(i64::from(min_block_x)
.wrapping_mul(a)
.wrapping_add(i64::from(min_block_z).wrapping_mul(b)))
^ world_seed
}
/// `WorldgenRandom.setFeatureSeed(decorationSeed, featureIndex, step)`.
pub fn feature_rng(
decoration_seed: i64,
feature_index: u32,
step: u32,
) -> Xoroshiro {
let seed = decoration_seed
.wrapping_add(i64::from(feature_index))
.wrapping_add(10_000_i64.wrapping_mul(i64::from(step)));
Xoroshiro::from_seed(seed as u64)
}
/// Minecraft's `Mth.sin` lookup-table sine, used by the ore blob's segment
/// radius curve. The table quantizes the angle to 1/65536 of a turn.
pub fn mc_sin(value: f32) -> f32 {
let index = ((value * 10430.378) as i32) as u16;
(f64::from(index) * std::f64::consts::PI * 2.0 / 65536.0).sin() as f32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xoroshiro_from_seed_matches_known_vector() {
let mut rng = Xoroshiro::from_seed(0);
let first = rng.next_u64();
let second = rng.next_u64();
assert_ne!(first, second);
let mut replay = Xoroshiro::from_seed(0);
assert_eq!(replay.next_u64(), first);
assert_eq!(replay.next_u64(), second);
}
#[test]
fn next_int_stays_in_bounds() {
let mut rng = Xoroshiro::from_seed(123);
for _ in 0..10_000 {
assert!(rng.next_int(16) < 16);
}
}
#[test]
fn decoration_seed_is_stable() {
let seed = decoration_seed(10_292_992, 0, 0);
assert_eq!(seed, decoration_seed(10_292_992, 0, 0));
assert_ne!(seed, decoration_seed(10_292_992, 16, 0));
}
#[test]
fn mc_sin_matches_table_quantization() {
assert!((mc_sin(0.0)).abs() < 1e-6);
assert!((mc_sin(std::f32::consts::PI / 2.0) - 1.0).abs() < 1e-3);
}
}

View File

@ -0,0 +1,574 @@
use crate::api::Result;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex};
use tauri::http::HeaderValue;
use tauri::http::header::ACCEPT;
use tauri::{Manager, ResourceId, Runtime, Webview};
use tauri_plugin_http::reqwest;
use tauri_plugin_http::reqwest::ClientBuilder;
use tauri_plugin_updater::{Error, Update, UpdaterExt};
use theseus::{
LoadingBarType, emit_loading, init_loading, launcher_user_agent,
};
use tokio::time::Instant;
use url::Url;
const UPDATE_SERVER_LATEST_URL: &str = "https://update.axlmc.org/latest";
const UPDATE_SERVER_API: &str = "https://update.axlmc.org/api/versions";
const UPDATE_SERVER_BASE: &str = "https://update.axlmc.org/";
// The updater plugin builds `Update` with no request timeout, so a stalled
// connection would hang the download forever. Bound the whole download.
const UPDATE_DOWNLOAD_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(15 * 60);
// ── Shared types ─────────────────────────────────────────────────
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateMetadata {
rid: ResourceId,
current_version: String,
version: String,
date: Option<String>,
body: Option<String>,
published_at: Option<String>,
force_update: bool,
raw_json: serde_json::Value,
}
#[derive(Default)]
pub struct PendingUpdateData(pub Mutex<Option<(Arc<Update>, Vec<u8>)>>);
// ── Update Server API types ─────────────────────────────────────
#[derive(Deserialize)]
struct VersionsResponse {
versions: Vec<VersionEntry>,
}
#[derive(Deserialize)]
struct VersionEntry {
version: String,
artifacts: Vec<ArtifactEntry>,
}
#[derive(Deserialize)]
struct ArtifactEntry {
kind: String,
#[serde(default)]
variant: Option<String>,
platform: String,
architecture: String,
relative_path: String,
#[serde(default)]
sha256: Option<String>,
#[serde(default)]
size: u64,
}
/// The .deb asset for an apt-managed Linux update, from the Update Server
/// catalog (`/api/versions`). The deb has no minisign signature, so its
/// integrity is verified with the catalog's sha256 and size instead.
struct AptDebAsset {
url: Url,
sha256: String,
size: u64,
}
fn apt_deb_arch() -> Result<&'static str> {
match std::env::consts::ARCH {
"x86_64" => Ok("amd64"),
"aarch64" => Ok("arm64"),
arch => Err(theseus::Error::from(theseus::ErrorKind::OtherError(
format!("Unsupported architecture for apt updates: {arch}"),
))
.into()),
}
}
async fn fetch_apt_deb_asset(version: &str) -> Result<AptDebAsset> {
let response = ClientBuilder::new()
.user_agent(launcher_user_agent())
.timeout(UPDATE_DOWNLOAD_TIMEOUT)
.build()?
.get(UPDATE_SERVER_API)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::Network(format!(
"Failed to fetch update catalog: {}",
response.status()
))
.into());
}
let catalog: VersionsResponse = response.json().await?;
let release = catalog
.versions
.iter()
.find(|entry| entry.version == version)
.ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Update catalog has no entry for version {version}"
)))
})?;
let arch = std::env::consts::ARCH;
let artifact = release
.artifacts
.iter()
.find(|entry| {
entry.kind == "installer"
&& entry.variant.as_deref() == Some("deb")
&& entry.platform == "linux"
&& entry.architecture == arch
})
.ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Update catalog has no deb artifact for {version} on {arch}"
)))
})?;
let sha256 = artifact.sha256.clone().ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Update catalog has no sha256 for the deb artifact of {version}"
)))
})?;
let url =
Url::parse(&format!("{UPDATE_SERVER_BASE}{}", artifact.relative_path))
.map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(
error.to_string(),
))
})?;
Ok(AptDebAsset {
url,
sha256,
size: artifact.size,
})
}
// ── Updater plugin helpers ───────────────────────────────────────
fn update_channel(channel: &str) -> Result<&str> {
match channel {
"release" | "beta" => Ok(channel),
_ => Err(theseus::Error::from(theseus::ErrorKind::OtherError(
format!("Unknown update channel: {channel}"),
))
.into()),
}
}
fn update_platform() -> Result<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("windows", "x86_64") => Ok("windows-x86_64"),
("linux", "x86_64") => Ok("linux-x86_64"),
("linux", "aarch64") => Ok("linux-aarch64"),
("macos", "x86_64") => Ok("darwin-x86_64"),
("macos", "aarch64") => Ok("darwin-aarch64"),
(os, arch) => {
Err(theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Unsupported updater platform: {os}-{arch}"
)))
.into())
}
}
}
fn update_endpoint() -> Result<Url> {
Url::parse(UPDATE_SERVER_LATEST_URL).map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(error.to_string()))
.into()
})
}
/// Build the platform-updater with the given endpoints and run a check.
async fn check_with_endpoints<R: Runtime>(
webview: &Webview<R>,
channel: &str,
) -> Result<Option<Update>> {
let channel = update_channel(channel)?;
let platform = update_platform()?;
let current_version =
webview.app_handle().package_info().version.to_string();
let mut updater = webview
.updater_builder()
.endpoints(vec![update_endpoint()?])?
.header("Accept", "application/json")?
.header("X-Axolotl-Channel", channel)?
.header("X-Axolotl-Platform", platform)?
.header("X-Axolotl-Version", current_version)?;
#[cfg(target_os = "windows")]
{
let install_dir = std::env::current_exe()
.map_err(|error| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to resolve current executable: {error}"
)))
})?
.parent()
.ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::OtherError(
"Current executable has no parent directory".to_string(),
))
})?
.to_path_buf();
tracing::debug!(
install_dir = %install_dir.display(),
"Using current executable directory for Windows app updates"
);
updater = updater.installer_arg(format!(
"/INSTALL_DIR=\"{}\"",
install_dir.display()
));
}
let updater = updater.build()?;
updater.check().await.map_err(Into::into)
}
/// Check the updater manifest through the configured Update Server endpoint.
async fn check_with_updater<R: Runtime>(
webview: &Webview<R>,
channel: &str,
) -> Result<Option<UpdateMetadata>> {
let Some(mut update) = check_with_endpoints(webview, channel).await? else {
return Ok(None);
};
update.timeout = Some(UPDATE_DOWNLOAD_TIMEOUT);
// On Debian and derivatives the plugin's minisign signature check cannot
// validate the unsigned .deb, so point the download at the deb from the
// Update Server catalog instead of the AppImage artifact. Its integrity
// is verified with the catalog's sha256/size during the download.
if is_apt_linux() {
update.download_url = fetch_apt_deb_asset(&update.version).await?.url;
}
let published_at = update
.raw_json
.get("published_at")
.and_then(serde_json::Value::as_str)
.map(str::to_owned);
let force_update = update
.raw_json
.get("force_update")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let metadata = UpdateMetadata {
rid: webview.resources_table().add(update.clone()),
current_version: update.current_version.clone(),
version: update.version.clone(),
date: None,
body: update.body.clone(),
published_at,
force_update,
raw_json: update.raw_json,
};
Ok(Some(metadata))
}
// ── Tauri commands ───────────────────────────────────────────────
#[tauri::command]
pub async fn check_app_update<R: Runtime>(
webview: Webview<R>,
channel: String,
) -> Result<Option<UpdateMetadata>> {
check_with_updater(&webview, &channel).await
}
// Reimplementation of Update::download mostly, minus the actual download part
#[tauri::command]
pub async fn get_update_size<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> Result<Option<u64>> {
let update = webview.resources_table().get::<Update>(rid)?;
let mut headers = update.headers.clone();
if !headers.contains_key(ACCEPT) {
headers.insert(
ACCEPT,
HeaderValue::from_static("application/octet-stream"),
);
}
let mut request = ClientBuilder::new().user_agent(launcher_user_agent());
if let Some(timeout) = update.timeout {
request = request.timeout(timeout);
}
if let Some(ref proxy) = update.proxy {
let proxy = reqwest::Proxy::all(proxy.as_str())?;
request = request.proxy(proxy);
}
let response = request
.build()?
.head(update.download_url.clone())
.headers(headers)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::Network(format!(
"Download request failed with status: {}",
response.status()
))
.into());
}
let content_length = response
.headers()
.get("Content-Length")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok());
Ok(content_length)
}
#[tauri::command]
pub async fn enqueue_update_for_installation<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> Result<()> {
let pending_data = webview.state::<PendingUpdateData>().inner();
let update = webview.resources_table().get::<Update>(rid)?;
let progress = init_loading(
LoadingBarType::LauncherUpdate {
version: update.version.clone(),
current_version: update.current_version.clone(),
},
1.0,
"Downloading update...",
)
.await?;
let download_start = Instant::now();
let update_data = if is_apt_linux() {
// The .deb carries no minisign signature, so the plugin's signed
// download cannot be used. Fetch the catalog entry and verify the
// downloaded bytes against its sha256 and size instead.
let asset = fetch_apt_deb_asset(&update.version).await?;
let mut headers = update.headers.clone();
if !headers.contains_key(ACCEPT) {
headers.insert(
ACCEPT,
HeaderValue::from_static("application/octet-stream"),
);
}
let mut request =
ClientBuilder::new().user_agent(launcher_user_agent());
if let Some(timeout) = update.timeout {
request = request.timeout(timeout);
}
if let Some(ref proxy) = update.proxy {
let proxy = reqwest::Proxy::all(proxy.as_str())?;
request = request.proxy(proxy);
}
let response = request
.build()?
.get(update.download_url.clone())
.headers(headers)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::Network(format!(
"Download request failed with status: {}",
response.status()
))
.into());
}
let total_size = response.content_length().unwrap_or(asset.size);
let mut buffer = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
buffer.extend_from_slice(&chunk);
if total_size > 0 {
if let Err(e) = emit_loading(
&progress,
buffer.len() as f64 / total_size as f64,
None,
) {
tracing::error!(
"Failed to update download progress bar: {e}"
);
}
}
}
if buffer.len() as u64 != asset.size {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
format!(
"Downloaded deb size mismatch: expected {}, got {}",
asset.size,
buffer.len()
),
))
.into());
}
let digest = Sha256::digest(&buffer);
let digest_hex = digest
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
if digest_hex != asset.sha256 {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"Downloaded deb sha256 mismatch".to_string(),
))
.into());
}
buffer
} else {
update
.download(
|chunk_size, total_size| {
let Some(total_size) = total_size else {
return;
};
if let Err(e) = emit_loading(
&progress,
chunk_size as f64 / total_size as f64,
None,
) {
tracing::error!(
"Failed to update download progress bar: {e}"
);
}
},
|| {},
)
.await?
};
let download_duration = download_start.elapsed();
tracing::info!("Downloaded update in {download_duration:?}");
pending_data
.0
.lock()
.unwrap()
.replace((update, update_data));
Ok(())
}
#[tauri::command]
pub fn remove_enqueued_update<R: Runtime>(webview: Webview<R>) {
let pending_data = webview.state::<PendingUpdateData>().inner();
pending_data.0.lock().unwrap().take();
}
// ── Debian / derivatives apt update ─────────────────────────────
/// Whether this Linux system updates Axolotl through apt (Debian and its
/// derivatives) and has `pkexec` available for a single privileged prompt.
#[tauri::command]
pub fn is_apt_linux() -> bool {
#[cfg(target_os = "linux")]
{
let debian_like = std::path::Path::new("/etc/debian_version").exists()
|| std::path::Path::new("/etc/apt").is_dir()
|| std::path::Path::new("/usr/bin/apt-get").exists();
let has_pkexec = ["/usr/bin/pkexec", "/bin/pkexec"]
.iter()
.any(|path| std::path::Path::new(path).exists());
debian_like && has_pkexec
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
/// Install the downloaded .deb on Debian and its derivatives, prompting for
/// root once via `pkexec`. The package is installed from the absolute path
/// of a temporary file, which is removed afterwards.
pub async fn install_apt_package(version: &str, data: &[u8]) -> Result<()> {
if !is_apt_linux() {
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
"apt updates are only supported on Debian-based Linux systems with pkexec"
.to_string(),
))
.into());
}
let arch = apt_deb_arch()?;
let deb_path = std::env::temp_dir()
.join(format!("Axolotl.Launcher_{version}_{arch}.deb"));
std::fs::write(&deb_path, data).map_err(|io| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to write the downloaded deb: {io}"
)))
})?;
let _deb_cleanup = TempDebFile(deb_path.clone());
let install_path = deb_path.clone();
let output = tokio::task::spawn_blocking(move || {
std::process::Command::new("pkexec")
.arg("apt")
.arg("install")
.arg("-y")
.arg(&install_path)
.output()
})
.await
.map_err(|join| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to run the apt updater: {join}"
)))
})?
.map_err(|io| {
theseus::Error::from(theseus::ErrorKind::OtherError(format!(
"Failed to start pkexec: {io}"
)))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(theseus::Error::from(theseus::ErrorKind::OtherError(
format!("apt install failed: {}", stderr.trim()),
))
.into());
}
Ok(())
}
/// Removes a temporary Debian package when installation finishes or fails.
///
/// The installer awaits a blocking task and has several fallible operations
/// after creating the file. Keeping cleanup in `Drop` makes every return path
/// (including task and process-launch errors) remove the package.
struct TempDebFile(std::path::PathBuf);
impl Drop for TempDebFile {
fn drop(&mut self) {
if let Err(error) = std::fs::remove_file(&self.0) {
if error.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(
path = %self.0.display(),
error = %error,
"Failed to remove temporary deb file"
);
}
}
}
}

View File

@ -0,0 +1,27 @@
use crate::api::Result;
#[derive(Default)]
pub struct PendingUpdateData(());
#[tauri::command]
pub fn check_app_update() -> Result<()> {
Ok(())
}
#[tauri::command]
pub fn get_update_size() -> Result<()> {
Ok(())
}
#[tauri::command]
pub fn enqueue_update_for_installation() -> Result<()> {
Ok(())
}
#[tauri::command]
pub fn remove_enqueued_update() {}
#[tauri::command]
pub fn is_apt_linux() -> bool {
false
}