From fbcaed528bb58f748cb878e71f5cef028fd5ba97 Mon Sep 17 00:00:00 2001 From: Disy Date: Mon, 14 Sep 2026 11:29:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20StarLight=20?= =?UTF-8?q?=E7=9A=AE=E8=82=A4=E7=AB=99=E9=9B=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/app-frontend/src/App.vue | 14 +- .../src/assets/icons/lucky-clover.svg | 41 ++ .../src/components/home/HomeMinecraftNews.vue | 151 ++++-- .../src/components/ui/AccountsCard.vue | 487 ++++-------------- .../src/components/ui/SkinSiteFrame.vue | 13 +- .../src/composables/skin-site-session.test.ts | 153 +++++- .../src/composables/skin-site-session.ts | 181 ++++++- apps/app-frontend/src/helpers/auth.js | 4 +- .../app-frontend/src/locales/en-US/index.json | 30 +- .../app-frontend/src/locales/zh-CN/index.json | 26 +- .../app-frontend/src/locales/zh-TW/index.json | 26 +- apps/app/src/api/auth.rs | 2 +- apps/app/src/skin_site_bridge.js | 340 ++++++++---- apps/app/src/skin_site_bridge.test.cjs | 243 +++++++-- packages/app-lib/src/api/minecraft_auth.rs | 9 +- 15 files changed, 1105 insertions(+), 615 deletions(-) create mode 100644 apps/app-frontend/src/assets/icons/lucky-clover.svg diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 47d1eb7..5d7871f 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -3,7 +3,6 @@ import { AuthFeature, TauriModrinthClient, VerboseLoggingFeature } from '@modrin import { ChangeSkinIcon, CompassIcon, - GlobeIcon, DownloadIcon, ExternalIcon, FlaskConicalIcon, @@ -57,13 +56,13 @@ import SymlinkMethodCards from '@modrinth/ui/src/components/flows/drop/SymlinkMe import { useQuery } from '@tanstack/vue-query' import { getVersion } from '@tauri-apps/api/app' import { convertFileSrc, invoke } from '@tauri-apps/api/core' -import { hideAllPoppers } from 'floating-vue' import { listen } from '@tauri-apps/api/event' import { Effect, getCurrentWindow } from '@tauri-apps/api/window' import { fetch as tauriFetch } from '@tauri-apps/plugin-http' import { openUrl } from '@tauri-apps/plugin-opener' import { type as getOsType } from '@tauri-apps/plugin-os' import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state' +import { hideAllPoppers } from 'floating-vue' import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue' import { type RouteLocationNormalizedLoaded, RouterView, useRoute, useRouter } from 'vue-router' @@ -730,10 +729,6 @@ const messages = defineMessages({ id: 'app.navigation.skin-selector', defaultMessage: 'Skin selector', }, - starlightSkin: { - id: 'app.navigation.starlight-skin', - defaultMessage: '斯达莱特', - }, library: { id: 'app.navigation.library', defaultMessage: 'Library', @@ -2237,13 +2232,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload) > - - - + 幸运四叶草 + + + + + + + diff --git a/apps/app-frontend/src/components/home/HomeMinecraftNews.vue b/apps/app-frontend/src/components/home/HomeMinecraftNews.vue index e7a3eee..cdc68f2 100644 --- a/apps/app-frontend/src/components/home/HomeMinecraftNews.vue +++ b/apps/app-frontend/src/components/home/HomeMinecraftNews.vue @@ -1,35 +1,70 @@ + + diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue index 6cb0799..f6211cf 100644 --- a/apps/app-frontend/src/components/ui/AccountsCard.vue +++ b/apps/app-frontend/src/components/ui/AccountsCard.vue @@ -12,6 +12,55 @@

{{ formatMessage(messages.skinSiteSyncError) }}

+
+
+ + {{ formatMessage(messages.skinSitePlayers, { count: skinSitePlayers.length }) }} + + + +
+
+
+ +
+ {{ player.name }} + {{ player.uuid }} +
+ + {{ + launcherAccountIds.has(player.uuid) + ? formatMessage(messages.skinSitePlayerReady) + : formatMessage(messages.skinSitePlayerSynced) + }} + +
+
+

+ {{ formatMessage(messages.noSkinSitePlayers) }} +

+

+ {{ formatMessage(messages.skinSitePlayersError) }} +

+
- - - - - - - - - - - - - -
- - - - - - -
- - - -
-

{{ formatMessage(messages.selectProfileDescription) }}

- - - -
-
diff --git a/apps/app-frontend/src/composables/skin-site-session.test.ts b/apps/app-frontend/src/composables/skin-site-session.test.ts index c7865de..2c84402 100644 --- a/apps/app-frontend/src/composables/skin-site-session.test.ts +++ b/apps/app-frontend/src/composables/skin-site-session.test.ts @@ -3,10 +3,15 @@ import test from 'node:test' import { openSkinSiteLogin, - receiveSkinSiteSession, + receiveSkinSiteMessage, + requestSkinSiteLuck, + requestSkinSitePlayers, resetSkinSiteSession, + setSkinSiteFrame, SKIN_SITE_ORIGIN, skinSiteFrameUrl, + skinSitePlayers, + skinSitePlayersStatus, skinSiteStatus, skinSiteUser, } from './skin-site-session.ts' @@ -28,24 +33,154 @@ test('skin site session accepts only the embedded origin and window, and redirec jwt: 'must-not-copy', }) assert.equal( - receiveSkinSiteSession({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame), + receiveSkinSiteMessage({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame), false, ) - assert.equal(receiveSkinSiteSession(signedIn, {} as Window), false) - assert.equal(receiveSkinSiteSession(message('signed-in', {}), frame), false) + assert.equal(receiveSkinSiteMessage(signedIn, {} as Window), false) + assert.equal(receiveSkinSiteMessage(message('signed-in', {}), frame), false) assert.equal(skinSiteUser.value, null) - assert.equal(receiveSkinSiteSession(signedIn, frame), true) + assert.equal(receiveSkinSiteMessage(signedIn, frame), true) assert.deepEqual(skinSiteUser.value, { uuid: 'test-user', username: '测试用户' }) assert.equal(skinSiteStatus.value, 'signed-in') assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/profile`) - assert.equal(receiveSkinSiteSession(message('checking'), frame), true) + assert.equal(receiveSkinSiteMessage(message('checking'), frame), true) assert.equal(skinSiteUser.value, null) - receiveSkinSiteSession(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame) + receiveSkinSiteMessage(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame) assert.deepEqual(skinSiteUser.value, { uuid: 'second', username: '另一个账号' }) - receiveSkinSiteSession(message('signed-out'), frame) + receiveSkinSiteMessage(message('signed-out'), frame) assert.equal(skinSiteUser.value, null) assert.equal(skinSiteStatus.value, 'signed-out') - receiveSkinSiteSession(message('error'), frame) + receiveSkinSiteMessage(message('error'), frame) assert.equal(skinSiteStatus.value, 'error') resetSkinSiteSession() }) + +test('luck requests use only the connected skin site frame and validate its result', async () => { + const sent: Array<{ data: unknown; targetOrigin: string }> = [] + const frame = { + postMessage(data: unknown, targetOrigin: string) { + sent.push({ data, targetOrigin }) + }, + } as unknown as Window + const sessionMessage = { + origin: SKIN_SITE_ORIGIN, + source: frame, + data: { + type: 'starlight-skin-session', + status: 'signed-in', + user: { uuid: 'lucky-user', username: 'Lucky' }, + }, + } as MessageEvent + + resetSkinSiteSession() + setSkinSiteFrame(frame) + receiveSkinSiteMessage(sessionMessage, frame) + const automaticPlayersRequest = sent.at(-1)?.data as { type: string; requestId: string } + assert.equal(automaticPlayersRequest.type, 'starlight-skin-players-request') + receiveSkinSiteMessage( + { + origin: SKIN_SITE_ORIGIN, + source: frame, + data: { + type: 'starlight-skin-players-result', + requestId: automaticPlayersRequest.requestId, + ok: true, + players: [], + }, + } as MessageEvent, + frame, + ) + const result = requestSkinSiteLuck() + assert.equal(sent.at(-1)?.targetOrigin, SKIN_SITE_ORIGIN) + const request = sent.at(-1)?.data as { type: string; requestId: string } + assert.equal(request.type, 'starlight-skin-luck-request') + assert.match(request.requestId, /^skin-luck-\d+-\d+$/) + assert.equal( + receiveSkinSiteMessage( + { + origin: SKIN_SITE_ORIGIN, + source: frame, + data: { + type: 'starlight-skin-luck-result', + requestId: request.requestId, + ok: true, + luck: 88, + }, + } as MessageEvent, + frame, + ), + true, + ) + assert.equal(await result, 88) + setSkinSiteFrame(null) + resetSkinSiteSession() +}) + +test('player requests validate identities and replace the collection atomically', async () => { + const sent: Array<{ data: unknown; targetOrigin: string }> = [] + const frame = { + postMessage(data: unknown, targetOrigin: string) { + sent.push({ data, targetOrigin }) + }, + } as unknown as Window + + resetSkinSiteSession() + setSkinSiteFrame(frame) + receiveSkinSiteMessage( + { + origin: SKIN_SITE_ORIGIN, + source: frame, + data: { + type: 'starlight-skin-session', + status: 'signed-in', + user: { uuid: 'skin-user', username: 'Skin User' }, + }, + } as MessageEvent, + frame, + ) + const automaticRequest = sent.at(-1)?.data as { requestId: string } + assert.equal(skinSitePlayersStatus.value, 'checking') + assert.equal( + receiveSkinSiteMessage( + { + origin: SKIN_SITE_ORIGIN, + source: frame, + data: { + type: 'starlight-skin-players-result', + requestId: automaticRequest.requestId, + ok: true, + players: [ + { uuid: '0123456789abcdef0123456789abcdef', name: 'PlayerOne', isMojang: false }, + { uuid: 'invalid', name: 'Ignored', isMojang: false }, + ], + }, + } as MessageEvent, + frame, + ), + true, + ) + assert.deepEqual(skinSitePlayers.value, [ + { uuid: '0123456789abcdef0123456789abcdef', name: 'PlayerOne', isMojang: false }, + ]) + assert.equal(skinSitePlayersStatus.value, 'ready') + + const retry = requestSkinSitePlayers() + const retryRequest = sent.at(-1)?.data as { requestId: string } + receiveSkinSiteMessage( + { + origin: SKIN_SITE_ORIGIN, + source: frame, + data: { + type: 'starlight-skin-players-result', + requestId: retryRequest.requestId, + ok: true, + players: [], + }, + } as MessageEvent, + frame, + ) + assert.deepEqual(await retry, []) + assert.deepEqual(skinSitePlayers.value, []) + setSkinSiteFrame(null) + resetSkinSiteSession() +}) diff --git a/apps/app-frontend/src/composables/skin-site-session.ts b/apps/app-frontend/src/composables/skin-site-session.ts index fe02450..f5c6f9c 100644 --- a/apps/app-frontend/src/composables/skin-site-session.ts +++ b/apps/app-frontend/src/composables/skin-site-session.ts @@ -3,29 +3,195 @@ import { readonly, ref } from 'vue' export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool' export type SkinSiteUser = { uuid: string; username: string } export type SkinSiteStatus = 'checking' | 'signed-out' | 'signed-in' | 'error' +export type SkinSitePlayer = { uuid: string; name: string; isMojang: boolean } +export type SkinSitePlayersStatus = 'idle' | 'checking' | 'ready' | 'error' const user = ref(null) const status = ref('signed-out') +const players = ref([]) +const playersStatus = ref('idle') const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`) +let connectedFrame: Window | null = null +let luckRequestSequence = 0 +let playersRequestSequence = 0 + +type PendingLuckRequest = { + resolve: (luck: number) => void + reject: (error: Error) => void + timeout: ReturnType +} + +const pendingLuckRequests = new Map() +type PendingPlayersRequest = { + resolve: (players: SkinSitePlayer[]) => void + reject: (error: Error) => void + timeout: ReturnType +} +const pendingPlayersRequests = new Map() export const skinSiteUser = readonly(user) export const skinSiteStatus = readonly(status) +export const skinSitePlayers = readonly(players) +export const skinSitePlayersStatus = readonly(playersStatus) export const skinSiteFrameUrl = readonly(frameUrl) +function rejectPendingLuckRequests(message: string) { + for (const request of pendingLuckRequests.values()) { + clearTimeout(request.timeout) + request.reject(new Error(message)) + } + pendingLuckRequests.clear() +} + +function rejectPendingPlayersRequests(message: string) { + for (const request of pendingPlayersRequests.values()) { + clearTimeout(request.timeout) + request.reject(new Error(message)) + } + pendingPlayersRequests.clear() +} + +export function setSkinSiteFrame(frame: Window | null) { + if (connectedFrame === frame) return + rejectPendingLuckRequests('The skin site connection changed.') + rejectPendingPlayersRequests('The skin site connection changed.') + connectedFrame = frame +} + export function openSkinSiteLogin() { frameUrl.value = `${SKIN_SITE_ORIGIN}/login` } export function resetSkinSiteSession() { + rejectPendingLuckRequests('The skin site session ended.') + rejectPendingPlayersRequests('The skin site session ended.') user.value = null status.value = 'signed-out' + players.value = [] + playersStatus.value = 'idle' } -export function receiveSkinSiteSession(event: MessageEvent, frame: Window | null) { +export function requestSkinSitePlayers() { + if (!connectedFrame || status.value !== 'signed-in' || !user.value) { + return Promise.reject(new Error('Sign in to the skin site before requesting players.')) + } + + playersStatus.value = 'checking' + const requestId = `skin-players-${Date.now()}-${++playersRequestSequence}` + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pendingPlayersRequests.delete(requestId) + playersStatus.value = 'error' + reject(new Error('The skin site player request timed out.')) + }, 12_000) + pendingPlayersRequests.set(requestId, { resolve, reject, timeout }) + try { + connectedFrame?.postMessage( + { type: 'starlight-skin-players-request', requestId }, + SKIN_SITE_ORIGIN, + ) + } catch (error) { + clearTimeout(timeout) + pendingPlayersRequests.delete(requestId) + playersStatus.value = 'error' + reject(error instanceof Error ? error : new Error(String(error))) + } + }) +} + +export function requestSkinSiteLuck() { + if (!connectedFrame || status.value !== 'signed-in' || !user.value) { + return Promise.reject(new Error('Sign in to the skin site before requesting luck.')) + } + + const requestId = `skin-luck-${Date.now()}-${++luckRequestSequence}` + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pendingLuckRequests.delete(requestId) + reject(new Error('The skin site luck request timed out.')) + }, 12_000) + pendingLuckRequests.set(requestId, { resolve, reject, timeout }) + try { + connectedFrame?.postMessage( + { type: 'starlight-skin-luck-request', requestId }, + SKIN_SITE_ORIGIN, + ) + } catch (error) { + clearTimeout(timeout) + pendingLuckRequests.delete(requestId) + reject(error instanceof Error ? error : new Error(String(error))) + } + }) +} + +export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null) { if (!frame || event.source !== frame || event.origin !== SKIN_SITE_ORIGIN) return false const data = event.data - if (!data || data.type !== 'starlight-skin-session') return false + if (!data || typeof data !== 'object') return false + + if (data.type === 'starlight-skin-players-result') { + if (typeof data.requestId !== 'string') return false + const pending = pendingPlayersRequests.get(data.requestId) + if (!pending) return false + clearTimeout(pending.timeout) + pendingPlayersRequests.delete(data.requestId) + if (data.ok === true && Array.isArray(data.players)) { + const nextPlayers = data.players.filter( + (player: unknown): player is SkinSitePlayer => + typeof player === 'object' && + player !== null && + typeof (player as SkinSitePlayer).uuid === 'string' && + /^[0-9a-f-]{32,36}$/i.test((player as SkinSitePlayer).uuid) && + typeof (player as SkinSitePlayer).name === 'string' && + (player as SkinSitePlayer).name.length > 0 && + (player as SkinSitePlayer).name.length <= 64 && + typeof (player as SkinSitePlayer).isMojang === 'boolean', + ) + players.value = nextPlayers + playersStatus.value = 'ready' + pending.resolve(nextPlayers) + } else { + playersStatus.value = 'error' + pending.reject( + new Error( + typeof data.error === 'string' && data.error + ? data.error.slice(0, 300) + : 'The skin site returned an invalid player list.', + ), + ) + } + return true + } + + if (data.type === 'starlight-skin-luck-result') { + if (typeof data.requestId !== 'string') return false + const pending = pendingLuckRequests.get(data.requestId) + if (!pending) return false + clearTimeout(pending.timeout) + pendingLuckRequests.delete(data.requestId) + if ( + data.ok === true && + typeof data.luck === 'number' && + Number.isFinite(data.luck) && + data.luck >= 0 && + data.luck <= 100 + ) { + pending.resolve(data.luck) + } else { + pending.reject( + new Error( + typeof data.error === 'string' && data.error + ? data.error.slice(0, 300) + : 'The skin site returned an invalid luck result.', + ), + ) + } + return true + } + + if (data.type !== 'starlight-skin-session') return false if (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return false + let refreshPlayers = false if (data.status === 'signed-in') { if ( !data.user || @@ -36,13 +202,24 @@ export function receiveSkinSiteSession(event: MessageEvent, frame: Window | null data.user.username.length > 256 ) return false + if (user.value?.uuid && user.value.uuid !== data.user.uuid) { + rejectPendingPlayersRequests('The skin site account changed.') + players.value = [] + playersStatus.value = 'idle' + } user.value = { uuid: data.user.uuid, username: data.user.username } if ([`${SKIN_SITE_ORIGIN}/`, `${SKIN_SITE_ORIGIN}/login`].includes(frameUrl.value)) { frameUrl.value = `${SKIN_SITE_ORIGIN}/profile` } + refreshPlayers = true } else { + rejectPendingLuckRequests('The skin site session is unavailable.') + rejectPendingPlayersRequests('The skin site session is unavailable.') user.value = null + players.value = [] + playersStatus.value = 'idle' } status.value = data.status + if (refreshPlayers) void requestSkinSitePlayers().catch(() => {}) return true } diff --git a/apps/app-frontend/src/helpers/auth.js b/apps/app-frontend/src/helpers/auth.js index ff10e34..982f2af 100644 --- a/apps/app-frontend/src/helpers/auth.js +++ b/apps/app-frontend/src/helpers/auth.js @@ -14,8 +14,8 @@ import { invoke } from '@tauri-apps/api/core' // } /** - * Check if the authentication servers are reachable, throwing an exception if - * not reachable. + * Check if the StarLight authentication server is reachable, throwing an + * exception if it is not reachable. */ export async function check_reachable() { await invoke('plugin:auth|check_reachable') diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 2af4787..0a2b260 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -1,8 +1,27 @@ { + "app.home.luck.title": { "message": "Daily luck index" }, + "app.home.luck.get": { "message": "Get with one click" }, + "app.home.luck.login-to-get": { "message": "Sign in to get it" }, + "app.home.luck.loading": { "message": "Asking the little sprite..." }, + "app.home.luck.error": { + "message": "The little sprite did not answer. Please try again later." + }, + "app.home.luck.out-of": { "message": "out of 100" }, "minecraft-account.skin-site.signed-in": { "message": "Signed in to StarLight Skin Site" }, "minecraft-account.skin-site.checking": { "message": "Checking skin site session…" }, - "minecraft-account.skin-site.sync-error": { "message": "Could not verify the skin site session. Retrying automatically." }, - "minecraft-account.add-skin-game-account": { "message": "Add skin site game account" }, + "minecraft-account.skin-site.sync-error": { + "message": "Could not verify the skin site session. Retrying automatically." + }, + "minecraft-account.skin-site.players": { "message": "Skin site players ({count})" }, + "minecraft-account.skin-site.players.retry": { "message": "Retry" }, + "minecraft-account.skin-site.players.empty": { + "message": "No player profiles are attached to this skin site account." + }, + "minecraft-account.skin-site.players.error": { + "message": "Could not refresh the player list. Previously loaded players are kept." + }, + "minecraft-account.skin-site.player.ready": { "message": "Ready to launch" }, + "minecraft-account.skin-site.player.synced": { "message": "From skin site" }, "app.easteregg.color-mine.title": { "message": "Starlight Mine: Chromatic Realms" }, @@ -10146,8 +10165,11 @@ "minecraft-account.offline-mode": { "message": "Offline mode" }, - "minecraft-account.offline-mode.description": { - "message": "Only offline accounts are available. You can launch fully downloaded instances." + "minecraft-account.offline-mode.description.no-internet": { + "message": "It looks like this device may not be connected to the internet. You can currently only launch fully downloaded instances and will most likely be unable to connect to StarLight servers. Check your network connection. If you are using a proxy, try disabling or enabling it, then refresh the connection status below." + }, + "minecraft-account.offline-mode.description.server-unavailable": { + "message": "Your internet connection is working, but StarLight's authentication server cannot be reached. The StarLight server may be undergoing maintenance, or your proxy may be misconfigured. If you are using a proxy, try disabling or enabling it, then refresh the connection status below. If that does not help, contact a server administrator in the StarLight community group to confirm the maintenance status." }, "minecraft-account.offline-mode.refresh": { "message": "Refresh connection status" diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index 9e1faa9..21d031c 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -1,8 +1,23 @@ { + "app.home.luck.title": { "message": "每日幸运指数" }, + "app.home.luck.get": { "message": "一键获取" }, + "app.home.luck.login-to-get": { "message": "登录后获取" }, + "app.home.luck.loading": { "message": "正在询问小精灵..." }, + "app.home.luck.error": { "message": "小精灵暂时没有回应,请稍后重试。" }, + "app.home.luck.out-of": { "message": "满分 100" }, "minecraft-account.skin-site.signed-in": { "message": "已登录 StarLight皮肤站" }, "minecraft-account.skin-site.checking": { "message": "正在验证皮肤站登录状态…" }, - "minecraft-account.skin-site.sync-error": { "message": "暂时无法验证皮肤站登录状态,将自动重试。" }, - "minecraft-account.add-skin-game-account": { "message": "添加皮肤站游戏账号" }, + "minecraft-account.skin-site.sync-error": { + "message": "暂时无法验证皮肤站登录状态,将自动重试。" + }, + "minecraft-account.skin-site.players": { "message": "皮肤站玩家({count})" }, + "minecraft-account.skin-site.players.retry": { "message": "重试" }, + "minecraft-account.skin-site.players.empty": { "message": "这个皮肤站账号还没有玩家。" }, + "minecraft-account.skin-site.players.error": { + "message": "暂时无法刷新玩家列表,已保留上一次结果。" + }, + "minecraft-account.skin-site.player.ready": { "message": "可启动" }, + "minecraft-account.skin-site.player.synced": { "message": "来自皮肤站" }, "app.easteregg.color-mine.title": { "message": "星光矿井:彩域" }, @@ -10227,8 +10242,11 @@ "minecraft-account.offline-mode": { "message": "离线模式" }, - "minecraft-account.offline-mode.description": { - "message": "仅可使用离线账号,并且只能启动已完整下载的实例。这通常是因为你的电脑无法连接到Mojang的认证服务器,请检查网络连接。如果你正在使用代理,请关闭或开启代理后点击下方按钮刷新连接状态。" + "minecraft-account.offline-mode.description.no-internet": { + "message": "看起来本机似乎并没有连接到互联网,您目前只能启动已完整下载的实例,且极可能无法连接到StarLight服务器,请检查网络连接。如果您正在使用代理,请关闭或开启代理后点击下方按钮刷新连接状态。" + }, + "minecraft-account.offline-mode.description.server-unavailable": { + "message": "您目前网络正常,但无法连接到StarLight验证服务器;这可能是由于StarLight服务器正在维护,或者您配置的代理存在问题。如果您正在使用代理,请关闭或开启代理后点击下方按钮刷新连接状态;若该操作不生效,请前往StarLight交流群,联系服务器管理确认服务器的维护状态。" }, "minecraft-account.offline-mode.refresh": { "message": "刷新连接状态" diff --git a/apps/app-frontend/src/locales/zh-TW/index.json b/apps/app-frontend/src/locales/zh-TW/index.json index 8292796..f949b65 100644 --- a/apps/app-frontend/src/locales/zh-TW/index.json +++ b/apps/app-frontend/src/locales/zh-TW/index.json @@ -1,8 +1,23 @@ { + "app.home.luck.title": { "message": "每日幸運指數" }, + "app.home.luck.get": { "message": "一鍵獲取" }, + "app.home.luck.login-to-get": { "message": "登入後獲取" }, + "app.home.luck.loading": { "message": "正在詢問小精靈..." }, + "app.home.luck.error": { "message": "小精靈暫時沒有回應,請稍後重試。" }, + "app.home.luck.out-of": { "message": "滿分 100" }, "minecraft-account.skin-site.signed-in": { "message": "已登入 StarLight皮膚站" }, "minecraft-account.skin-site.checking": { "message": "正在驗證皮膚站登入狀態…" }, - "minecraft-account.skin-site.sync-error": { "message": "暫時無法驗證皮膚站登入狀態,將自動重試。" }, - "minecraft-account.add-skin-game-account": { "message": "新增皮膚站遊戲帳號" }, + "minecraft-account.skin-site.sync-error": { + "message": "暫時無法驗證皮膚站登入狀態,將自動重試。" + }, + "minecraft-account.skin-site.players": { "message": "皮膚站玩家({count})" }, + "minecraft-account.skin-site.players.retry": { "message": "重試" }, + "minecraft-account.skin-site.players.empty": { "message": "這個皮膚站帳號還沒有玩家。" }, + "minecraft-account.skin-site.players.error": { + "message": "暫時無法重新整理玩家清單,已保留上一次結果。" + }, + "minecraft-account.skin-site.player.ready": { "message": "可啟動" }, + "minecraft-account.skin-site.player.synced": { "message": "來自皮膚站" }, "app.easteregg.color-mine.title": { "message": "星光礦井:彩域" }, @@ -9873,8 +9888,11 @@ "minecraft-account.offline-mode": { "message": "離線模式" }, - "minecraft-account.offline-mode.description": { - "message": "僅可使用離線帳號,並且只能啟動已完整下載的實例。" + "minecraft-account.offline-mode.description.no-internet": { + "message": "看起來本機似乎並未連線到網際網路,您目前只能啟動已完整下載的實例,且極可能無法連線到 StarLight 伺服器,請檢查網路連線。如果您正在使用代理,請關閉或開啟代理後點擊下方按鈕重新整理連線狀態。" + }, + "minecraft-account.offline-mode.description.server-unavailable": { + "message": "您目前網路正常,但無法連線到 StarLight 驗證伺服器;這可能是因為 StarLight 伺服器正在維護,或者您設定的代理存在問題。如果您正在使用代理,請關閉或開啟代理後點擊下方按鈕重新整理連線狀態;若該操作沒有作用,請前往 StarLight 交流群組,聯絡伺服器管理員確認維護狀態。" }, "minecraft-account.offline-mode.refresh": { "message": "刷新連接狀態" diff --git a/apps/app/src/api/auth.rs b/apps/app/src/api/auth.rs index 04e4276..a24d088 100644 --- a/apps/app/src/api/auth.rs +++ b/apps/app/src/api/auth.rs @@ -35,7 +35,7 @@ pub fn init() -> TauriPlugin { .build() } -/// Checks if the authentication servers are reachable. +/// Checks if the StarLight authentication server is reachable. #[tauri::command] pub async fn check_reachable() -> Result<()> { minecraft_auth::check_reachable().await?; diff --git a/apps/app/src/skin_site_bridge.js b/apps/app/src/skin_site_bridge.js index dc3bb61..20ca31a 100644 --- a/apps/app/src/skin_site_bridge.js +++ b/apps/app/src/skin_site_bridge.js @@ -1,98 +1,254 @@ // Runs inside the embedded skin site only. The JWT never leaves that origin. -(() => { - if (location.origin !== 'https://skin.starlight.cool' || window.parent === window) return +;(() => { + if (location.origin !== 'https://skin.starlight.cool' || window.parent === window) return - const launcherOrigins = new Set([ - 'http://localhost:5201', - 'http://tauri.localhost', - 'https://tauri.localhost', - 'tauri://localhost', - ]) - let parentOrigin - let lastToken - let lastCheck = 0 - let generation = 0 - let pending - let snapshot = { status: 'checking', user: null } + const launcherOrigins = new Set([ + 'http://localhost:5201', + 'http://tauri.localhost', + 'https://tauri.localhost', + 'tauri://localhost', + ]) + let parentOrigin + let lastToken + let lastCheck = 0 + let generation = 0 + let pending + const pendingLuck = new Map() + const pendingPlayers = new Map() + let snapshot = { status: 'checking', user: null } - function publish(status, user = null) { - snapshot = { status, user } - if (parentOrigin) { - window.parent.postMessage({ type: 'starlight-skin-session', ...snapshot }, parentOrigin) - } - } + function publish(status, user = null) { + snapshot = { status, user } + if (parentOrigin) { + window.parent.postMessage({ type: 'starlight-skin-session', ...snapshot }, parentOrigin) + } + } - async function checkSession(force = false) { - if (!parentOrigin) return - let token - try { - token = localStorage.getItem('loginToken') || '' - } catch { - publish('error') - return - } - const changed = token !== lastToken - if (!changed && (pending || (!force && Date.now() - lastCheck < 60_000))) return - const revision = ++generation - pending?.abort() - pending = undefined - lastToken = token - lastCheck = Date.now() - if (!token) { - publish('signed-out') - return - } - if (changed) publish('checking') - const controller = new AbortController() - pending = controller - const timeout = setTimeout(() => controller.abort(), 10_000) - try { - const response = await fetch('/starlight/user', { - headers: { Authorization: `Bearer ${token}` }, - signal: controller.signal, - cache: 'no-store', - redirect: 'error', - }) - const body = response.ok ? await response.json() : null - // A logout or account switch must win over an older in-flight response. - if (revision !== generation || localStorage.getItem('loginToken') !== token) return - if (response.status === 401 || response.status === 403 || body?.payload?.banned) { - publish('signed-out') - } else if ( - response.ok && typeof body?.payload?.uuid === 'string' && - typeof body.payload.username === 'string' && body.payload.username.length > 0 - ) { - publish('signed-in', { uuid: body.payload.uuid, username: body.payload.username }) - } else { - publish('error') - } - } catch { - if (revision === generation) publish('error') - } finally { - clearTimeout(timeout) - if (revision === generation) pending = undefined - } - } + function publishLuck(requestId, result) { + if (!parentOrigin) return + window.parent.postMessage( + { type: 'starlight-skin-luck-result', requestId, ...result }, + parentOrigin, + ) + } - window.addEventListener('message', (event) => { - if (event.source !== window.parent || !launcherOrigins.has(event.origin)) return - if (event.data?.type !== 'starlight-skin-session-connect') return - parentOrigin = event.origin - publish(snapshot.status, snapshot.user) - void checkSession(true) - }) - // Storage events cover other tabs; polling also covers same-document SPA login/logout. - window.addEventListener('storage', () => void checkSession()) - let timer = setInterval(() => void checkSession(), 1000) - window.addEventListener('pagehide', () => { - clearInterval(timer) - ++generation - pending?.abort() - pending = undefined - }) - window.addEventListener('pageshow', (event) => { - if (!event.persisted) return - timer = setInterval(() => void checkSession(), 1000) - void checkSession(true) - }) + function publishPlayers(requestId, result) { + if (!parentOrigin) return + window.parent.postMessage( + { type: 'starlight-skin-players-result', requestId, ...result }, + parentOrigin, + ) + } + + async function requestPlayers(requestId) { + if (pendingPlayers.has(requestId)) return + let token + try { + token = localStorage.getItem('loginToken') || '' + } catch { + publishPlayers(requestId, { ok: false, error: 'Unable to read the skin site session.' }) + return + } + if (!token) { + publish('signed-out') + publishPlayers(requestId, { ok: false, error: 'Sign in to the skin site first.' }) + return + } + + const controller = new AbortController() + pendingPlayers.set(requestId, controller) + const timeout = setTimeout(() => controller.abort(), 10_000) + try { + const response = await fetch('/starlight/skin/player', { + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + cache: 'no-store', + redirect: 'error', + }) + const body = await response.json().catch(() => null) + if (localStorage.getItem('loginToken') !== token) { + publishPlayers(requestId, { ok: false, error: 'The skin site account changed.' }) + return + } + if (response.status === 401 || response.status === 403) publish('signed-out') + const rawPlayers = Array.isArray(body?.payload) ? body.payload : null + const players = rawPlayers?.slice(0, 100).flatMap((player) => { + if ( + typeof player?.uuid !== 'string' || + !/^[0-9a-f-]{32,36}$/i.test(player.uuid) || + typeof player?.name !== 'string' || + player.name.length < 1 || + player.name.length > 64 + ) + return [] + return [{ uuid: player.uuid, name: player.name, isMojang: player.isMojang === true }] + }) + if (response.ok && players) { + publishPlayers(requestId, { ok: true, players }) + } else { + publishPlayers(requestId, { + ok: false, + error: + typeof body?.errorMessage === 'string' && body.errorMessage + ? body.errorMessage.slice(0, 300) + : 'The skin site returned an invalid player list.', + }) + } + } catch { + publishPlayers(requestId, { ok: false, error: 'Unable to reach the player service.' }) + } finally { + clearTimeout(timeout) + pendingPlayers.delete(requestId) + } + } + + async function requestLuck(requestId) { + if (pendingLuck.has(requestId)) return + let token + try { + token = localStorage.getItem('loginToken') || '' + } catch { + publishLuck(requestId, { ok: false, error: 'Unable to read the skin site session.' }) + return + } + if (!token) { + publish('signed-out') + publishLuck(requestId, { ok: false, error: 'Sign in to the skin site first.' }) + return + } + + const controller = new AbortController() + pendingLuck.set(requestId, controller) + const timeout = setTimeout(() => controller.abort(), 10_000) + try { + const response = await fetch('/starlight/luck', { + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + cache: 'no-store', + redirect: 'error', + }) + const body = await response.json().catch(() => null) + if (localStorage.getItem('loginToken') !== token) { + publishLuck(requestId, { ok: false, error: 'The skin site account changed.' }) + return + } + if (response.status === 401 || response.status === 403) publish('signed-out') + const luck = Number(body?.payload?.luck) + if (response.ok && Number.isFinite(luck) && luck >= 0 && luck <= 100) { + publishLuck(requestId, { ok: true, luck }) + } else { + publishLuck(requestId, { + ok: false, + error: + typeof body?.errorMessage === 'string' && body.errorMessage + ? body.errorMessage.slice(0, 300) + : 'The skin site returned an invalid luck result.', + }) + } + } catch { + publishLuck(requestId, { ok: false, error: 'Unable to reach the luck service.' }) + } finally { + clearTimeout(timeout) + pendingLuck.delete(requestId) + } + } + + async function checkSession(force = false) { + if (!parentOrigin) return + let token + try { + token = localStorage.getItem('loginToken') || '' + } catch { + publish('error') + return + } + const changed = token !== lastToken + if (!changed && (pending || (!force && Date.now() - lastCheck < 60_000))) return + const revision = ++generation + pending?.abort() + pending = undefined + lastToken = token + lastCheck = Date.now() + if (!token) { + publish('signed-out') + return + } + if (changed) publish('checking') + const controller = new AbortController() + pending = controller + const timeout = setTimeout(() => controller.abort(), 10_000) + try { + const response = await fetch('/starlight/user', { + headers: { Authorization: `Bearer ${token}` }, + signal: controller.signal, + cache: 'no-store', + redirect: 'error', + }) + const body = response.ok ? await response.json() : null + // A logout or account switch must win over an older in-flight response. + if (revision !== generation || localStorage.getItem('loginToken') !== token) return + if (response.status === 401 || response.status === 403 || body?.payload?.banned) { + publish('signed-out') + } else if ( + response.ok && + typeof body?.payload?.uuid === 'string' && + typeof body.payload.username === 'string' && + body.payload.username.length > 0 + ) { + publish('signed-in', { uuid: body.payload.uuid, username: body.payload.username }) + } else { + publish('error') + } + } catch { + if (revision === generation) publish('error') + } finally { + clearTimeout(timeout) + if (revision === generation) pending = undefined + } + } + + window.addEventListener('message', (event) => { + if (event.source !== window.parent || !launcherOrigins.has(event.origin)) return + if (event.data?.type === 'starlight-skin-session-connect') { + parentOrigin = event.origin + publish(snapshot.status, snapshot.user) + void checkSession(true) + return + } + if ( + event.data?.type === 'starlight-skin-luck-request' && + parentOrigin === event.origin && + typeof event.data.requestId === 'string' && + /^skin-luck-\d+-\d+$/.test(event.data.requestId) + ) { + void requestLuck(event.data.requestId) + return + } + if ( + event.data?.type === 'starlight-skin-players-request' && + parentOrigin === event.origin && + typeof event.data.requestId === 'string' && + /^skin-players-\d+-\d+$/.test(event.data.requestId) + ) { + void requestPlayers(event.data.requestId) + } + }) + // Storage events cover other tabs; polling also covers same-document SPA login/logout. + window.addEventListener('storage', () => void checkSession()) + let timer = setInterval(() => void checkSession(), 1000) + window.addEventListener('pagehide', () => { + clearInterval(timer) + ++generation + pending?.abort() + pending = undefined + for (const controller of pendingLuck.values()) controller.abort() + pendingLuck.clear() + for (const controller of pendingPlayers.values()) controller.abort() + pendingPlayers.clear() + }) + window.addEventListener('pageshow', (event) => { + if (!event.persisted) return + timer = setInterval(() => void checkSession(), 1000) + void checkSession(true) + }) })() diff --git a/apps/app/src/skin_site_bridge.test.cjs b/apps/app/src/skin_site_bridge.test.cjs index dbf1909..eb9001e 100644 --- a/apps/app/src/skin_site_bridge.test.cjs +++ b/apps/app/src/skin_site_bridge.test.cjs @@ -5,63 +5,206 @@ const test = require('node:test') const source = fs.readFileSync(require('node:path').join(__dirname, 'skin_site_bridge.js'), 'utf8') function harness(origin = 'https://skin.starlight.cool') { - let token = null - const listeners = {}, messages = [], requests = [] - let tick - let result = async () => ({ ok: true, status: 200, json: async () => ({ payload: { uuid: 'one', username: 'One' } }) }) - const parent = { postMessage(data, target) { messages.push({ data, target }) } } - const context = { - location: { origin }, - window: { parent, addEventListener(type, callback) { listeners[type] = callback } }, - localStorage: { getItem() { return token } }, - fetch(...args) { requests.push(args); return result(...args) }, - AbortController, Date, setTimeout, clearTimeout, - setInterval(callback) { tick = callback; return 1 }, clearInterval() {}, - } - vm.runInNewContext(source, context) - return { - messages, requests, listeners, parent, - token(value) { token = value }, result(value) { result = value }, - async tick() { tick?.(); await new Promise(setImmediate) }, - async connect(origin = 'http://localhost:5201', source = parent) { - listeners.message?.({ source, origin, data: { type: 'starlight-skin-session-connect' } }) - await new Promise(setImmediate) - }, - } + let token = null + const listeners = {}, + messages = [], + requests = [] + let tick + let result = async () => ({ + ok: true, + status: 200, + json: async () => ({ payload: { uuid: 'one', username: 'One' } }), + }) + const parent = { + postMessage(data, target) { + messages.push({ data, target }) + }, + } + const context = { + location: { origin }, + window: { + parent, + addEventListener(type, callback) { + listeners[type] = callback + }, + }, + localStorage: { + getItem() { + return token + }, + }, + fetch(...args) { + requests.push(args) + return result(...args) + }, + AbortController, + Date, + setTimeout, + clearTimeout, + setInterval(callback) { + tick = callback + return 1 + }, + clearInterval() {}, + } + vm.runInNewContext(source, context) + return { + messages, + requests, + listeners, + parent, + token(value) { + token = value + }, + result(value) { + result = value + }, + async tick() { + tick?.() + await new Promise(setImmediate) + }, + async connect(origin = 'http://localhost:5201', source = parent) { + listeners.message?.({ source, origin, data: { type: 'starlight-skin-session-connect' } }) + await new Promise(setImmediate) + }, + async message(data, origin = 'http://localhost:5201', source = parent) { + listeners.message?.({ source, origin, data }) + await new Promise(setImmediate) + }, + } } test('bridge is restricted to the skin origin and an allowlisted parent', async () => { - assert.equal(harness('https://evil.example').listeners.message, undefined) - const h = harness(); h.token('test-token') - await h.connect('https://evil.example'); await h.connect('http://localhost:5201', {}) - assert.equal(h.requests.length, 0); assert.equal(h.messages.length, 0) - await h.connect() - assert.equal(h.requests[0][0], '/starlight/user') - assert.equal(h.requests[0][1].headers.Authorization, 'Bearer test-token') - assert.equal(h.requests[0][1].redirect, 'error') - assert.equal(h.messages.at(-1).data.status, 'signed-in') - assert.ok(h.messages.every(m => m.target === 'http://localhost:5201')) - assert.ok(!JSON.stringify(h.messages).includes('test-token')) + assert.equal(harness('https://evil.example').listeners.message, undefined) + const h = harness() + h.token('test-token') + await h.connect('https://evil.example') + await h.connect('http://localhost:5201', {}) + assert.equal(h.requests.length, 0) + assert.equal(h.messages.length, 0) + await h.connect() + assert.equal(h.requests[0][0], '/starlight/user') + assert.equal(h.requests[0][1].headers.Authorization, 'Bearer test-token') + assert.equal(h.requests[0][1].redirect, 'error') + assert.equal(h.messages.at(-1).data.status, 'signed-in') + assert.ok(h.messages.every((m) => m.target === 'http://localhost:5201')) + assert.ok(!JSON.stringify(h.messages).includes('test-token')) }) test('logout, invalid token, network error and account switching replace old state', async () => { - const h = harness(); await h.connect() - assert.equal(h.messages.at(-1).data.status, 'signed-out') - h.token('first'); await h.tick(); assert.equal(h.messages.at(-1).data.user.username, 'One') - h.result(async () => ({ ok: false, status: 401 })) - h.token('expired'); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'signed-out') - h.result(async () => { throw Error('offline') }) - h.token('network-error'); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'error') - h.token(null); await h.tick(); assert.equal(h.messages.at(-1).data.status, 'signed-out') + const h = harness() + await h.connect() + assert.equal(h.messages.at(-1).data.status, 'signed-out') + h.token('first') + await h.tick() + assert.equal(h.messages.at(-1).data.user.username, 'One') + h.result(async () => ({ ok: false, status: 401 })) + h.token('expired') + await h.tick() + assert.equal(h.messages.at(-1).data.status, 'signed-out') + h.result(async () => { + throw Error('offline') + }) + h.token('network-error') + await h.tick() + assert.equal(h.messages.at(-1).data.status, 'error') + h.token(null) + await h.tick() + assert.equal(h.messages.at(-1).data.status, 'signed-out') }) test('a delayed response cannot restore the user after logout', async () => { - const h = harness(); let finish - h.result(() => new Promise(resolve => { finish = resolve })) - h.token('first'); await h.connect() - h.token(null); await h.tick() - finish({ ok: true, status: 200, json: async () => ({ payload: { uuid: 'one', username: 'One' } }) }) - await new Promise(setImmediate) - assert.equal(h.messages.at(-1).data.status, 'signed-out') - assert.ok(!h.messages.some(m => m.data.status === 'signed-in')) + const h = harness() + let finish + h.result( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + h.token('first') + await h.connect() + h.token(null) + await h.tick() + finish({ + ok: true, + status: 200, + json: async () => ({ payload: { uuid: 'one', username: 'One' } }), + }) + await new Promise(setImmediate) + assert.equal(h.messages.at(-1).data.status, 'signed-out') + assert.ok(!h.messages.some((m) => m.data.status === 'signed-in')) +}) + +test('luck requests stay on the skin origin and return only the validated score', async () => { + const h = harness() + h.token('test-token') + await h.connect() + h.result(async () => ({ + ok: true, + status: 200, + json: async () => ({ payload: { luck: 73 } }), + })) + await h.message({ type: 'starlight-skin-luck-request', requestId: 'skin-luck-1-1' }) + const request = h.requests.at(-1) + assert.equal(request[0], '/starlight/luck') + assert.equal(request[1].headers.Authorization, 'Bearer test-token') + assert.equal(request[1].redirect, 'error') + const message = h.messages.at(-1) + assert.equal(message.target, 'http://localhost:5201') + assert.equal(message.data.type, 'starlight-skin-luck-result') + assert.equal(message.data.requestId, 'skin-luck-1-1') + assert.equal(message.data.ok, true) + assert.equal(message.data.luck, 73) + assert.ok(!JSON.stringify(h.messages).includes('test-token')) +}) + +test('luck requests reject untrusted parents, missing sessions, and invalid scores', async () => { + const h = harness() + await h.connect() + const before = h.requests.length + await h.message( + { type: 'starlight-skin-luck-request', requestId: 'skin-luck-2-1' }, + 'https://evil.example', + ) + assert.equal(h.requests.length, before) + await h.message({ type: 'starlight-skin-luck-request', requestId: 'skin-luck-2-2' }) + assert.equal(h.requests.length, before) + assert.equal(h.messages.at(-1).data.ok, false) + + h.token('test-token') + h.result(async () => ({ + ok: true, + status: 200, + json: async () => ({ payload: { luck: 101 } }), + })) + await h.message({ type: 'starlight-skin-luck-request', requestId: 'skin-luck-2-3' }) + assert.equal(h.messages.at(-1).data.ok, false) +}) + +test('player requests return a sanitized complete player collection without exposing the token', async () => { + const h = harness() + h.token('test-token') + await h.connect() + h.result(async () => ({ + ok: true, + status: 200, + json: async () => ({ + payload: [ + { uuid: '0123456789abcdef0123456789abcdef', name: 'PlayerOne', isMojang: false }, + { uuid: 'fedcba9876543210fedcba9876543210', name: 'Official', isMojang: true }, + { uuid: 'bad', name: 'Ignored', isMojang: false }, + ], + }), + })) + await h.message({ type: 'starlight-skin-players-request', requestId: 'skin-players-1-1' }) + const request = h.requests.at(-1) + assert.equal(request[0], '/starlight/skin/player') + assert.equal(request[1].headers.Authorization, 'Bearer test-token') + const message = h.messages.at(-1) + assert.equal(message.data.type, 'starlight-skin-players-result') + assert.equal(message.data.ok, true) + assert.equal(message.data.players.length, 2) + assert.equal(message.data.players[1].isMojang, true) + assert.ok(!JSON.stringify(h.messages).includes('test-token')) }) diff --git a/packages/app-lib/src/api/minecraft_auth.rs b/packages/app-lib/src/api/minecraft_auth.rs index a67ba34..824eacd 100644 --- a/packages/app-lib/src/api/minecraft_auth.rs +++ b/packages/app-lib/src/api/minecraft_auth.rs @@ -14,16 +14,13 @@ use crate::state::{ }; pub use crate::state::{MinecraftDeviceLoginFlow, MinecraftDeviceLoginPoll}; use crate::util::fetch::INSECURE_REQWEST_CLIENT; -use crate::util::mojang::{mojang_service_url, should_use_mojang_mirror}; + +const STARLIGHT_SESSION_SERVER_URL: &str = "https://skin.starlight.cool/yggdrasil/sessionserver/session/minecraft/hasJoined"; #[tracing::instrument] pub async fn check_reachable() -> crate::Result<()> { - let url = mojang_service_url( - "https://sessionserver.mojang.com/session/minecraft/hasJoined", - should_use_mojang_mirror(), - ); let resp = INSECURE_REQWEST_CLIENT - .get(url.as_ref()) + .get(STARLIGHT_SESSION_SERVER_URL) .timeout(Duration::from_secs(5)) .send() .await?;