diff --git a/public/images/icons/external/HMCL.png b/public/images/icons/external/HMCL.png new file mode 100644 index 000000000..71b6d32f9 Binary files /dev/null and b/public/images/icons/external/HMCL.png differ diff --git a/public/images/icons/external/PCL.png b/public/images/icons/external/PCL.png new file mode 100644 index 000000000..a309a11f2 Binary files /dev/null and b/public/images/icons/external/PCL.png differ diff --git a/public/images/icons/external/SCL.png b/public/images/icons/external/SCL.png new file mode 100644 index 000000000..a7a0bff4e Binary files /dev/null and b/public/images/icons/external/SCL.png differ diff --git a/src-tauri/src/account/commands.rs b/src-tauri/src/account/commands.rs index 79c5e6c6b..e2ae8d199 100644 --- a/src-tauri/src/account/commands.rs +++ b/src-tauri/src/account/commands.rs @@ -3,6 +3,8 @@ use crate::account::helpers::authlib_injector::info::{ }; use crate::account::helpers::authlib_injector::jar::check_authlib_jar; use crate::account::helpers::authlib_injector::{self}; +use crate::account::helpers::import::hmcl::retrieve_hmcl_account_info; +use crate::account::helpers::import::ImportLauncherType; use crate::account::helpers::{microsoft, misc, offline}; use crate::account::models::{ AccountError, AccountInfo, AuthServer, DeviceAuthResponseInfo, Player, PlayerInfo, PlayerType, @@ -619,3 +621,96 @@ pub fn delete_auth_server(app: AppHandle, url: String) -> SJMCLResult<()> { config_state.save()?; Ok(()) } + +// Stage 1 of importing accounts (players and auth servers) from other launchers +#[tauri::command] +pub async fn retrieve_other_launcher_account_info( + app: AppHandle, + launcher_type: ImportLauncherType, +) -> SJMCLResult<(Vec, Vec)> { + let (mut player_infos, urls) = match launcher_type { + ImportLauncherType::HMCL => retrieve_hmcl_account_info(&app).await?, + _ => return Ok((vec![], vec![])), + }; + + // remove trailing slashes for deduplication + let mut url_set = std::collections::HashSet::::new(); + for u in urls { + url_set.insert(u.as_str().trim_end_matches('/').to_string()); + } + for p in &mut player_infos { + if let Some(url) = p.auth_server_url.as_mut() { + *url = url.trim_end_matches('/').to_string(); + } + } + + // fetch auth servers + let mut auth_server_infos = Vec::new(); + for url in url_set { + auth_server_infos.push(fetch_auth_server_info(&app, url).await?); + } + + Ok(( + player_infos + .into_iter() + .map(|p| Player::from_player_info(p, Some(&auth_server_infos))) + .collect(), + auth_server_infos + .into_iter() + .map(AuthServer::from) + .collect(), + )) +} + +// Stage 2 of importing accounts from other launchers +#[tauri::command] +pub async fn import_external_account_info( + app: AppHandle, + players: Vec, + auth_servers: Vec, +) -> SJMCLResult<()> { + // fetch auth servers + let fetch_tasks = auth_servers.into_iter().map(|server| { + let app = app.clone(); + async move { fetch_auth_server_info(&app, server.auth_url).await } + }); + + let fetched = futures::future::join_all(fetch_tasks).await; + let mut fetched_infos = Vec::with_capacity(fetched.len()); + for r in fetched { + fetched_infos.push(r?); + } + + let account_binding = app.state::>(); + let mut account_state = account_binding.lock()?; + + // servers: same url overwritten + for server_info in fetched_infos { + if let Some(existing) = account_state + .auth_servers + .iter_mut() + .find(|s| s.auth_url == server_info.auth_url) + { + *existing = server_info; + } else { + account_state.auth_servers.push(server_info); + } + } + + // players: same id overwritten + for player in players { + let player_info: PlayerInfo = player.into(); + if let Some(existing) = account_state + .players + .iter_mut() + .find(|p| p.id == player_info.id) + { + *existing = player_info; + } else { + account_state.players.push(player_info); + } + } + + account_state.save()?; + Ok(()) +} diff --git a/src-tauri/src/account/helpers/authlib_injector/common.rs b/src-tauri/src/account/helpers/authlib_injector/common.rs index 31f57527e..c680fe1ed 100644 --- a/src-tauri/src/account/helpers/authlib_injector/common.rs +++ b/src-tauri/src/account/helpers/authlib_injector/common.rs @@ -60,22 +60,24 @@ pub async fn parse_profile( .map(|b| b as char) .collect::(); - let texture_info_value: TextureInfo = - serde_json::from_str(&texture_info).map_err(|_| AccountError::ParseError)?; + if !texture_info.is_empty() { + let texture_info_value: TextureInfo = + serde_json::from_str(&texture_info).map_err(|_| AccountError::ParseError)?; - for texture_type in TextureType::iter() { - if let Some(skin) = texture_info_value.textures.get(&texture_type.to_string()) { - textures.push(Texture { - image: fetch_image(app, skin.url.clone()).await?, - texture_type, - model: skin - .metadata - .as_ref() - .and_then(|metadata| metadata.get("model").cloned()) - .map(|model_str| SkinModel::from_str(&model_str).unwrap_or(SkinModel::Default)) - .unwrap_or_default(), - preset: None, - }); + for texture_type in TextureType::iter() { + if let Some(skin) = texture_info_value.textures.get(&texture_type.to_string()) { + textures.push(Texture { + image: fetch_image(app, skin.url.clone()).await?, + texture_type, + model: skin + .metadata + .as_ref() + .and_then(|metadata| metadata.get("model").cloned()) + .map(|model_str| SkinModel::from_str(&model_str).unwrap_or(SkinModel::Default)) + .unwrap_or_default(), + preset: None, + }); + } } } } diff --git a/src-tauri/src/account/helpers/authlib_injector/models.rs b/src-tauri/src/account/helpers/authlib_injector/models.rs index 1b76e4ccf..b09c02e9b 100644 --- a/src-tauri/src/account/helpers/authlib_injector/models.rs +++ b/src-tauri/src/account/helpers/authlib_injector/models.rs @@ -1,15 +1,16 @@ use std::collections::HashMap; -structstruck::strike! { - #[strikethrough[derive(serde::Deserialize, serde::Serialize)]] - pub struct MinecraftProfile { - pub id: String, - pub name: String, - pub properties: Option> - } +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct MinecraftProfileProperty { + pub name: String, + pub value: String, +} + +#[derive(serde::Deserialize, serde::Serialize)] +pub struct MinecraftProfile { + pub id: String, + pub name: String, + pub properties: Option>, } structstruck::strike! { diff --git a/src-tauri/src/account/helpers/import/hmcl.rs b/src-tauri/src/account/helpers/import/hmcl.rs new file mode 100644 index 000000000..c807a2cb8 --- /dev/null +++ b/src-tauri/src/account/helpers/import/hmcl.rs @@ -0,0 +1,214 @@ +use crate::account::helpers::authlib_injector::common::parse_profile; +use crate::account::helpers::authlib_injector::models::{ + MinecraftProfile, MinecraftProfileProperty, +}; +use crate::account::helpers::microsoft::oauth::fetch_minecraft_profile; +use crate::account::helpers::misc::fetch_image; +use crate::account::helpers::offline::load_preset_skin; +use crate::account::models::{ + AccountError, PlayerInfo, PlayerType, PresetRole, SkinModel, Texture, TextureType, +}; +use crate::error::SJMCLResult; +use serde::Deserialize; +use std::collections::HashSet; +use std::fs; +use std::str::FromStr; +use tauri::path::BaseDirectory; +use tauri::{AppHandle, Manager}; +use url::Url; +use uuid::Uuid; + +#[derive(Debug, Clone, Deserialize)] +pub struct HmclOfflineAccount { + pub uuid: String, + pub username: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HmclMicrosoftAccount { + pub uuid: String, + pub display_name: String, + pub token_type: String, + pub access_token: String, + pub refresh_token: String, + pub not_after: i64, + pub userid: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct HmclProfileProperties { + pub textures: Option, +} +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HmclThirdPartyAccount { + #[serde(rename = "serverBaseURL")] + pub server_base_url: String, + pub client_token: String, + pub display_name: String, + pub access_token: String, + pub profile_properties: HmclProfileProperties, + pub uuid: String, + pub username: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "type")] +pub enum HmclAccountEntry { + #[serde(rename = "offline")] + Offline(HmclOfflineAccount), + #[serde(rename = "microsoft")] + Microsoft(HmclMicrosoftAccount), + #[serde(rename = "authlibInjector")] + ThirdParty(HmclThirdPartyAccount), +} + +async fn offline_to_player(app: &AppHandle, acc: &HmclOfflineAccount) -> SJMCLResult { + let uuid = uuid::Uuid::parse_str(&acc.uuid).map_err(|_| AccountError::ParseError)?; + let textures = load_preset_skin(app, PresetRole::Steve)?; + Ok( + PlayerInfo { + id: "".to_string(), + uuid, + name: acc.username.clone(), + player_type: PlayerType::Offline, + auth_account: None, + auth_server_url: None, + access_token: None, + refresh_token: None, + textures, + } + .with_generated_id(), + ) +} + +async fn microsoft_to_player( + app: &AppHandle, + acc: &HmclMicrosoftAccount, +) -> SJMCLResult { + let profile = fetch_minecraft_profile(app, acc.access_token.clone()).await?; + + let mut textures = vec![]; + if let Some(skins) = &profile.skins { + for skin in skins { + if skin.state == "ACTIVE" { + textures.push(Texture { + texture_type: TextureType::Skin, + image: fetch_image(app, skin.url.clone()).await?, + model: skin.variant.clone().unwrap_or_default(), + preset: None, + }); + } + } + } + if let Some(capes) = &profile.capes { + for cape in capes { + if cape.state == "ACTIVE" { + textures.push(Texture { + texture_type: TextureType::Cape, + image: fetch_image(app, cape.url.clone()).await?, + model: SkinModel::Default, + preset: None, + }); + } + } + } + + if textures.is_empty() { + // this player didn't have a texture, use preset Steve skin instead + textures = load_preset_skin(app, PresetRole::Steve)?; + } + + Ok( + PlayerInfo { + id: "".to_string(), + uuid: Uuid::from_str(&profile.id).map_err(|_| AccountError::ParseError)?, + name: profile.name.clone(), + player_type: PlayerType::Microsoft, + auth_account: Some(profile.name.clone()), + access_token: Some(acc.access_token.clone()), + refresh_token: Some(acc.refresh_token.clone()), + textures, + auth_server_url: None, + } + .with_generated_id(), + ) +} + +async fn thirdparty_to_player( + app: &AppHandle, + acc: &HmclThirdPartyAccount, +) -> SJMCLResult { + let profile = MinecraftProfile { + id: acc.uuid.clone(), + name: acc.display_name.clone(), + properties: Some(vec![MinecraftProfileProperty { + name: "textures".to_string(), + value: acc.profile_properties.textures.clone().unwrap_or_default(), + }]), + }; + let p = parse_profile( + app, + &profile, + Some(acc.access_token.clone()), + None, + Some(acc.server_base_url.clone()), + Some(acc.username.clone()), + ) + .await?; + Ok(p) +} + +pub async fn retrieve_hmcl_account_info( + app: &AppHandle, +) -> SJMCLResult<(Vec, Vec)> { + let hmcl_json_path = if cfg!(target_os = "linux") { + app + .path() + .resolve("", BaseDirectory::Home)? + .join(".hmcl") + .join("accounts.json") + } else { + let app_data = app.path().resolve("", BaseDirectory::AppData)?; + let base = app_data + .parent() + .ok_or(AccountError::NotFound)? + .to_path_buf(); + if cfg!(target_os = "macos") { + base.join("hmcl").join("accounts.json") + } else { + base.join(".hmcl").join("accounts.json") + } + }; + + if !hmcl_json_path.is_file() { + return Ok((vec![], vec![])); + } + + let hmcl_json = fs::read_to_string(&hmcl_json_path).map_err(|_| AccountError::NotFound)?; + + let hmcl_entries: Vec = + serde_json::from_str(&hmcl_json).map_err(|_| AccountError::Invalid)?; + let mut player_infos: Vec = Vec::new(); + let mut url_set: HashSet = HashSet::new(); + + for e in &hmcl_entries { + match e { + HmclAccountEntry::Offline(acc) => { + player_infos.push(offline_to_player(app, acc).await?); + } + HmclAccountEntry::Microsoft(acc) => { + player_infos.push(microsoft_to_player(app, acc).await?); + } + HmclAccountEntry::ThirdParty(acc) => { + if let Ok(url) = Url::parse(&acc.server_base_url) { + url_set.insert(url); + } + player_infos.push(thirdparty_to_player(app, acc).await?); + } + } + } + + Ok((player_infos, url_set.into_iter().collect())) +} diff --git a/src-tauri/src/account/helpers/import/mod.rs b/src-tauri/src/account/helpers/import/mod.rs new file mode 100644 index 000000000..80c019b10 --- /dev/null +++ b/src-tauri/src/account/helpers/import/mod.rs @@ -0,0 +1,12 @@ +pub mod hmcl; + +use serde::Deserialize; + +// other launchers we support import accounts from +#[allow(clippy::upper_case_acronyms)] +#[derive(Debug, Clone, Deserialize)] +pub enum ImportLauncherType { + HMCL, + PCL, // only on Windows + SCL, // only on macOS +} diff --git a/src-tauri/src/account/helpers/microsoft/oauth.rs b/src-tauri/src/account/helpers/microsoft/oauth.rs index fb15b5f13..c394c067f 100644 --- a/src-tauri/src/account/helpers/microsoft/oauth.rs +++ b/src-tauri/src/account/helpers/microsoft/oauth.rs @@ -132,7 +132,7 @@ async fn fetch_minecraft_token( Ok(response["access_token"].as_str().unwrap_or("").to_string()) } -async fn fetch_minecraft_profile( +pub async fn fetch_minecraft_profile( app: &AppHandle, minecraft_token: String, ) -> SJMCLResult { diff --git a/src-tauri/src/account/helpers/mod.rs b/src-tauri/src/account/helpers/mod.rs index 46829d7be..4dd38b447 100644 --- a/src-tauri/src/account/helpers/mod.rs +++ b/src-tauri/src/account/helpers/mod.rs @@ -1,4 +1,5 @@ pub mod authlib_injector; +pub mod import; pub mod microsoft; pub mod misc; pub mod offline; diff --git a/src-tauri/src/account/models.rs b/src-tauri/src/account/models.rs index 8ab201382..c3e08d39e 100644 --- a/src-tauri/src/account/models.rs +++ b/src-tauri/src/account/models.rs @@ -90,22 +90,30 @@ pub struct Player { pub textures: Vec, } -impl From for Player { - fn from(player_info: PlayerInfo) -> Self { - let state: AccountInfo = Storage::load().unwrap_or_default(); +impl Player { + pub fn from_player_info( + player_info: PlayerInfo, + auth_servers: Option<&[AuthServerInfo]>, + ) -> Self { + let owned_auth_servers; + let auth_servers = match auth_servers { + Some(list) => list, + None => { + let state: AccountInfo = Storage::load().unwrap_or_default(); + owned_auth_servers = state.auth_servers.into_iter().collect::>(); + &owned_auth_servers + } + }; - let auth_server = if let Some(auth_server_url) = player_info.auth_server_url { - Some(AuthServer::from( - state - .auth_servers + let auth_server = player_info.auth_server_url.clone().map(|auth_server_url| { + AuthServer::from( + auth_servers .iter() .find(|server| server.auth_url == auth_server_url) .cloned() .unwrap_or_default(), - )) - } else { - None - }; + ) + }); Player { id: player_info.id, @@ -122,6 +130,12 @@ impl From for Player { } } +impl From for Player { + fn from(player_info: PlayerInfo) -> Self { + Player::from_player_info(player_info, None) + } +} + // for backend storage, without saving the whole auth server info #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e2c32bd0b..c026083c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -90,9 +90,11 @@ pub async fn run() { account::commands::delete_player, account::commands::refresh_player, account::commands::retrieve_auth_server_list, + account::commands::fetch_auth_server, account::commands::add_auth_server, account::commands::delete_auth_server, - account::commands::fetch_auth_server, + account::commands::retrieve_other_launcher_account_info, + account::commands::import_external_account_info, instance::commands::retrieve_instance_list, instance::commands::create_instance, instance::commands::update_instance_config, diff --git a/src/components/common/selectable-card.tsx b/src/components/common/selectable-card.tsx index 0a7f6f1ca..d252e88c5 100644 --- a/src/components/common/selectable-card.tsx +++ b/src/components/common/selectable-card.tsx @@ -72,7 +72,6 @@ const SelectableCard: React.FC = ({ fontSize="xs-sm" fontWeight={isSelected ? "bold" : "normal"} color={isSelected ? `${primaryColor}.600` : "inherit"} - mt={displayMode === "entry" && isSelected ? -0.5 : 0} > {title} diff --git a/src/components/modals/import-account-info-modal.tsx b/src/components/modals/import-account-info-modal.tsx new file mode 100644 index 000000000..80bd596d9 --- /dev/null +++ b/src/components/modals/import-account-info-modal.tsx @@ -0,0 +1,365 @@ +import { + Box, + Button, + Center, + Checkbox, + Grid, + HStack, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + ModalProps, + Tooltip, + VStack, +} from "@chakra-ui/react"; +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuTriangleAlert } from "react-icons/lu"; +import { BeatLoader } from "react-spinners"; +import Empty from "@/components/common/empty"; +import { OptionItemGroup } from "@/components/common/option-item"; +import { Section } from "@/components/common/section"; +import SelectableCard from "@/components/common/selectable-card"; +import PlayerAvatar from "@/components/player-avatar"; +import { useLauncherConfig } from "@/contexts/config"; +import { useGlobalData } from "@/contexts/global-data"; +import { useToast } from "@/contexts/toast"; +import { ImportLauncherType, PlayerType } from "@/enums/account"; +import { AuthServer, Player } from "@/models/account"; +import { AccountService } from "@/services/account"; +import { generatePlayerDesc } from "@/utils/account"; + +interface ImportAccountInfoModalProps extends Omit { + currAuthServers?: AuthServer[]; + currPlayers?: Player[]; +} + +const ImportAccountInfoModal: React.FC = ({ + currAuthServers, + currPlayers, + ...props +}) => { + const toast = useToast(); + const { t } = useTranslation(); + const { config } = useLauncherConfig(); + const primaryColor = config.appearance.theme.primaryColor; + const { getPlayerList } = useGlobalData(); + + const [isRetrieving, setIsRetrieving] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [selectedType, setSelectedType] = useState( + ImportLauncherType.HMCL + ); + + // new players and auth servers to be imported + const [newAuthServers, setNewAuthServers] = useState([]); + const [newPlayers, setNewPlayers] = useState([]); + + // checkbox state + const [serverChecked, setServerChecked] = useState>( + {} + ); + const [playerChecked, setPlayerChecked] = useState>( + {} + ); + + const importLauncherTypes = [ + ImportLauncherType.HMCL, + // ...(config.basicInfo.osType === "windows" ? [ImportLauncherType.PCL] : []), + // ...(config.basicInfo.osType === "macos" ? [ImportLauncherType.SCL] : []), + ]; + + const isThirdParty = (p: Player) => + p.playerType === PlayerType.ThirdParty && !!p.authServer?.authUrl; + + const handleRetrieveOtherLauncherAccountInfo = useCallback( + (type: ImportLauncherType) => { + setIsRetrieving(true); + AccountService.retrieveOtherLauncherAccountInfo(type) + .then((res) => { + if (res.status === "success") { + const [players, authServers] = res.data; + setNewPlayers(players); + setNewAuthServers(authServers); + + // default: select all servers + const nextServerChecked: Record = {}; + authServers.forEach((s) => { + nextServerChecked[s.authUrl] = true; + }); + setServerChecked(nextServerChecked); + + // default: select all players that are allowed + const nextPlayerChecked: Record = {}; + players.forEach((p) => { + const pid = String((p as any).id); + if (isThirdParty(p)) { + nextPlayerChecked[pid] = + !!nextServerChecked[p.authServer!.authUrl]; + } else { + nextPlayerChecked[pid] = true; + } + }); + setPlayerChecked(nextPlayerChecked); + } else { + setNewPlayers([]); + setNewAuthServers([]); + setServerChecked({}); + setPlayerChecked({}); + toast({ + status: "error", + title: res.message, + description: res.details, + }); + } + }) + .finally(() => setIsRetrieving(false)); + }, + [toast] + ); + + useEffect(() => { + if (!props.isOpen) return; + handleRetrieveOtherLauncherAccountInfo(selectedType); + }, [selectedType, handleRetrieveOtherLauncherAccountInfo, props.isOpen]); + + // if a server is unchecked, all its players MUST be unchecked + useEffect(() => { + setPlayerChecked((prev) => { + let changed = false; + const next = { ...prev }; + + newPlayers.forEach((p) => { + const pid = String((p as any).id); + if ( + isThirdParty(p) && + !serverChecked[p.authServer!.authUrl] && + next[pid] + ) { + next[pid] = false; + changed = true; + } + }); + + return changed ? next : prev; + }); + }, [serverChecked, newPlayers]); + + const selectedAuthServers = newAuthServers.filter( + (s) => serverChecked[s.authUrl] + ); + + const selectedPlayers = newPlayers.filter((p) => { + const pid = String((p as any).id); + if (!playerChecked[pid]) return false; + + if (isThirdParty(p)) { + return !!serverChecked[p.authServer!.authUrl]; + } + return true; + }); + + const handleImportExternalAccountInfo = useCallback(() => { + setIsImporting(true); + AccountService.importExternalAccountInfo( + selectedPlayers, + selectedAuthServers + ) + .then((res) => { + if (res.status === "success") { + getPlayerList(true); // refresh player list + toast({ + status: "success", + title: res.message, + }); + props.onClose && props.onClose(); + } else { + toast({ + status: "error", + title: res.message, + description: res.details, + }); + } + }) + .finally(() => setIsImporting(false)); + }, [props, toast, getPlayerList, selectedPlayers, selectedAuthServers]); + + // check if auth server in curAuthServers + const isInCurAuthServers = useCallback( + (s: AuthServer) => { + if (!currAuthServers) return false; + return currAuthServers.some((cs) => cs.authUrl === s.authUrl); + }, + [currAuthServers] + ); + + // check if player in curPlayers + const isInCurPlayers = useCallback( + (p: Player) => { + if (!currPlayers) return false; + return currPlayers.some( + (cp) => + cp.uuid === p.uuid && + cp.playerType === p.playerType && + cp.authServer?.authUrl === p.authServer?.authUrl + ); + }, + [currPlayers] + ); + + return ( + + + + {t("ImportAccountInfoModal.header.title")} + + + + + {importLauncherTypes.map((type, index) => ( + { + selectedType !== type && setSelectedType(type); + }} + /> + ))} + + + {isRetrieving ? ( +
+ +
+ ) : ( + <> +
+ {newAuthServers.length === 0 ? ( +
+ +
+ ) : ( + ({ + title: s.name, + description: s.authUrl, + prefixElement: ( + + setServerChecked((prev) => ({ + ...prev, + [s.authUrl]: e.target.checked, + })) + } + /> + ), + children: isInCurAuthServers(s) && ( + + + + + + ), + })) || [] + } + /> + )} +
+
+ {newPlayers.length === 0 ? ( +
+ +
+ ) : ( + { + const pid = String(p.id); + const serverEnabled = + !isThirdParty(p) || + !!serverChecked[p.authServer!.authUrl]; + + return { + title: p.name, + description: generatePlayerDesc(p, true), + prefixElement: ( + + + setPlayerChecked((prev) => ({ + ...prev, + [pid]: e.target.checked, + })) + } + /> + + + ), + children: isInCurPlayers(p) && ( + + + + + + ), + }; + })} + /> + )} +
+ + )} +
+
+
+ + + + +
+
+ ); +}; + +export default ImportAccountInfoModal; diff --git a/src/components/modals/import-modpack-modal.tsx b/src/components/modals/import-modpack-modal.tsx index 5472490d8..8dd54e07c 100644 --- a/src/components/modals/import-modpack-modal.tsx +++ b/src/components/modals/import-modpack-modal.tsx @@ -316,7 +316,7 @@ const ImportModpackModal: React.FC = ({ onClick={() => handleImportModpack()} isLoading={isBtnLoading || isPageLoading} > - {t("ImportModpackModal.button.import")} + {t("General.import")} diff --git a/src/enums/account.ts b/src/enums/account.ts index 8ea3fe5cf..123f1c350 100644 --- a/src/enums/account.ts +++ b/src/enums/account.ts @@ -18,3 +18,9 @@ export enum TextureType { Skin = "SKIN", Cape = "CAPE", } + +export enum ImportLauncherType { + HMCL = "HMCL", + PCL = "PCL", + SCL = "SCL", +} diff --git a/src/locales/en.json b/src/locales/en.json index 0e380638a..2fa667d8c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -74,9 +74,10 @@ "AccountsPage": { "button": { "addPlayer": "Add Player", - "add3rdPartyServer": "Add 3rd-party Auth", + "add3rdPartyServer": "Add auth server", "sourceHomepage": "View Homepage", - "deleteServer": "Delete Auth Server" + "deleteServer": "Delete Auth Server", + "importFromOtherLaunchers": "Import" }, "playerTypeList": { "all": "All Source" @@ -899,6 +900,7 @@ "edit": "Edit", "enable": "Enable", "finish": "Finish", + "import": "Import", "launch": "Launch", "next": "Next", "notice": "Notice", @@ -1143,6 +1145,22 @@ } } }, + "ImportAccountInfoModal": { + "header": { + "title": "Import Players and Auth Servers" + }, + "launcherDesc": { + "HMCL": "Hello Minecraft! Launcher" + }, + "body": { + "authServers": "Auth Servers", + "players": "Players" + }, + "tooltips": { + "existingServer": "Existing auth server data with the same address will be overwritten.", + "existingPlayer": "Existing player data with the same UUID will be overwritten." + } + }, "ImportModpackModal": { "header": { "title": "Import Modpack" @@ -1155,9 +1173,6 @@ "author": "Author", "modLoader": "Mod Loader", "gameVersion": "Game Version" - }, - "button": { - "import": "Import" } }, "InstanceBasicSettings": { @@ -2047,6 +2062,20 @@ "NOT_FOUND": "The authentication server does not exist" } } + }, + "retrieveOtherLauncherAccountInfo": { + "error": { + "title": "Failed to retrieve external account information", + "description": { + "NOT_FOUND": "Failed to read the external account information file." + } + } + }, + "importExternalAccountInfo": { + "success": "External players and auth servers imported successfully", + "error": { + "title": "Failed to import external players and auth servers" + } } }, "launch": { diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index cf3b13267..ad01b147f 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -76,7 +76,8 @@ "addPlayer": "添加角色", "add3rdPartyServer": "添加认证服务器", "sourceHomepage": "访问主页", - "deleteServer": "删除此服务器" + "deleteServer": "删除此服务器", + "importFromOtherLaunchers": "从其他启动器导入" }, "playerTypeList": { "all": "全部来源" @@ -899,6 +900,7 @@ "edit": "编辑", "enable": "启用", "finish": "完成", + "import": "导入", "launch": "启动", "next": "下一步", "notice": "提示", @@ -1143,6 +1145,22 @@ } } }, + "ImportAccountInfoModal": { + "header": { + "title": "从其他启动器导入账户信息" + }, + "launcherDesc": { + "HMCL": "Hello Minecraft! Launcher" + }, + "body": { + "authServers": "认证服务器", + "players": "角色" + }, + "tooltips": { + "existingServer": "已存在相同地址的认证服务器,导入将会覆盖数据", + "existingPlayer": "已存在相同 UUID 的角色,导入将会覆盖数据" + } + }, "ImportModpackModal": { "header": { "title": "导入整合包" @@ -1155,9 +1173,6 @@ "author": "作者", "modLoader": "模组加载器", "gameVersion": "游戏版本" - }, - "button": { - "import": "导入" } }, "InstanceBasicSettings": { @@ -2047,6 +2062,20 @@ "NOT_FOUND": "认证服务器不存在" } } + }, + "retrieveOtherLauncherAccountInfo": { + "error": { + "title": "获取外部账号信息失败", + "description": { + "NOT_FOUND": "读取账号信息文件错误" + } + } + }, + "importExternalAccountInfo": { + "success": "外部账户信息导入成功", + "error": { + "title": "外部账号信息导入失败" + } } }, "launch": { diff --git a/src/pages/accounts.tsx b/src/pages/accounts.tsx index 97c48a9f1..6e3c86e5e 100644 --- a/src/pages/accounts.tsx +++ b/src/pages/accounts.tsx @@ -19,6 +19,7 @@ import { LuCirclePlus, LuGrid2X2, LuHouse, + LuImport, LuLayoutGrid, LuLayoutList, LuLink2Off, @@ -32,6 +33,7 @@ import { Section } from "@/components/common/section"; import SegmentedControl from "@/components/common/segmented"; import SelectableButton from "@/components/common/selectable-button"; import AddPlayerModal from "@/components/modals/add-player-modal"; +import ImportAccountInfoModal from "@/components/modals/import-account-info-modal"; import PlayersView from "@/components/players-view"; import { useLauncherConfig } from "@/contexts/config"; import { useGlobalData } from "@/contexts/global-data"; @@ -56,6 +58,18 @@ const AccountsPage = () => { const [playerList, setPlayerList] = useState([]); const [authServerList, setAuthServerList] = useState([]); + const { + isOpen: isAddPlayerModalOpen, + onOpen: onAddPlayerModalOpen, + onClose: onAddPlayerModalClose, + } = useDisclosure(); + + const { + isOpen: isImportAccountInfoModalOpen, + onOpen: onImportAccountInfoModalOpen, + onClose: onImportAccountInfoModalClose, + } = useDisclosure(); + useEffect(() => { setPlayerList(getPlayerList() || []); }, [getPlayerList]); @@ -64,12 +78,6 @@ const AccountsPage = () => { setAuthServerList(getAuthServerList() || []); }, [getAuthServerList]); - const { - isOpen: isAddPlayerModalOpen, - onOpen: onAddPlayerModalOpen, - onClose: onAddPlayerModalClose, - } = useDisclosure(); - useEffect(() => { const { add } = router.query; if (add) { @@ -182,20 +190,32 @@ const AccountsPage = () => { }))} /> - { - openSharedModal("add-auth-server", {}); - }} - > - - - - {t("AccountsPage.button.add3rdPartyServer")} - - - + + + + + + {t("AccountsPage.button.importFromOtherLaunchers")} + + + + { + openSharedModal("add-auth-server", {}); + }} + > + + + + {t("AccountsPage.button.add3rdPartyServer")} + + + + @@ -319,6 +339,12 @@ const AccountsPage = () => { : selectedPlayerType } /> + ); }; diff --git a/src/services/account.ts b/src/services/account.ts index 370058774..d1521d46c 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import { SkinModel, TextureType } from "@/enums/account"; +import { ImportLauncherType, SkinModel, TextureType } from "@/enums/account"; import { AuthServer, DeviceAuthResponseInfo, Player } from "@/models/account"; import { InvokeResponse } from "@/models/response"; import { responseHandler } from "@/utils/response"; @@ -243,4 +243,35 @@ export class AccountService { static async deleteAuthServer(url: string): Promise> { return await invoke("delete_auth_server", { url }); } + + /** + * RETRIEVE other launcher account info for importing (stage 1). + * @param {ImportLauncherType} launcherType - The external launcher type (e.g., HMCL / PCL). + * @returns {Promise>} - The other launcher account info for user selection. + */ + @responseHandler("account") + static async retrieveOtherLauncherAccountInfo( + launcherType: ImportLauncherType + ): Promise> { + return await invoke("retrieve_other_launcher_account_info", { + launcherType, + }); + } + + /** + * IMPORT external account info into the current launcher (stage 2). + * @param {Player[]} players - The array of players to be imported. + * @param {AuthServer[]} authServers - The array of authentication servers to be imported. + * @returns {Promise>} + */ + @responseHandler("account") + static async importExternalAccountInfo( + players: Player[], + authServers: AuthServer[] + ): Promise> { + return await invoke("import_external_account_info", { + players, + authServers, + }); + } }