feat:集成sls皮肤站,但是未测试
Some checks failed
Axolotl desktop CI / guardrails (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
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

This commit is contained in:
2026-09-09 22:52:32 +08:00
parent 6a295f9a7a
commit aff1512241
5 changed files with 328 additions and 195 deletions

View File

@ -31,12 +31,6 @@
{{ formatMessage(messages.addThirdPartyAccount) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="loginDisabled" @click="showOfflineAccountModal()">
<PlusIcon />
{{ formatMessage(messages.addOfflineAccount) }}
</button>
</ButtonStyled>
</div>
<Accordion
v-else
@ -154,88 +148,10 @@
{{ formatMessage(messages.addThirdPartyAccount) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="accounts.length > 0" class="w-full">
<button :disabled="loginDisabled" @click="showOfflineAccountModal()">
<PlusIcon />
{{ formatMessage(messages.addOfflineAccount) }}
</button>
</ButtonStyled>
</div>
</div>
</Accordion>
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
<ModalWrapper ref="offlineAccountModal" :header="formatMessage(messages.offlineModalTitle)">
<div class="flex min-w-[22rem] flex-col gap-4">
<p class="m-0 text-secondary">{{ formatMessage(messages.offlineModalDescription) }}</p>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.usernameLabel) }}
<StyledInput
v-model="offlineUsername"
:disabled="loginDisabled"
:placeholder="formatMessage(messages.usernamePlaceholder)"
autocomplete="off"
maxlength="16"
@keyup.enter="addOfflineAccount()"
/>
</label>
<p v-if="offlineUsername.length > 0 && !offlineUsernameValid" class="m-0 text-sm text-red">
{{ formatMessage(messages.usernameValidation) }}
</p>
<p
v-if="offlineUsernameContainsChinese"
class="m-0 rounded-lg border border-solid border-orange bg-highlight-orange p-3 text-sm text-contrast"
>
{{ formatMessage(messages.chineseUsernameWarning) }}
</p>
<Checkbox
v-model="offlineCustomUuid"
:disabled="loginDisabled"
:label="formatMessage(messages.customUuidLabel)"
/>
<Admonition
v-if="offlineCustomUuid"
type="warning"
:body="formatMessage(messages.customUuidWarning)"
/>
<label v-if="offlineCustomUuid" class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.customUuidInputLabel) }}
<StyledInput
v-model="offlineUuid"
:disabled="loginDisabled"
:placeholder="formatMessage(messages.customUuidPlaceholder)"
autocomplete="off"
spellcheck="false"
maxlength="36"
@keyup.enter="addOfflineAccount()"
/>
</label>
<p
v-if="offlineCustomUuid && offlineUuid.length > 0 && !offlineUuidValid"
class="m-0 text-sm text-red"
>
{{ formatMessage(messages.customUuidValidation) }}
</p>
<Admonition
v-if="offlineUuidDuplicate"
type="critical"
:body="formatMessage(messages.customUuidDuplicate)"
/>
<div class="input-group push-right">
<ButtonStyled>
<button :disabled="loginDisabled" @click="offlineAccountModal?.hide()">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="loginDisabled || !offlineFormValid" @click="addOfflineAccount()">
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
<PlusIcon v-else />
{{ formatMessage(messages.createOfflineAccount) }}
</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="yggdrasilAccountModal" :header="formatMessage(messages.thirdPartyModalTitle)">
<div class="flex min-w-[24rem] flex-col gap-4">
<p class="m-0 text-secondary">{{ formatMessage(messages.thirdPartyModalDescription) }}</p>
@ -265,16 +181,12 @@
</ButtonStyled>
</div>
</div>
<ButtonStyled class="w-full">
<button :disabled="loginDisabled" @click="useLittleSkinPreset()">
{{ formatMessage(messages.useLittleSkin) }}
</button>
</ButtonStyled>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.apiRootLabel) }}
<StyledInput
v-model="yggdrasilApiRoot"
:disabled="loginDisabled"
:disabled="true"
readonly
:placeholder="formatMessage(messages.apiRootPlaceholder)"
inputmode="url"
@blur="loadRememberedYggdrasilPassword()"
@ -348,10 +260,8 @@ import {
} from '@modrinth/assets'
import {
Accordion,
Admonition,
Avatar,
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
injectNotificationManager,
@ -372,7 +282,6 @@ import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { compareMinecraftAccounts } from '@/helpers/accounts'
import { trackEvent } from '@/helpers/analytics'
import {
add_offline_user,
begin_yggdrasil_login,
delete_yggdrasil_password,
finish_yggdrasil_login,
@ -453,7 +362,7 @@ type YggdrasilLoginResult =
| { status: 'complete'; credentials: MinecraftCredential }
| { status: 'select_profile'; flow_id: string; profiles: YggdrasilProfile[] }
const LITTLE_SKIN_API_ROOT = 'https://littleskin.cn/api/yggdrasil'
const STARLIGHT_YGGDRASIL_API_ROOT = 'https://skin.starlight.cool/yggdrasil'
const accounts: Ref<MinecraftCredential[]> = ref([])
const loginDisabled = ref(false)
@ -467,29 +376,9 @@ let refreshGeneration = 0
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
let defaultUserUpdateQueue = Promise.resolve()
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
const offlineAccountModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
const offlineUsername = ref('')
const offlineCustomUuid = ref(false)
const offlineUuid = ref('')
const offlineUuidDuplicate = ref(false)
const offlineUsernameValid = computed(() =>
/^[\p{L}\p{N}_]{1,16}$/u.test(offlineUsername.value.trim()),
)
const offlineUuidValid = computed(() =>
/^[a-fA-F0-9]{32}$/u.test(offlineUuid.value.replaceAll('-', '')),
)
const offlineFormValid = computed(
() => offlineUsernameValid.value && (!offlineCustomUuid.value || offlineUuidValid.value),
)
watch([offlineUuid, offlineCustomUuid], () => {
offlineUuidDuplicate.value = false
})
const offlineUsernameContainsChinese = computed(() =>
/\p{Script=Han}/u.test(offlineUsername.value.trim()),
)
const yggdrasilAccountModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
const yggdrasilProfileModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
const yggdrasilApiRoot = ref(LITTLE_SKIN_API_ROOT)
const yggdrasilApiRoot = ref(STARLIGHT_YGGDRASIL_API_ROOT)
const yggdrasilLogin = ref('')
const yggdrasilPassword = ref('')
const rememberYggdrasilPassword = ref(true)
@ -573,7 +462,12 @@ async function refreshValues(headRefreshAttempt = 0) {
// carry this refinement. The double cast is deliberate — the shape is
// correct and verified at runtime by the backend.
accounts.value = Array.isArray(userList)
? [...(userList as unknown as MinecraftCredential[])]
? [...(userList as unknown as MinecraftCredential[])].filter(
(account) =>
account.account_type === 'microsoft' ||
(account.account_type === 'yggdrasil' &&
account.yggdrasil?.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT),
)
: []
accounts.value.sort(compareMinecraftAccounts)
await renderAccountHeads(accounts.value, generation)
@ -792,16 +686,8 @@ async function onMicrosoftLogin(account: MinecraftCredential) {
}
}
function showOfflineAccountModal() {
offlineUsername.value = ''
offlineCustomUuid.value = false
offlineUuid.value = ''
offlineUuidDuplicate.value = false
offlineAccountModal.value?.show()
}
async function showYggdrasilAccountModal() {
yggdrasilApiRoot.value = LITTLE_SKIN_API_ROOT
yggdrasilApiRoot.value = STARLIGHT_YGGDRASIL_API_ROOT
yggdrasilLogin.value = ''
yggdrasilPassword.value = ''
rememberYggdrasilPassword.value = true
@ -813,13 +699,21 @@ async function showYggdrasilAccountModal() {
async function loadSavedYggdrasilLogins() {
const storedLogins = await list_yggdrasil_saved_logins().catch(handleError)
const savedLogins: SavedYggdrasilLogin[] = Array.isArray(storedLogins) ? [...storedLogins] : []
const savedLogins: SavedYggdrasilLogin[] = Array.isArray(storedLogins)
? [...storedLogins].filter(
(savedLogin) => savedLogin.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT,
)
: []
const savedLoginKeys = new Set(
savedLogins.map((savedLogin) => `${savedLogin.api_root}\n${savedLogin.login}`),
)
for (const account of accounts.value) {
if (!account.yggdrasil) continue
if (
!account.yggdrasil ||
account.yggdrasil.api_root.replace(/\/+$/, '') !== STARLIGHT_YGGDRASIL_API_ROOT
)
continue
const savedLogin = {
api_root: account.yggdrasil.api_root,
login: account.yggdrasil.login,
@ -888,10 +782,6 @@ async function removeSavedYggdrasilLogin(savedLogin: SavedYggdrasilLogin) {
}
}
function useLittleSkinPreset() {
yggdrasilApiRoot.value = LITTLE_SKIN_API_ROOT
}
async function loadRememberedYggdrasilPassword() {
if (
!rememberYggdrasilPassword.value ||
@ -930,6 +820,7 @@ async function persistYggdrasilPasswordPreference() {
async function addYggdrasilAccount() {
if (!yggdrasilFormValid.value || loginDisabled.value) return
if (yggdrasilApiRoot.value.trim().replace(/\/+$/, '') !== STARLIGHT_YGGDRASIL_API_ROOT) return
loginDisabled.value = true
try {
@ -976,37 +867,6 @@ async function selectYggdrasilProfile(profileId: string) {
}
}
async function addOfflineAccount() {
if (!offlineFormValid.value || loginDisabled.value) return
loginDisabled.value = true
offlineUuidDuplicate.value = false
try {
const account = await add_offline_user(
offlineUsername.value.trim(),
offlineCustomUuid.value ? offlineUuid.value.replaceAll('-', '') : undefined,
)
offlineAccountModal.value?.hide()
await setAccount(account)
trackEvent('OfflineAccountAdd')
} catch (error) {
offlineUuidDuplicate.value = isDuplicateUuidError(error)
if (!offlineUuidDuplicate.value) handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
function isDuplicateUuidError(error: unknown) {
const rawMessage =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: JSON.stringify(error)
return rawMessage?.includes('An account with this UUID already exists') ?? false
}
async function logout(account: MinecraftCredential) {
await remove_user(account.profile.id).catch(handleError)
await refreshValues()
@ -1069,31 +929,27 @@ const messages = defineMessages({
},
thirdPartyAccount: {
id: 'minecraft-account.third-party-account',
defaultMessage: 'Third-party Minecraft account',
defaultMessage: 'StarLight skin account',
},
thirdPartyBadge: {
id: 'minecraft-account.third-party-badge',
defaultMessage: 'Third-party',
defaultMessage: 'StarLight skin',
},
thirdPartyModalTitle: {
id: 'minecraft-account.third-party-modal.title',
defaultMessage: 'Sign in with a third-party service',
defaultMessage: 'Sign in with StarLight skin',
},
thirdPartyModalDescription: {
id: 'minecraft-account.third-party-modal.description',
defaultMessage: 'Use LittleSkin or another compatible Yggdrasil authentication service.',
},
useLittleSkin: {
id: 'minecraft-account.third-party-modal.littleskin',
defaultMessage: 'Use LittleSkin',
defaultMessage: 'Sign in with your StarLight skin account.',
},
apiRootLabel: {
id: 'minecraft-account.third-party-modal.api-root',
defaultMessage: 'Yggdrasil API address',
defaultMessage: 'StarLight skin API address',
},
apiRootPlaceholder: {
id: 'minecraft-account.third-party-modal.api-root-placeholder',
defaultMessage: 'https://example.com/api/yggdrasil',
defaultMessage: 'https://skin.starlight.cool/yggdrasil',
},
accountLabel: {
id: 'minecraft-account.third-party-modal.account',
@ -1131,10 +987,6 @@ const messages = defineMessages({
id: 'minecraft-account.third-party-profile.description',
defaultMessage: 'Choose the Minecraft profile to use with this account.',
},
addOfflineAccount: {
id: 'minecraft-account.add-offline-account',
defaultMessage: 'Add offline account',
},
offlineAccount: {
id: 'minecraft-account.offline-account',
defaultMessage: 'Offline Minecraft account',

View File

@ -10167,10 +10167,10 @@
"message": "登录 Minecraft"
},
"minecraft-account.third-party-account": {
"message": "Minecraft 第三方账号"
"message": "StarLight 皮肤站账号"
},
"minecraft-account.third-party-badge": {
"message": "第三方"
"message": "StarLight 皮肤站"
},
"minecraft-account.third-party-modal.account": {
"message": "账号或邮箱"
@ -10179,16 +10179,13 @@
"message": "请输入账号或邮箱"
},
"minecraft-account.third-party-modal.api-root": {
"message": "Yggdrasil API 地址"
"message": "StarLight 皮肤站 API 地址"
},
"minecraft-account.third-party-modal.api-root-placeholder": {
"message": "https://example.com/api/yggdrasil"
"message": "https://skin.starlight.cool/yggdrasil"
},
"minecraft-account.third-party-modal.description": {
"message": "使用 LittleSkin 或其他兼容的 Yggdrasil 外置登录服务。"
},
"minecraft-account.third-party-modal.littleskin": {
"message": "使用 LittleSkin"
"message": "使用 StarLight 皮肤站账号登录。"
},
"minecraft-account.third-party-modal.password": {
"message": "密码"
@ -10206,7 +10203,7 @@
"message": "登录"
},
"minecraft-account.third-party-modal.title": {
"message": "登录第三方账号"
"message": "登录 StarLight 皮肤站"
},
"minecraft-account.third-party-profile.description": {
"message": "请选择此账号要使用的 Minecraft 角色。"

View File

@ -301,6 +301,7 @@ pub async fn begin_yggdrasil_login(
login: String,
password: String,
) -> Result<minecraft_auth::YggdrasilLoginResult> {
let api_root = normalize_starlight_yggdrasil_api_root(&api_root)?;
Ok(
minecraft_auth::begin_yggdrasil_login(&api_root, &login, &password)
.await?,
@ -323,6 +324,21 @@ pub struct SavedYggdrasilLogin {
pub login: String,
}
const STARLIGHT_YGGDRASIL_API_ROOT: &str =
"https://skin.starlight.cool/yggdrasil";
fn normalize_starlight_yggdrasil_api_root(api_root: &str) -> Result<String> {
let normalized = minecraft_auth::normalize_yggdrasil_api_root(api_root)?;
if normalized != STARLIGHT_YGGDRASIL_API_ROOT {
return Err(theseus::ErrorKind::InputError(
"Only the StarLight skin server is supported".to_string(),
)
.as_error()
.into());
}
Ok(normalized)
}
#[tauri::command]
pub fn list_yggdrasil_saved_logins() -> Result<Vec<SavedYggdrasilLogin>> {
read_yggdrasil_saved_logins()
@ -333,6 +349,7 @@ pub fn get_yggdrasil_password(
api_root: String,
login: String,
) -> Result<Option<String>> {
let api_root = normalize_starlight_yggdrasil_api_root(&api_root)?;
let entry = yggdrasil_password_entry(&api_root, &login)?;
match entry.get_password() {
Ok(password) => Ok(Some(password)),
@ -411,7 +428,7 @@ fn normalize_saved_yggdrasil_login(
.into());
}
Ok(SavedYggdrasilLogin {
api_root: minecraft_auth::normalize_yggdrasil_api_root(api_root)?,
api_root: normalize_starlight_yggdrasil_api_root(api_root)?,
login: login.to_string(),
})
}
@ -426,8 +443,15 @@ fn yggdrasil_saved_logins_entry() -> Result<keyring::Entry> {
fn read_yggdrasil_saved_logins() -> Result<Vec<SavedYggdrasilLogin>> {
match yggdrasil_saved_logins_entry()?.get_password() {
Ok(saved_logins) => match serde_json::from_str(&saved_logins) {
Ok(saved_logins) => Ok(saved_logins),
Ok(saved_logins) => match serde_json::from_str::<Vec<SavedYggdrasilLogin>>(
&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}"
@ -515,13 +539,15 @@ fn parse_custom_uuid(uuid: Option<String>) -> Result<Option<uuid::Uuid>> {
#[tauri::command]
pub async fn add_offline_user(
username: String,
uuid: Option<String>,
_username: String,
_uuid: Option<String>,
) -> Result<Credentials> {
Ok(
minecraft_auth::add_offline_user(&username, parse_custom_uuid(uuid)?)
.await?,
Err(theseus::ErrorKind::InputError(
"Offline accounts are disabled; use Microsoft or StarLight skin login"
.to_string(),
)
.as_error()
.into())
}
#[tauri::command]