fix: 完善整合包同步与启动器交互
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
This commit is contained in:
@ -78,19 +78,32 @@ async fn run_with_extra_launch_args_inner(
|
||||
let _hosted_guard =
|
||||
crate::pack::hosted::prepare_launch(instance_id, offline_mode).await?;
|
||||
let state = State::get().await?;
|
||||
let launch_preparation_timeout =
|
||||
let context =
|
||||
crate::state::instances::commands::get_instance_launch_context(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.await?;
|
||||
let launch_preparation_timeout = context
|
||||
.as_ref()
|
||||
.and_then(|context| context.launch_overrides.launch_preparation_timeout)
|
||||
.unwrap_or(DEFAULT_LAUNCH_PREPARATION_TIMEOUT)
|
||||
.clamp(
|
||||
MIN_LAUNCH_PREPARATION_TIMEOUT,
|
||||
MAX_LAUNCH_PREPARATION_TIMEOUT,
|
||||
);
|
||||
let default_account = if offline_mode {
|
||||
let saved_player = context
|
||||
.as_ref()
|
||||
.and_then(|context| context.launch_overrides.player.as_ref());
|
||||
let default_account = if let Some(player) = saved_player {
|
||||
if offline_mode
|
||||
&& player.account_type
|
||||
!= crate::state::MinecraftAccountType::Offline
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError("当前实例绑定的是在线玩家,请恢复网络后启动,或在实例设置中手动切换玩家".into()).as_error());
|
||||
}
|
||||
Credentials::for_instance_player(player, &state.pool).await?
|
||||
} else if offline_mode {
|
||||
Credentials::get_offline_credential(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
|
||||
@ -7,6 +7,7 @@ use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::State;
|
||||
pub use crate::state::InstancePlayer;
|
||||
pub use crate::state::YggdrasilLoginResult;
|
||||
use crate::state::{
|
||||
Credentials, MinecraftAccountType, MinecraftLoginFlow, MinecraftProfile,
|
||||
@ -172,6 +173,72 @@ pub fn normalize_yggdrasil_api_root(api_root: &str) -> crate::Result<String> {
|
||||
crate::state::normalize_api_root(api_root)
|
||||
}
|
||||
|
||||
pub async fn get_instance_player(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<Option<crate::state::InstancePlayer>> {
|
||||
let state = State::get().await?;
|
||||
let context =
|
||||
crate::state::instances::commands::get_instance_launch_context(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".into()).as_error()
|
||||
})?;
|
||||
Ok(context.launch_overrides.player)
|
||||
}
|
||||
|
||||
pub async fn set_instance_player(
|
||||
instance_id: &str,
|
||||
player: crate::state::InstancePlayer,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let accounts = Credentials::get_all_without_refresh(&state.pool).await?;
|
||||
let account = accounts.get(&player.id).ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("所选玩家未登录,请重新登录该账号".into())
|
||||
.as_error()
|
||||
})?;
|
||||
if account.account_type != player.account_type
|
||||
|| player.skin_site_user.as_ref().is_some_and(|user| {
|
||||
account.yggdrasil.as_ref().is_none_or(|ygg| {
|
||||
ygg.login != *user
|
||||
|| ygg.api_root != "https://skin.starlight.cool/yggdrasil"
|
||||
})
|
||||
})
|
||||
{
|
||||
return Err(
|
||||
crate::ErrorKind::InputError("玩家身份不匹配".into()).as_error()
|
||||
);
|
||||
}
|
||||
drop(account);
|
||||
crate::state::edit_instance(
|
||||
instance_id,
|
||||
crate::state::EditInstance {
|
||||
launch_overrides: Some(
|
||||
crate::state::InstanceLaunchOverridesPatch {
|
||||
player: Some(player),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn login_skin_site_player(
|
||||
token: &str,
|
||||
player_id: uuid::Uuid,
|
||||
user_id: &str,
|
||||
) -> crate::Result<Credentials> {
|
||||
let state = State::get().await?;
|
||||
crate::state::login_skin_site_player(token, player_id, user_id, &state.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_default_user(
|
||||
offline_mode: bool,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
|
||||
mod progress;
|
||||
mod tagged;
|
||||
mod transport;
|
||||
use crate::{
|
||||
State,
|
||||
state::{
|
||||
@ -32,6 +33,7 @@ use tokio::{
|
||||
const API: &str = "https://skin.starlight.cool/starlight/mod/packs";
|
||||
const BINDING: &str = ".starlight-pack.json";
|
||||
const JOURNAL: &str = ".starlight-pack-pending.json";
|
||||
const MAX_HOSTED_CONCURRENT_FILES: usize = 8;
|
||||
static GATES: LazyLock<dashmap::DashMap<String, Arc<Mutex<()>>>> =
|
||||
LazyLock::new(dashmap::DashMap::new);
|
||||
static SESSION: Mutex<Option<DownloadSession>> = Mutex::const_new(None);
|
||||
@ -230,11 +232,42 @@ fn validate(files: &[PackFile]) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
/// Reject symlinks and junctions in every existing ancestor, including internal state files.
|
||||
#[cfg(windows)]
|
||||
fn filesystem_path(path: &Path) -> PathBuf {
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
||||
|
||||
if !path.is_absolute() {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
|
||||
let path = path.as_os_str().encode_wide().collect::<Vec<_>>();
|
||||
const BACKSLASH: u16 = b'\\' as u16;
|
||||
const QUESTION_MARK: u16 = b'?' as u16;
|
||||
if path.starts_with(&[BACKSLASH, BACKSLASH, QUESTION_MARK, BACKSLASH]) {
|
||||
return PathBuf::from(OsString::from_wide(&path));
|
||||
}
|
||||
|
||||
let mut extended = "\\\\?\\".encode_utf16().collect::<Vec<_>>();
|
||||
if path.starts_with(&[BACKSLASH, BACKSLASH]) {
|
||||
extended.extend("UNC\\".encode_utf16());
|
||||
extended.extend_from_slice(&path[2..]);
|
||||
} else {
|
||||
extended.extend_from_slice(&path);
|
||||
}
|
||||
PathBuf::from(OsString::from_wide(&extended))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn filesystem_path(path: &Path) -> PathBuf {
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
fn target(root: &Path, relative: &str) -> crate::Result<PathBuf> {
|
||||
let mut path = root.to_path_buf();
|
||||
for part in relative.split('/') {
|
||||
path.push(part);
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
match std::fs::symlink_metadata(filesystem_path(&path)) {
|
||||
Ok(meta) => {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
@ -250,21 +283,32 @@ fn target(root: &Path, relative: &str) -> crate::Result<PathBuf> {
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
|
||||
Err(e) => return Err(e.into()),
|
||||
Err(e) => {
|
||||
return Err(
|
||||
crate::util::io::IOError::with_path(e, &path).into()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(path)
|
||||
// Windows' legacy Win32 path parser reports ERROR_PATH_NOT_FOUND once a
|
||||
// perfectly valid pack path exceeds MAX_PATH. Extended-length paths keep
|
||||
// deep KubeJS/data-pack trees addressable without changing their layout.
|
||||
Ok(filesystem_path(&path))
|
||||
}
|
||||
async fn hash(path: &Path) -> crate::Result<Option<String>> {
|
||||
let mut input = match tokio::fs::File::open(path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
Err(e) => {
|
||||
return Err(crate::util::io::IOError::with_path(e, path).into());
|
||||
}
|
||||
};
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = vec![0; 64 * 1024];
|
||||
loop {
|
||||
let n = input.read(&mut buffer).await?;
|
||||
let n = input.read(&mut buffer).await.map_err(|error| {
|
||||
crate::util::io::IOError::with_path(error, path)
|
||||
})?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
@ -276,10 +320,11 @@ async fn read_json<T: serde::de::DeserializeOwned>(
|
||||
root: &Path,
|
||||
name: &str,
|
||||
) -> crate::Result<Option<T>> {
|
||||
match tokio::fs::read(target(root, name)?).await {
|
||||
let path = target(root, name)?;
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
Err(e) => Err(crate::util::io::IOError::with_path(e, &path).into()),
|
||||
}
|
||||
}
|
||||
fn write_json<T: Serialize>(
|
||||
@ -289,10 +334,31 @@ fn write_json<T: Serialize>(
|
||||
) -> crate::Result<()> {
|
||||
use std::io::Write;
|
||||
let dest = target(root, name)?;
|
||||
let mut file = tempfile::NamedTempFile::new_in(root)?;
|
||||
file.write_all(&serde_json::to_vec(value)?)?;
|
||||
file.as_file().sync_all()?;
|
||||
file.persist(dest).map_err(|e| e.error)?;
|
||||
let mut file = tempfile::NamedTempFile::new_in(root).map_err(|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(error, root))
|
||||
.with_context(format!("创建 {name} 的临时文件失败"))
|
||||
})?;
|
||||
let temporary = file.path().to_path_buf();
|
||||
file.write_all(&serde_json::to_vec(value)?)
|
||||
.map_err(|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(
|
||||
error, &temporary,
|
||||
))
|
||||
.with_context(format!("写入 {name} 的临时文件失败"))
|
||||
})?;
|
||||
file.as_file().sync_all().map_err(|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(
|
||||
error, &temporary,
|
||||
))
|
||||
.with_context(format!("同步 {name} 的临时文件失败"))
|
||||
})?;
|
||||
file.persist(&dest).map_err(|error| {
|
||||
crate::Error::from(error.error).with_context(format!(
|
||||
"提交 {name} 失败;临时文件:{};目标文件:{}",
|
||||
temporary.display(),
|
||||
dest.display()
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
async fn authorization() -> crate::Result<String> {
|
||||
@ -330,18 +396,22 @@ async fn request_authorized<T: serde::de::DeserializeOwned>(
|
||||
auth: &str,
|
||||
) -> crate::Result<T> {
|
||||
ensure_session(auth).await?;
|
||||
let response = configured_client()
|
||||
.await?
|
||||
.get(format!("{API}{suffix}"))
|
||||
.header(reqwest::header::AUTHORIZATION, auth)
|
||||
.header(reqwest::header::CACHE_CONTROL, "no-cache")
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await?;
|
||||
if matches!(response.status().as_u16(), 401 | 403) {
|
||||
return Err(invalid(
|
||||
"StarLight 登录凭据已失效或无权限,请重新登录后重试",
|
||||
));
|
||||
let client = configured_client().await?;
|
||||
let response =
|
||||
transport::metadata_request(&client, &format!("{API}{suffix}"), auth)
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
let message = transport::response_failure(
|
||||
&client,
|
||||
response,
|
||||
"https://skin.starlight.cool/starlight/user",
|
||||
suffix,
|
||||
auth,
|
||||
)
|
||||
.await;
|
||||
ensure_session(auth).await?;
|
||||
tracing::warn!("{message}");
|
||||
return Err(invalid(message));
|
||||
}
|
||||
let data: Response<T> = response.error_for_status()?.json().await?;
|
||||
ensure_session(auth).await?;
|
||||
@ -433,7 +503,13 @@ async fn apply_files(
|
||||
) -> crate::Result<()> {
|
||||
for action in actions {
|
||||
let live = target(root, &action.path)?;
|
||||
if hash(&live).await? != action.old_hash {
|
||||
if hash(&live).await.map_err(|error| {
|
||||
error.with_context(format!(
|
||||
"检查整合包目标文件失败:{}",
|
||||
live.display()
|
||||
))
|
||||
})? != action.old_hash
|
||||
{
|
||||
return Err(invalid(format!(
|
||||
"File changed during sync: {}",
|
||||
action.path
|
||||
@ -442,16 +518,82 @@ async fn apply_files(
|
||||
if action.old_hash.is_some() {
|
||||
let backup =
|
||||
target(root, &format!("{}/{}", backup_dir, action.path))?;
|
||||
tokio::fs::create_dir_all(backup.parent().unwrap()).await?;
|
||||
tokio::fs::rename(&live, backup).await?;
|
||||
let backup_parent = backup.parent().unwrap();
|
||||
tokio::fs::create_dir_all(backup_parent).await.map_err(
|
||||
|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(
|
||||
error,
|
||||
backup_parent,
|
||||
))
|
||||
.with_context(format!(
|
||||
"创建整合包备份目录失败:{}",
|
||||
backup_parent.display()
|
||||
))
|
||||
},
|
||||
)?;
|
||||
tokio::fs::rename(&live, &backup).await.map_err(|error| {
|
||||
crate::Error::from(error).with_context(format!(
|
||||
"备份整合包文件失败;源文件:{};目标文件:{}",
|
||||
live.display(),
|
||||
backup.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
if let Some(next) = &action.next_hash {
|
||||
tokio::fs::create_dir_all(live.parent().unwrap()).await?;
|
||||
let staged =
|
||||
tempfile::NamedTempFile::new_in(live.parent().unwrap())?;
|
||||
tokio::fs::copy(target(cache, next)?, staged.path()).await?;
|
||||
staged.as_file().sync_all()?;
|
||||
staged.persist(&live).map_err(|e| e.error)?;
|
||||
let live_parent = live.parent().unwrap();
|
||||
tokio::fs::create_dir_all(live_parent)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(
|
||||
error,
|
||||
live_parent,
|
||||
))
|
||||
.with_context(format!(
|
||||
"创建整合包目标目录失败:{}",
|
||||
live_parent.display()
|
||||
))
|
||||
})?;
|
||||
let staged = tempfile::NamedTempFile::new_in(live_parent).map_err(
|
||||
|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(
|
||||
error,
|
||||
live_parent,
|
||||
))
|
||||
.with_context(format!(
|
||||
"创建整合包临时文件失败:{}",
|
||||
live.display()
|
||||
))
|
||||
},
|
||||
)?;
|
||||
let staged_path = staged.path().to_path_buf();
|
||||
let cache_object = target(cache, next)?;
|
||||
tokio::fs::copy(&cache_object, &staged_path)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::Error::from(error).with_context(format!(
|
||||
"复制整合包缓存文件失败;缓存文件:{};临时文件:{};目标文件:{}",
|
||||
cache_object.display(),
|
||||
staged_path.display(),
|
||||
live.display()
|
||||
))
|
||||
})?;
|
||||
staged.as_file().sync_all().map_err(|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(
|
||||
error,
|
||||
&staged_path,
|
||||
))
|
||||
.with_context(format!(
|
||||
"同步整合包临时文件失败:{}",
|
||||
staged_path.display()
|
||||
))
|
||||
})?;
|
||||
staged.persist(&live).map_err(|error| {
|
||||
crate::Error::from(error.error).with_context(format!(
|
||||
"提交整合包文件失败;临时文件:{};目标文件:{}",
|
||||
staged_path.display(),
|
||||
live.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@ -975,7 +1117,7 @@ async fn synchronize_with_progress(
|
||||
let concurrency = crate::api::settings::get()
|
||||
.await?
|
||||
.effective_max_concurrent_downloads()
|
||||
.clamp(1, 16);
|
||||
.clamp(1, MAX_HOSTED_CONCURRENT_FILES);
|
||||
let current = std::sync::Mutex::new(BTreeMap::<String, u64>::new());
|
||||
let completed = AtomicUsize::new(0);
|
||||
progress.download(
|
||||
@ -1038,6 +1180,10 @@ async fn synchronize_with_progress(
|
||||
};
|
||||
let mut request =
|
||||
DownloadRequest::new(&url, ResourceClass::Modpack)
|
||||
// The batch already downloads several files in
|
||||
// parallel. Do not let an HTTP/1 fallback multiply
|
||||
// every large file into another four connections.
|
||||
.with_http1_segmented_download(false)
|
||||
.with_integrity(Integrity {
|
||||
size: Some(file_size),
|
||||
sha256: Some(file.sha256),
|
||||
@ -1047,14 +1193,34 @@ async fn synchronize_with_progress(
|
||||
request = request
|
||||
.with_header("Authorization", auth.clone());
|
||||
}
|
||||
download_to_path(
|
||||
request,
|
||||
let first_result = download_to_path(
|
||||
request.clone(),
|
||||
&object,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
Some(&mut on_progress),
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
if let Err(error) = first_result {
|
||||
tracing::warn!(
|
||||
path = %file_path,
|
||||
%error,
|
||||
"Parallel StarLight file download failed; retrying after the batch load has eased"
|
||||
);
|
||||
ensure_session(auth).await?;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
750,
|
||||
))
|
||||
.await;
|
||||
download_to_path(
|
||||
request,
|
||||
&object,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
Some(&mut on_progress),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let completed_files =
|
||||
completed.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let completed_bytes = {
|
||||
@ -1158,12 +1324,33 @@ async fn synchronize_with_progress(
|
||||
}
|
||||
ensure_session(&auth).await?;
|
||||
progress.update(0, 0, "正在应用更新", true);
|
||||
apply_files(&root, &cache, &journal.backup, &journal.actions).await?;
|
||||
apply_files(&root, &cache, &journal.backup, &journal.actions)
|
||||
.await
|
||||
.map_err(|error| error.with_context(format!(
|
||||
"应用 StarLight 整合包文件失败;实例目录:{}",
|
||||
root.display()
|
||||
)))?;
|
||||
progress.update(0, 0, "正在完成安装", true);
|
||||
write_json(&root, BINDING, &Binding { publication: publication.clone(), files, sync_marker: Some(sync_marker), resolved_external })?;
|
||||
write_json(&root, BINDING, &Binding { publication: publication.clone(), files, sync_marker: Some(sync_marker), resolved_external })
|
||||
.map_err(|error| error.with_context(format!(
|
||||
"写入 StarLight 整合包绑定信息失败;实例目录:{}",
|
||||
root.display()
|
||||
)))?;
|
||||
crate::instance::edit(instance_id, EditInstance { install_stage: Some(InstanceInstallStage::Installed), ..Default::default() }).await?;
|
||||
crate::instance::sync_content_files(instance_id).await?;
|
||||
tokio::fs::remove_file(target(&root, JOURNAL)?).await?;
|
||||
crate::instance::sync_content_files(instance_id).await.map_err(|error| {
|
||||
error.with_context(format!(
|
||||
"扫描 StarLight 整合包内容失败;实例目录:{}",
|
||||
root.display()
|
||||
))
|
||||
})?;
|
||||
let journal_path = target(&root, JOURNAL)?;
|
||||
tokio::fs::remove_file(&journal_path).await.map_err(|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(
|
||||
error,
|
||||
&journal_path,
|
||||
))
|
||||
.with_context("清理 StarLight 整合包安装日志失败")
|
||||
})?;
|
||||
Ok::<_, crate::Error>(())
|
||||
}.await;
|
||||
if let Err(error) = result {
|
||||
|
||||
@ -184,6 +184,41 @@ mod tests {
|
||||
assert!(root.path().join("mods/personal.jar").is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identical_tagged_mod_keeps_the_modpack_file() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let original = PackFile {
|
||||
mod_ids: vec![],
|
||||
external: None,
|
||||
path: "mods/example-from-pack.jar".into(),
|
||||
sha256: "a".repeat(64),
|
||||
size: 10,
|
||||
force: true,
|
||||
preserve: false,
|
||||
};
|
||||
let mut files = vec![original.clone()];
|
||||
let mut sources = BTreeMap::from([(
|
||||
original.path.clone(),
|
||||
"https://example.invalid/pack-file".into(),
|
||||
)]);
|
||||
let mut tagged = manifest();
|
||||
tagged.replaces.push(original.path.clone());
|
||||
|
||||
let actions = merge(
|
||||
root.path(),
|
||||
&root.path().join("cache"),
|
||||
&mut files,
|
||||
&mut sources,
|
||||
&tagged,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(files, vec![original]);
|
||||
assert!(!sources.contains_key("mods/example.starlight.jar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_mod_jar_cannot_be_partially_replaced() {
|
||||
assert!(is_managed_file(&manifest().files[0].pack_file()));
|
||||
@ -266,10 +301,33 @@ pub(super) async fn merge(
|
||||
if ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut replaced: HashSet<String> =
|
||||
manifest.replaces.iter().cloned().collect();
|
||||
|
||||
// The modpack is the installation baseline. A tagged snapshot with the
|
||||
// same content already passes verification, even when the skin site uses
|
||||
// a canonical filename. Keep the pack's file and only replace it when the
|
||||
// tagged JAR is actually different.
|
||||
let satisfied_pack_paths: HashSet<String> = manifest
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|tagged| {
|
||||
files
|
||||
.iter()
|
||||
.find(|file| {
|
||||
is_mod_path(&file.path) && file.sha256 == tagged.sha256
|
||||
})
|
||||
.map(|file| file.path.clone())
|
||||
})
|
||||
.collect();
|
||||
let mut replaced: HashSet<String> = manifest
|
||||
.replaces
|
||||
.iter()
|
||||
.filter(|path| !satisfied_pack_paths.contains(*path))
|
||||
.cloned()
|
||||
.collect();
|
||||
for file in files.iter().filter(|file| is_mod_path(&file.path)) {
|
||||
if replaced.contains(&file.path) {
|
||||
if satisfied_pack_paths.contains(&file.path)
|
||||
|| replaced.contains(&file.path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let object = target(cache, &file.sha256)?;
|
||||
@ -284,6 +342,11 @@ pub(super) async fn merge(
|
||||
}
|
||||
files.retain(|file| !replaced.contains(&file.path));
|
||||
for file in &manifest.files {
|
||||
if files.iter().any(|existing| {
|
||||
is_mod_path(&existing.path) && existing.sha256 == file.sha256
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if files
|
||||
.iter()
|
||||
.any(|existing| existing.path.eq_ignore_ascii_case(&file.path))
|
||||
@ -299,6 +362,11 @@ pub(super) async fn merge(
|
||||
);
|
||||
files.push(file.pack_file());
|
||||
}
|
||||
let desired_paths: HashSet<_> = files
|
||||
.iter()
|
||||
.filter(|file| is_mod_path(&file.path))
|
||||
.map(|file| file.path.to_ascii_lowercase())
|
||||
.collect();
|
||||
let mut duplicate_actions = Vec::new();
|
||||
let mods_dir = target(root, "mods")?;
|
||||
if mods_dir.is_dir() {
|
||||
@ -309,7 +377,9 @@ pub(super) async fn merge(
|
||||
continue;
|
||||
};
|
||||
let path = format!("mods/{name}");
|
||||
if !is_mod_path(&path) || manifest.contains(&path) {
|
||||
if !is_mod_path(&path)
|
||||
|| desired_paths.contains(&path.to_ascii_lowercase())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let local = target(root, &path)?;
|
||||
|
||||
228
packages/app-lib/src/api/pack/hosted/transport.rs
Normal file
228
packages/app-lib/src/api/pack/hosted/transport.rs
Normal file
@ -0,0 +1,228 @@
|
||||
use reqwest::{Client, Response, header};
|
||||
|
||||
pub(super) async fn metadata_request(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
auth: &str,
|
||||
) -> Result<Response, reqwest::Error> {
|
||||
client
|
||||
.get(url)
|
||||
.query(&[("starlight_request", uuid::Uuid::new_v4().to_string())])
|
||||
.header(header::AUTHORIZATION, auth)
|
||||
.header(header::CACHE_CONTROL, "no-cache, no-store")
|
||||
.header(header::PRAGMA, "no-cache")
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn bounded_json(mut response: Response) -> Option<serde_json::Value> {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.ok()? {
|
||||
if bytes.len() + chunk.len() > 16_384 {
|
||||
return None;
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
pub(super) async fn response_failure(
|
||||
client: &Client,
|
||||
response: Response,
|
||||
user_url: &str,
|
||||
suffix: &str,
|
||||
auth: &str,
|
||||
) -> String {
|
||||
let status = response.status().as_u16();
|
||||
let trace = response
|
||||
.headers()
|
||||
.get("eo-log-uuid")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.filter(|value| {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == b'-')
|
||||
})
|
||||
.map(|value| format!(";CDN 请求编号 {value}"))
|
||||
.unwrap_or_default();
|
||||
let context = format!("(整合包 HTTP {status};接口 {suffix}{trace})");
|
||||
if let Some(body) = bounded_json(response).await {
|
||||
if let Some(message) = body["errorMessage"]
|
||||
.as_str()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
{
|
||||
let token = auth.strip_prefix("Bearer ").unwrap_or(auth);
|
||||
let message = if token.is_empty() {
|
||||
message.to_owned()
|
||||
} else {
|
||||
message.replace(token, "[凭据已隐藏]")
|
||||
};
|
||||
let message: String = message
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.take(500)
|
||||
.collect();
|
||||
return format!("StarLight 服务端返回:{message}{context}");
|
||||
}
|
||||
}
|
||||
if !matches!(status, 401 | 403) {
|
||||
return format!("StarLight 服务端请求失败{context}");
|
||||
}
|
||||
let detail = match metadata_request(client, user_url, auth).await {
|
||||
Ok(user_response) => {
|
||||
let user_status = user_response.status().as_u16();
|
||||
if user_status == 200 {
|
||||
let valid_user =
|
||||
bounded_json(user_response).await.is_some_and(|body| {
|
||||
body["payload"]["uuid"]
|
||||
.as_str()
|
||||
.is_some_and(|id| !id.is_empty())
|
||||
&& body["payload"]["username"].is_string()
|
||||
});
|
||||
if valid_user {
|
||||
"皮肤站登录有效,但整合包接口返回错误,且没有提供具体原因。请用请求编号查询服务端或 CDN 日志".to_owned()
|
||||
} else {
|
||||
"整合包接口拒绝访问,用户接口返回内容异常,无法确认登录状态"
|
||||
.to_owned()
|
||||
}
|
||||
} else if matches!(user_status, 401 | 403) {
|
||||
format!(
|
||||
"整合包和用户接口均拒绝登录凭据(用户接口 HTTP {user_status})。请重新登录;若仍失败,请检查服务端鉴权及 CDN 配置"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"整合包接口拒绝访问,暂时无法复核登录状态(用户接口 HTTP {user_status})"
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(_) => "整合包接口拒绝访问,用户接口连接失败,暂时无法复核登录状态"
|
||||
.to_owned(),
|
||||
};
|
||||
format!("{detail}{context}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_auth_failure_verifies_same_credential_without_exposing_it()
|
||||
{
|
||||
for (pack_status, user_status, body, expected) in [
|
||||
(
|
||||
401,
|
||||
200,
|
||||
r#"{"payload":{"uuid":"owner","username":"Player"}}"#,
|
||||
"皮肤站登录有效",
|
||||
),
|
||||
(403, 401, "{}", "均拒绝登录凭据"),
|
||||
(401, 200, "<html>Proxy error</html>", "用户接口返回内容异常"),
|
||||
] {
|
||||
let listener =
|
||||
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
let mut requests = Vec::new();
|
||||
for (status, body) in [(pack_status, ""), (user_status, body)] {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
request.push(socket.read_u8().await.unwrap());
|
||||
}
|
||||
requests.push(String::from_utf8(request).unwrap());
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nEO-LOG-UUID: trace-123\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
requests
|
||||
});
|
||||
let client = Client::builder().no_proxy().build().unwrap();
|
||||
let auth = "Bearer test-secret-never-display";
|
||||
let response =
|
||||
metadata_request(&client, &format!("{base}/default"), auth)
|
||||
.await
|
||||
.unwrap();
|
||||
let message = response_failure(
|
||||
&client,
|
||||
response,
|
||||
&format!("{base}/user"),
|
||||
"/default",
|
||||
auth,
|
||||
)
|
||||
.await;
|
||||
assert!(message.contains(expected), "{message}");
|
||||
assert!(message.contains(&format!("整合包 HTTP {pack_status}")));
|
||||
assert!(message.contains("trace-123"));
|
||||
assert!(!message.contains("test-secret"));
|
||||
let requests = server.await.unwrap();
|
||||
for (request, path) in requests.iter().zip(["/default", "/user"]) {
|
||||
assert!(
|
||||
request
|
||||
.starts_with(&format!("GET {path}?starlight_request="))
|
||||
);
|
||||
let headers = request.to_ascii_lowercase();
|
||||
assert!(headers.contains(
|
||||
"authorization: bearer test-secret-never-display\r\n"
|
||||
));
|
||||
assert!(
|
||||
headers.contains("cache-control: no-cache, no-store\r\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_business_errors_preserve_server_reason_without_login_probe()
|
||||
{
|
||||
for status in [400, 403, 409, 500] {
|
||||
let listener =
|
||||
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
request.push(socket.read_u8().await.unwrap());
|
||||
}
|
||||
let body = r#"{"error":"IllegalState","errorMessage":"整合包 Mod 标签已删除,请管理员重新设置 test-secret-never-display"}"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
});
|
||||
let client = Client::builder().no_proxy().build().unwrap();
|
||||
let auth = "Bearer test-secret-never-display";
|
||||
let response = metadata_request(
|
||||
&client,
|
||||
&format!("{base}/tagged-mods/2"),
|
||||
auth,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let message = response_failure(
|
||||
&client,
|
||||
response,
|
||||
&format!("{base}/user"),
|
||||
"/tagged-mods/2",
|
||||
auth,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
message.contains("整合包 Mod 标签已删除,请管理员重新设置"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(message.contains("/tagged-mods/2"));
|
||||
assert!(message.contains(&format!("HTTP {status}")));
|
||||
assert!(!message.contains("test-secret"));
|
||||
assert!(!message.contains("请重新登录"));
|
||||
server.await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -142,6 +142,7 @@ pub(crate) async fn import_axolotl(
|
||||
loader_version: Some(config.content_set.loader_version.clone()),
|
||||
}),
|
||||
launch_overrides: Some(InstanceLaunchOverridesPatch {
|
||||
player: None,
|
||||
instance_mode: Some(crate::state::InstanceMode::Local),
|
||||
java_path: Some(config.launch_overrides.java_path.clone()),
|
||||
extra_launch_args: Some(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
pub(crate) mod archive_util;
|
||||
pub mod detect;
|
||||
pub mod import;
|
||||
pub mod hosted;
|
||||
pub mod import;
|
||||
pub mod install_from;
|
||||
pub(crate) mod install_hmcl;
|
||||
pub(crate) mod install_mcbbs;
|
||||
|
||||
@ -16,7 +16,10 @@ mod tests {
|
||||
fn user_agent_is_unique_and_contains_no_contact_information() {
|
||||
let user_agent = user_agent("1.2.3", "windows");
|
||||
|
||||
assert_eq!(user_agent, "garbage-human-studio/starlight/1.2.3 (windows)");
|
||||
assert_eq!(
|
||||
user_agent,
|
||||
"garbage-human-studio/starlight/1.2.3 (windows)"
|
||||
);
|
||||
assert!(!user_agent.contains("ghs.red"));
|
||||
assert!(!user_agent.contains("http"));
|
||||
assert!(!user_agent.contains('@'));
|
||||
|
||||
@ -249,7 +249,11 @@ impl std::fmt::Display for Error {
|
||||
|
||||
impl Error {
|
||||
pub(crate) fn with_context(mut self, context: impl Into<String>) -> Self {
|
||||
self.context = Some(context.into());
|
||||
let context = context.into();
|
||||
self.context = Some(match self.context.take() {
|
||||
Some(existing) => format!("{existing}\n{context}"),
|
||||
None => context,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
@ -22,17 +22,12 @@ const DEFAULT_CONSOLE_COLUMNS: usize = 80;
|
||||
#[cfg(debug_assertions)]
|
||||
const CONSOLE_TRUNCATION_MARKER: &str = "... [console output truncated]";
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
const LAUNCHER_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024;
|
||||
#[cfg(not(debug_assertions))]
|
||||
const LAUNCHER_WARN_ERROR_MAX_BYTES: u64 = 30 * 1024 * 1024;
|
||||
#[cfg(not(debug_assertions))]
|
||||
const LAUNCHER_LOG_MAX_FILES: usize = 5;
|
||||
#[cfg(not(debug_assertions))]
|
||||
const LAUNCHER_LOG_MAX_AGE: std::time::Duration =
|
||||
std::time::Duration::from_secs(3 * 24 * 60 * 60);
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
#[derive(Clone)]
|
||||
struct RotatingLogWriter {
|
||||
state: std::sync::Arc<std::sync::Mutex<RotatingLogState>>,
|
||||
@ -40,7 +35,6 @@ struct RotatingLogWriter {
|
||||
warn_error_max_bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
struct RotatingLogState {
|
||||
logs_dir: std::path::PathBuf,
|
||||
session_name: String,
|
||||
@ -51,7 +45,6 @@ struct RotatingLogState {
|
||||
max_age: std::time::Duration,
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
impl RotatingLogWriter {
|
||||
fn new(
|
||||
logs_dir: std::path::PathBuf,
|
||||
@ -120,7 +113,6 @@ impl RotatingLogWriter {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
impl RotatingLogState {
|
||||
fn write_event(
|
||||
&mut self,
|
||||
@ -157,7 +149,6 @@ impl RotatingLogState {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
fn rotating_log_path(
|
||||
logs_dir: &std::path::Path,
|
||||
session_name: &str,
|
||||
@ -170,7 +161,6 @@ fn rotating_log_path(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
fn open_log_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
@ -178,7 +168,6 @@ fn open_log_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
|
||||
.open(path)
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
fn cleanup_launcher_logs(
|
||||
logs_dir: &std::path::Path,
|
||||
max_files: usize,
|
||||
@ -194,7 +183,6 @@ fn cleanup_launcher_logs(
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
fn cleanup_launcher_logs_at(
|
||||
logs_dir: &std::path::Path,
|
||||
max_files: usize,
|
||||
@ -244,7 +232,6 @@ fn cleanup_launcher_logs_at(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
fn launcher_log_is_expired(
|
||||
created: std::time::SystemTime,
|
||||
now: std::time::SystemTime,
|
||||
@ -253,14 +240,12 @@ fn launcher_log_is_expired(
|
||||
now.duration_since(created).is_ok_and(|age| age > max_age)
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
struct LogEventWriter {
|
||||
writer: RotatingLogWriter,
|
||||
buffer: Vec<u8>,
|
||||
max_file_bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
impl LogEventWriter {
|
||||
fn commit(&mut self) -> std::io::Result<()> {
|
||||
if self.buffer.is_empty() {
|
||||
@ -272,7 +257,6 @@ impl LogEventWriter {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
impl std::io::Write for LogEventWriter {
|
||||
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
|
||||
self.buffer.extend_from_slice(buffer);
|
||||
@ -285,14 +269,12 @@ impl std::io::Write for LogEventWriter {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
impl Drop for LogEventWriter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.commit();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RotatingLogWriter {
|
||||
type Writer = LogEventWriter;
|
||||
|
||||
@ -314,7 +296,6 @@ impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RotatingLogWriter {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
impl RotatingLogWriter {
|
||||
fn event_writer(&self, max_file_bytes: u64) -> LogEventWriter {
|
||||
LogEventWriter {
|
||||
@ -627,11 +608,35 @@ impl std::io::Write for TruncatedConsoleWriter {
|
||||
}
|
||||
}
|
||||
|
||||
// Handling for the live development logging
|
||||
// This will log to the console, and will not log to a file
|
||||
// Handling for live development logging. Keep the compact console output, but
|
||||
// also write the full event stream so exported error reports contain the
|
||||
// session that produced the error.
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn start_logger(_app_identifier: &str) -> Option<()> {
|
||||
pub fn start_logger(app_identifier: &str) -> Option<()> {
|
||||
use crate::prelude::DirectoryInfo;
|
||||
use chrono::Local;
|
||||
use tracing_subscriber::fmt::time::ChronoLocal;
|
||||
use tracing_subscriber::prelude::*;
|
||||
let logs_dir = DirectoryInfo::launcher_logs_dir_path(app_identifier)
|
||||
.or_else(|| {
|
||||
eprintln!("Could not resolve launcher logs directory");
|
||||
None
|
||||
})?;
|
||||
let session_name =
|
||||
format!("session_{}", Local::now().format("%Y%m%d_%H%M%S"));
|
||||
let file_writer = RotatingLogWriter::new(
|
||||
logs_dir,
|
||||
session_name,
|
||||
LAUNCHER_LOG_MAX_BYTES,
|
||||
LAUNCHER_WARN_ERROR_MAX_BYTES,
|
||||
LAUNCHER_LOG_MAX_FILES,
|
||||
LAUNCHER_LOG_MAX_AGE,
|
||||
)
|
||||
.map_err(|error| {
|
||||
eprintln!("Could not start launcher log writer: {error}");
|
||||
error
|
||||
})
|
||||
.ok()?;
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| {
|
||||
tracing_subscriber::EnvFilter::new("theseus=info,theseus_gui=info")
|
||||
@ -646,6 +651,12 @@ pub fn start_logger(_app_identifier: &str) -> Option<()> {
|
||||
stdout: std::io::stdout(),
|
||||
}
|
||||
}))
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(file_writer)
|
||||
.with_ansi(false)
|
||||
.with_timer(ChronoLocal::rfc_3339()),
|
||||
)
|
||||
.with(filter)
|
||||
.with(tracing_error::ErrorLayer::default())
|
||||
.init();
|
||||
|
||||
@ -515,19 +515,18 @@ fn app_db_backup_dir_for(db_path: &Path) -> crate::Result<PathBuf> {
|
||||
))
|
||||
})?;
|
||||
|
||||
let backup_dir = base.join("Backups").join("app-db");
|
||||
match db_path
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|name| name.to_str())
|
||||
{
|
||||
Some("beta") | Some("release") => Ok(backup_dir.join(
|
||||
db_path
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.expect("database channel directory has a name"),
|
||||
)),
|
||||
_ => Ok(backup_dir),
|
||||
Ok(default_app_db_backup_dir(base))
|
||||
}
|
||||
|
||||
fn default_app_db_backup_dir(database_dir: &Path) -> PathBuf {
|
||||
match database_dir.file_name().and_then(|name| name.to_str()) {
|
||||
Some(channel @ ("beta" | "release")) => database_dir
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""))
|
||||
.join("Backups")
|
||||
.join("app-db")
|
||||
.join(channel),
|
||||
_ => database_dir.join("Backups").join("app-db"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -653,6 +652,21 @@ async fn create_sqlite_snapshot(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recovery_and_update_share_the_channel_backup_directory() {
|
||||
for channel in ["release", "beta"] {
|
||||
let settings = Path::new("launcher-settings");
|
||||
assert_eq!(
|
||||
default_app_db_backup_dir(&settings.join(channel)),
|
||||
settings.join("Backups").join("app-db").join(channel)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
default_app_db_backup_dir(Path::new("legacy-settings")),
|
||||
Path::new("legacy-settings").join("Backups").join("app-db")
|
||||
);
|
||||
}
|
||||
|
||||
async fn create_test_app_db(path: &Path, marker: &str) {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
|
||||
@ -54,6 +54,7 @@ pub struct EditInstance {
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct InstanceLaunchOverridesPatch {
|
||||
pub player: Option<crate::state::InstancePlayer>,
|
||||
pub instance_mode: Option<crate::state::InstanceMode>,
|
||||
#[serde(
|
||||
default,
|
||||
@ -373,6 +374,9 @@ fn apply_launch_overrides_patch(
|
||||
if let Some(mode) = patch.instance_mode {
|
||||
overrides.instance_mode = Some(mode);
|
||||
}
|
||||
if let Some(player) = patch.player {
|
||||
overrides.player = Some(player);
|
||||
}
|
||||
if let Some(timeout) = patch.launch_preparation_timeout {
|
||||
overrides.launch_preparation_timeout = timeout;
|
||||
}
|
||||
|
||||
@ -14,8 +14,19 @@ pub enum InstanceMode {
|
||||
Local,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstancePlayer {
|
||||
pub id: uuid::Uuid,
|
||||
pub name: String,
|
||||
pub account_type: crate::state::MinecraftAccountType,
|
||||
#[serde(default)]
|
||||
pub skin_site_user: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceLaunchOverrides {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub player: Option<InstancePlayer>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub instance_mode: Option<InstanceMode>,
|
||||
pub instance_id: String,
|
||||
@ -34,6 +45,7 @@ pub struct InstanceLaunchOverrides {
|
||||
impl InstanceLaunchOverrides {
|
||||
pub fn empty(instance_id: String) -> Self {
|
||||
Self {
|
||||
player: None,
|
||||
instance_mode: None,
|
||||
instance_id,
|
||||
java_path: None,
|
||||
@ -55,6 +67,8 @@ impl InstanceLaunchOverrides {
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct InstanceLaunchOverridesData {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub player: Option<InstancePlayer>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub instance_mode: Option<InstanceMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@ -83,6 +97,7 @@ impl InstanceLaunchOverridesData {
|
||||
instance_id: String,
|
||||
) -> InstanceLaunchOverrides {
|
||||
InstanceLaunchOverrides {
|
||||
player: self.player,
|
||||
instance_mode: self.instance_mode,
|
||||
instance_id,
|
||||
java_path: self.java_path,
|
||||
@ -101,6 +116,7 @@ impl InstanceLaunchOverridesData {
|
||||
impl From<&InstanceLaunchOverrides> for InstanceLaunchOverridesData {
|
||||
fn from(overrides: &InstanceLaunchOverrides) -> Self {
|
||||
Self {
|
||||
player: overrides.player.clone(),
|
||||
instance_mode: overrides.instance_mode,
|
||||
java_path: overrides.java_path.clone(),
|
||||
extra_launch_args: overrides.extra_launch_args.clone(),
|
||||
@ -154,4 +170,35 @@ mod tests {
|
||||
);
|
||||
assert!(serde_json::from_str::<InstanceMode>("\"invalid\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_player_survives_configuration_round_trip() {
|
||||
let mut original = InstanceLaunchOverrides::empty("one".into());
|
||||
let id = uuid::Uuid::new_v4();
|
||||
original.player = Some(InstancePlayer {
|
||||
id,
|
||||
name: "SavedPlayer".into(),
|
||||
account_type: crate::state::MinecraftAccountType::Yggdrasil,
|
||||
skin_site_user: Some("owner".into()),
|
||||
});
|
||||
let encoded = serde_json::to_string(
|
||||
&InstanceLaunchOverridesData::from(&original),
|
||||
)
|
||||
.unwrap();
|
||||
let decoded: InstanceLaunchOverridesData =
|
||||
serde_json::from_str(&encoded).unwrap();
|
||||
let saved = decoded.into_launch_overrides("one".into()).player.unwrap();
|
||||
assert_eq!(saved.id, id);
|
||||
assert_eq!(saved.skin_site_user.as_deref(), Some("owner"));
|
||||
assert_eq!(
|
||||
saved.account_type,
|
||||
crate::state::MinecraftAccountType::Yggdrasil
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<InstanceLaunchOverridesData>("{}")
|
||||
.unwrap()
|
||||
.player
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -611,6 +611,7 @@ where
|
||||
}
|
||||
|
||||
let launch_overrides = InstanceLaunchOverrides {
|
||||
player: None,
|
||||
instance_mode: None,
|
||||
instance_id: instance_id.clone(),
|
||||
java_path: input.java_path,
|
||||
|
||||
@ -798,6 +798,39 @@ impl Credentials {
|
||||
Self::get_active_with_refresh(exec, true).await
|
||||
}
|
||||
|
||||
pub async fn for_instance_player(
|
||||
player: &crate::state::InstancePlayer,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<Self> {
|
||||
let accounts = Self::get_all_without_refresh(exec).await?;
|
||||
let mut account = accounts
|
||||
.remove(&player.id)
|
||||
.map(|(_, account)| account)
|
||||
.ok_or_else(|| {
|
||||
ErrorKind::InputError(format!(
|
||||
"实例玩家 {} 已退出登录,请重新登录该玩家或在实例设置中切换",
|
||||
player.name
|
||||
))
|
||||
.as_error()
|
||||
})?;
|
||||
if account.account_type != player.account_type
|
||||
|| player.skin_site_user.as_ref().is_some_and(|user| {
|
||||
account.yggdrasil.as_ref().is_none_or(|ygg| {
|
||||
ygg.login != *user
|
||||
|| ygg.api_root
|
||||
!= "https://skin.starlight.cool/yggdrasil"
|
||||
})
|
||||
})
|
||||
{
|
||||
return Err(ErrorKind::InputError(
|
||||
"实例玩家身份不匹配,请在实例设置中重新选择".into(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
account.refresh(exec).await?;
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
pub async fn get_active_without_refresh(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<Option<Self>> {
|
||||
@ -900,6 +933,21 @@ impl Credentials {
|
||||
pub async fn upsert(
|
||||
&self,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<()> {
|
||||
self.upsert_inner(exec, false).await
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_preserving_selection(
|
||||
&self,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<()> {
|
||||
self.upsert_inner(exec, true).await
|
||||
}
|
||||
|
||||
async fn upsert_inner(
|
||||
&self,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
preserve_selection: bool,
|
||||
) -> crate::Result<()> {
|
||||
let profile = self.maybe_online_profile().await;
|
||||
let expires = self.expires.timestamp();
|
||||
@ -922,7 +970,7 @@ impl Credentials {
|
||||
.as_ref()
|
||||
.map_or("", |account| account.client_token.as_str());
|
||||
|
||||
if self.active {
|
||||
if self.active && !preserve_selection {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE minecraft_users
|
||||
@ -943,7 +991,7 @@ impl Credentials {
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (uuid) DO UPDATE SET
|
||||
active = $2,
|
||||
active = CASE WHEN $12 THEN minecraft_users.active ELSE $2 END,
|
||||
username = $3,
|
||||
account_type = $4,
|
||||
access_token = $5,
|
||||
@ -956,7 +1004,7 @@ impl Credentials {
|
||||
",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(self.active)
|
||||
.bind(self.active && !preserve_selection)
|
||||
.bind(&profile.name)
|
||||
.bind(account_type)
|
||||
.bind(&self.access_token)
|
||||
@ -966,6 +1014,7 @@ impl Credentials {
|
||||
.bind(yggdrasil_server_name)
|
||||
.bind(yggdrasil_login)
|
||||
.bind(yggdrasil_client_token)
|
||||
.bind(preserve_selection)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
@ -1042,6 +1091,75 @@ impl Serialize for Credentials {
|
||||
mod offline_account_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_player_never_falls_back_to_global_account() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::migrate!().run(&pool).await.unwrap();
|
||||
let first = Credentials::offline("First").unwrap();
|
||||
let second = Credentials::offline("Second").unwrap();
|
||||
first.upsert(&pool).await.unwrap();
|
||||
let mut refreshed = Credentials::offline("First").unwrap();
|
||||
refreshed.active = false;
|
||||
refreshed.upsert_preserving_selection(&pool).await.unwrap();
|
||||
assert_eq!(
|
||||
Credentials::get_active_without_refresh(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.offline_profile
|
||||
.id,
|
||||
first.offline_profile.id
|
||||
);
|
||||
second.upsert(&pool).await.unwrap();
|
||||
first.upsert_preserving_selection(&pool).await.unwrap();
|
||||
let binding = crate::state::InstancePlayer {
|
||||
id: first.offline_profile.id,
|
||||
name: "First".into(),
|
||||
account_type: MinecraftAccountType::Offline,
|
||||
skin_site_user: None,
|
||||
};
|
||||
assert_eq!(
|
||||
Credentials::get_active_without_refresh(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.offline_profile
|
||||
.id,
|
||||
second.offline_profile.id
|
||||
);
|
||||
assert_eq!(
|
||||
Credentials::for_instance_player(&binding, &pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.offline_profile
|
||||
.id,
|
||||
first.offline_profile.id
|
||||
);
|
||||
let mismatched = crate::state::InstancePlayer {
|
||||
account_type: MinecraftAccountType::Microsoft,
|
||||
..binding.clone()
|
||||
};
|
||||
assert!(
|
||||
Credentials::for_instance_player(&mismatched, &pool)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
sqlx::query("DELETE FROM minecraft_users WHERE uuid = ?")
|
||||
.bind(binding.id.to_string())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
Credentials::for_instance_player(&binding, &pool)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_java_compatible_offline_uuid() {
|
||||
let credentials = Credentials::offline("Notch").unwrap();
|
||||
|
||||
@ -199,7 +199,8 @@ pub async fn begin_yggdrasil_login(
|
||||
.await?;
|
||||
|
||||
// 收集本次登录可用的所有角色;优先使用 availableProfiles,回退到 selectedProfile。
|
||||
let mut profiles: Vec<YggdrasilProfile> = response.available_profiles.clone();
|
||||
let mut profiles: Vec<YggdrasilProfile> =
|
||||
response.available_profiles.clone();
|
||||
if profiles.is_empty() {
|
||||
if let Some(selected) = response.selected_profile.clone() {
|
||||
profiles.push(selected);
|
||||
@ -607,10 +608,122 @@ fn create_credentials(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login_skin_site_player(
|
||||
token: &str,
|
||||
player_id: Uuid,
|
||||
user_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = Sqlite> + Copy,
|
||||
) -> crate::Result<Credentials> {
|
||||
#[derive(Deserialize)]
|
||||
struct Envelope {
|
||||
payload: SkinSiteLogin,
|
||||
}
|
||||
let client_token = Uuid::new_v4().to_string();
|
||||
let response = reqwest::Client::builder()
|
||||
.timeout(StdDuration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?
|
||||
.post("https://skin.starlight.cool/starlight/launcher/login")
|
||||
.bearer_auth(token)
|
||||
.json(&json!({ "playerId": player_id, "clientToken": client_token }))
|
||||
.send()
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(ErrorKind::InputError(format!(
|
||||
"皮肤站玩家登录失败(HTTP {}),请检查登录状态后重试",
|
||||
response.status().as_u16()
|
||||
))
|
||||
.as_error());
|
||||
}
|
||||
let login = response.json::<Envelope>().await?.payload;
|
||||
let credential =
|
||||
skin_site_credentials(login, player_id, user_id, &client_token)?;
|
||||
credential.upsert_preserving_selection(exec).await?;
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SkinSiteLogin {
|
||||
access_token: String,
|
||||
client_token: String,
|
||||
selected_profile: YggdrasilProfile,
|
||||
user_id: String,
|
||||
}
|
||||
|
||||
fn skin_site_credentials(
|
||||
login: SkinSiteLogin,
|
||||
player_id: Uuid,
|
||||
user_id: &str,
|
||||
client_token: &str,
|
||||
) -> crate::Result<Credentials> {
|
||||
if login.selected_profile.id != player_id
|
||||
|| login.user_id != user_id
|
||||
|| login.client_token != client_token
|
||||
|| login.access_token.is_empty()
|
||||
{
|
||||
return Err(ErrorKind::InputError(
|
||||
"皮肤站返回的玩家身份不匹配,请重试".into(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
let mut credential = create_credentials(
|
||||
login.selected_profile,
|
||||
login.access_token,
|
||||
login.client_token,
|
||||
YggdrasilMetadata {
|
||||
api_root: "https://skin.starlight.cool/yggdrasil".into(),
|
||||
server_name: "StarLight".into(),
|
||||
raw: String::new(),
|
||||
},
|
||||
user_id,
|
||||
);
|
||||
credential.active = false;
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn skin_site_login_validates_identity_and_keeps_global_selection() {
|
||||
let id = Uuid::new_v4();
|
||||
let response = || SkinSiteLogin {
|
||||
access_token: "game-token".into(),
|
||||
client_token: "client".into(),
|
||||
selected_profile: YggdrasilProfile {
|
||||
id,
|
||||
name: "Player".into(),
|
||||
},
|
||||
user_id: "owner".into(),
|
||||
};
|
||||
let credentials =
|
||||
skin_site_credentials(response(), id, "owner", "client").unwrap();
|
||||
assert!(!credentials.active);
|
||||
assert_eq!(credentials.account_type, MinecraftAccountType::Yggdrasil);
|
||||
assert_eq!(
|
||||
credentials.yggdrasil.unwrap().api_root,
|
||||
"https://skin.starlight.cool/yggdrasil"
|
||||
);
|
||||
assert!(
|
||||
skin_site_credentials(
|
||||
response(),
|
||||
Uuid::new_v4(),
|
||||
"owner",
|
||||
"client"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
skin_site_credentials(response(), id, "other", "client").is_err()
|
||||
);
|
||||
assert!(
|
||||
skin_site_credentials(response(), id, "owner", "other-client")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_api_roots() {
|
||||
assert_eq!(
|
||||
|
||||
@ -105,7 +105,11 @@ const REASSIGNABLE_FIRST_BYTE_TIMEOUT: time::Duration =
|
||||
const MAX_DOWNLOAD_ATTEMPT_HISTORY: usize = 12;
|
||||
const MAX_DOWNLOAD_DIAGNOSTIC_BYTES: usize = 8 * 1024;
|
||||
const MAX_FAILURE_COOLDOWN: time::Duration = time::Duration::from_secs(1);
|
||||
const H2_FALLBACK_TTL: time::Duration = MAX_FAILURE_COOLDOWN;
|
||||
// Once an authority has returned a broken HTTP/2 body, keep subsequent file
|
||||
// retries on HTTP/1.1 for the rest of a typical install. A one-second memory
|
||||
// allowed longer batch downloads to fall straight back onto the same bad H2
|
||||
// connection between retry rounds.
|
||||
const H2_FALLBACK_TTL: time::Duration = time::Duration::from_secs(10 * 60);
|
||||
const TASK_PROBE_MAX_ROUTES: usize = 3;
|
||||
const MAX_TASK_PROBE_STATES: usize = 64;
|
||||
#[cfg(not(test))]
|
||||
@ -6495,7 +6499,10 @@ async fn download_to_path_inner(
|
||||
Ok(chunk) => chunk,
|
||||
Err(error) => {
|
||||
let decode_failure = error.is_decode();
|
||||
if is_h2_protocol_failure(&error)
|
||||
if (is_h2_protocol_failure(&error)
|
||||
|| (decode_failure
|
||||
&& http_version
|
||||
== reqwest::Version::HTTP_2))
|
||||
&& let Some(authority) =
|
||||
url_authority(&final_url)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user