feat:移除了弹窗,服务器添加sls
This commit is contained in:
105
apps/app/src/api/ai.rs
Normal file
105
apps/app/src/api/ai.rs
Normal 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
623
apps/app/src/api/auth.rs
Normal 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
161
apps/app/src/api/cache.rs
Normal 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,]
|
||||
);
|
||||
}
|
||||
}
|
||||
35
apps/app/src/api/content_favorites.rs
Normal file
35
apps/app/src/api/content_favorites.rs
Normal 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(())
|
||||
}
|
||||
61
apps/app/src/api/content_search.rs
Normal file
61
apps/app/src/api/content_search.rs
Normal 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,
|
||||
)
|
||||
}
|
||||
267
apps/app/src/api/curseforge.rs
Normal file
267
apps/app/src/api/curseforge.rs
Normal 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?)
|
||||
}
|
||||
102
apps/app/src/api/datapacks.rs
Normal file
102
apps/app/src/api/datapacks.rs
Normal 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
552
apps/app/src/api/drop.rs
Normal 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
614
apps/app/src/api/files.rs
Normal 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(())
|
||||
}
|
||||
33
apps/app/src/api/friends.rs
Normal file
33
apps/app/src/api/friends.rs
Normal 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?)
|
||||
}
|
||||
48
apps/app/src/api/import.rs
Normal file
48
apps/app/src/api/import.rs
Normal 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
405
apps/app/src/api/install.rs
Normal 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
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
170
apps/app/src/api/jre.rs
Normal 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
187
apps/app/src/api/logs.rs
Normal 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(())
|
||||
}
|
||||
48
apps/app/src/api/mcarchive.rs
Normal file
48
apps/app/src/api/mcarchive.rs
Normal 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?)
|
||||
}
|
||||
35
apps/app/src/api/metadata.rs
Normal file
35
apps/app/src/api/metadata.rs
Normal 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?)
|
||||
}
|
||||
}
|
||||
147
apps/app/src/api/minecraft_skins.rs
Normal file
147
apps/app/src/api/minecraft_skins.rs
Normal 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
142
apps/app/src/api/mod.rs
Normal 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,
|
||||
}
|
||||
754
apps/app/src/api/mod_translation.rs
Normal file
754
apps/app/src/api/mod_translation.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
84
apps/app/src/api/mr_auth.rs
Normal file
84
apps/app/src/api/mr_auth.rs
Normal 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();
|
||||
}
|
||||
110
apps/app/src/api/multiplayer.rs
Normal file
110
apps/app/src/api/multiplayer.rs
Normal 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(())
|
||||
}
|
||||
219
apps/app/src/api/oauth_utils/auth_code_reply.rs
Normal file
219
apps/app/src/api/oauth_utils/auth_code_reply.rs
Normal 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)
|
||||
}
|
||||
1
apps/app/src/api/oauth_utils/auth_code_reply/page.html
Normal file
1
apps/app/src/api/oauth_utils/auth_code_reply/page.html
Normal file
File diff suppressed because one or more lines are too long
3
apps/app/src/api/oauth_utils/mod.rs
Normal file
3
apps/app/src/api/oauth_utils/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
//! Assorted utilities for OAuth 2.0 authorization flows.
|
||||
|
||||
pub mod auth_code_reply;
|
||||
31
apps/app/src/api/planet_minecraft.rs
Normal file
31
apps/app/src/api/planet_minecraft.rs
Normal 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?)
|
||||
}
|
||||
36
apps/app/src/api/process.rs
Normal file
36
apps/app/src/api/process.rs
Normal 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?)
|
||||
}
|
||||
2333
apps/app/src/api/schematic_preview.rs
Normal file
2333
apps/app/src/api/schematic_preview.rs
Normal file
File diff suppressed because it is too large
Load Diff
151
apps/app/src/api/search_cancellation.rs
Normal file
151
apps/app/src/api/search_cancellation.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
97
apps/app/src/api/seed_map.rs
Normal file
97
apps/app/src/api/seed_map.rs
Normal 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
218
apps/app/src/api/servers.rs
Normal 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?)
|
||||
}
|
||||
133
apps/app/src/api/settings.rs
Normal file
133
apps/app/src/api/settings.rs
Normal 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}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
55
apps/app/src/api/shortcuts/linux.rs
Normal file
55
apps/app/src/api/shortcuts/linux.rs
Normal 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('`', "\\`")
|
||||
)
|
||||
}
|
||||
90
apps/app/src/api/shortcuts/macos.rs
Normal file
90
apps/app/src/api/shortcuts/macos.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
114
apps/app/src/api/shortcuts/mod.rs
Normal file
114
apps/app/src/api/shortcuts/mod.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
125
apps/app/src/api/shortcuts/windows.rs
Normal file
125
apps/app/src/api/shortcuts/windows.rs
Normal 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
173
apps/app/src/api/storage.rs
Normal 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 }
|
||||
}
|
||||
269
apps/app/src/api/system_accent.rs
Normal file
269
apps/app/src/api/system_accent.rs
Normal 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
44
apps/app/src/api/tags.rs
Normal 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?)
|
||||
}
|
||||
10
apps/app/src/api/telemetry.rs
Normal file
10
apps/app/src/api/telemetry.rs
Normal 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();
|
||||
}
|
||||
180
apps/app/src/api/terracotta.rs
Normal file
180
apps/app/src/api/terracotta.rs
Normal 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)
|
||||
})?)
|
||||
}
|
||||
55
apps/app/src/api/translation.rs
Normal file
55
apps/app/src/api/translation.rs
Normal 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
309
apps/app/src/api/utils.rs
Normal 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
294
apps/app/src/api/worlds.rs
Normal 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
18
apps/app/src/error.rs
Normal 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())
|
||||
}
|
||||
751
apps/app/src/lightweight_mode.rs
Normal file
751
apps/app/src/lightweight_mode.rs
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
27
apps/app/src/macos/deep_link.rs
Normal file
27
apps/app/src/macos/deep_link.rs
Normal 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
|
||||
}
|
||||
}
|
||||
1
apps/app/src/macos/mod.rs
Normal file
1
apps/app/src/macos/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod deep_link;
|
||||
1060
apps/app/src/main.rs
Normal file
1060
apps/app/src/main.rs
Normal file
File diff suppressed because it is too large
Load Diff
1874
apps/app/src/mod_translation/analyze.rs
Normal file
1874
apps/app/src/mod_translation/analyze.rs
Normal file
File diff suppressed because it is too large
Load Diff
157
apps/app/src/mod_translation/error.rs
Normal file
157
apps/app/src/mod_translation/error.rs
Normal 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>;
|
||||
651
apps/app/src/mod_translation/jar.rs
Normal file
651
apps/app/src/mod_translation/jar.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
428
apps/app/src/mod_translation/ledger.rs
Normal file
428
apps/app/src/mod_translation/ledger.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
181
apps/app/src/mod_translation/memory.rs
Normal file
181
apps/app/src/mod_translation/memory.rs
Normal 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")
|
||||
}
|
||||
11
apps/app/src/mod_translation/mod.rs
Normal file
11
apps/app/src/mod_translation/mod.rs
Normal 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;
|
||||
319
apps/app/src/mod_translation/mod_name.rs
Normal file
319
apps/app/src/mod_translation/mod_name.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
731
apps/app/src/mod_translation/quality.rs
Normal file
731
apps/app/src/mod_translation/quality.rs
Normal 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("种译法")));
|
||||
}
|
||||
}
|
||||
1072
apps/app/src/mod_translation/repair.rs
Normal file
1072
apps/app/src/mod_translation/repair.rs
Normal file
File diff suppressed because it is too large
Load Diff
395
apps/app/src/mod_translation/resume.rs
Normal file
395
apps/app/src/mod_translation/resume.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
2640
apps/app/src/mod_translation/translate.rs
Normal file
2640
apps/app/src/mod_translation/translate.rs
Normal file
File diff suppressed because it is too large
Load Diff
898
apps/app/src/mod_translation/writeback.rs
Normal file
898
apps/app/src/mod_translation/writeback.rs
Normal 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
74
apps/app/src/portable.rs
Normal 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)
|
||||
}
|
||||
826
apps/app/src/seed_map/cubiomes_bridge.c
Normal file
826
apps/app/src/seed_map/cubiomes_bridge.c
Normal 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);
|
||||
}
|
||||
115
apps/app/src/seed_map/cubiomes_bridge.h
Normal file
115
apps/app/src/seed_map/cubiomes_bridge.h
Normal 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
1197
apps/app/src/seed_map/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
873
apps/app/src/seed_map/ores.rs
Normal file
873
apps/app/src/seed_map/ores.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
150
apps/app/src/seed_map/rng.rs
Normal file
150
apps/app/src/seed_map/rng.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
574
apps/app/src/updater_impl.rs
Normal file
574
apps/app/src/updater_impl.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
apps/app/src/updater_impl_noop.rs
Normal file
27
apps/app/src/updater_impl_noop.rs
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user