1716 lines
55 KiB
Rust
1716 lines
55 KiB
Rust
use eyre::{Context, bail};
|
|
use flate2::read::GzDecoder;
|
|
use futures::StreamExt;
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha512};
|
|
use std::collections::VecDeque;
|
|
use std::path::PathBuf;
|
|
use std::process::Stdio;
|
|
use std::sync::{Arc, LazyLock};
|
|
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
|
|
use tokio::process::{Child, Command};
|
|
use tokio::sync::Mutex;
|
|
use tokio::task::JoinHandle;
|
|
use tracing::{info, warn};
|
|
|
|
use self::binary::{
|
|
cleanup_legacy_versions, find_terracotta_executable,
|
|
install_terracotta_binary, is_terracotta_executable,
|
|
resolve_installed_terracotta_binary_path, resolve_terracotta_binary_path,
|
|
terracotta_binary_name, terracotta_binary_path,
|
|
validate_terracotta_version, versioned_terracotta_binary_name,
|
|
};
|
|
pub use self::binary::{terracotta_download_urls, terracotta_platform_key};
|
|
use self::lan::MinecraftLanAnnouncer;
|
|
use super::data::Credentials;
|
|
|
|
mod binary;
|
|
mod lan;
|
|
|
|
const MAX_TERRACOTTA_ARCHIVE_SIZE: u64 = 256 * 1024 * 1024;
|
|
const MAX_TERRACOTTA_DIAGNOSTIC_LINES: usize = 2_000;
|
|
const MAX_CONSECUTIVE_POLL_FAILURES: u8 = 3;
|
|
const TERRACOTTA_REQUEST_TIMEOUT: std::time::Duration =
|
|
std::time::Duration::from_secs(10);
|
|
|
|
static TERRACOTTA_STATE: LazyLock<Mutex<TerracottaState>> =
|
|
LazyLock::new(|| Mutex::new(TerracottaState::default()));
|
|
|
|
static TERRACOTTA_RUNTIME: LazyLock<Mutex<TerracottaRuntime>> =
|
|
LazyLock::new(|| Mutex::new(TerracottaRuntime::default()));
|
|
|
|
static TERRACOTTA_OPERATION: LazyLock<Mutex<()>> =
|
|
LazyLock::new(|| Mutex::new(()));
|
|
|
|
static TERRACOTTA_DIAGNOSTIC_OUTPUT: LazyLock<
|
|
std::sync::Mutex<VecDeque<String>>,
|
|
> = LazyLock::new(|| std::sync::Mutex::new(VecDeque::new()));
|
|
|
|
fn record_terracotta_output(pid: u32, stream: &str, text: &str) {
|
|
let line = format!(
|
|
"{} pid={pid} {stream}: {text}",
|
|
chrono::Local::now().to_rfc3339()
|
|
);
|
|
if let Ok(mut output) = TERRACOTTA_DIAGNOSTIC_OUTPUT.lock() {
|
|
output.push_back(line);
|
|
while output.len() > MAX_TERRACOTTA_DIAGNOSTIC_LINES {
|
|
output.pop_front();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
pub struct TerracottaState {
|
|
pub status: TerracottaStatus,
|
|
pub http_port: Option<u16>,
|
|
pub room_code: Option<String>,
|
|
pub server_port: Option<u16>,
|
|
pub players: Vec<PlayerInfo>,
|
|
pub download_progress: Option<u8>,
|
|
pub download_stage: Option<TerracottaDownloadStage>,
|
|
pub binary_installed: bool,
|
|
pub installed_version: Option<String>,
|
|
pub error_type: Option<TerracottaErrorType>,
|
|
pub error_message: Option<String>,
|
|
pub profile_index: Option<u32>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
#[derive(Default)]
|
|
pub enum TerracottaStatus {
|
|
#[default]
|
|
Idle,
|
|
Starting,
|
|
Downloading,
|
|
Waiting,
|
|
HostScanning,
|
|
HostStarting,
|
|
HostReady,
|
|
GuestConnecting,
|
|
GuestStarting,
|
|
GuestReady,
|
|
Error,
|
|
Fatal,
|
|
}
|
|
|
|
impl TerracottaStatus {
|
|
fn from_api(value: &str) -> Option<Self> {
|
|
match value {
|
|
"idle" => Some(Self::Idle),
|
|
"starting" => Some(Self::Starting),
|
|
"waiting" => Some(Self::Waiting),
|
|
"host-scanning" => Some(Self::HostScanning),
|
|
"host-starting" => Some(Self::HostStarting),
|
|
"host-ok" => Some(Self::HostReady),
|
|
"guest-connecting" => Some(Self::GuestConnecting),
|
|
"guest-starting" => Some(Self::GuestStarting),
|
|
"guest-ok" => Some(Self::GuestReady),
|
|
"exception" => Some(Self::Error),
|
|
"fatal" => Some(Self::Fatal),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum TerracottaDownloadStage {
|
|
Preparing,
|
|
Downloading,
|
|
Verifying,
|
|
Extracting,
|
|
Installing,
|
|
Complete,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum TerracottaErrorType {
|
|
Os,
|
|
Network,
|
|
Install,
|
|
Terracotta,
|
|
Unknown,
|
|
}
|
|
|
|
impl From<i32> for TerracottaErrorType {
|
|
fn from(value: i32) -> Self {
|
|
match value {
|
|
0 => Self::Os,
|
|
1 => Self::Network,
|
|
2 => Self::Install,
|
|
3 => Self::Terracotta,
|
|
_ => Self::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct PlayerInfo {
|
|
pub machine_id: String,
|
|
pub name: String,
|
|
pub vendor: String,
|
|
pub kind: TerracottaPlayerKind,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
|
pub enum TerracottaPlayerKind {
|
|
Host,
|
|
Guest,
|
|
Unknown,
|
|
}
|
|
|
|
impl From<&str> for TerracottaPlayerKind {
|
|
fn from(value: &str) -> Self {
|
|
match value.to_ascii_uppercase().as_str() {
|
|
"HOST" => Self::Host,
|
|
"GUEST" => Self::Guest,
|
|
_ => Self::Unknown,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct TerracottaMeta {
|
|
pub version: String,
|
|
pub compile_timestamp: String,
|
|
pub easytier_version: String,
|
|
pub yggdrasil_port: u16,
|
|
pub target_tuple: String,
|
|
pub target_os: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct TerracottaUpdate {
|
|
pub installed_version: Option<String>,
|
|
pub latest_version: String,
|
|
pub update_available: bool,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize)]
|
|
struct InstalledTerracottaInfo {
|
|
version: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
struct TerracottaApiState {
|
|
state: String,
|
|
#[serde(default)]
|
|
index: Option<u32>,
|
|
#[serde(default)]
|
|
room: Option<String>,
|
|
#[serde(default)]
|
|
profile_index: Option<u32>,
|
|
#[serde(default)]
|
|
profiles: Option<Vec<TerracottaApiProfile>>,
|
|
#[serde(default)]
|
|
url: Option<String>,
|
|
#[serde(default)]
|
|
r#type: Option<i32>,
|
|
#[serde(default)]
|
|
difficulty: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
struct TerracottaApiProfile {
|
|
machine_id: String,
|
|
name: String,
|
|
vendor: String,
|
|
kind: String,
|
|
}
|
|
|
|
struct TerracottaProcess {
|
|
child: Child,
|
|
output: Arc<Mutex<VecDeque<String>>>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct TerracottaRuntime {
|
|
process: Option<TerracottaProcess>,
|
|
state_poller: Option<JoinHandle<()>>,
|
|
lan_announcer: MinecraftLanAnnouncer,
|
|
}
|
|
|
|
impl Drop for TerracottaProcess {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.start_kill();
|
|
}
|
|
}
|
|
|
|
fn capture_terracotta_output<R>(
|
|
reader: R,
|
|
stream: &'static str,
|
|
pid: u32,
|
|
output: Arc<Mutex<VecDeque<String>>>,
|
|
) where
|
|
R: AsyncRead + Unpin + Send + 'static,
|
|
{
|
|
tokio::spawn(async move {
|
|
let mut lines = BufReader::new(reader).lines();
|
|
while let Ok(Some(text)) = lines.next_line().await {
|
|
tracing::info!(pid, stream, "terracotta process: {text}");
|
|
record_terracotta_output(pid, stream, &text);
|
|
let mut captured = output.lock().await;
|
|
captured.push_back(format!("{stream}: {text}"));
|
|
if captured.len() > 50 {
|
|
captured.pop_front();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async fn take_terminated_terracotta_process() -> eyre::Result<
|
|
Option<(std::process::ExitStatus, Arc<Mutex<VecDeque<String>>>)>,
|
|
> {
|
|
let mut runtime = TERRACOTTA_RUNTIME.lock().await;
|
|
let Some(process) = runtime.process.as_mut() else {
|
|
return Ok(None);
|
|
};
|
|
let Some(status) = process
|
|
.child
|
|
.try_wait()
|
|
.wrap_err("failed to inspect terracotta process")?
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let output = process.output.clone();
|
|
runtime.process.take();
|
|
Ok(Some((status, output)))
|
|
}
|
|
|
|
async fn format_terracotta_exit(
|
|
status: std::process::ExitStatus,
|
|
output: Arc<Mutex<VecDeque<String>>>,
|
|
) -> String {
|
|
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
|
let captured = output.lock().await;
|
|
let details = captured.iter().cloned().collect::<Vec<_>>().join("\n");
|
|
if details.is_empty() {
|
|
format!("terracotta exited before startup completed ({status})")
|
|
} else {
|
|
format!(
|
|
"terracotta exited before startup completed ({status}):\n{details}"
|
|
)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn reported_terracotta_port(output: &str) -> Option<u16> {
|
|
output.lines().find_map(|line| {
|
|
let (_, port) = line.rsplit_once("port = ")?;
|
|
port.trim().parse().ok()
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn terracotta_startup_arguments(target_os: &str) -> &'static [&'static str] {
|
|
if target_os == "macos" {
|
|
&["--daemon"]
|
|
} else {
|
|
&[]
|
|
}
|
|
}
|
|
|
|
async fn get_latest_terracotta_version() -> eyre::Result<String> {
|
|
#[derive(Deserialize)]
|
|
struct ReleaseInfo {
|
|
tag_name: String,
|
|
}
|
|
|
|
let endpoints = [
|
|
"https://gitee.com/api/v5/repos/burningtnt/Terracotta/releases/latest",
|
|
"https://api.github.com/repos/burningtnt/Terracotta/releases/latest",
|
|
];
|
|
let mut failures = Vec::new();
|
|
|
|
for endpoint in endpoints {
|
|
let response = crate::util::fetch::INSECURE_REQWEST_CLIENT
|
|
.get(endpoint)
|
|
.header("Accept", "application/json")
|
|
.header("User-Agent", crate::launcher_user_agent())
|
|
.send()
|
|
.await;
|
|
match response {
|
|
Ok(response) if response.status().is_success() => {
|
|
match response.json::<ReleaseInfo>().await {
|
|
Ok(info) => {
|
|
return Ok(info
|
|
.tag_name
|
|
.trim_start_matches('v')
|
|
.to_string());
|
|
}
|
|
Err(error) => failures.push(format!("{endpoint}: {error}")),
|
|
}
|
|
}
|
|
Ok(response) => {
|
|
failures.push(format!("{endpoint}: HTTP {}", response.status()))
|
|
}
|
|
Err(error) => failures.push(format!("{endpoint}: {error}")),
|
|
}
|
|
}
|
|
|
|
bail!(
|
|
"failed to fetch latest terracotta release info: {}",
|
|
failures.join("; ")
|
|
)
|
|
}
|
|
|
|
fn terracotta_version_file() -> PathBuf {
|
|
terracotta_binary_path().with_file_name("terracotta-version.json")
|
|
}
|
|
|
|
fn installed_terracotta_version() -> Option<String> {
|
|
if !is_binary_installed() {
|
|
return None;
|
|
}
|
|
|
|
let bytes = std::fs::read(terracotta_version_file()).ok()?;
|
|
let info =
|
|
serde_json::from_slice::<InstalledTerracottaInfo>(&bytes).ok()?;
|
|
validate_terracotta_version(&info.version).ok()?;
|
|
Some(info.version)
|
|
}
|
|
|
|
async fn persist_installed_terracotta_version(
|
|
version: &str,
|
|
) -> eyre::Result<()> {
|
|
let version_file = terracotta_version_file();
|
|
let temporary_file =
|
|
version_file.with_file_name("terracotta-version.json.tmp");
|
|
let contents = serde_json::to_vec(&InstalledTerracottaInfo {
|
|
version: version.to_string(),
|
|
})?;
|
|
|
|
tokio::fs::write(&temporary_file, contents)
|
|
.await
|
|
.wrap_err("failed to write terracotta version metadata")?;
|
|
tokio::fs::rename(&temporary_file, &version_file)
|
|
.await
|
|
.wrap_err("failed to install terracotta version metadata")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn update_is_available(
|
|
installed_version: Option<&str>,
|
|
latest_version: &str,
|
|
) -> bool {
|
|
installed_version != Some(latest_version)
|
|
}
|
|
|
|
pub async fn check_for_update() -> eyre::Result<TerracottaUpdate> {
|
|
let latest_version = get_latest_terracotta_version().await?;
|
|
let installed_version = installed_terracotta_version();
|
|
Ok(TerracottaUpdate {
|
|
update_available: update_is_available(
|
|
installed_version.as_deref(),
|
|
&latest_version,
|
|
),
|
|
installed_version,
|
|
latest_version,
|
|
})
|
|
}
|
|
|
|
async fn download_terracotta_inner(
|
|
version: Option<String>,
|
|
) -> eyre::Result<()> {
|
|
info!(requested_version = ?version, "starting terracotta download");
|
|
{
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
state.status = TerracottaStatus::Downloading;
|
|
state.download_progress = Some(0);
|
|
state.download_stage = Some(TerracottaDownloadStage::Preparing);
|
|
state.error_type = None;
|
|
state.error_message = None;
|
|
}
|
|
|
|
let version = match version {
|
|
Some(v) => v,
|
|
None => get_latest_terracotta_version().await?,
|
|
};
|
|
validate_terracotta_version(&version)?;
|
|
|
|
let platform = terracotta_platform_key();
|
|
info!(version, platform, "resolved terracotta download");
|
|
if platform == "unsupported" {
|
|
bail!(
|
|
"no terracotta binary available for {}/{}",
|
|
std::env::consts::OS,
|
|
std::env::consts::ARCH
|
|
);
|
|
}
|
|
|
|
let urls = terracotta_download_urls(&version, platform);
|
|
let mut archive_data: Option<Vec<u8>> = None;
|
|
|
|
for url in &urls {
|
|
info!("attempting to download terracotta from {url}");
|
|
|
|
{
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
state.download_stage = Some(TerracottaDownloadStage::Downloading);
|
|
}
|
|
|
|
match crate::util::fetch::INSECURE_REQWEST_CLIENT
|
|
.get(url)
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(response) if response.status().is_success() => {
|
|
let total_size = response.content_length().unwrap_or(0);
|
|
if total_size > MAX_TERRACOTTA_ARCHIVE_SIZE {
|
|
warn!(
|
|
"terracotta archive from {url} is too large: {total_size} bytes"
|
|
);
|
|
continue;
|
|
}
|
|
let mut downloaded: u64 = 0;
|
|
let mut stream = response.bytes_stream();
|
|
let mut data = Vec::with_capacity(total_size as usize);
|
|
let mut hasher = Sha512::new();
|
|
let mut stream_error = None;
|
|
|
|
while let Some(chunk) = stream.next().await {
|
|
let chunk = match chunk {
|
|
Ok(chunk) => chunk,
|
|
Err(error) => {
|
|
stream_error = Some(error.to_string());
|
|
break;
|
|
}
|
|
};
|
|
hasher.update(&chunk);
|
|
data.extend_from_slice(&chunk);
|
|
downloaded += chunk.len() as u64;
|
|
if downloaded > MAX_TERRACOTTA_ARCHIVE_SIZE {
|
|
stream_error = Some(format!(
|
|
"archive exceeds {MAX_TERRACOTTA_ARCHIVE_SIZE} bytes"
|
|
));
|
|
break;
|
|
}
|
|
|
|
if total_size > 0 {
|
|
let pct = ((downloaded as f64 / total_size as f64)
|
|
* 100.0)
|
|
.clamp(0.0, 100.0)
|
|
as u8;
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
state.download_progress = Some(pct);
|
|
}
|
|
}
|
|
if let Some(error) = stream_error {
|
|
warn!("download from {url} failed: {error}");
|
|
continue;
|
|
}
|
|
|
|
info!(
|
|
url,
|
|
downloaded_bytes = downloaded,
|
|
"downloaded terracotta archive"
|
|
);
|
|
|
|
let computed_hash = format!("{:x}", hasher.finalize());
|
|
|
|
{
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
state.download_stage =
|
|
Some(TerracottaDownloadStage::Verifying);
|
|
}
|
|
|
|
let hash_url = format!("{url}.sha512");
|
|
match crate::util::fetch::INSECURE_REQWEST_CLIENT
|
|
.get(&hash_url)
|
|
.send()
|
|
.await
|
|
{
|
|
Ok(hash_resp) if hash_resp.status().is_success() => {
|
|
let checksum = hash_resp
|
|
.text()
|
|
.await
|
|
.unwrap_or_default()
|
|
.trim()
|
|
.to_string();
|
|
let expected_hash = checksum.split_whitespace().next();
|
|
if expected_hash.is_none_or(|hash| {
|
|
hash.len() != 128
|
|
|| !hash.chars().all(|character| {
|
|
character.is_ascii_hexdigit()
|
|
})
|
|
|| !computed_hash.eq_ignore_ascii_case(hash)
|
|
}) {
|
|
warn!(
|
|
"SHA-512 mismatch for terracotta archive from {url}: \
|
|
expected {expected_hash:?}, computed {computed_hash}"
|
|
);
|
|
continue;
|
|
}
|
|
info!(
|
|
"SHA-512 verification passed for terracotta archive"
|
|
);
|
|
}
|
|
_ => {
|
|
warn!(
|
|
"no SHA-512 checksum available at {hash_url}, \
|
|
skipping verification"
|
|
);
|
|
}
|
|
}
|
|
|
|
archive_data = Some(data);
|
|
break;
|
|
}
|
|
Ok(response) => {
|
|
warn!(
|
|
"download from {url} returned HTTP {}",
|
|
response.status()
|
|
);
|
|
}
|
|
Err(e) => {
|
|
warn!("download from {url} failed: {e}");
|
|
}
|
|
}
|
|
}
|
|
|
|
let archive_data = archive_data.ok_or_else(|| {
|
|
eyre::eyre!("all download sources failed for terracotta v{version}")
|
|
})?;
|
|
|
|
{
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
state.download_stage = Some(TerracottaDownloadStage::Extracting);
|
|
state.download_progress = None;
|
|
}
|
|
|
|
let target_dir = terracotta_binary_path()
|
|
.parent()
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or_else(|| PathBuf::from("terracotta"));
|
|
|
|
tokio::fs::create_dir_all(&target_dir)
|
|
.await
|
|
.wrap_err("failed to create terracotta directory")?;
|
|
|
|
let staging_dir = tempfile::Builder::new()
|
|
.prefix("terracotta-install-")
|
|
.tempdir_in(&target_dir)
|
|
.wrap_err(
|
|
"failed to create terracotta installation staging directory",
|
|
)?;
|
|
let staging_path = staging_dir.path().to_path_buf();
|
|
let archive_data_clone = archive_data;
|
|
|
|
tokio::task::spawn_blocking(move || -> eyre::Result<()> {
|
|
let decoder = GzDecoder::new(&archive_data_clone[..]);
|
|
let mut archive = tar::Archive::new(decoder);
|
|
archive
|
|
.unpack(&staging_path)
|
|
.wrap_err("failed to extract terracotta archive")?;
|
|
Ok(())
|
|
})
|
|
.await??;
|
|
|
|
{
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
state.download_stage = Some(TerracottaDownloadStage::Installing);
|
|
}
|
|
|
|
let expected_path = target_dir.join(terracotta_binary_name());
|
|
let versioned_name = versioned_terracotta_binary_name(&version, platform);
|
|
let candidate =
|
|
find_terracotta_executable(staging_dir.path(), Some(&versioned_name))
|
|
.ok_or_else(|| {
|
|
eyre::eyre!(
|
|
"no valid terracotta executable found in downloaded archive"
|
|
)
|
|
})?;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let metadata = std::fs::metadata(&candidate)?;
|
|
let mut permissions = metadata.permissions();
|
|
permissions.set_mode(0o755);
|
|
std::fs::set_permissions(&candidate, permissions)?;
|
|
}
|
|
|
|
install_terracotta_binary(&candidate, &expected_path).await?;
|
|
if !is_terracotta_executable(&expected_path) {
|
|
bail!("installed terracotta binary failed executable validation");
|
|
}
|
|
persist_installed_terracotta_version(&version).await?;
|
|
info!(
|
|
"installed {} as {}",
|
|
candidate.display(),
|
|
expected_path.display()
|
|
);
|
|
|
|
cleanup_legacy_versions(&version).await?;
|
|
|
|
info!(
|
|
"terracotta v{version} installed to {}",
|
|
target_dir.display()
|
|
);
|
|
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
state.download_progress = Some(100);
|
|
state.download_stage = Some(TerracottaDownloadStage::Complete);
|
|
drop(state);
|
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
state.status = TerracottaStatus::Idle;
|
|
state.download_progress = None;
|
|
state.download_stage = None;
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_terracotta_download(version: Option<String>) -> eyre::Result<()> {
|
|
let result = download_terracotta_inner(version).await;
|
|
if let Err(error) = &result {
|
|
warn!("terracotta download failed: {error:#}");
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
state.status = TerracottaStatus::Error;
|
|
state.download_progress = None;
|
|
state.download_stage = None;
|
|
state.error_type = Some(TerracottaErrorType::Install);
|
|
state.error_message = Some(format!("{error:#}"));
|
|
}
|
|
result
|
|
}
|
|
|
|
pub async fn download_terracotta(version: Option<String>) -> eyre::Result<()> {
|
|
let _operation = TERRACOTTA_OPERATION.lock().await;
|
|
if TERRACOTTA_STATE.lock().await.http_port.is_some() {
|
|
bail!(
|
|
"cannot replace terracotta while the multiplayer service is running"
|
|
);
|
|
}
|
|
run_terracotta_download(version).await
|
|
}
|
|
|
|
pub async fn update_terracotta() -> eyre::Result<TerracottaUpdate> {
|
|
let _operation = TERRACOTTA_OPERATION.lock().await;
|
|
if TERRACOTTA_STATE.lock().await.http_port.is_some() {
|
|
bail!(
|
|
"cannot update terracotta while the multiplayer service is running"
|
|
);
|
|
}
|
|
|
|
let latest_version = get_latest_terracotta_version().await?;
|
|
let installed_version = installed_terracotta_version();
|
|
if update_is_available(installed_version.as_deref(), &latest_version) {
|
|
run_terracotta_download(Some(latest_version.clone())).await?;
|
|
}
|
|
|
|
Ok(TerracottaUpdate {
|
|
installed_version: installed_terracotta_version(),
|
|
latest_version,
|
|
update_available: false,
|
|
})
|
|
}
|
|
|
|
fn terracotta_client() -> &'static reqwest::Client {
|
|
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
|
reqwest::Client::builder()
|
|
.no_proxy()
|
|
.connect_timeout(std::time::Duration::from_secs(2))
|
|
.timeout(TERRACOTTA_REQUEST_TIMEOUT)
|
|
.build()
|
|
.expect("terracotta client should build")
|
|
});
|
|
&CLIENT
|
|
}
|
|
|
|
async fn terracotta_get<T: serde::de::DeserializeOwned>(
|
|
port: u16,
|
|
path: &str,
|
|
) -> eyre::Result<T> {
|
|
let client = terracotta_client();
|
|
let url = format!("http://127.0.0.1:{port}{path}");
|
|
let resp = client.get(&url).send().await.wrap_err_with(|| {
|
|
format!("failed to connect to terracotta at {url}")
|
|
})?;
|
|
let status = resp.status();
|
|
let body = resp
|
|
.text()
|
|
.await
|
|
.wrap_err("failed to read terracotta response")?;
|
|
if !status.is_success() {
|
|
bail!(
|
|
"terracotta returned {} for {}: {}",
|
|
status.as_u16(),
|
|
path,
|
|
body
|
|
);
|
|
}
|
|
let parsed: T = serde_json::from_str(&body).wrap_err_with(|| {
|
|
format!("failed to parse terracotta response for {path}: {body}")
|
|
})?;
|
|
Ok(parsed)
|
|
}
|
|
|
|
fn terracotta_server_port(url: Option<&str>) -> Option<u16> {
|
|
url?.parse::<std::net::SocketAddr>()
|
|
.ok()
|
|
.map(|url| url.port())
|
|
}
|
|
|
|
async fn sync_minecraft_lan_announcer(port: Option<u16>) {
|
|
TERRACOTTA_RUNTIME.lock().await.lan_announcer.sync(port);
|
|
}
|
|
|
|
async fn spawn_terracotta_state_poller(port: u16) {
|
|
let mut runtime = TERRACOTTA_RUNTIME.lock().await;
|
|
if let Some(task) = runtime.state_poller.take() {
|
|
task.abort();
|
|
}
|
|
runtime.state_poller = Some(tokio::spawn(poll_terracotta_state(port)));
|
|
}
|
|
|
|
async fn poll_terracotta_state(port: u16) {
|
|
let mut last_index: u32 = 0;
|
|
let mut consecutive_failures = 0;
|
|
loop {
|
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
|
match terracotta_get::<TerracottaApiState>(port, "/state").await {
|
|
Ok(api_state) => {
|
|
consecutive_failures = 0;
|
|
let new_index = api_state.index.unwrap_or(0);
|
|
if new_index > 0 && new_index <= last_index {
|
|
continue;
|
|
}
|
|
last_index = new_index;
|
|
let server_port = if api_state.state == "guest-ok" {
|
|
terracotta_server_port(api_state.url.as_deref())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
let previous_status = state.status;
|
|
state.http_port = Some(port);
|
|
state.room_code = api_state.room.clone();
|
|
state.server_port = server_port;
|
|
state.profile_index = api_state.profile_index;
|
|
|
|
state.status = TerracottaStatus::from_api(&api_state.state)
|
|
.unwrap_or_else(|| {
|
|
warn!("unknown terracotta state: {}", api_state.state);
|
|
TerracottaStatus::Error
|
|
});
|
|
if state.status != previous_status {
|
|
info!(
|
|
?previous_status,
|
|
status = ?state.status,
|
|
player_count = api_state
|
|
.profiles
|
|
.as_ref()
|
|
.map_or(state.players.len(), Vec::len),
|
|
has_room_code = api_state.room.is_some(),
|
|
"terracotta state changed"
|
|
);
|
|
}
|
|
|
|
if TerracottaStatus::from_api(&api_state.state).is_none() {
|
|
state.error_type = Some(TerracottaErrorType::Terracotta);
|
|
state.error_message = Some(format!(
|
|
"unsupported terracotta state: {}",
|
|
api_state.state
|
|
));
|
|
} else if state.status == TerracottaStatus::Fatal {
|
|
state.error_type =
|
|
api_state.r#type.map(TerracottaErrorType::from);
|
|
state.error_message = api_state.url.clone();
|
|
} else if state.status != TerracottaStatus::Error {
|
|
state.error_type = None;
|
|
state.error_message = None;
|
|
}
|
|
|
|
if let Some(profiles) = api_state.profiles {
|
|
state.players = profiles
|
|
.into_iter()
|
|
.map(|p| PlayerInfo {
|
|
machine_id: p.machine_id,
|
|
name: p.name,
|
|
vendor: p.vendor,
|
|
kind: TerracottaPlayerKind::from(p.kind.as_str()),
|
|
})
|
|
.collect();
|
|
}
|
|
drop(state);
|
|
sync_minecraft_lan_announcer(server_port).await;
|
|
}
|
|
Err(e) => {
|
|
warn!("failed to poll terracotta state: {e:#}");
|
|
consecutive_failures += 1;
|
|
if consecutive_failures < MAX_CONSECUTIVE_POLL_FAILURES {
|
|
continue;
|
|
}
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
if state.status != TerracottaStatus::Idle {
|
|
state.status = TerracottaStatus::Error;
|
|
}
|
|
state.http_port = None;
|
|
state.server_port = None;
|
|
drop(state);
|
|
let mut runtime = TERRACOTTA_RUNTIME.lock().await;
|
|
if let Some(mut process) = runtime.process.take() {
|
|
process.child.start_kill().ok();
|
|
}
|
|
runtime.lan_announcer.sync(None);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_binary_installed() -> bool {
|
|
is_terracotta_executable(&resolve_installed_terracotta_binary_path())
|
|
}
|
|
|
|
pub async fn get_state() -> TerracottaState {
|
|
let mut state = TERRACOTTA_STATE.lock().await.clone();
|
|
state.binary_installed = is_binary_installed();
|
|
state.installed_version = installed_terracotta_version();
|
|
state
|
|
}
|
|
|
|
pub async fn get_meta() -> eyre::Result<TerracottaMeta> {
|
|
let state = TERRACOTTA_STATE.lock().await;
|
|
let port = state
|
|
.http_port
|
|
.ok_or_else(|| eyre::eyre!("terracotta is not running"))?;
|
|
drop(state);
|
|
terracotta_get::<TerracottaMeta>(port, "/meta").await
|
|
}
|
|
|
|
pub async fn get_player_name() -> String {
|
|
match crate::State::get_if_initialized() {
|
|
Some(state_ref) => match Credentials::get_active(&state_ref.pool).await
|
|
{
|
|
Ok(Some(creds)) => creds.offline_profile.name,
|
|
_ => "Anonymous".to_string(),
|
|
},
|
|
None => "Anonymous".to_string(),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct TerracottaPortInfo {
|
|
port: u16,
|
|
}
|
|
|
|
fn terracotta_port_file() -> PathBuf {
|
|
std::env::temp_dir()
|
|
.join(format!("terracotta_port_{}.json", std::process::id()))
|
|
}
|
|
|
|
pub async fn start_terracotta(
|
|
binary_path: Option<String>,
|
|
auto_download: bool,
|
|
) -> eyre::Result<()> {
|
|
let _operation = TERRACOTTA_OPERATION.lock().await;
|
|
info!(
|
|
custom_binary = binary_path.is_some(),
|
|
auto_download,
|
|
platform = terracotta_platform_key(),
|
|
"starting terracotta multiplayer service"
|
|
);
|
|
let custom_bin_path = binary_path.map(PathBuf::from);
|
|
let mut final_path = custom_bin_path.as_deref().map_or_else(
|
|
resolve_installed_terracotta_binary_path,
|
|
resolve_terracotta_binary_path,
|
|
);
|
|
|
|
if !is_terracotta_executable(&final_path) && auto_download {
|
|
info!("terracotta binary not found, attempting auto-download");
|
|
run_terracotta_download(None).await?;
|
|
final_path = custom_bin_path.as_deref().map_or_else(
|
|
resolve_installed_terracotta_binary_path,
|
|
resolve_terracotta_binary_path,
|
|
);
|
|
}
|
|
|
|
if !is_terracotta_executable(&final_path) {
|
|
bail!(
|
|
"valid terracotta executable not found at {} (platform: {}, expected name: {})",
|
|
final_path.display(),
|
|
terracotta_platform_key(),
|
|
terracotta_binary_name()
|
|
);
|
|
}
|
|
info!(binary = %final_path.display(), "using terracotta executable");
|
|
|
|
let mut runtime = TERRACOTTA_RUNTIME.lock().await;
|
|
if let Some(process) = runtime.process.as_mut() {
|
|
if process
|
|
.child
|
|
.try_wait()
|
|
.wrap_err("failed to inspect existing terracotta process")?
|
|
.is_none()
|
|
{
|
|
bail!("terracotta is already running");
|
|
}
|
|
warn!("discarding exited terracotta process before restart");
|
|
runtime.process.take();
|
|
}
|
|
|
|
let port_file = terracotta_port_file();
|
|
let _ = std::fs::remove_file(&port_file);
|
|
|
|
let is_macos = cfg!(target_os = "macos");
|
|
let mut command = Command::new(&final_path);
|
|
if is_macos {
|
|
command.arg("--daemon");
|
|
} else {
|
|
command.arg("--hmcl").arg(&port_file);
|
|
}
|
|
let mut child = command
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.kill_on_drop(true)
|
|
.spawn()
|
|
.wrap_err_with(|| {
|
|
let mode = if is_macos { "daemon" } else { "--hmcl" };
|
|
format!(
|
|
"failed to start terracotta {mode} at {}",
|
|
final_path.display()
|
|
)
|
|
})?;
|
|
|
|
let stdout = child.stdout.take();
|
|
let stderr = child.stderr.take();
|
|
let pid = child.id().unwrap_or(0);
|
|
let output = Arc::new(Mutex::new(VecDeque::new()));
|
|
|
|
if let Some(stdout) = stdout {
|
|
capture_terracotta_output(stdout, "stdout", pid, output.clone());
|
|
}
|
|
if let Some(stderr) = stderr {
|
|
capture_terracotta_output(stderr, "stderr", pid, output.clone());
|
|
}
|
|
|
|
let pid = child.id().unwrap_or(0);
|
|
info!("started terracotta (pid {pid})");
|
|
|
|
runtime.process = Some(TerracottaProcess {
|
|
child,
|
|
output: output.clone(),
|
|
});
|
|
drop(runtime);
|
|
|
|
{
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
state.status = TerracottaStatus::Starting;
|
|
state.http_port = None;
|
|
state.room_code = None;
|
|
state.server_port = None;
|
|
state.players.clear();
|
|
state.error_type = None;
|
|
state.error_message = None;
|
|
state.profile_index = None;
|
|
}
|
|
|
|
let mut attempts = 0;
|
|
const MAX_ATTEMPTS: u32 = 30;
|
|
let mut hmcl_helper: Option<Child> = None;
|
|
let port = loop {
|
|
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
|
attempts += 1;
|
|
if attempts > MAX_ATTEMPTS {
|
|
let output = {
|
|
let mut runtime = TERRACOTTA_RUNTIME.lock().await;
|
|
runtime.process.take().map(|mut p| {
|
|
let _ = p.child.start_kill();
|
|
p.output.clone()
|
|
})
|
|
};
|
|
let details = match output {
|
|
Some(out) => {
|
|
tokio::time::sleep(std::time::Duration::from_millis(25))
|
|
.await;
|
|
out.lock()
|
|
.await
|
|
.iter()
|
|
.cloned()
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
None => String::new(),
|
|
};
|
|
let msg = if details.is_empty() {
|
|
"timed out waiting for terracotta to start".to_string()
|
|
} else {
|
|
format!("timed out waiting for terracotta to start:\n{details}")
|
|
};
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
state.status = TerracottaStatus::Error;
|
|
state.error_type = Some(TerracottaErrorType::Terracotta);
|
|
state.error_message = Some(msg.clone());
|
|
bail!(msg);
|
|
}
|
|
|
|
match std::fs::read_to_string(&port_file) {
|
|
Ok(contents) => {
|
|
match serde_json::from_str::<TerracottaPortInfo>(&contents) {
|
|
Ok(info) => {
|
|
let _ = std::fs::remove_file(&port_file);
|
|
break info.port;
|
|
}
|
|
Err(e) => {
|
|
warn!("failed to parse terracotta port file: {}", e);
|
|
}
|
|
}
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
if is_macos {
|
|
let helper_exited = match hmcl_helper.as_mut() {
|
|
Some(helper) => helper
|
|
.try_wait()
|
|
.wrap_err(
|
|
"failed to inspect terracotta helper process",
|
|
)?
|
|
.is_some(),
|
|
None => true,
|
|
};
|
|
if helper_exited {
|
|
hmcl_helper = Some(
|
|
Command::new(&final_path)
|
|
.arg("--hmcl")
|
|
.arg(&port_file)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.kill_on_drop(true)
|
|
.spawn()
|
|
.wrap_err_with(|| {
|
|
format!(
|
|
"failed to start terracotta --hmcl helper at {}",
|
|
final_path.display()
|
|
)
|
|
})?,
|
|
);
|
|
}
|
|
}
|
|
|
|
if !is_macos
|
|
&& let Some((status, output)) =
|
|
take_terminated_terracotta_process().await?
|
|
{
|
|
let message = format_terracotta_exit(status, output).await;
|
|
// On Windows the `--hmcl` wrapper exits right after
|
|
// the delegate writes the port file, so give the file
|
|
// one final check before treating the exit as a
|
|
// failure.
|
|
if let Ok(contents) = std::fs::read_to_string(&port_file)
|
|
&& let Ok(info) = serde_json::from_str::<
|
|
TerracottaPortInfo,
|
|
>(&contents)
|
|
{
|
|
let _ = std::fs::remove_file(&port_file);
|
|
break info.port;
|
|
}
|
|
let msg = format!(
|
|
"terracotta exited before writing port file: {message}"
|
|
);
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
state.status = TerracottaStatus::Error;
|
|
state.error_type = Some(TerracottaErrorType::Terracotta);
|
|
state.error_message = Some(msg.clone());
|
|
bail!(msg);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!("failed to read terracotta port file: {}", e);
|
|
}
|
|
}
|
|
};
|
|
|
|
info!("terracotta started on port {}", port);
|
|
spawn_terracotta_state_poller(port).await;
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
state.http_port = Some(port);
|
|
state.status = TerracottaStatus::Idle;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn stop_terracotta() -> eyre::Result<()> {
|
|
let _operation = TERRACOTTA_OPERATION.lock().await;
|
|
let mut runtime = TERRACOTTA_RUNTIME.lock().await;
|
|
if let Some(task) = runtime.state_poller.take() {
|
|
task.abort();
|
|
}
|
|
if let Some(mut process) = runtime.process.take() {
|
|
process.child.start_kill().ok();
|
|
info!("stopped terracotta");
|
|
}
|
|
runtime.lan_announcer.sync(None);
|
|
drop(runtime);
|
|
|
|
// On Windows, `--hmcl` spawns a detached delegate and the tracked child
|
|
// exits as soon as the port file appears, so killing the child may not
|
|
// stop the actual server. Ask it to shut down over HTTP as a fallback.
|
|
let port = {
|
|
let state = TERRACOTTA_STATE.lock().await;
|
|
state.http_port.or_else(|| {
|
|
std::fs::read_to_string(terracotta_port_file())
|
|
.ok()
|
|
.and_then(|contents| {
|
|
serde_json::from_str::<TerracottaPortInfo>(&contents).ok()
|
|
})
|
|
.map(|info| info.port)
|
|
})
|
|
};
|
|
if let Some(port) = port {
|
|
let _ = tokio::time::timeout(
|
|
std::time::Duration::from_secs(3),
|
|
terracotta_client()
|
|
.get(format!("http://127.0.0.1:{port}/panic?peaceful=true"))
|
|
.send(),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
*state = TerracottaState::default();
|
|
state.binary_installed = is_binary_installed();
|
|
drop(state);
|
|
super::multiplayer::release_provider(
|
|
super::multiplayer::MultiplayerProvider::Terracotta,
|
|
)
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
fn build_room_url(
|
|
port: u16,
|
|
path: &str,
|
|
room: &str,
|
|
player: &str,
|
|
nodes: &[String],
|
|
) -> String {
|
|
let mut url = format!(
|
|
"http://127.0.0.1:{port}{path}?room={}&player={}",
|
|
urlencoding::encode(room),
|
|
urlencoding::encode(player),
|
|
);
|
|
for node in nodes {
|
|
url.push_str("&public_nodes=");
|
|
url.push_str(&urlencoding::encode(node));
|
|
}
|
|
url
|
|
}
|
|
|
|
pub(crate) fn validate_public_nodes(nodes: &[String]) -> eyre::Result<()> {
|
|
for node in nodes {
|
|
let parsed = url::Url::parse(node).wrap_err_with(|| {
|
|
format!("invalid terracotta public node: {node}")
|
|
})?;
|
|
if !matches!(
|
|
parsed.scheme(),
|
|
"http" | "https" | "tcp" | "tls" | "udp" | "ws" | "wss"
|
|
) || parsed.host_str().is_none()
|
|
{
|
|
bail!("unsupported terracotta public node: {node}");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn configured_public_nodes() -> eyre::Result<Vec<String>> {
|
|
let settings = super::settings::get().await?;
|
|
validate_public_nodes(&settings.terracotta_public_nodes)?;
|
|
Ok(settings.terracotta_public_nodes)
|
|
}
|
|
|
|
pub async fn start_hosting(
|
|
room_code: Option<String>,
|
|
player_name: String,
|
|
) -> eyre::Result<()> {
|
|
let _operation = TERRACOTTA_OPERATION.lock().await;
|
|
let player_name = player_name.trim();
|
|
if player_name.is_empty() {
|
|
bail!("player name cannot be empty");
|
|
}
|
|
let state = TERRACOTTA_STATE.lock().await;
|
|
let port = state
|
|
.http_port
|
|
.ok_or_else(|| eyre::eyre!("terracotta is not running"))?;
|
|
drop(state);
|
|
|
|
let room_param = room_code.as_deref().unwrap_or("");
|
|
let nodes = configured_public_nodes().await?;
|
|
info!(
|
|
port,
|
|
custom_room_code = room_code.is_some(),
|
|
"requesting terracotta host session"
|
|
);
|
|
|
|
let client = terracotta_client();
|
|
let url = build_room_url(
|
|
port,
|
|
"/state/scanning",
|
|
room_param,
|
|
player_name,
|
|
&nodes,
|
|
);
|
|
let resp = client
|
|
.get(&url)
|
|
.send()
|
|
.await
|
|
.wrap_err("failed to send hosting request to terracotta")?;
|
|
let status = resp.status();
|
|
if !status.is_success() {
|
|
let body = resp.text().await.unwrap_or_default();
|
|
bail!(
|
|
"terracotta hosting failed with status {}: {body}",
|
|
status.as_u16()
|
|
);
|
|
}
|
|
info!(port, "terracotta accepted host session request");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn start_joining(
|
|
room_code: String,
|
|
player_name: String,
|
|
) -> eyre::Result<()> {
|
|
let _operation = TERRACOTTA_OPERATION.lock().await;
|
|
let player_name = player_name.trim();
|
|
if player_name.is_empty() {
|
|
bail!("player name cannot be empty");
|
|
}
|
|
let room_code = parse_room_code(&room_code)?;
|
|
let state = TERRACOTTA_STATE.lock().await;
|
|
let port = state
|
|
.http_port
|
|
.ok_or_else(|| eyre::eyre!("terracotta is not running"))?;
|
|
drop(state);
|
|
|
|
let nodes = configured_public_nodes().await?;
|
|
info!(port, "requesting terracotta guest session");
|
|
|
|
let client = terracotta_client();
|
|
let url = build_room_url(
|
|
port,
|
|
"/state/guesting",
|
|
&room_code,
|
|
player_name,
|
|
&nodes,
|
|
);
|
|
let resp = client
|
|
.get(&url)
|
|
.send()
|
|
.await
|
|
.wrap_err("failed to send joining request to terracotta")?;
|
|
let status = resp.status();
|
|
if !status.is_success() {
|
|
let body = resp.text().await.unwrap_or_default();
|
|
bail!(
|
|
"terracotta joining failed with status {}: {body}",
|
|
status.as_u16()
|
|
);
|
|
}
|
|
info!(port, "terracotta accepted guest session request");
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn reset_state() -> eyre::Result<()> {
|
|
let _operation = TERRACOTTA_OPERATION.lock().await;
|
|
let state = TERRACOTTA_STATE.lock().await;
|
|
let port = state.http_port;
|
|
drop(state);
|
|
info!(?port, "resetting terracotta multiplayer state");
|
|
|
|
if let Some(port) = port {
|
|
let client = terracotta_client();
|
|
let url = format!("http://127.0.0.1:{port}/state/ide");
|
|
let response = client.get(&url).send().await.wrap_err_with(|| {
|
|
format!("failed to connect to terracotta at {url}")
|
|
})?;
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
let body = response.text().await.unwrap_or_default();
|
|
bail!(
|
|
"terracotta reset failed with status {}: {body}",
|
|
status.as_u16()
|
|
);
|
|
}
|
|
}
|
|
|
|
let mut state = TERRACOTTA_STATE.lock().await;
|
|
state.status = TerracottaStatus::Idle;
|
|
state.room_code = None;
|
|
state.server_port = None;
|
|
state.players.clear();
|
|
state.error_type = None;
|
|
state.error_message = None;
|
|
state.profile_index = None;
|
|
drop(state);
|
|
sync_minecraft_lan_announcer(None).await;
|
|
info!("terracotta multiplayer state reset");
|
|
Ok(())
|
|
}
|
|
|
|
pub fn parse_room_code(code: &str) -> eyre::Result<String> {
|
|
if code.starts_with("U/") || code.starts_with("u/") {
|
|
let inner = &code[2..];
|
|
if inner.len() == 19 && inner.chars().filter(|&c| c == '-').count() == 3
|
|
{
|
|
let segments: Vec<&str> = inner.split('-').collect();
|
|
if segments.len() == 4
|
|
&& segments.iter().all(|s| s.len() == 4)
|
|
&& segments
|
|
.iter()
|
|
.all(|s| s.chars().all(|c| c.is_ascii_alphanumeric()))
|
|
{
|
|
return Ok(format!("U/{inner}"));
|
|
}
|
|
}
|
|
}
|
|
bail!(
|
|
"invalid room code format: {code}. Expected format: U/XXXX-XXXX-XXXX-XXXX"
|
|
)
|
|
}
|
|
|
|
pub async fn get_logs() -> eyre::Result<String> {
|
|
let state = TERRACOTTA_STATE.lock().await;
|
|
let port = state
|
|
.http_port
|
|
.ok_or_else(|| eyre::eyre!("terracotta is not running"))?;
|
|
drop(state);
|
|
|
|
let client = terracotta_client();
|
|
let url = format!("http://127.0.0.1:{port}/log?fetch=");
|
|
let resp = client
|
|
.get(&url)
|
|
.send()
|
|
.await
|
|
.wrap_err("failed to fetch terracotta logs")?;
|
|
let body = resp
|
|
.text()
|
|
.await
|
|
.wrap_err("failed to read terracotta logs")?;
|
|
Ok(body)
|
|
}
|
|
|
|
pub async fn get_diagnostic_report() -> eyre::Result<String> {
|
|
let state = get_state().await;
|
|
let state = serde_json::to_string_pretty(&state)
|
|
.wrap_err("failed to serialize terracotta state")?;
|
|
let output = TERRACOTTA_DIAGNOSTIC_OUTPUT
|
|
.lock()
|
|
.map(|output| output.iter().cloned().collect::<Vec<_>>().join("\n"))
|
|
.unwrap_or_else(|_| "diagnostic output lock was poisoned".to_string());
|
|
let output = if output.is_empty() {
|
|
"No Terracotta process output was captured.".to_string()
|
|
} else {
|
|
output
|
|
};
|
|
|
|
Ok(format!(
|
|
"Terracotta multiplayer diagnostics\nPlatform: {}\nBinary: {}\n\nState:\n{}\n\nTerracotta process output:\n{}\n",
|
|
terracotta_platform_key(),
|
|
resolve_installed_terracotta_binary_path().display(),
|
|
state,
|
|
output
|
|
))
|
|
}
|
|
|
|
#[cfg(all(
|
|
test,
|
|
any(
|
|
target_os = "macos",
|
|
target_os = "linux",
|
|
target_os = "freebsd",
|
|
target_os = "windows"
|
|
)
|
|
))]
|
|
mod tests {
|
|
use super::*;
|
|
use std::path::Path;
|
|
|
|
fn executable_magic() -> [u8; 4] {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
[0xcf, 0xfa, 0xed, 0xfe]
|
|
}
|
|
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
|
|
{
|
|
[0x7f, b'E', b'L', b'F']
|
|
}
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
[b'M', b'Z', 0, 0]
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn resolver_ignores_installer_package_and_finds_executable() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let canonical = temp.path().join(terracotta_binary_name());
|
|
std::fs::write(&canonical, b"xar!").unwrap();
|
|
|
|
let versioned = temp.path().join(versioned_terracotta_binary_name(
|
|
"0.4.2",
|
|
terracotta_platform_key(),
|
|
));
|
|
std::fs::write(&versioned, executable_magic()).unwrap();
|
|
|
|
assert!(!is_terracotta_executable(&canonical));
|
|
assert!(is_terracotta_executable(&versioned));
|
|
assert_eq!(resolve_terracotta_binary_path(&canonical), versioned);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn executable_validation_rejects_symbolic_links() {
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let target = temp.path().join("target");
|
|
let link = temp.path().join(terracotta_binary_name());
|
|
std::fs::write(&target, executable_magic()).unwrap();
|
|
symlink(&target, &link).unwrap();
|
|
|
|
assert!(!is_terracotta_executable(&link));
|
|
}
|
|
|
|
#[test]
|
|
fn executable_search_prefers_requested_version() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let old = temp.path().join("terracotta-0.4.1-test");
|
|
let preferred = temp.path().join("terracotta-0.4.2-test");
|
|
std::fs::write(&old, executable_magic()).unwrap();
|
|
std::fs::write(&preferred, executable_magic()).unwrap();
|
|
|
|
assert_eq!(
|
|
find_terracotta_executable(
|
|
temp.path(),
|
|
Some("terracotta-0.4.2-test")
|
|
),
|
|
Some(preferred)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn binary_install_replaces_existing_file_and_removes_backup() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let candidate = temp.path().join("candidate");
|
|
let destination = temp.path().join(terracotta_binary_name());
|
|
std::fs::write(&candidate, b"new").unwrap();
|
|
std::fs::write(&destination, b"old").unwrap();
|
|
|
|
install_terracotta_binary(&candidate, &destination)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(std::fs::read(&destination).unwrap(), b"new");
|
|
assert!(
|
|
!destination
|
|
.with_file_name(format!("{}.old", terracotta_binary_name()))
|
|
.exists()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn binary_install_restores_existing_file_when_replacement_fails() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let candidate = temp.path().join("missing");
|
|
let destination = temp.path().join(terracotta_binary_name());
|
|
std::fs::write(&destination, b"old").unwrap();
|
|
|
|
assert!(
|
|
install_terracotta_binary(&candidate, &destination)
|
|
.await
|
|
.is_err()
|
|
);
|
|
assert_eq!(std::fs::read(&destination).unwrap(), b"old");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_port_reported_by_an_existing_daemon() {
|
|
let output =
|
|
"stdout: [Lock]: Successfully join the global mutex, port = 57924";
|
|
assert_eq!(reported_terracotta_port(output), Some(57924));
|
|
}
|
|
|
|
#[test]
|
|
fn only_macos_uses_the_daemon_startup_argument() {
|
|
assert_eq!(terracotta_startup_arguments("macos"), &["--daemon"]);
|
|
assert!(terracotta_startup_arguments("windows").is_empty());
|
|
assert!(terracotta_startup_arguments("linux").is_empty());
|
|
assert!(terracotta_startup_arguments("freebsd").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn builds_the_binary_path_below_the_launcher_data_directory() {
|
|
let root = Path::new("launcher-data");
|
|
assert_eq!(
|
|
binary::terracotta_binary_path_in(root),
|
|
root.join("terracotta").join(terracotta_binary_name())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn installed_binary_resolution_falls_back_to_the_legacy_location() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let installed = temp
|
|
.path()
|
|
.join("launcher-data")
|
|
.join(terracotta_binary_name());
|
|
let legacy = temp
|
|
.path()
|
|
.join("application")
|
|
.join(terracotta_binary_name());
|
|
std::fs::create_dir_all(installed.parent().unwrap()).unwrap();
|
|
std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
|
|
std::fs::write(&legacy, executable_magic()).unwrap();
|
|
|
|
assert_eq!(
|
|
binary::resolve_installed_terracotta_binary_path_from(
|
|
&installed, &legacy
|
|
),
|
|
legacy
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn installed_binary_resolution_keeps_the_writable_path_when_missing() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let installed = temp
|
|
.path()
|
|
.join("launcher-data")
|
|
.join(terracotta_binary_name());
|
|
let legacy = temp
|
|
.path()
|
|
.join("application")
|
|
.join(terracotta_binary_name());
|
|
|
|
assert_eq!(
|
|
binary::resolve_installed_terracotta_binary_path_from(
|
|
&installed, &legacy
|
|
),
|
|
installed
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_guest_server_port() {
|
|
assert_eq!(
|
|
terracotta_server_port(Some("127.0.0.1:38449")),
|
|
Some(38449)
|
|
);
|
|
assert_eq!(terracotta_server_port(Some("invalid")), None);
|
|
assert_eq!(terracotta_server_port(None), None);
|
|
}
|
|
|
|
#[test]
|
|
fn validates_release_versions_used_in_paths_and_urls() {
|
|
assert!(validate_terracotta_version("0.4.2").is_ok());
|
|
assert!(validate_terracotta_version("0.4.2-rc.1").is_ok());
|
|
assert!(validate_terracotta_version("").is_err());
|
|
assert!(validate_terracotta_version("../terracotta").is_err());
|
|
assert!(validate_terracotta_version("0.4.2/other").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn flags_unknown_or_different_installed_versions_for_update() {
|
|
assert!(update_is_available(None, "0.4.2"));
|
|
assert!(update_is_available(Some("0.4.1"), "0.4.2"));
|
|
assert!(!update_is_available(Some("0.4.2"), "0.4.2"));
|
|
}
|
|
|
|
#[test]
|
|
fn cleanup_preserves_version_metadata() {
|
|
assert!(binary::should_cleanup_terracotta_file(
|
|
"terracotta-0.4.1-windows-x86_64.exe"
|
|
));
|
|
assert!(!binary::should_cleanup_terracotta_file(
|
|
"terracotta-version.json"
|
|
));
|
|
assert!(!binary::should_cleanup_terracotta_file(
|
|
"terracotta-version.json.tmp"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn provides_independent_download_sources() {
|
|
let urls = terracotta_download_urls("0.4.2", "macos-arm64");
|
|
assert_eq!(urls.len(), 2);
|
|
assert!(urls.iter().any(|url| url.contains("gitee.com")));
|
|
assert!(urls.iter().any(|url| url.contains("github.com")));
|
|
}
|
|
|
|
#[test]
|
|
fn maps_protocol_states_without_silently_accepting_unknown_values() {
|
|
assert_eq!(
|
|
TerracottaStatus::from_api("guest-ok"),
|
|
Some(TerracottaStatus::GuestReady)
|
|
);
|
|
assert_eq!(TerracottaStatus::from_api("future-state"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn validates_room_codes_at_the_backend_boundary() {
|
|
assert_eq!(
|
|
parse_room_code("u/ABCD-EFGH-IJKL-MNOP").unwrap(),
|
|
"U/ABCD-EFGH-IJKL-MNOP"
|
|
);
|
|
assert!(parse_room_code("ABCD-EFGH-IJKL-MNOP").is_err());
|
|
assert!(parse_room_code("U/ABCD-EFGH-IJKL-MNO!").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn builds_room_urls_with_repeated_public_node_parameters() {
|
|
let nodes = vec![
|
|
"wss://center.node.1tmc.top".to_string(),
|
|
"tcp://example.com:11010".to_string(),
|
|
];
|
|
let url = build_room_url(
|
|
12345,
|
|
"/state/scanning",
|
|
"U/ABCD-EFGH-IJKL-MNOP",
|
|
"Player One",
|
|
&nodes,
|
|
);
|
|
|
|
assert_eq!(
|
|
url,
|
|
"http://127.0.0.1:12345/state/scanning?room=U%2FABCD-EFGH-IJKL-MNOP&player=Player%20One&public_nodes=wss%3A%2F%2Fcenter.node.1tmc.top&public_nodes=tcp%3A%2F%2Fexample.com%3A11010"
|
|
);
|
|
assert!(
|
|
!build_room_url(12345, "/state/scanning", "", "Player", &[])
|
|
.contains("public_nodes")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validates_supported_public_node_urls() {
|
|
assert!(
|
|
validate_public_nodes(&[
|
|
"wss://center.node.1tmc.top".to_string(),
|
|
"udp://example.com:11010".to_string(),
|
|
])
|
|
.is_ok()
|
|
);
|
|
assert!(validate_public_nodes(&[]).is_ok());
|
|
assert!(
|
|
validate_public_nodes(&["ftp://example.com".to_string()]).is_err()
|
|
);
|
|
assert!(validate_public_nodes(&["wss://".to_string()]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn serializes_error_types_for_the_frontend_contract() {
|
|
assert_eq!(
|
|
serde_json::to_string(&TerracottaErrorType::Terracotta).unwrap(),
|
|
"\"terracotta\""
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_string(&TerracottaPlayerKind::Host).unwrap(),
|
|
"\"HOST\""
|
|
);
|
|
}
|
|
}
|