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

This commit is contained in:
2026-09-16 17:01:22 +08:00
parent bd09fed0ed
commit 51fb0c30f7
45 changed files with 1804 additions and 219 deletions

View File

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

View File

@ -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;
}

View File

@ -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()
);
}
}

View File

@ -611,6 +611,7 @@ where
}
let launch_overrides = InstanceLaunchOverrides {
player: None,
instance_mode: None,
instance_id: instance_id.clone(),
java_path: input.java_path,

View File

@ -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();

View File

@ -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!(