diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index d7eb03e..93e3bfb 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -92,6 +92,7 @@ import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue' import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' import SplashScreen from '@/components/ui/SplashScreen.vue' import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue' +import InstancePlayerModal from '@/components/instance/InstancePlayerModal.vue' import WindowControls from '@/components/ui/WindowControls.vue' import { useCheckDisableMouseover } from '@/composables/macCssFix.js' import { useDropImport } from '@/composables/useDropImport' @@ -2154,6 +2155,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) diff --git a/apps/app-frontend/src/components/instance/instance-player-messages.ts b/apps/app-frontend/src/components/instance/instance-player-messages.ts new file mode 100644 index 0000000..3f1c402 --- /dev/null +++ b/apps/app-frontend/src/components/instance/instance-player-messages.ts @@ -0,0 +1,37 @@ +import { defineMessages } from '@modrinth/ui' + +export const playerMessages = defineMessages({ + title: { id: 'app.instance-player.title', defaultMessage: 'Choose an instance player' }, + remember: { + id: 'app.instance-player.remember', + defaultMessage: + 'This instance will keep using your choice. Switch players in instance settings.', + }, + restore: { + id: 'app.instance-player.restore', + defaultMessage: 'Sign in again as {name}. To choose someone else, open instance settings.', + }, + loading: { id: 'app.instance-player.loading', defaultMessage: 'Loading signed-in players…' }, + licensed: { id: 'app.instance-player.licensed', defaultMessage: 'Minecraft account' }, + skin: { id: 'app.instance-player.skin', defaultMessage: 'Skin site player' }, + offline: { id: 'app.instance-player.offline', defaultMessage: 'Offline player' }, + empty: { + id: 'app.instance-player.empty', + defaultMessage: 'No available skin site players. You can create a player on the skin site.', + }, + signIn: { id: 'app.instance-player.sign-in', defaultMessage: 'Sign in to choose a player.' }, + retry: { id: 'app.instance-player.retry', defaultMessage: 'Retry' }, + skinLogin: { id: 'app.instance-player.skin-login', defaultMessage: 'Sign in to skin site' }, + or: { id: 'app.instance-player.or', defaultMessage: 'Or' }, + useMicrosoft: { + id: 'app.instance-player.use-microsoft', + defaultMessage: 'Use a Minecraft account', + }, + saving: { id: 'app.instance-player.saving', defaultMessage: 'Saving instance player…' }, + setting: { id: 'app.instance-player.setting', defaultMessage: 'Instance player' }, + firstLaunch: { + id: 'app.instance-player.first-launch', + defaultMessage: 'Choose a player on first launch', + }, + change: { id: 'app.instance-player.change', defaultMessage: 'Switch instance player' }, +}) diff --git a/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineBoard.vue b/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineBoard.vue index c1cb7af..8140ab6 100644 --- a/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineBoard.vue +++ b/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineBoard.vue @@ -22,7 +22,8 @@ const { formatMessage } = useVIntl() const viewport = ref() const grid = ref() const view = ref({ left: 0, top: 0, width: 0, height: 0 }) -const brushCursor = ref({ x: 0, y: 0, color: '', visible: false }) +const brushCursor = ref() +const brushActive = computed(() => props.playing && props.selected >= 0) const large = computed(() => props.puzzle.difficulty !== 'easy') const visibleCells = computed(() => props.puzzle.answer.flatMap((color, i) => @@ -87,37 +88,21 @@ function onWheel(event: WheelEvent) { void changeZoom(props.zoom + (event.deltaY < 0 ? 0.1 : -0.1)) } -function moveBrushCursor(event: PointerEvent) { - if (event.pointerType === 'touch') { - hideBrushCursor() - return - } - const cell = (event.target as Element | null)?.closest('.mine-cell') - const visible = Boolean( - props.selected >= 0 && cell && !cell.disabled && !cell.classList.contains('mine-open'), - ) - brushCursor.value = { - x: event.clientX, - y: event.clientY, - color: visible ? getComputedStyle(event.currentTarget as HTMLElement).color : '', - visible, - } +let cursorColor = '' +function updateBrushCursor() { + if (!brushActive.value || !grid.value) return + const color = getComputedStyle(grid.value).color + if (color === cursorColor) return + cursorColor = color + // Let the native cursor follow the pointer independently of Vue and board rendering. + // The hotspot is the same brush tip as the previous 32px floating SVG. + const svg = `` + brushCursor.value = `url("data:image/svg+xml,${encodeURIComponent(svg)}") 5 27, crosshair` } -function hideBrushCursor() { - brushCursor.value.visible = false -} - -watch( - () => [props.playing, props.selected] as const, - ([playing, selected]) => { - if (!playing || selected < 0) hideBrushCursor() - else if (brushCursor.value.visible && grid.value) { - brushCursor.value.color = getComputedStyle(grid.value).color - } - }, - { flush: 'post' }, -) +watch(() => [props.playing, props.selected], updateBrushCursor, { + flush: 'post', +}) function label(index: number) { const values = { @@ -135,6 +120,7 @@ function label(index: number) { let observer: ResizeObserver | undefined onMounted(() => { + updateBrushCursor() observer = new ResizeObserver(updateView) if (viewport.value) observer.observe(viewport.value) }) @@ -142,7 +128,10 @@ onScopeDispose(() => observer?.disconnect()) defineExpose({ centerFirstClue, setView, - getView: () => ({ left: viewport.value?.scrollLeft ?? 0, top: viewport.value?.scrollTop ?? 0 }), + getView: () => ({ + left: viewport.value?.scrollLeft ?? 0, + top: viewport.value?.scrollTop ?? 0, + }), }) @@ -152,16 +141,18 @@ defineExpose({
- - -
.mine-navigation { + box-sizing: border-box; + width: 100%; min-width: 0; + max-width: 100%; display: grid; gap: var(--gap-md); } @@ -268,6 +244,9 @@ defineExpose({ grid-template-columns: minmax(0, 1fr) 7rem; } .mine-viewport { + box-sizing: border-box; + width: 100%; + max-width: 100%; overflow: auto; max-height: min(52vh, 32rem); min-width: 0; @@ -308,17 +287,9 @@ defineExpose({ user-select: none; touch-action: manipulation; } +.mine-grid-brush-active, .mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) { - cursor: none; -} -.mine-brush-cursor { - position: fixed; - z-index: 2147483647; - width: 2rem; - height: 2rem; - filter: drop-shadow(0 1px 1px rgb(0 0 0 / 45%)); - pointer-events: none; - transform: translate(-16%, -84%); + cursor: var(--mine-brush-cursor, crosshair); } .mine-cell:hover:not(:disabled) { background: var(--surface-5); @@ -392,12 +363,4 @@ defineExpose({ width: 4.5rem; } } -@media (pointer: coarse) { - .mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) { - cursor: pointer; - } - .mine-brush-cursor { - display: none; - } -} diff --git a/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineModal.vue b/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineModal.vue index 60163aa..82b72be 100644 --- a/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineModal.vue +++ b/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineModal.vue @@ -301,6 +301,7 @@ defineExpose({ show }) :header="formatMessage(messages.title)" width="56rem" max-width="56rem" + scrollable :closable="false" :close-on-esc="false" actions-divider @@ -455,15 +456,21 @@ defineExpose({ show }) justify-content: space-between; } .mine-frame { + box-sizing: border-box; + min-width: 0; + max-width: 100%; border: 2px solid var(--mine-ink); border-radius: var(--radius-md); padding: var(--gap-md); background: var(--surface-2); } .mine-status { + min-width: 0; + max-width: 100%; margin: 0 0 var(--gap-md); color: var(--color-contrast); font-size: 0.875rem; + overflow-wrap: anywhere; } .mine-actions { justify-content: flex-end; diff --git a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue index 7159ab6..ba774f6 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue @@ -18,6 +18,7 @@ import { useRouter } from 'vue-router' import InstanceIcon from '@/components/ui/InstanceIcon.vue' import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue' +import InstancePlayerSettings from '@/components/instance/InstancePlayerSettings.vue' import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue' import { install_duplicate_instance } from '@/helpers/install' import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance' @@ -381,6 +382,7 @@ const messages = defineMessages({
-
- -
-

{{ formatMessage(messages.noInstances) }}

+
+ +

{{ formatMessage(messages.noInstances) }}

- - diff --git a/apps/app/build.rs b/apps/app/build.rs index 8fa93bc..9b16e66 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -111,6 +111,9 @@ fn main() { "poll_device_login", "begin_yggdrasil_login", "finish_yggdrasil_login", + "login_skin_site_player", + "get_instance_player", + "set_instance_player", "list_yggdrasil_saved_logins", "get_yggdrasil_password", "set_yggdrasil_password", diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs index a24d088..82413e7 100644 --- a/apps/app/src/api/auth.rs +++ b/apps/app/src/api/auth.rs @@ -31,6 +31,9 @@ pub fn init() -> TauriPlugin { get_default_user, set_default_user, get_users, + get_instance_player, + set_instance_player, + login_skin_site_player, ]) .build() } @@ -318,6 +321,33 @@ pub async fn finish_yggdrasil_login( const YGGDRASIL_SAVED_LOGINS_KEY: &str = "yggdrasil-saved-logins"; +#[tauri::command] +pub async fn get_instance_player( + instance_id: String, +) -> Result> { + Ok(minecraft_auth::get_instance_player(&instance_id).await?) +} + +#[tauri::command] +pub async fn set_instance_player( + instance_id: String, + player: minecraft_auth::InstancePlayer, +) -> Result<()> { + Ok(minecraft_auth::set_instance_player(&instance_id, player).await?) +} + +#[tauri::command] +pub async fn login_skin_site_player( + token: String, + player_id: uuid::Uuid, + user_id: String, +) -> Result { + Ok( + minecraft_auth::login_skin_site_player(&token, player_id, &user_id) + .await?, + ) +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct SavedYggdrasilLogin { pub api_root: String, @@ -443,22 +473,24 @@ fn yggdrasil_saved_logins_entry() -> Result { fn read_yggdrasil_saved_logins() -> Result> { match yggdrasil_saved_logins_entry()?.get_password() { - Ok(saved_logins) => match serde_json::from_str::>( - &saved_logins, - ) { - Ok(saved_logins) => Ok(saved_logins - .into_iter() - .filter(|saved_login| { - saved_login.api_root == STARLIGHT_YGGDRASIL_API_ROOT - }) - .collect()), - Err(error) => { - tracing::warn!( - "Ignoring an invalid saved Yggdrasil login index: {error}" - ); - Ok(Vec::new()) + Ok(saved_logins) => { + match serde_json::from_str::>( + &saved_logins, + ) { + Ok(saved_logins) => Ok(saved_logins + .into_iter() + .filter(|saved_login| { + saved_login.api_root == STARLIGHT_YGGDRASIL_API_ROOT + }) + .collect()), + Err(error) => { + tracing::warn!( + "Ignoring an invalid saved Yggdrasil login index: {error}" + ); + Ok(Vec::new()) + } } - }, + } Err(keyring::Error::NoEntry) => Ok(Vec::new()), Err(error) => Err(keyring_error(error)), } diff --git a/apps/app/src/api/instance.rs b/apps/app/src/api/instance.rs index b2f4805..6f48dad 100644 --- a/apps/app/src/api/instance.rs +++ b/apps/app/src/api/instance.rs @@ -462,6 +462,7 @@ fn edit_to_core(edit_instance: EditInstance) -> Result { }) .transpose()?, launch_overrides: Some(InstanceLaunchOverridesPatch { + player: None, instance_mode: None, java_path: edit_instance.java_path, extra_launch_args: edit_instance.extra_launch_args, diff --git a/apps/app/src/lightweight_mode.rs b/apps/app/src/lightweight_mode.rs index fba37e7..784e925 100644 --- a/apps/app/src/lightweight_mode.rs +++ b/apps/app/src/lightweight_mode.rs @@ -354,7 +354,8 @@ unsafe extern "system" fn maximize_if_owned_by_process( _: windows::Win32::Foundation::LPARAM, ) -> windows::core::BOOL { use windows::Win32::UI::WindowsAndMessaging::{ - GetWindowThreadProcessId, IsWindowVisible, SW_MAXIMIZE, SetForegroundWindow, ShowWindow, + GetWindowThreadProcessId, IsWindowVisible, SW_MAXIMIZE, + SetForegroundWindow, ShowWindow, }; use windows::core::BOOL; diff --git a/apps/installer-ui/src/windows.rs b/apps/installer-ui/src/windows.rs index 2a916a1..cc309ea 100644 --- a/apps/installer-ui/src/windows.rs +++ b/apps/installer-ui/src/windows.rs @@ -148,7 +148,6 @@ pub fn run() -> Result<(), String> { let arguments = parse_arguments()?; let event_loop = EventLoopBuilder::::with_user_event().build(); let window = WindowBuilder::new() - .with_title("Starlight Launcher") .with_inner_size(LogicalSize::new(940.0, 620.0)) .with_min_inner_size(LogicalSize::new(940.0, 620.0)) diff --git a/packages/app-lib/src/api/instance/run.rs b/packages/app-lib/src/api/instance/run.rs index fdb9713..7d51a16 100644 --- a/packages/app-lib/src/api/instance/run.rs +++ b/packages/app-lib/src/api/instance/run.rs @@ -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(|| { diff --git a/packages/app-lib/src/api/minecraft_auth.rs b/packages/app-lib/src/api/minecraft_auth.rs index 824eacd..f3d94c9 100644 --- a/packages/app-lib/src/api/minecraft_auth.rs +++ b/packages/app-lib/src/api/minecraft_auth.rs @@ -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 { crate::state::normalize_api_root(api_root) } +pub async fn get_instance_player( + instance_id: &str, +) -> crate::Result> { + 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 { + 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, diff --git a/packages/app-lib/src/api/pack/hosted.rs b/packages/app-lib/src/api/pack/hosted.rs index f96a2cb..c03d019 100644 --- a/packages/app-lib/src/api/pack/hosted.rs +++ b/packages/app-lib/src/api/pack/hosted.rs @@ -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>>> = LazyLock::new(dashmap::DashMap::new); static SESSION: Mutex> = 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::>(); + 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::>(); + 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 { 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 { } } 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> { 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( root: &Path, name: &str, ) -> crate::Result> { - 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( @@ -289,10 +334,31 @@ fn write_json( ) -> 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 { @@ -330,18 +396,22 @@ async fn request_authorized( auth: &str, ) -> crate::Result { 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 = 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::::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 { diff --git a/packages/app-lib/src/api/pack/hosted/tagged.rs b/packages/app-lib/src/api/pack/hosted/tagged.rs index 78ccd21..15cb4be 100644 --- a/packages/app-lib/src/api/pack/hosted/tagged.rs +++ b/packages/app-lib/src/api/pack/hosted/tagged.rs @@ -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 = - 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 = 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 = 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)?; diff --git a/packages/app-lib/src/api/pack/hosted/transport.rs b/packages/app-lib/src/api/pack/hosted/transport.rs new file mode 100644 index 0000000..16eb65c --- /dev/null +++ b/packages/app-lib/src/api/pack/hosted/transport.rs @@ -0,0 +1,228 @@ +use reqwest::{Client, Response, header}; + +pub(super) async fn metadata_request( + client: &Client, + url: &str, + auth: &str, +) -> Result { + 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 { + 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, "Proxy error", "用户接口返回内容异常"), + ] { + 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(); + } + } +} diff --git a/packages/app-lib/src/api/pack/import/axolotl.rs b/packages/app-lib/src/api/pack/import/axolotl.rs index 50a96d3..a9ddb6f 100644 --- a/packages/app-lib/src/api/pack/import/axolotl.rs +++ b/packages/app-lib/src/api/pack/import/axolotl.rs @@ -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( diff --git a/packages/app-lib/src/api/pack/mod.rs b/packages/app-lib/src/api/pack/mod.rs index 9485daa..534f018 100644 --- a/packages/app-lib/src/api/pack/mod.rs +++ b/packages/app-lib/src/api/pack/mod.rs @@ -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; diff --git a/packages/app-lib/src/brand.rs b/packages/app-lib/src/brand.rs index d4a0090..99c98f0 100644 --- a/packages/app-lib/src/brand.rs +++ b/packages/app-lib/src/brand.rs @@ -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('@')); diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index 5d85031..b8d7c26 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -249,7 +249,11 @@ impl std::fmt::Display for Error { impl Error { pub(crate) fn with_context(mut self, context: impl Into) -> 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 } diff --git a/packages/app-lib/src/logger.rs b/packages/app-lib/src/logger.rs index 69c89d8..d00cca1 100644 --- a/packages/app-lib/src/logger.rs +++ b/packages/app-lib/src/logger.rs @@ -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>, @@ -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::OpenOptions::new() .create(true) @@ -178,7 +168,6 @@ fn open_log_file(path: &std::path::Path) -> std::io::Result { .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, 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 { 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(); diff --git a/packages/app-lib/src/state/db_backup.rs b/packages/app-lib/src/state/db_backup.rs index d722828..abfe0e4 100644 --- a/packages/app-lib/src/state/db_backup.rs +++ b/packages/app-lib/src/state/db_backup.rs @@ -515,19 +515,18 @@ fn app_db_backup_dir_for(db_path: &Path) -> crate::Result { )) })?; - 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) diff --git a/packages/app-lib/src/state/instances/commands/edit_instance.rs b/packages/app-lib/src/state/instances/commands/edit_instance.rs index d48609a..e2af145 100644 --- a/packages/app-lib/src/state/instances/commands/edit_instance.rs +++ b/packages/app-lib/src/state/instances/commands/edit_instance.rs @@ -54,6 +54,7 @@ pub struct EditInstance { #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct InstanceLaunchOverridesPatch { + pub player: Option, pub instance_mode: Option, #[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; } diff --git a/packages/app-lib/src/state/instances/model/launch.rs b/packages/app-lib/src/state/instances/model/launch.rs index 07b8c0c..848866b 100644 --- a/packages/app-lib/src/state/instances/model/launch.rs +++ b/packages/app-lib/src/state/instances/model/launch.rs @@ -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, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct InstanceLaunchOverrides { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub instance_mode: Option, 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, #[serde(default, skip_serializing_if = "Option::is_none")] pub instance_mode: Option, #[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::("\"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::("{}") + .unwrap() + .player + .is_none() + ); + } } diff --git a/packages/app-lib/src/state/legacy_converter.rs b/packages/app-lib/src/state/legacy_converter.rs index b1b0c09..ebe4902 100644 --- a/packages/app-lib/src/state/legacy_converter.rs +++ b/packages/app-lib/src/state/legacy_converter.rs @@ -611,6 +611,7 @@ where } let launch_overrides = InstanceLaunchOverrides { + player: None, instance_mode: None, instance_id: instance_id.clone(), java_path: input.java_path, diff --git a/packages/app-lib/src/state/minecraft_auth.rs b/packages/app-lib/src/state/minecraft_auth.rs index 7e4ef69..3e516f8 100644 --- a/packages/app-lib/src/state/minecraft_auth.rs +++ b/packages/app-lib/src/state/minecraft_auth.rs @@ -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 { + 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> { @@ -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(); diff --git a/packages/app-lib/src/state/minecraft_auth/yggdrasil.rs b/packages/app-lib/src/state/minecraft_auth/yggdrasil.rs index 711699c..a416623 100644 --- a/packages/app-lib/src/state/minecraft_auth/yggdrasil.rs +++ b/packages/app-lib/src/state/minecraft_auth/yggdrasil.rs @@ -199,7 +199,8 @@ pub async fn begin_yggdrasil_login( .await?; // 收集本次登录可用的所有角色;优先使用 availableProfiles,回退到 selectedProfile。 - let mut profiles: Vec = response.available_profiles.clone(); + let mut profiles: Vec = + 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 { + #[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::().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 { + 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!( diff --git a/packages/app-lib/src/util/fetch.rs b/packages/app-lib/src/util/fetch.rs index 648e08d..c6c46b7 100644 --- a/packages/app-lib/src/util/fetch.rs +++ b/packages/app-lib/src/util/fetch.rs @@ -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) { diff --git a/standards/hosted-modpacks.md b/standards/hosted-modpacks.md index a557b10..830fb56 100644 --- a/standards/hosted-modpacks.md +++ b/standards/hosted-modpacks.md @@ -56,7 +56,7 @@ 管理员在「Mod 管理 → 管理整合包 → 整合包 Mod 标签」选择已有常用标签。标签按现有规则集合匹配 Mod,每次启动独立获取最新清单并检查标签文件 SHA-256;包标志相同也不跳过这一步。服务端保存内容快照,上传新 Mod 不会破坏正在下载的旧快照。设置审计保存于 `launcher_modpack_tag_audit`,快照登记于 `launcher_tagged_mod_object`,文件位于 `launcher-modpacks/tagged-mods`,不得公开挂载。 -标签 Mod 优先于整合包中同标识的旧 Mod,安装路径为 `mods/{modId}.starlight.jar`。完整识别一个 JAR 声明的所有 Mod;部分覆盖多 Mod JAR 时明确报错,不擅自删除其他 Mod。相同标识的旧文件与新文件统一在下载校验完成后通过恢复日志替换。标签取消或 Mod 移出标签后,移除启动器管理的标签文件并恢复仍在整合包清单中的原版;用户其他 Mod 不作为删除目标。CurseForge 引用保存项目、文件及 Mod 身份,清理缓存后仍能使用原下载源恢复。 +整合包自带 Mod 是安装基线,之后再校验标签 Mod。标签快照与整合包 JAR 的 SHA-256 相同时保留整合包原文件和原文件名,不重复下载或改名;只有内容确实不同时,标签 Mod 才优先替换同标识的旧 Mod,安装路径为 `mods/{modId}.starlight.jar`。完整识别一个 JAR 声明的所有 Mod;部分覆盖多 Mod JAR 时明确报错,不擅自删除其他 Mod。相同标识且内容不同的旧文件与新文件统一在下载校验完成后通过恢复日志替换。标签取消或 Mod 移出标签后,移除启动器管理的标签文件并恢复仍在整合包清单中的原版;用户其他 Mod 不作为删除目标。CurseForge 引用保存项目、文件及 Mod 身份,清理缓存后仍能使用原下载源恢复。 标签下载使用启动器有效并发设置,上限 16 个文件,复用现有下载重试、连接管理与完整性校验。独立弹窗逐个显示文件字节进度,下载页同时显示这些任务;所有文件成功下载、替换后才允许启动,任一失败则保留错误并阻止启动。再次尝试会清理上一轮弹窗进度和失败任务。