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:
@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user