fix: 修复皮肤站登录后侧栏玩家选择器未立即显示
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
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
侧栏直接合并皮肤站角色与已保存账户并按 UUID 去重,无需先启动实例。 选择角色时才进行认证并保存默认账户,认证失败时保留原选择并允许重试。 移除逐个预认证角色的流程,修复无皮肤玩家的默认头像引用异常。 补充组件回归测试,覆盖首次登录、默认玩家切换、失败重试、退出登录和无皮肤角色。 验证:14 项相关测试及 ESLint 通过,前端生产构建通过。 全量检查仍有既有的设置搜索、升级结果组件测试失败及类型错误。
This commit is contained in:
1 parent
42b0c7e7af
commit
e4fa234df1
3 files changed
+365
-79
No files matched your search
@@ -0,0 +1,292 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import { createRenderer, h, nextTick, Suspense } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
receiveSkinSiteMessage,
|
||||||
|
resetSkinSiteSession,
|
||||||
|
setSkinSiteFrame,
|
||||||
|
SKIN_SITE_ORIGIN,
|
||||||
|
} from '../../composables/skin-site-session.ts'
|
||||||
|
import { useNetworkStatus } from '../../composables/useNetworkStatus.ts'
|
||||||
|
|
||||||
|
// Exercise the real component's setup and reactive account list. Only platform,
|
||||||
|
// navigation, and visual dependencies are replaced; auth/session logic stays real.
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const { parse, compileScript } = require('vue/compiler-sfc')
|
||||||
|
const componentUrl = new URL('./AccountsCard.vue', import.meta.url).href
|
||||||
|
const stubs: Record<string, string> = {
|
||||||
|
'@modrinth/assets': `export const CopyIcon={}, LogInIcon={}, PlusIcon={}, RadioButtonCheckedIcon={}, RadioButtonIcon={}, RefreshCwIcon={}, SpinnerIcon={}, TrashIcon={}`,
|
||||||
|
'@modrinth/ui': `export const Accordion={}, Avatar={}, ButtonStyled={}; export const defineMessages=x=>x; export const injectNotificationManager=()=>({handleError:()=>{}}); export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})`,
|
||||||
|
'@tanstack/vue-query': `export const useQueryClient=()=>({refetchQueries:async()=>{}})`,
|
||||||
|
'@tauri-apps/api/event': `export const listen=async()=>()=>{}`,
|
||||||
|
'vue-router': `export const useRoute=()=>({fullPath:'/'}); export const useRouter=()=>({push:async()=>{}})`,
|
||||||
|
'@/assets/netherstar.png': `export default 'logo'`,
|
||||||
|
'@/components/ui/MinecraftLoginModal.vue': `export default {}`,
|
||||||
|
'@/helpers/events': `export const process_listener=async()=>()=>{}`,
|
||||||
|
'@/helpers/rendering/batch-skin-renderer.ts': `export const getPlayerHeadUrl=async()=> 'head'`,
|
||||||
|
'@/helpers/skins': `export const get_available_skins=async()=>[]`,
|
||||||
|
'@/store/error.js': `export const handleSevereError=()=>{}`,
|
||||||
|
'@/store/state': `export const useTheming=()=>({homeLayout:'standard'})`,
|
||||||
|
}
|
||||||
|
const hooks = registerHooks({
|
||||||
|
resolve(specifier, context, nextResolve) {
|
||||||
|
if (specifier in stubs) {
|
||||||
|
return {
|
||||||
|
url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`,
|
||||||
|
shortCircuit: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (specifier.startsWith('@/')) {
|
||||||
|
const file = new URL(`../../${specifier.slice(2)}`, import.meta.url)
|
||||||
|
if (!/\.(ts|js)$/.test(file.pathname))
|
||||||
|
file.pathname += specifier === '@/helpers/auth' ? '.js' : '.ts'
|
||||||
|
return nextResolve(file.href, context)
|
||||||
|
}
|
||||||
|
return nextResolve(specifier, context)
|
||||||
|
},
|
||||||
|
load(url, context, nextLoad) {
|
||||||
|
if (url !== componentUrl) return nextLoad(url, context)
|
||||||
|
const { descriptor } = parse(readFileSync(new URL(url), 'utf8'))
|
||||||
|
return {
|
||||||
|
format: 'module',
|
||||||
|
source: stripTypeScriptTypes(compileScript(descriptor, { id: 'accounts-card-test' }).content),
|
||||||
|
shortCircuit: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { default: AccountsCard } = await import(componentUrl)
|
||||||
|
hooks.deregister()
|
||||||
|
|
||||||
|
type TestNode = Record<string, unknown>
|
||||||
|
type TestAccount = {
|
||||||
|
account_type: 'microsoft' | 'yggdrasil'
|
||||||
|
profile: { id: string; name: string }
|
||||||
|
yggdrasil?: { api_root: string; server_name: string; login: string }
|
||||||
|
}
|
||||||
|
type CardState = {
|
||||||
|
accounts: TestAccount[]
|
||||||
|
defaultUser: string | undefined
|
||||||
|
selectedAccount: TestAccount | undefined
|
||||||
|
setAccount: (account: TestAccount) => Promise<void>
|
||||||
|
getAccountAvatarUrl: (account: TestAccount) => string | undefined
|
||||||
|
}
|
||||||
|
const renderer = createRenderer<TestNode, TestNode>({
|
||||||
|
createElement: () => ({}),
|
||||||
|
createText: () => ({}),
|
||||||
|
createComment: () => ({}),
|
||||||
|
insert() {},
|
||||||
|
remove() {},
|
||||||
|
setText() {},
|
||||||
|
setElementText() {},
|
||||||
|
patchProp() {},
|
||||||
|
parentNode: () => null,
|
||||||
|
nextSibling: () => null,
|
||||||
|
})
|
||||||
|
const flush = async () => {
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
const firstId = '12345678-1234-1234-1234-123456789abc'
|
||||||
|
const secondId = '22345678-1234-1234-1234-123456789abc'
|
||||||
|
|
||||||
|
async function fixture(hasOfficial = true) {
|
||||||
|
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||||
|
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
||||||
|
const stored: TestAccount[] = hasOfficial
|
||||||
|
? [{ account_type: 'microsoft', profile: { id: 'official', name: 'Official' } }]
|
||||||
|
: []
|
||||||
|
let selected: string | undefined = hasOfficial ? 'official' : undefined
|
||||||
|
let rejectLogin = false
|
||||||
|
let loginCalls = 0
|
||||||
|
const frame = {
|
||||||
|
postMessage(data: { type: string; requestId: string }) {
|
||||||
|
if (data.type === 'starlight-pack-token-request')
|
||||||
|
message({
|
||||||
|
type: 'starlight-pack-token-result',
|
||||||
|
requestId: data.requestId,
|
||||||
|
token: 'fixture.jwt',
|
||||||
|
})
|
||||||
|
if (data.type === 'starlight-skin-players-request')
|
||||||
|
message({
|
||||||
|
type: 'starlight-skin-players-result',
|
||||||
|
requestId: data.requestId,
|
||||||
|
ok: true,
|
||||||
|
players: [
|
||||||
|
{ uuid: firstId, name: 'First', isMojang: false, skinState: 'empty' },
|
||||||
|
{ uuid: secondId, name: 'Second', isMojang: false, skinState: 'empty' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
} as unknown as Window
|
||||||
|
function message(data: unknown) {
|
||||||
|
receiveSkinSiteMessage({ origin: SKIN_SITE_ORIGIN, source: frame, data } as MessageEvent, frame)
|
||||||
|
}
|
||||||
|
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
|
||||||
|
useNetworkStatus().refreshBrowserOffline()
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
__TAURI_INTERNALS__: {
|
||||||
|
async invoke(command: string, args: Record<string, unknown>) {
|
||||||
|
if (command === 'plugin:auth|get_default_user') return selected
|
||||||
|
if (command === 'plugin:auth|get_users') return stored
|
||||||
|
if (command === 'plugin:auth|login_skin_site_player') {
|
||||||
|
loginCalls++
|
||||||
|
assert.equal(args.userId, 'owner')
|
||||||
|
assert.equal(args.token, 'fixture.jwt')
|
||||||
|
assert.ok(typeof args.playerId === 'string')
|
||||||
|
if (rejectLogin) throw new Error('Login failed')
|
||||||
|
stored.push({
|
||||||
|
profile: { id: args.playerId, name: args.playerId === firstId ? 'First' : 'Second' },
|
||||||
|
account_type: 'yggdrasil',
|
||||||
|
yggdrasil: {
|
||||||
|
api_root: `${SKIN_SITE_ORIGIN}/yggdrasil`,
|
||||||
|
server_name: 'StarLight',
|
||||||
|
login: 'owner',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return stored[stored.length - 1]
|
||||||
|
}
|
||||||
|
if (command === 'plugin:auth|set_default_user') {
|
||||||
|
assert.ok(typeof args.user === 'string')
|
||||||
|
assert.ok(stored.some((account) => account.profile.id === args.user))
|
||||||
|
selected = args.user
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected command: ${command}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
resetSkinSiteSession()
|
||||||
|
setSkinSiteFrame(frame)
|
||||||
|
const node = h({ ...AccountsCard, render: () => null })
|
||||||
|
const app = renderer.createApp({ render: () => h(Suspense, null, { default: () => node }) })
|
||||||
|
app.mount({})
|
||||||
|
await flush()
|
||||||
|
return {
|
||||||
|
state: (node.component! as unknown as { setupState: CardState }).setupState,
|
||||||
|
login() {
|
||||||
|
message({
|
||||||
|
type: 'starlight-skin-session',
|
||||||
|
status: 'signed-in',
|
||||||
|
user: { uuid: 'owner', username: 'Owner' },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
signOut() {
|
||||||
|
message({ type: 'starlight-skin-session', status: 'signed-out' })
|
||||||
|
},
|
||||||
|
failLogin(value: boolean) {
|
||||||
|
rejectLogin = value
|
||||||
|
},
|
||||||
|
get selected() {
|
||||||
|
return selected
|
||||||
|
},
|
||||||
|
get loginCalls() {
|
||||||
|
return loginCalls
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
app.unmount()
|
||||||
|
resetSkinSiteSession()
|
||||||
|
setSkinSiteFrame(null)
|
||||||
|
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
|
||||||
|
else Reflect.deleteProperty(globalThis, 'window')
|
||||||
|
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
|
||||||
|
else Reflect.deleteProperty(globalThis, 'navigator')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sidebar offers every skin-site player before any game authentication or launch', async () => {
|
||||||
|
const view = await fixture(false)
|
||||||
|
try {
|
||||||
|
view.failLogin(true)
|
||||||
|
view.login()
|
||||||
|
await flush()
|
||||||
|
assert.deepEqual(
|
||||||
|
view.state.accounts.map((account) => account.profile.id),
|
||||||
|
[firstId, secondId],
|
||||||
|
)
|
||||||
|
assert.equal(view.loginCalls, 0)
|
||||||
|
assert.equal(view.selected, undefined)
|
||||||
|
} finally {
|
||||||
|
view.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('choosing an unused skin-site player authenticates and persists the default without a launch', async () => {
|
||||||
|
const view = await fixture()
|
||||||
|
try {
|
||||||
|
view.login()
|
||||||
|
await flush()
|
||||||
|
await view.state.setAccount(
|
||||||
|
view.state.accounts.find((account) => account.profile.id === secondId)!,
|
||||||
|
)
|
||||||
|
assert.equal(view.selected, secondId)
|
||||||
|
assert.equal(view.state.selectedAccount?.profile.id, secondId)
|
||||||
|
assert.equal(view.state.accounts.length, 3)
|
||||||
|
assert.equal(view.loginCalls, 1)
|
||||||
|
} finally {
|
||||||
|
view.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('failed player authentication preserves the default and allows retry', async () => {
|
||||||
|
const view = await fixture()
|
||||||
|
try {
|
||||||
|
view.login()
|
||||||
|
await flush()
|
||||||
|
view.failLogin(true)
|
||||||
|
await view.state.setAccount(
|
||||||
|
view.state.accounts.find((account) => account.profile.id === secondId)!,
|
||||||
|
)
|
||||||
|
assert.equal(view.selected, 'official')
|
||||||
|
assert.equal(view.state.defaultUser, 'official')
|
||||||
|
view.failLogin(false)
|
||||||
|
await view.state.setAccount(
|
||||||
|
view.state.accounts.find((account) => account.profile.id === secondId)!,
|
||||||
|
)
|
||||||
|
assert.equal(view.selected, secondId)
|
||||||
|
} finally {
|
||||||
|
view.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('signing out removes unregistered choices while keeping saved accounts', async () => {
|
||||||
|
const view = await fixture()
|
||||||
|
try {
|
||||||
|
view.login()
|
||||||
|
await flush()
|
||||||
|
await view.state.setAccount(
|
||||||
|
view.state.accounts.find((account) => account.profile.id === firstId)!,
|
||||||
|
)
|
||||||
|
view.signOut()
|
||||||
|
await flush()
|
||||||
|
assert.deepEqual(
|
||||||
|
view.state.accounts.map((account) => account.profile.id),
|
||||||
|
[firstId, 'official'],
|
||||||
|
)
|
||||||
|
assert.equal(view.selected, firstId)
|
||||||
|
view.login()
|
||||||
|
await flush()
|
||||||
|
assert.equal(view.state.accounts.length, 3)
|
||||||
|
assert.equal(view.loginCalls, 1)
|
||||||
|
} finally {
|
||||||
|
view.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a new player without a skin can render the shared empty avatar', async () => {
|
||||||
|
const view = await fixture(false)
|
||||||
|
try {
|
||||||
|
view.login()
|
||||||
|
await flush()
|
||||||
|
assert.equal(view.state.getAccountAvatarUrl(view.state.accounts[0]), undefined)
|
||||||
|
} finally {
|
||||||
|
view.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -164,6 +164,7 @@
|
|||||||
<div v-for="account in accounts" :key="account.profile.id" class="flex gap-1 items-center">
|
<div v-for="account in accounts" :key="account.profile.id" class="flex gap-1 items-center">
|
||||||
<button
|
<button
|
||||||
class="flex items-center flex-shrink flex-grow overflow-clip gap-2 p-2 border-0 bg-transparent cursor-pointer button-base min-w-0"
|
class="flex items-center flex-shrink flex-grow overflow-clip gap-2 p-2 border-0 bg-transparent cursor-pointer button-base min-w-0"
|
||||||
|
:disabled="accountSelectionPending"
|
||||||
@click="setAccount(account)"
|
@click="setAccount(account)"
|
||||||
>
|
>
|
||||||
<RadioButtonCheckedIcon
|
<RadioButtonCheckedIcon
|
||||||
@@ -221,6 +222,7 @@
|
|||||||
<CopyIcon />
|
<CopyIcon />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="!account.skin_site_user"
|
||||||
v-tooltip="formatMessage(messages.removeAccount)"
|
v-tooltip="formatMessage(messages.removeAccount)"
|
||||||
type="button"
|
type="button"
|
||||||
class="button-base border-0 bg-transparent p-1.5 cursor-pointer text-secondary hover:text-red"
|
class="button-base border-0 bg-transparent p-1.5 cursor-pointer text-secondary hover:text-red"
|
||||||
@@ -291,7 +293,7 @@ import {
|
|||||||
users,
|
users,
|
||||||
} from '@/helpers/auth'
|
} from '@/helpers/auth'
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
import { registerSkinSitePlayers } from '@/helpers/instance-player'
|
import { authenticateInstancePlayer } from '@/helpers/instance-player'
|
||||||
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||||
import type { Skin } from '@/helpers/skins'
|
import type { Skin } from '@/helpers/skins'
|
||||||
import { get_available_skins } from '@/helpers/skins'
|
import { get_available_skins } from '@/helpers/skins'
|
||||||
@@ -353,11 +355,45 @@ type MinecraftCredential = {
|
|||||||
server_name: string
|
server_name: string
|
||||||
login: string
|
login: string
|
||||||
}
|
}
|
||||||
|
skin_site_user?: string
|
||||||
|
head?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const STARLIGHT_YGGDRASIL_API_ROOT = 'https://skin.starlight.cool/yggdrasil'
|
const STARLIGHT_YGGDRASIL_API_ROOT = 'https://skin.starlight.cool/yggdrasil'
|
||||||
|
|
||||||
const accounts: Ref<MinecraftCredential[]> = ref([])
|
const storedAccounts: Ref<MinecraftCredential[]> = ref([])
|
||||||
|
const accounts = computed(() => {
|
||||||
|
// A site's player list is already authoritative for available choices.
|
||||||
|
// Game credentials are created only when the user selects a player.
|
||||||
|
const byId = new Map(
|
||||||
|
storedAccounts.value.map((account) => [
|
||||||
|
account.profile.id.replaceAll('-', '').toLowerCase(),
|
||||||
|
account,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
if (skinSiteStatus.value === 'signed-in' && skinSiteUser.value) {
|
||||||
|
for (const player of skinSitePlayers.value) {
|
||||||
|
if (player.isMojang) continue
|
||||||
|
const id = player.uuid.replaceAll('-', '').toLowerCase()
|
||||||
|
const stored = byId.get(id)
|
||||||
|
if (stored && stored.account_type !== 'yggdrasil') continue
|
||||||
|
byId.set(id, {
|
||||||
|
...stored,
|
||||||
|
account_type: 'yggdrasil',
|
||||||
|
profile: { ...stored?.profile, id: stored?.profile.id ?? player.uuid, name: player.name },
|
||||||
|
yggdrasil: {
|
||||||
|
api_root: STARLIGHT_YGGDRASIL_API_ROOT,
|
||||||
|
server_name: 'StarLight',
|
||||||
|
login: skinSiteUser.value.uuid,
|
||||||
|
},
|
||||||
|
skin_site_user: skinSiteUser.value.uuid,
|
||||||
|
head: player.headDataUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()].sort(compareMinecraftAccounts)
|
||||||
|
})
|
||||||
|
const accountSelectionPending = ref(false)
|
||||||
const loginDisabled = ref(false)
|
const loginDisabled = ref(false)
|
||||||
const defaultUser = ref<string | undefined>()
|
const defaultUser = ref<string | undefined>()
|
||||||
const equippedSkin = ref<Skin | null>(null)
|
const equippedSkin = ref<Skin | null>(null)
|
||||||
@@ -390,7 +426,7 @@ function hasResolvedAccountHead(account: MinecraftCredential) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasMissingAccountHeads() {
|
function hasMissingAccountHeads() {
|
||||||
return accounts.value.some(
|
return storedAccounts.value.some(
|
||||||
(account) => account.account_type !== 'offline' && !hasResolvedAccountHead(account),
|
(account) => account.account_type !== 'offline' && !hasResolvedAccountHead(account),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -424,7 +460,7 @@ async function refreshValues(headRefreshAttempt = 0) {
|
|||||||
|
|
||||||
defaultUser.value = selectedUser
|
defaultUser.value = selectedUser
|
||||||
if (offline.value && selectedUser) {
|
if (offline.value && selectedUser) {
|
||||||
await persistDefaultUser(selectedUser)
|
await persistDefaultUser(selectedUser).catch(handleError)
|
||||||
if (generation !== refreshGeneration) return
|
if (generation !== refreshGeneration) return
|
||||||
}
|
}
|
||||||
const userList = await users(offline.value).catch(handleError)
|
const userList = await users(offline.value).catch(handleError)
|
||||||
@@ -433,7 +469,7 @@ async function refreshValues(headRefreshAttempt = 0) {
|
|||||||
// MinecraftCredential but the TS types from the Tauri IPC bridge do not
|
// MinecraftCredential but the TS types from the Tauri IPC bridge do not
|
||||||
// carry this refinement. The double cast is deliberate — the shape is
|
// carry this refinement. The double cast is deliberate — the shape is
|
||||||
// correct and verified at runtime by the backend.
|
// correct and verified at runtime by the backend.
|
||||||
accounts.value = Array.isArray(userList)
|
storedAccounts.value = Array.isArray(userList)
|
||||||
? [...(userList as unknown as MinecraftCredential[])].filter(
|
? [...(userList as unknown as MinecraftCredential[])].filter(
|
||||||
(account) =>
|
(account) =>
|
||||||
account.account_type === 'microsoft' ||
|
account.account_type === 'microsoft' ||
|
||||||
@@ -441,8 +477,7 @@ async function refreshValues(headRefreshAttempt = 0) {
|
|||||||
account.yggdrasil?.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT),
|
account.yggdrasil?.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT),
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
accounts.value.sort(compareMinecraftAccounts)
|
await renderAccountHeads(storedAccounts.value, generation)
|
||||||
await renderAccountHeads(accounts.value, generation)
|
|
||||||
if (generation !== refreshGeneration) return
|
if (generation !== refreshGeneration) return
|
||||||
try {
|
try {
|
||||||
const skins = await get_available_skins()
|
const skins = await get_available_skins()
|
||||||
@@ -580,6 +615,7 @@ async function renderAccountHeads(accountList: MinecraftCredential[], generation
|
|||||||
|
|
||||||
const avatarUrl = computed(() => {
|
const avatarUrl = computed(() => {
|
||||||
if (selectedAccount.value) {
|
if (selectedAccount.value) {
|
||||||
|
if (selectedAccount.value.head) return selectedAccount.value.head
|
||||||
const cachedHeadUrl = accountHeadUrlCache.value.get(selectedAccount.value.profile.id)
|
const cachedHeadUrl = accountHeadUrlCache.value.get(selectedAccount.value.profile.id)
|
||||||
if (cachedHeadUrl) return cachedHeadUrl
|
if (cachedHeadUrl) return cachedHeadUrl
|
||||||
}
|
}
|
||||||
@@ -589,10 +625,11 @@ const avatarUrl = computed(() => {
|
|||||||
return cachedUrl
|
return cachedUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return selectedAccount.value ? defaultSteveHeadUrl : axolotlLogo
|
return selectedAccount.value ? undefined : axolotlLogo
|
||||||
})
|
})
|
||||||
|
|
||||||
function getAccountAvatarUrl(account: MinecraftCredential) {
|
function getAccountAvatarUrl(account: MinecraftCredential) {
|
||||||
|
if (account.head) return account.head
|
||||||
const cachedHeadUrl = accountHeadUrlCache.value.get(account.profile.id)
|
const cachedHeadUrl = accountHeadUrlCache.value.get(account.profile.id)
|
||||||
if (cachedHeadUrl) {
|
if (cachedHeadUrl) {
|
||||||
return cachedHeadUrl
|
return cachedHeadUrl
|
||||||
@@ -606,47 +643,50 @@ function getAccountAvatarUrl(account: MinecraftCredential) {
|
|||||||
return cachedUrl
|
return cachedUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return defaultSteveHeadUrl
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistDefaultUser(userId: string) {
|
function persistDefaultUser(userId: string) {
|
||||||
const update = defaultUserUpdateQueue.then(async () => {
|
const update = defaultUserUpdateQueue.then(async () => {
|
||||||
await set_default_user(userId).catch(handleError)
|
await set_default_user(userId)
|
||||||
})
|
})
|
||||||
defaultUserUpdateQueue = update.catch(() => {})
|
defaultUserUpdateQueue = update.catch(() => {})
|
||||||
return update
|
return update
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setAccount(account: MinecraftCredential) {
|
async function setAccount(account: MinecraftCredential) {
|
||||||
|
if (accountSelectionPending.value) return
|
||||||
|
accountSelectionPending.value = true
|
||||||
const userId = account.profile.id
|
const userId = account.profile.id
|
||||||
refreshGeneration += 1
|
refreshGeneration += 1
|
||||||
selectSkinSitePlayer(null)
|
try {
|
||||||
defaultUser.value = userId
|
if (account.skin_site_user) {
|
||||||
equippedSkin.value = null
|
await authenticateInstancePlayer({
|
||||||
|
id: userId,
|
||||||
await persistDefaultUser(userId)
|
name: account.profile.name,
|
||||||
if (defaultUser.value !== userId) return
|
account_type: account.account_type,
|
||||||
await refreshValues()
|
skin_site_user: account.skin_site_user,
|
||||||
if (defaultUser.value === userId) notifyAccountChange()
|
})
|
||||||
|
}
|
||||||
|
await persistDefaultUser(userId)
|
||||||
|
selectSkinSitePlayer(null)
|
||||||
|
defaultUser.value = userId
|
||||||
|
equippedSkin.value = null
|
||||||
|
notifyAccountChange()
|
||||||
|
await refreshValues()
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error as Error)
|
||||||
|
} finally {
|
||||||
|
accountSelectionPending.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[skinSitePlayers, defaultUser, skinSiteUser],
|
[skinSitePlayers, defaultUser, skinSiteUser],
|
||||||
([availablePlayers, selectedLocalUser, siteUser]) => {
|
([availablePlayers, selectedLocalUser]) => {
|
||||||
if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
|
if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
|
||||||
selectSkinSitePlayer(availablePlayers[0].uuid)
|
selectSkinSitePlayer(availablePlayers[0].uuid)
|
||||||
}
|
}
|
||||||
// Register skin-site players as launcher accounts as soon as they are
|
|
||||||
// available, so the account picker shows them without requiring a first
|
|
||||||
// launch. `registerSkinSitePlayers` is idempotent and best-effort.
|
|
||||||
if (siteUser?.uuid && availablePlayers.length > 0) {
|
|
||||||
const pendingIds = availablePlayers.map((player) => player.uuid)
|
|
||||||
void registerSkinSitePlayers(pendingIds, siteUser.uuid)
|
|
||||||
.then(() => refreshValues())
|
|
||||||
.catch((error) => {
|
|
||||||
console.warn('Failed to register skin site players:', error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -697,7 +737,7 @@ async function copyAccountUuid(account: MinecraftCredential) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const unlisten = await process_listener(async (e) => {
|
const unlisten = await process_listener(async (e: { event: string }) => {
|
||||||
if (e.event === 'launched') {
|
if (e.event === 'launched') {
|
||||||
await refreshValues()
|
await refreshValues()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import {
|
|||||||
skinSiteUser,
|
skinSiteUser,
|
||||||
waitForSkinSiteSession,
|
waitForSkinSiteSession,
|
||||||
} from '../composables/skin-site-session.ts'
|
} from '../composables/skin-site-session.ts'
|
||||||
export { waitForSkinSiteSession } from '../composables/skin-site-session.ts'
|
|
||||||
import { users } from './auth.js'
|
|
||||||
import { isOfflineMode } from '../composables/useNetworkStatus.ts'
|
import { isOfflineMode } from '../composables/useNetworkStatus.ts'
|
||||||
|
import { users } from './auth.js'
|
||||||
|
export { waitForSkinSiteSession } from '../composables/skin-site-session.ts'
|
||||||
|
|
||||||
export type InstancePlayer = {
|
export type InstancePlayer = {
|
||||||
id: string
|
id: string
|
||||||
@@ -68,52 +68,6 @@ export async function authenticateInstancePlayer(player: InstancePlayer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers every skin-site player as a launcher account so they show up in the
|
|
||||||
* account picker immediately after signing in to the skin site, instead of only
|
|
||||||
* after a first launch. Idempotent: players that already exist in the account
|
|
||||||
* list (matched by profile UUID) are skipped, and already-signed-in players are
|
|
||||||
* not re-requested. Best-effort: a single player failing does not abort the rest.
|
|
||||||
*/
|
|
||||||
export async function registerSkinSitePlayers(
|
|
||||||
playerIds: string[],
|
|
||||||
userUuid: string,
|
|
||||||
): Promise<void> {
|
|
||||||
if (playerIds.length === 0) return
|
|
||||||
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== userUuid) return
|
|
||||||
|
|
||||||
let known = new Set<string>()
|
|
||||||
try {
|
|
||||||
const existing = await users()
|
|
||||||
known = new Set(
|
|
||||||
(existing as Array<{ profile?: { id?: string } }>)
|
|
||||||
.map((account) => account?.profile?.id)
|
|
||||||
.filter((id): id is string => typeof id === 'string'),
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
// If the account list cannot be read, still attempt to register; the
|
|
||||||
// backend upsert is idempotent.
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const playerId of playerIds) {
|
|
||||||
if (known.has(playerId)) continue
|
|
||||||
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== userUuid) return
|
|
||||||
try {
|
|
||||||
// Request a fresh download token per player: the skin site login
|
|
||||||
// endpoint may bind a token to a single player id.
|
|
||||||
const token = await requestSkinSiteDownloadToken()
|
|
||||||
await invoke('plugin:auth|login_skin_site_player', {
|
|
||||||
token,
|
|
||||||
playerId,
|
|
||||||
userId: userUuid,
|
|
||||||
})
|
|
||||||
known.add(playerId)
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to register skin site player ${playerId}:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveInstancePlayer(instanceId: string, player: InstancePlayer) {
|
export async function saveInstancePlayer(instanceId: string, player: InstancePlayer) {
|
||||||
await authenticateInstancePlayer(player)
|
await authenticateInstancePlayer(player)
|
||||||
await invoke('plugin:auth|set_instance_player', { instanceId, player })
|
await invoke('plugin:auth|set_instance_player', { instanceId, player })
|
||||||
|
|||||||
Reference in new issue
Block a user