From bb6b8cb8d2ec86ebf8b023e7f68393a037676ada Mon Sep 17 00:00:00 2001 From: Disy Date: Tue, 29 Sep 2026 17:36:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=9C=AA=E7=99=BB=E5=BD=95=E6=97=B6?= =?UTF-8?q?=E9=98=BB=E6=AD=A2=E4=B8=8B=E8=BD=BDStarLight=E5=AE=9E=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/composables/skin-site-session.test.ts | 49 ++++++++++++- .../src/composables/useHostedCreation.ts | 8 ++- apps/app-frontend/src/helpers/hosted-packs.ts | 18 ++++- apps/app/src/api/install.rs | 6 ++ packages/app-lib/src/api/pack/hosted.rs | 69 ++++++++++++++++++- 5 files changed, 140 insertions(+), 10 deletions(-) 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 f687acb..7b29daf 100644 --- a/apps/app-frontend/src/composables/skin-site-session.test.ts +++ b/apps/app-frontend/src/composables/skin-site-session.test.ts @@ -3,6 +3,7 @@ import test from 'node:test' import { clearHostedSession, + HOSTED_ACCOUNT_REQUIRED_MESSAGE, hostedCreate, hostedDefault, hostedSync, @@ -46,6 +47,10 @@ test('hosted installation sends the JWT to native commands while Local needs no let createdId = 'instance' let instanceExists = false let tokenRequests = 0 + let accounts: Array<{ + account_type: string + yggdrasil?: { api_root: string } + }> = [{ account_type: 'microsoft' }] const frame = { postMessage(data: { type: string; requestId: string }) { if (data.type !== 'starlight-pack-token-request') return @@ -70,6 +75,14 @@ test('hosted installation sends the JWT to native commands while Local needs no __TAURI_INTERNALS__: { async invoke(command: string, args: Record) { calls.push({ command, args }) + if (command === 'plugin:install|hosted_has_game_account') + return accounts.some( + (account) => + account.account_type === 'microsoft' || + (account.account_type === 'yggdrasil' && + account.yggdrasil?.api_root.replace(/\/+$/, '') === + 'https://skin.starlight.cool/yggdrasil'), + ) if (command === 'plugin:install|hosted_sync' && syncResponse) return syncResponse() if (command === 'plugin:instance|instance_get') return instanceExists ? { id: args.instanceId } : null @@ -99,7 +112,13 @@ test('hosted installation sends the JWT to native commands while Local needs no await setInstanceMode('instance', 'starlight') assert.equal(tokenRequests, 4) assert.deepEqual( - calls.map(({ command }) => command), + calls + .map(({ command }) => command) + .filter( + (command) => + command.startsWith('plugin:install|') && + command !== 'plugin:install|hosted_has_game_account', + ), [ 'hosted_set_session', 'hosted_default', @@ -210,9 +229,35 @@ test('hosted installation sends the JWT to native commands while Local needs no assert.equal(tokenRequests, requestsBeforeLogout) const attempts: string[] = [] const stopListening = onHostedPackAttemptStarted((id) => attempts.push(id)) + accounts = [] + const installCallsBeforeAccountFailure = calls.filter( + ({ command }) => + command.startsWith('plugin:install|') && + command !== 'plugin:install|hosted_has_game_account', + ).length + await assert.rejects(hostedSync('failed-instance'), { + message: HOSTED_ACCOUNT_REQUIRED_MESSAGE, + }) + await assert.rejects(hostedCreate(), { + message: HOSTED_ACCOUNT_REQUIRED_MESSAGE, + }) + assert.deepEqual(attempts, []) + assert.equal( + calls.filter( + ({ command }) => + command.startsWith('plugin:install|') && + command !== 'plugin:install|hosted_has_game_account', + ).length, + installCallsBeforeAccountFailure, + ) + const unauthenticatedCreation = useHostedCreation() + await unauthenticatedCreation.install() + assert.equal(unauthenticatedCreation.installError.value, HOSTED_ACCOUNT_REQUIRED_MESSAGE) + assert.equal(unauthenticatedCreation.createdInstance.value, undefined) + accounts = [{ account_type: 'microsoft' }] const unauthenticatedRetry = hostedSync('failed-instance') - assert.deepEqual(attempts, ['failed-instance']) await assert.rejects(unauthenticatedRetry) + assert.deepEqual(attempts, ['failed-instance']) stopListening() await assert.rejects(hostedSync('failed-instance')) assert.equal(attempts.length, 1) diff --git a/apps/app-frontend/src/composables/useHostedCreation.ts b/apps/app-frontend/src/composables/useHostedCreation.ts index b98158d..58fdfa4 100644 --- a/apps/app-frontend/src/composables/useHostedCreation.ts +++ b/apps/app-frontend/src/composables/useHostedCreation.ts @@ -10,6 +10,10 @@ const createdInstance = ref() const completed = ref(false) let generation = 0 +function errorMessage(cause: unknown) { + return cause instanceof Error ? cause.message : String(cause) +} + export function forgetHostedCreation(instanceId: string) { if (createdInstance.value !== instanceId) return generation++ @@ -27,7 +31,7 @@ export function markHostedCreationCompleted(instanceId: string) { export function markHostedCreationFailed(instanceId: string, cause: unknown) { if (createdInstance.value !== instanceId) return completed.value = false - installError.value = String(cause) + installError.value = errorMessage(cause) } export function useHostedCreation() { @@ -63,7 +67,7 @@ export function useHostedCreation() { } catch (cause) { if (attempt === generation) { if (createdInstance.value) markHostedCreationFailed(createdInstance.value, cause) - else installError.value = String(cause) + else installError.value = errorMessage(cause) } } finally { installing.value = false diff --git a/apps/app-frontend/src/helpers/hosted-packs.ts b/apps/app-frontend/src/helpers/hosted-packs.ts index 4bd66e0..7519a04 100644 --- a/apps/app-frontend/src/helpers/hosted-packs.ts +++ b/apps/app-frontend/src/helpers/hosted-packs.ts @@ -7,6 +7,8 @@ import { waitForSkinSiteSession, } from '../composables/skin-site-session.ts' +export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作' + let sessionUpdate: Promise = Promise.resolve() const attemptListeners = new Set<(instanceId: string) => void>() @@ -21,6 +23,13 @@ function startHostedPackAttempt(instanceId: string) { for (const listener of attemptListeners) listener(instanceId) } +export async function requireHostedGameAccount(): Promise { + const available = await invokeHosted('plugin:install|hosted_has_game_account') + if (!available) { + throw new Error(HOSTED_ACCOUNT_REQUIRED_MESSAGE) + } +} + export function clearHostedSession(): Promise { const update = sessionUpdate .catch(() => {}) @@ -100,13 +109,16 @@ export interface HostedSyncResult { } export const hostedDefault = () => invokeWithSession('plugin:install|hosted_default') -export const hostedCreate = (gameDirRoot?: string | null) => - invokeWithSession('plugin:install|hosted_create', { +export const hostedCreate = async (gameDirRoot?: string | null) => { + await requireHostedGameAccount() + return invokeWithSession('plugin:install|hosted_create', { gameDirRoot: gameDirRoot ?? null, }) +} export const hostedBinding = (instanceId: string) => invokeHosted('plugin:install|hosted_binding', { instanceId }) -export const hostedSync = (instanceId: string) => { +export const hostedSync = async (instanceId: string) => { + await requireHostedGameAccount() startHostedPackAttempt(instanceId) return invokeWithSession('plugin:install|hosted_sync', { instanceId }) } diff --git a/apps/app/src/api/install.rs b/apps/app/src/api/install.rs index 189a0cd..d5d6b8d 100644 --- a/apps/app/src/api/install.rs +++ b/apps/app/src/api/install.rs @@ -16,6 +16,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { .invoke_handler(tauri::generate_handler![ hosted_default, hosted_set_session, + hosted_has_game_account, hosted_create, hosted_binding, hosted_sync, @@ -65,6 +66,11 @@ pub async fn hosted_default() -> Result { Ok(theseus::pack::hosted::default_publication().await?) } +#[tauri::command] +pub async fn hosted_has_game_account() -> Result { + Ok(theseus::pack::hosted::has_game_account().await?) +} + #[tauri::command] pub async fn hosted_create(game_dir_root: Option) -> Result { Ok(theseus::pack::hosted::create(game_dir_root).await?) diff --git a/packages/app-lib/src/api/pack/hosted.rs b/packages/app-lib/src/api/pack/hosted.rs index 94cb32e..92926ed 100644 --- a/packages/app-lib/src/api/pack/hosted.rs +++ b/packages/app-lib/src/api/pack/hosted.rs @@ -5,8 +5,9 @@ mod transport; use crate::{ State, state::{ - AppliedContentSetPatch, EditInstance, InstanceInstallStage, - InstanceLaunchOverridesPatch, InstanceMode, ModLoader, + AppliedContentSetPatch, Credentials, EditInstance, + InstanceInstallStage, InstanceLaunchOverridesPatch, InstanceMode, + MinecraftAccountType, ModLoader, }, util::fetch::{ DownloadRequest, Integrity, ResourceClass, configured_client, @@ -31,6 +32,9 @@ use tokio::{ }; const API: &str = "https://skin.starlight.cool/starlight/mod/packs"; +const STARLIGHT_YGGDRASIL_API_ROOT: &str = + "https://skin.starlight.cool/yggdrasil"; +const ACCOUNT_REQUIRED_MESSAGE: &str = "您需要登录正版或皮肤站账号后才能操作"; const BINDING: &str = ".starlight-pack.json"; const JOURNAL: &str = ".starlight-pack-pending.json"; const MAX_HOSTED_CONCURRENT_FILES: usize = 8; @@ -426,10 +430,46 @@ pub async fn default_publication() -> crate::Result { }) } +fn is_supported_game_account( + account_type: MinecraftAccountType, + yggdrasil_api_root: Option<&str>, +) -> bool { + account_type == MinecraftAccountType::Microsoft + || (account_type == MinecraftAccountType::Yggdrasil + && yggdrasil_api_root.map(|value| value.trim_end_matches('/')) + == Some(STARLIGHT_YGGDRASIL_API_ROOT)) +} + +async fn has_game_account_in(state: &State) -> crate::Result { + let accounts = Credentials::get_all_without_refresh(&state.pool).await?; + Ok(accounts.values().any(|account| { + is_supported_game_account( + account.account_type, + account + .yggdrasil + .as_ref() + .map(|value| value.api_root.as_str()), + ) + })) +} + +pub async fn has_game_account() -> crate::Result { + let state = State::get().await?; + has_game_account_in(&state).await +} + +async fn require_game_account(state: &State) -> crate::Result<()> { + if !has_game_account_in(state).await? { + return Err(invalid(ACCOUNT_REQUIRED_MESSAGE)); + } + Ok(()) +} + pub async fn create(game_dir_root: Option) -> crate::Result { + let state = State::get().await?; + require_game_account(&state).await?; let publication = default_publication().await?; let runtime = &publication.manifest.runtime; - let state = State::get().await?; // The pack's game files live in their own folder under the chosen root, // e.g. `/`. Avoid a `versions/` layout: that shape // is reserved for externally linked launcher instances and would make the @@ -728,6 +768,8 @@ async fn recover(instance_id: &str) -> crate::Result<()> { } pub async fn synchronize(instance_id: &str) -> crate::Result { + let state = State::get().await?; + require_game_account(&state).await?; let _guard = instance_gate(instance_id).lock_owned().await; if instance_mode(instance_id).await? != InstanceMode::StarLight { return Err(invalid( @@ -1492,6 +1534,27 @@ async fn synchronize_with_progress( #[cfg(test)] mod tests { use super::*; + + #[test] + fn hosted_operations_accept_only_microsoft_or_starlight_accounts() { + assert!(is_supported_game_account( + MinecraftAccountType::Microsoft, + None + )); + assert!(is_supported_game_account( + MinecraftAccountType::Yggdrasil, + Some("https://skin.starlight.cool/yggdrasil/") + )); + assert!(!is_supported_game_account( + MinecraftAccountType::Yggdrasil, + Some("https://auth.example.com/yggdrasil") + )); + assert!(!is_supported_game_account( + MinecraftAccountType::Offline, + None + )); + } + #[tokio::test] async fn running_pack_checks_tag_changes_without_writing_live_files() { let root = tempfile::tempdir().unwrap();