fix: 完善整合包同步与启动器交互
Some checks failed
Axolotl desktop CI / guardrails (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 / 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
Sync LobeHub models / sync (push) Has been cancelled
Some checks failed
Axolotl desktop CI / guardrails (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 / 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
Sync LobeHub models / sync (push) Has been cancelled
This commit is contained in:
@ -92,6 +92,7 @@ import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
|
||||
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import SplashScreen from '@/components/ui/SplashScreen.vue'
|
||||
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
|
||||
import InstancePlayerModal from '@/components/instance/InstancePlayerModal.vue'
|
||||
import WindowControls from '@/components/ui/WindowControls.vue'
|
||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||
import { useDropImport } from '@/composables/useDropImport'
|
||||
@ -2154,6 +2155,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<template>
|
||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||
<SkinSiteSessionFrame v-if="stateInitialized" />
|
||||
<InstancePlayerModal v-if="stateInitialized" />
|
||||
<div id="teleports"></div>
|
||||
<div
|
||||
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
|
||||
|
||||
BIN
apps/app-frontend/src/assets/illustrations/instance-thinking.png
Normal file
BIN
apps/app-frontend/src/assets/illustrations/instance-thinking.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 740 KiB |
@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
||||
import {
|
||||
openSkinSiteLogin,
|
||||
requestSkinSitePlayers,
|
||||
skinSitePlayers,
|
||||
skinSiteStatus,
|
||||
skinSiteUser,
|
||||
} from '@/composables/skin-site-session'
|
||||
import { users } from '@/helpers/auth'
|
||||
import { getInstanceMode } from '@/helpers/hosted-packs'
|
||||
import {
|
||||
registerInstancePlayerPicker,
|
||||
saveInstancePlayer,
|
||||
waitForSkinSiteSession,
|
||||
type InstancePlayer,
|
||||
type PlayerChoice,
|
||||
} from '@/helpers/instance-player'
|
||||
import { playerMessages as messages } from './instance-player-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const microsoftLogin = ref<InstanceType<typeof MinecraftLoginModal>>()
|
||||
const router = useRouter()
|
||||
const accounts = ref<PlayerChoice[]>([])
|
||||
const active = ref(false)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const instanceId = ref('')
|
||||
const locked = ref<InstancePlayer | null>(null)
|
||||
const awaitingSkinLogin = ref(false)
|
||||
let hideForLogin = false
|
||||
let generation = 0
|
||||
let resolveSelection: ((player: InstancePlayer) => void) | undefined
|
||||
let rejectSelection: ((reason: Error) => void) | undefined
|
||||
const choices = computed(() => {
|
||||
const skin: PlayerChoice[] =
|
||||
skinSiteStatus.value === 'signed-in'
|
||||
? skinSitePlayers.value
|
||||
.filter((player) => !player.isMojang)
|
||||
.map((player) => ({
|
||||
id: player.uuid,
|
||||
name: player.name,
|
||||
account_type: 'yggdrasil',
|
||||
skin_site_user: skinSiteUser.value?.uuid,
|
||||
head: player.headDataUrl,
|
||||
}))
|
||||
: []
|
||||
return [...skin, ...accounts.value].filter(
|
||||
(player) =>
|
||||
!locked.value ||
|
||||
(player.id.replaceAll('-', '') === locked.value.id.replaceAll('-', '') &&
|
||||
player.account_type === locked.value.account_type &&
|
||||
(player.skin_site_user ?? null) === (locked.value.skin_site_user ?? null)),
|
||||
)
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
const revision = ++generation
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [available, mode] = await Promise.all([users(), getInstanceMode(instanceId.value)])
|
||||
if (revision !== generation) return
|
||||
accounts.value = available
|
||||
.filter(
|
||||
(account) =>
|
||||
account.account_type === 'microsoft' ||
|
||||
(mode === 'local' && account.account_type === 'offline'),
|
||||
)
|
||||
.map((account) => ({
|
||||
id: account.profile.id,
|
||||
name: account.profile.name,
|
||||
account_type: account.account_type,
|
||||
}))
|
||||
await waitForSkinSiteSession()
|
||||
if (revision !== generation) return
|
||||
if (skinSiteStatus.value === 'signed-in') await requestSkinSitePlayers()
|
||||
if (skinSiteStatus.value === 'error') throw new Error('无法确认皮肤站登录状态,请重试。')
|
||||
} catch (cause) {
|
||||
if (revision === generation)
|
||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
} finally {
|
||||
if (revision === generation) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelled() {
|
||||
if (hideForLogin) {
|
||||
hideForLogin = false
|
||||
return
|
||||
}
|
||||
if (!active.value) return
|
||||
active.value = false
|
||||
awaitingSkinLogin.value = false
|
||||
generation++
|
||||
rejectSelection?.(new Error('已取消选择实例玩家'))
|
||||
resolveSelection = undefined
|
||||
rejectSelection = undefined
|
||||
}
|
||||
|
||||
async function select(player: PlayerChoice) {
|
||||
if (loading.value || saving.value || !active.value) return
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const { head: _, ...binding } = player
|
||||
await saveInstancePlayer(instanceId.value, binding)
|
||||
active.value = false
|
||||
resolveSelection?.(binding)
|
||||
resolveSelection = undefined
|
||||
rejectSelection = undefined
|
||||
modal.value?.hide()
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function signInSkinSite() {
|
||||
awaitingSkinLogin.value = true
|
||||
hideForLogin = true
|
||||
openSkinSiteLogin()
|
||||
modal.value?.hide()
|
||||
void router.push('/starlight-skin')
|
||||
}
|
||||
|
||||
const unregister = registerInstancePlayerPicker((id, saved) => {
|
||||
if (active.value) return Promise.reject(new Error('请先完成当前实例的玩家选择。'))
|
||||
instanceId.value = id
|
||||
locked.value = saved
|
||||
accounts.value = []
|
||||
awaitingSkinLogin.value = false
|
||||
active.value = true
|
||||
modal.value?.show()
|
||||
void refresh()
|
||||
return new Promise((resolve, reject) => {
|
||||
resolveSelection = resolve
|
||||
rejectSelection = reject
|
||||
})
|
||||
})
|
||||
watch([skinSiteStatus, () => skinSiteUser.value?.uuid], () => {
|
||||
if (!active.value || saving.value) return
|
||||
if (
|
||||
awaitingSkinLogin.value &&
|
||||
skinSiteStatus.value === 'signed-in' &&
|
||||
(!locked.value?.skin_site_user || locked.value.skin_site_user === skinSiteUser.value?.uuid)
|
||||
) {
|
||||
awaitingSkinLogin.value = false
|
||||
modal.value?.show()
|
||||
}
|
||||
void refresh()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
unregister()
|
||||
cancelled()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.title)"
|
||||
:closable="!saving"
|
||||
:on-hide="cancelled"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="m-0 text-secondary">
|
||||
{{
|
||||
locked
|
||||
? formatMessage(messages.restore, { name: locked.name })
|
||||
: formatMessage(messages.remember)
|
||||
}}
|
||||
</p>
|
||||
<p v-if="loading" class="m-0" role="status">{{ formatMessage(messages.loading) }}</p>
|
||||
<template v-else>
|
||||
<ButtonStyled v-for="player in choices" :key="`${player.account_type}:${player.id}`">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
class="flex items-center gap-3"
|
||||
@click="select(player)"
|
||||
>
|
||||
<img
|
||||
v-if="player.head"
|
||||
:src="player.head"
|
||||
alt=""
|
||||
class="size-8 rounded-md [image-rendering:pixelated]"
|
||||
/>
|
||||
<span>{{ player.name }}</span
|
||||
><span class="text-secondary">{{
|
||||
formatMessage(
|
||||
player.account_type === 'microsoft'
|
||||
? messages.licensed
|
||||
: player.account_type === 'offline'
|
||||
? messages.offline
|
||||
: messages.skin,
|
||||
)
|
||||
}}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p v-if="!choices.length && !error && !locked" class="m-0 text-secondary">
|
||||
{{ formatMessage(skinSiteStatus === 'signed-in' ? messages.empty : messages.signIn) }}
|
||||
</p>
|
||||
</template>
|
||||
<p v-if="error" class="m-0 text-red" role="alert">{{ error }}</p>
|
||||
<ButtonStyled v-if="error"
|
||||
><button :disabled="loading || saving" @click="refresh">
|
||||
{{ formatMessage(messages.retry) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<ButtonStyled
|
||||
v-if="
|
||||
skinSiteStatus === 'signed-out' ||
|
||||
(locked?.skin_site_user && locked.skin_site_user !== skinSiteUser?.uuid)
|
||||
"
|
||||
><button :disabled="saving" @click="signInSkinSite">
|
||||
{{ formatMessage(messages.skinLogin) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<span class="text-secondary">{{ formatMessage(messages.or) }}</span>
|
||||
<ButtonStyled
|
||||
><button :disabled="loading || saving" @click="microsoftLogin?.showDeviceLogin()">
|
||||
{{ formatMessage(messages.useMicrosoft) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div>
|
||||
<p v-if="saving" class="m-0" role="status">{{ formatMessage(messages.saving) }}</p>
|
||||
</div>
|
||||
</NewModal>
|
||||
<MinecraftLoginModal ref="microsoftLogin" @complete="refresh" />
|
||||
</template>
|
||||
@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, useVIntl } from '@modrinth/ui'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import {
|
||||
chooseInstancePlayer,
|
||||
getInstancePlayer,
|
||||
onInstancePlayerChanged,
|
||||
type InstancePlayer,
|
||||
} from '@/helpers/instance-player'
|
||||
import { playerMessages as messages } from './instance-player-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{ instanceId: string }>()
|
||||
const player = ref<InstancePlayer | null>(null)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
let generation = 0
|
||||
const stop = onInstancePlayerChanged((id, saved) => {
|
||||
if (id === props.instanceId) {
|
||||
generation++
|
||||
player.value = saved
|
||||
busy.value = false
|
||||
error.value = ''
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
generation++
|
||||
stop()
|
||||
})
|
||||
watch(
|
||||
() => props.instanceId,
|
||||
async (id) => {
|
||||
const revision = ++generation
|
||||
player.value = null
|
||||
error.value = ''
|
||||
busy.value = true
|
||||
try {
|
||||
const saved = await getInstancePlayer(id)
|
||||
if (generation === revision) player.value = saved
|
||||
} catch (cause) {
|
||||
if (generation === revision) error.value = String(cause)
|
||||
} finally {
|
||||
if (generation === revision) busy.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
async function change() {
|
||||
if (busy.value) return
|
||||
const revision = generation
|
||||
const id = props.instanceId
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const saved = await chooseInstancePlayer(id)
|
||||
if (generation === revision) player.value = saved
|
||||
} catch (cause) {
|
||||
if (generation === revision)
|
||||
error.value = cause instanceof Error ? cause.message : String(cause)
|
||||
} finally {
|
||||
if (generation === revision) busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="mb-6 flex flex-col gap-2" data-onboarding-id="instance-player-settings">
|
||||
<h3 class="m-0 text-contrast">{{ formatMessage(messages.setting) }}</h3>
|
||||
<p class="m-0 text-secondary">
|
||||
{{ player ? player.name : formatMessage(messages.firstLaunch) }}
|
||||
</p>
|
||||
<ButtonStyled
|
||||
><button :disabled="busy" @click="change">
|
||||
{{ formatMessage(busy ? messages.loading : messages.change) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<p v-if="error" class="m-0 text-red" role="alert">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@ -133,14 +133,18 @@ function openDownloads() {
|
||||
</section>
|
||||
</div>
|
||||
<template #actions>
|
||||
<ButtonStyled
|
||||
><button @click="openDownloads">
|
||||
{{ formatMessage(messages.downloads) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
<ButtonStyled
|
||||
><button @click="modal?.hide()">{{ formatMessage(messages.close) }}</button></ButtonStyled
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="openDownloads">
|
||||
{{ formatMessage(messages.downloads) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modal?.hide()">
|
||||
{{ formatMessage(messages.close) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
import { defineMessages } from '@modrinth/ui'
|
||||
|
||||
export const playerMessages = defineMessages({
|
||||
title: { id: 'app.instance-player.title', defaultMessage: 'Choose an instance player' },
|
||||
remember: {
|
||||
id: 'app.instance-player.remember',
|
||||
defaultMessage:
|
||||
'This instance will keep using your choice. Switch players in instance settings.',
|
||||
},
|
||||
restore: {
|
||||
id: 'app.instance-player.restore',
|
||||
defaultMessage: 'Sign in again as {name}. To choose someone else, open instance settings.',
|
||||
},
|
||||
loading: { id: 'app.instance-player.loading', defaultMessage: 'Loading signed-in players…' },
|
||||
licensed: { id: 'app.instance-player.licensed', defaultMessage: 'Minecraft account' },
|
||||
skin: { id: 'app.instance-player.skin', defaultMessage: 'Skin site player' },
|
||||
offline: { id: 'app.instance-player.offline', defaultMessage: 'Offline player' },
|
||||
empty: {
|
||||
id: 'app.instance-player.empty',
|
||||
defaultMessage: 'No available skin site players. You can create a player on the skin site.',
|
||||
},
|
||||
signIn: { id: 'app.instance-player.sign-in', defaultMessage: 'Sign in to choose a player.' },
|
||||
retry: { id: 'app.instance-player.retry', defaultMessage: 'Retry' },
|
||||
skinLogin: { id: 'app.instance-player.skin-login', defaultMessage: 'Sign in to skin site' },
|
||||
or: { id: 'app.instance-player.or', defaultMessage: 'Or' },
|
||||
useMicrosoft: {
|
||||
id: 'app.instance-player.use-microsoft',
|
||||
defaultMessage: 'Use a Minecraft account',
|
||||
},
|
||||
saving: { id: 'app.instance-player.saving', defaultMessage: 'Saving instance player…' },
|
||||
setting: { id: 'app.instance-player.setting', defaultMessage: 'Instance player' },
|
||||
firstLaunch: {
|
||||
id: 'app.instance-player.first-launch',
|
||||
defaultMessage: 'Choose a player on first launch',
|
||||
},
|
||||
change: { id: 'app.instance-player.change', defaultMessage: 'Switch instance player' },
|
||||
})
|
||||
@ -22,7 +22,8 @@ const { formatMessage } = useVIntl()
|
||||
const viewport = ref<HTMLElement>()
|
||||
const grid = ref<HTMLElement>()
|
||||
const view = ref({ left: 0, top: 0, width: 0, height: 0 })
|
||||
const brushCursor = ref({ x: 0, y: 0, color: '', visible: false })
|
||||
const brushCursor = ref<string>()
|
||||
const brushActive = computed(() => props.playing && props.selected >= 0)
|
||||
const large = computed(() => props.puzzle.difficulty !== 'easy')
|
||||
const visibleCells = computed(() =>
|
||||
props.puzzle.answer.flatMap((color, i) =>
|
||||
@ -87,37 +88,21 @@ function onWheel(event: WheelEvent) {
|
||||
void changeZoom(props.zoom + (event.deltaY < 0 ? 0.1 : -0.1))
|
||||
}
|
||||
|
||||
function moveBrushCursor(event: PointerEvent) {
|
||||
if (event.pointerType === 'touch') {
|
||||
hideBrushCursor()
|
||||
return
|
||||
}
|
||||
const cell = (event.target as Element | null)?.closest<HTMLButtonElement>('.mine-cell')
|
||||
const visible = Boolean(
|
||||
props.selected >= 0 && cell && !cell.disabled && !cell.classList.contains('mine-open'),
|
||||
)
|
||||
brushCursor.value = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
color: visible ? getComputedStyle(event.currentTarget as HTMLElement).color : '',
|
||||
visible,
|
||||
}
|
||||
let cursorColor = ''
|
||||
function updateBrushCursor() {
|
||||
if (!brushActive.value || !grid.value) return
|
||||
const color = getComputedStyle(grid.value).color
|
||||
if (color === cursorColor) return
|
||||
cursorColor = color
|
||||
// Let the native cursor follow the pointer independently of Vue and board rendering.
|
||||
// The hotspot is the same brush tip as the previous 32px floating SVG.
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="${color}" d="M358.681 586.386s-90.968 49.4-94.488 126.827c-3.519 77.428-77.427 133.74-102.063 140.778s360.157 22.971 332.002-142.444l-135.45-125.16zm169.099 52.56c14.016 13.601 17.565 32.675 7.929 42.606-9.635 9.93-28.81 6.954-42.823-6.647l-92.767-88.518c-14.015-13.6-17.565-32.675-7.929-42.605 9.636-9.93 28.81-6.955 42.824 6.646l92.766 88.518zm321.734-465.083c-25.144-17.055-47.741-1.763-57.477 3.805-29.097 19.485-237.243 221.77-327.69 315.194-11.105 14.8-18.59 26.294 34.663 79.546 44.95 44.95 65.896 42.012 88.66 22.603 37.906-37.906 199.299-262.926 258.92-348.713 9.792-14.092 29.851-54.17 2.924-72.435z"/></svg>`
|
||||
brushCursor.value = `url("data:image/svg+xml,${encodeURIComponent(svg)}") 5 27, crosshair`
|
||||
}
|
||||
|
||||
function hideBrushCursor() {
|
||||
brushCursor.value.visible = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.playing, props.selected] as const,
|
||||
([playing, selected]) => {
|
||||
if (!playing || selected < 0) hideBrushCursor()
|
||||
else if (brushCursor.value.visible && grid.value) {
|
||||
brushCursor.value.color = getComputedStyle(grid.value).color
|
||||
}
|
||||
},
|
||||
{ flush: 'post' },
|
||||
)
|
||||
watch(() => [props.playing, props.selected], updateBrushCursor, {
|
||||
flush: 'post',
|
||||
})
|
||||
|
||||
function label(index: number) {
|
||||
const values = {
|
||||
@ -135,6 +120,7 @@ function label(index: number) {
|
||||
|
||||
let observer: ResizeObserver | undefined
|
||||
onMounted(() => {
|
||||
updateBrushCursor()
|
||||
observer = new ResizeObserver(updateView)
|
||||
if (viewport.value) observer.observe(viewport.value)
|
||||
})
|
||||
@ -142,7 +128,10 @@ onScopeDispose(() => observer?.disconnect())
|
||||
defineExpose({
|
||||
centerFirstClue,
|
||||
setView,
|
||||
getView: () => ({ left: viewport.value?.scrollLeft ?? 0, top: viewport.value?.scrollTop ?? 0 }),
|
||||
getView: () => ({
|
||||
left: viewport.value?.scrollLeft ?? 0,
|
||||
top: viewport.value?.scrollTop ?? 0,
|
||||
}),
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -152,16 +141,18 @@ defineExpose({
|
||||
<div
|
||||
ref="grid"
|
||||
class="mine-grid"
|
||||
:class="{ 'mine-grid-small': !large, 'mine-grid-brush-active': selected >= 0 }"
|
||||
:class="{
|
||||
'mine-grid-small': !large,
|
||||
'mine-grid-brush-active': brushActive,
|
||||
}"
|
||||
:style="{
|
||||
'--mine-size': puzzle.size,
|
||||
'--mine-cell-size': `${40 * zoom}px`,
|
||||
'--mine-brush-cursor': brushCursor,
|
||||
color: selected >= 0 ? `var(--mine-color-${selected})` : undefined,
|
||||
}"
|
||||
:aria-label="formatMessage(messages.board, { size: puzzle.size })"
|
||||
@pointermove="moveBrushCursor"
|
||||
@pointerleave="hideBrushCursor"
|
||||
@pointercancel="hideBrushCursor"
|
||||
@pointerenter="updateBrushCursor"
|
||||
>
|
||||
<button
|
||||
v-for="(_, i) in puzzle.answer"
|
||||
@ -190,24 +181,6 @@ defineExpose({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<svg
|
||||
v-show="brushCursor.visible"
|
||||
class="mine-brush-cursor"
|
||||
:style="{
|
||||
left: `${brushCursor.x}px`,
|
||||
top: `${brushCursor.y}px`,
|
||||
color: brushCursor.color,
|
||||
}"
|
||||
viewBox="0 0 1024 1024"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M358.681 586.386s-90.968 49.4-94.488 126.827c-3.519 77.428-77.427 133.74-102.063 140.778s360.157 22.971 332.002-142.444l-135.45-125.16zm169.099 52.56c14.016 13.601 17.565 32.675 7.929 42.606-9.635 9.93-28.81 6.954-42.823-6.647l-92.767-88.518c-14.015-13.6-17.565-32.675-7.929-42.605 9.636-9.93 28.81-6.955 42.824 6.646l92.766 88.518zm321.734-465.083c-25.144-17.055-47.741-1.763-57.477 3.805-29.097 19.485-237.243 221.77-327.69 315.194-11.105 14.8-18.59 26.294 34.663 79.546 44.95 44.95 65.896 42.012 88.66 22.603 37.906-37.906 199.299-262.926 258.92-348.713 9.792-14.092 29.851-54.17 2.924-72.435z"
|
||||
/>
|
||||
</svg>
|
||||
</Teleport>
|
||||
<div v-if="large" class="mine-overview">
|
||||
<svg
|
||||
class="mine-map"
|
||||
@ -260,7 +233,10 @@ defineExpose({
|
||||
|
||||
<style scoped>
|
||||
.mine-navigation {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: grid;
|
||||
gap: var(--gap-md);
|
||||
}
|
||||
@ -268,6 +244,9 @@ defineExpose({
|
||||
grid-template-columns: minmax(0, 1fr) 7rem;
|
||||
}
|
||||
.mine-viewport {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
max-height: min(52vh, 32rem);
|
||||
min-width: 0;
|
||||
@ -308,17 +287,9 @@ defineExpose({
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.mine-grid-brush-active,
|
||||
.mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) {
|
||||
cursor: none;
|
||||
}
|
||||
.mine-brush-cursor {
|
||||
position: fixed;
|
||||
z-index: 2147483647;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 45%));
|
||||
pointer-events: none;
|
||||
transform: translate(-16%, -84%);
|
||||
cursor: var(--mine-brush-cursor, crosshair);
|
||||
}
|
||||
.mine-cell:hover:not(:disabled) {
|
||||
background: var(--surface-5);
|
||||
@ -392,12 +363,4 @@ defineExpose({
|
||||
width: 4.5rem;
|
||||
}
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.mine-brush-cursor {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -301,6 +301,7 @@ defineExpose({ show })
|
||||
:header="formatMessage(messages.title)"
|
||||
width="56rem"
|
||||
max-width="56rem"
|
||||
scrollable
|
||||
:closable="false"
|
||||
:close-on-esc="false"
|
||||
actions-divider
|
||||
@ -455,15 +456,21 @@ defineExpose({ show })
|
||||
justify-content: space-between;
|
||||
}
|
||||
.mine-frame {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border: 2px solid var(--mine-ink);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--gap-md);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.mine-status {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
margin: 0 0 var(--gap-md);
|
||||
color: var(--color-contrast);
|
||||
font-size: 0.875rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mine-actions {
|
||||
justify-content: flex-end;
|
||||
|
||||
@ -18,6 +18,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
|
||||
import InstancePlayerSettings from '@/components/instance/InstancePlayerSettings.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
|
||||
@ -381,6 +382,7 @@ const messages = defineMessages({
|
||||
|
||||
<template>
|
||||
<InstanceModeSettings class="mb-6" :instance-id="instance.id" />
|
||||
<InstancePlayerSettings :instance-id="instance.id" />
|
||||
<ConfirmDeleteInstanceModal
|
||||
ref="deleteConfirmModal"
|
||||
:symlink-target="instance.symlink_target"
|
||||
|
||||
@ -242,7 +242,7 @@ export const onboardingMessages = defineMessages({
|
||||
instanceActionsDescription: {
|
||||
id: 'app.onboarding.instance-actions.description',
|
||||
defaultMessage:
|
||||
'Launch, stop, repair, configure, export, or open the instance from its header.',
|
||||
'Launch or configure this instance here. Choose a player on first launch; the instance remembers your choice until you switch it in settings.',
|
||||
},
|
||||
instanceTabsTitle: {
|
||||
id: 'app.onboarding.instance-tabs.title',
|
||||
|
||||
@ -28,6 +28,17 @@ import {
|
||||
skinSiteUser,
|
||||
} from './skin-site-session.ts'
|
||||
|
||||
test('reconnecting the same skin site frame waits for verification instead of reporting signed out', () => {
|
||||
const frame = { postMessage() {} } as unknown as Window
|
||||
resetSkinSiteSession()
|
||||
setSkinSiteFrame(frame)
|
||||
resetSkinSiteSession()
|
||||
setSkinSiteFrame(frame)
|
||||
assert.equal(skinSiteStatus.value, 'checking')
|
||||
setSkinSiteFrame(null)
|
||||
resetSkinSiteSession()
|
||||
})
|
||||
|
||||
test('hosted installation sends the JWT to native commands while Local needs no session', async () => {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||
const calls: Array<{ command: string; args: Record<string, unknown> }> = []
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
import { readonly, ref, watch } from 'vue'
|
||||
|
||||
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
|
||||
export type SkinSiteUser = { uuid: string; username: string }
|
||||
@ -113,6 +113,22 @@ export const skinSitePlayersStatus = readonly(playersStatus)
|
||||
export const selectedSkinSitePlayerId = readonly(selectedPlayerId)
|
||||
export const skinSiteFrameUrl = readonly(frameUrl)
|
||||
|
||||
export async function waitForSkinSiteSession() {
|
||||
if (status.value !== 'checking') return
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stop = watch(status, next => {
|
||||
if (next === 'checking') return
|
||||
clearTimeout(timer)
|
||||
stop()
|
||||
resolve()
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
stop()
|
||||
reject(new Error('皮肤站登录状态仍在检查,请稍后重试。'))
|
||||
}, 10_000)
|
||||
})
|
||||
}
|
||||
|
||||
function rejectPendingLuckRequests(message: string) {
|
||||
for (const request of pendingLuckRequests.values()) {
|
||||
clearTimeout(request.timeout)
|
||||
@ -138,6 +154,7 @@ function rejectPendingSkinUpdateRequests(message: string) {
|
||||
}
|
||||
|
||||
export function setSkinSiteFrame(frame: Window | null) {
|
||||
if (frame && !user.value) status.value = 'checking'
|
||||
if (connectedFrame === frame) return
|
||||
rejectPendingPackTokens()
|
||||
rejectPendingLuckRequests('The skin site connection changed.')
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
requestSkinSiteDownloadToken,
|
||||
skinSiteStatus,
|
||||
skinSiteUser,
|
||||
waitForSkinSiteSession,
|
||||
} from '../composables/skin-site-session.ts'
|
||||
|
||||
let sessionUpdate: Promise<void> = Promise.resolve()
|
||||
@ -28,6 +29,7 @@ export function clearHostedSession(): Promise<void> {
|
||||
}
|
||||
|
||||
export async function prepareHostedSession(): Promise<void> {
|
||||
await waitForSkinSiteSession()
|
||||
const userId = skinSiteUser.value?.uuid
|
||||
const token = await requestSkinSiteDownloadToken()
|
||||
const update = sessionUpdate
|
||||
|
||||
164
apps/app-frontend/src/helpers/instance-player.test.ts
Normal file
164
apps/app-frontend/src/helpers/instance-player.test.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { useNetworkStatus } from '../composables/useNetworkStatus.ts'
|
||||
import {
|
||||
setSkinSiteFrame,
|
||||
resetSkinSiteSession,
|
||||
receiveSkinSiteMessage,
|
||||
SKIN_SITE_ORIGIN,
|
||||
} from '../composables/skin-site-session.ts'
|
||||
import {
|
||||
prepareInstancePlayer,
|
||||
registerInstancePlayerPicker,
|
||||
saveInstancePlayer,
|
||||
onInstancePlayerChanged,
|
||||
type InstancePlayer,
|
||||
} from './instance-player.ts'
|
||||
|
||||
test('instance player selection persists independently from the globally selected account', async () => {
|
||||
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
||||
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
|
||||
useNetworkStatus().refreshBrowserOffline()
|
||||
const saved: InstancePlayer = { id: 'saved', name: 'Saved', account_type: 'microsoft' }
|
||||
let binding: InstancePlayer | null = saved
|
||||
let accounts = [
|
||||
{ profile: { id: 'other', name: 'Other' }, account_type: 'microsoft' },
|
||||
{ profile: { id: 'saved', name: 'Saved' }, account_type: 'microsoft' },
|
||||
]
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
__TAURI_INTERNALS__: {
|
||||
invoke: async (command: string) => {
|
||||
if (command === 'plugin:auth|get_instance_player') return binding
|
||||
if (command === 'plugin:auth|get_users') return accounts
|
||||
throw new Error(`Unexpected command ${command}`)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const requests: Array<InstancePlayer | null> = []
|
||||
const unregister = registerInstancePlayerPicker(async (_id, locked) => {
|
||||
requests.push(locked)
|
||||
binding = saved
|
||||
return saved
|
||||
})
|
||||
try {
|
||||
setSkinSiteFrame({ postMessage() {} } as unknown as Window)
|
||||
await prepareInstancePlayer('instance')
|
||||
assert.equal(requests.length, 0)
|
||||
setSkinSiteFrame(null)
|
||||
binding = null
|
||||
await Promise.all([prepareInstancePlayer('instance'), prepareInstancePlayer('instance')])
|
||||
assert.deepEqual(requests, [null])
|
||||
await prepareInstancePlayer('instance')
|
||||
assert.equal(requests.length, 1)
|
||||
accounts = accounts.filter((account) => account.profile.id !== saved.id)
|
||||
await prepareInstancePlayer('instance')
|
||||
assert.equal(
|
||||
requests[1],
|
||||
saved,
|
||||
'Missing saved player must remain locked, not fall back to the other account',
|
||||
)
|
||||
} finally {
|
||||
unregister()
|
||||
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
|
||||
else Reflect.deleteProperty(globalThis, 'navigator')
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous)
|
||||
else Reflect.deleteProperty(globalThis, 'window')
|
||||
}
|
||||
})
|
||||
|
||||
test('signed-in skin site renews the saved player without prompting and only notifies after saving', async () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window')
|
||||
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
|
||||
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { onLine: true } })
|
||||
useNetworkStatus().refreshBrowserOffline()
|
||||
const player: InstancePlayer = {
|
||||
id: '12345678-1234-1234-1234-123456789abc',
|
||||
name: 'Skin',
|
||||
account_type: 'yggdrasil',
|
||||
skin_site_user: 'owner',
|
||||
}
|
||||
const calls: string[] = []
|
||||
let failSave = false
|
||||
const frame = {
|
||||
postMessage(data: { type: string; requestId: string }) {
|
||||
if (data.type === 'starlight-pack-token-request')
|
||||
receiveSkinSiteMessage(
|
||||
{
|
||||
origin: SKIN_SITE_ORIGIN,
|
||||
source: frame,
|
||||
data: {
|
||||
type: 'starlight-pack-token-result',
|
||||
requestId: data.requestId,
|
||||
token: 'fixture.jwt',
|
||||
},
|
||||
} as unknown as MessageEvent,
|
||||
frame,
|
||||
)
|
||||
},
|
||||
} as unknown as Window
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
__TAURI_INTERNALS__: {
|
||||
async invoke(command: string, args: Record<string, unknown>) {
|
||||
calls.push(command)
|
||||
if (command.endsWith('get_instance_player')) return player
|
||||
if (command.endsWith('login_skin_site_player')) {
|
||||
assert.equal(args.playerId, player.id)
|
||||
assert.equal(args.userId, 'owner')
|
||||
assert.equal(args.token, 'fixture.jwt')
|
||||
} else if (command.endsWith('set_instance_player') && failSave)
|
||||
throw new Error('Save failed')
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
let prompts = 0
|
||||
const unregister = registerInstancePlayerPicker(async (_id, locked) => {
|
||||
prompts++
|
||||
assert.equal(locked, player)
|
||||
return player
|
||||
})
|
||||
const changes: string[] = []
|
||||
const stop = onInstancePlayerChanged((id) => changes.push(id))
|
||||
try {
|
||||
resetSkinSiteSession()
|
||||
setSkinSiteFrame(frame)
|
||||
receiveSkinSiteMessage(
|
||||
{
|
||||
origin: SKIN_SITE_ORIGIN,
|
||||
source: frame,
|
||||
data: {
|
||||
type: 'starlight-skin-session',
|
||||
status: 'signed-in',
|
||||
user: { uuid: 'owner', username: 'Owner' },
|
||||
},
|
||||
} as unknown as MessageEvent,
|
||||
frame,
|
||||
)
|
||||
await prepareInstancePlayer('one')
|
||||
assert.equal(prompts, 0)
|
||||
assert.ok(calls.includes('plugin:auth|login_skin_site_player'))
|
||||
await saveInstancePlayer('one', player)
|
||||
assert.deepEqual(changes, ['one'])
|
||||
failSave = true
|
||||
await assert.rejects(saveInstancePlayer('two', player), /Save failed/)
|
||||
assert.deepEqual(changes, ['one'])
|
||||
resetSkinSiteSession()
|
||||
await prepareInstancePlayer('one')
|
||||
assert.equal(prompts, 1)
|
||||
} finally {
|
||||
stop()
|
||||
unregister()
|
||||
resetSkinSiteSession()
|
||||
setSkinSiteFrame(null)
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous)
|
||||
else Reflect.deleteProperty(globalThis, 'window')
|
||||
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator)
|
||||
else Reflect.deleteProperty(globalThis, 'navigator')
|
||||
}
|
||||
})
|
||||
104
apps/app-frontend/src/helpers/instance-player.ts
Normal file
104
apps/app-frontend/src/helpers/instance-player.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
import {
|
||||
requestSkinSiteDownloadToken,
|
||||
skinSiteStatus,
|
||||
skinSiteUser,
|
||||
waitForSkinSiteSession,
|
||||
} 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'
|
||||
|
||||
export type InstancePlayer = {
|
||||
id: string
|
||||
name: string
|
||||
account_type: 'microsoft' | 'yggdrasil' | 'offline'
|
||||
skin_site_user?: string | null
|
||||
}
|
||||
export type PlayerChoice = InstancePlayer & { head?: string }
|
||||
type Picker = (instanceId: string, locked: InstancePlayer | null) => Promise<InstancePlayer>
|
||||
let picker: Picker | undefined
|
||||
const preparing = new Map<string, Promise<void>>()
|
||||
const listeners = new Set<(instanceId: string, player: InstancePlayer) => void>()
|
||||
|
||||
export function onInstancePlayerChanged(
|
||||
listener: (instanceId: string, player: InstancePlayer) => void,
|
||||
) {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
export function registerInstancePlayerPicker(value: Picker) {
|
||||
picker = value
|
||||
return () => {
|
||||
if (picker === value) picker = undefined
|
||||
}
|
||||
}
|
||||
export const getInstancePlayer = (instanceId: string) =>
|
||||
invoke<InstancePlayer | null>('plugin:auth|get_instance_player', { instanceId })
|
||||
|
||||
export async function authenticateInstancePlayer(player: InstancePlayer) {
|
||||
if (!player.skin_site_user) return
|
||||
if (skinSiteStatus.value !== 'signed-in' || skinSiteUser.value?.uuid !== player.skin_site_user) {
|
||||
throw new Error(`请登录实例玩家 ${player.name} 所属的皮肤站账号。`)
|
||||
}
|
||||
const token = await requestSkinSiteDownloadToken()
|
||||
await invoke('plugin:auth|login_skin_site_player', {
|
||||
token,
|
||||
playerId: player.id,
|
||||
userId: player.skin_site_user,
|
||||
})
|
||||
if (skinSiteUser.value?.uuid !== player.skin_site_user || skinSiteStatus.value !== 'signed-in') {
|
||||
throw new Error('皮肤站账号已变化,请重试。')
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveInstancePlayer(instanceId: string, player: InstancePlayer) {
|
||||
await authenticateInstancePlayer(player)
|
||||
await invoke('plugin:auth|set_instance_player', { instanceId, player })
|
||||
for (const listener of listeners) listener(instanceId, player)
|
||||
}
|
||||
|
||||
export async function chooseInstancePlayer(
|
||||
instanceId: string,
|
||||
locked: InstancePlayer | null = null,
|
||||
) {
|
||||
if (!picker) throw new Error('玩家选择界面尚未就绪,请稍后重试。')
|
||||
return picker(instanceId, locked)
|
||||
}
|
||||
|
||||
export function prepareInstancePlayer(instanceId: string): Promise<void> {
|
||||
if (isOfflineMode()) return Promise.resolve()
|
||||
const current = preparing.get(instanceId)
|
||||
if (current) return current
|
||||
const task = (async () => {
|
||||
const saved = await getInstancePlayer(instanceId)
|
||||
if (saved && !saved.skin_site_user) {
|
||||
const available = await users()
|
||||
if (
|
||||
available.some(
|
||||
(account) =>
|
||||
account.profile.id === saved.id && account.account_type === saved.account_type,
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (saved?.skin_site_user) await waitForSkinSiteSession()
|
||||
if (
|
||||
saved?.skin_site_user &&
|
||||
skinSiteUser.value?.uuid === saved.skin_site_user &&
|
||||
skinSiteStatus.value === 'signed-in'
|
||||
) {
|
||||
await authenticateInstancePlayer(saved)
|
||||
return
|
||||
}
|
||||
await chooseInstancePlayer(instanceId, saved)
|
||||
})().finally(() => {
|
||||
preparing.delete(instanceId)
|
||||
})
|
||||
preparing.set(instanceId, task)
|
||||
return task
|
||||
}
|
||||
@ -18,6 +18,7 @@ import { detectGcStrategy, GC_STRATEGY_DEFINITIONS } from '@/helpers/gc/strategi
|
||||
import type { GcContext, ResolvedGcStrategyId } from '@/helpers/gc/types'
|
||||
import { setLastGcLaunchReport } from '@/helpers/gc-notice'
|
||||
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
|
||||
import { prepareInstancePlayer } from '@/helpers/instance-player'
|
||||
import { AUTO_GC_PRESET_ARG } from '@/helpers/java-arguments'
|
||||
import { get_jre, get_memory_status } from '@/helpers/jre.js'
|
||||
import { get as getSettings } from '@/helpers/settings'
|
||||
@ -1074,6 +1075,7 @@ export async function run(
|
||||
instanceId: string,
|
||||
serverAddress: string | null = null,
|
||||
): Promise<InstanceRunResult> {
|
||||
await prepareInstancePlayer(instanceId)
|
||||
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
|
||||
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
|
||||
const result = await invoke<InstanceRunResult>('plugin:instance|instance_run', {
|
||||
|
||||
@ -4,6 +4,8 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { isOfflineMode } from '@/composables/useNetworkStatus'
|
||||
import { prepareInstancePlayer } from '@/helpers/instance-player'
|
||||
import { getInstanceMode, prepareHostedSession } from '@/helpers/hosted-packs'
|
||||
import { get_full_path, resolveGcLaunchIntent } from '@/helpers/instance'
|
||||
import { openPath } from '@/helpers/utils'
|
||||
|
||||
@ -248,6 +250,8 @@ export async function start_join_singleplayer_world(
|
||||
instanceId: string,
|
||||
world: string,
|
||||
): Promise<unknown> {
|
||||
await prepareInstancePlayer(instanceId)
|
||||
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
|
||||
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
|
||||
return await invoke('plugin:worlds|start_join_singleplayer_world', {
|
||||
instanceId,
|
||||
@ -259,6 +263,8 @@ export async function start_join_singleplayer_world(
|
||||
}
|
||||
|
||||
export async function start_join_server(instanceId: string, address: string): Promise<unknown> {
|
||||
await prepareInstancePlayer(instanceId)
|
||||
if (await getInstanceMode(instanceId) === 'starlight') await prepareHostedSession()
|
||||
const { args, gcIntent } = await resolveGcLaunchIntent(instanceId)
|
||||
return await invoke('plugin:worlds|start_join_server', {
|
||||
instanceId,
|
||||
|
||||
@ -1,4 +1,21 @@
|
||||
{
|
||||
"app.instance-player.title": { "message": "Choose an instance player" },
|
||||
"app.instance-player.remember": { "message": "This instance will keep using your choice. Switch players in instance settings." },
|
||||
"app.instance-player.restore": { "message": "Sign in again as {name}. To choose someone else, open instance settings." },
|
||||
"app.instance-player.loading": { "message": "Loading signed-in players…" },
|
||||
"app.instance-player.licensed": { "message": "Minecraft account" },
|
||||
"app.instance-player.skin": { "message": "Skin site player" },
|
||||
"app.instance-player.offline": { "message": "Offline player" },
|
||||
"app.instance-player.empty": { "message": "No available skin site players. You can create a player on the skin site." },
|
||||
"app.instance-player.sign-in": { "message": "Sign in to choose a player." },
|
||||
"app.instance-player.retry": { "message": "Retry" },
|
||||
"app.instance-player.skin-login": { "message": "Sign in to skin site" },
|
||||
"app.instance-player.or": { "message": "Or" },
|
||||
"app.instance-player.use-microsoft": { "message": "Use a Minecraft account" },
|
||||
"app.instance-player.saving": { "message": "Saving instance player…" },
|
||||
"app.instance-player.setting": { "message": "Instance player" },
|
||||
"app.instance-player.first-launch": { "message": "Choose a player on first launch" },
|
||||
"app.instance-player.change": { "message": "Switch instance player" },
|
||||
"app.hosted-mods.title": { "message": "Updating server Mods" },
|
||||
"app.hosted-mods.description": { "message": "The game starts after all required updates have been installed." },
|
||||
"app.hosted-mods.complete": { "message": "Downloaded" },
|
||||
@ -5861,7 +5878,7 @@
|
||||
"message": "Switch your home"
|
||||
},
|
||||
"app.onboarding.instance-actions.description": {
|
||||
"message": "Launch, stop, repair, configure, export, or open the instance from its header."
|
||||
"message": "Launch or configure this instance here. Choose a player on first launch; the instance remembers your choice until you switch it in settings."
|
||||
},
|
||||
"app.onboarding.instance-actions.title": {
|
||||
"message": "The main controls"
|
||||
@ -7721,7 +7738,7 @@
|
||||
"message": "Tiny Takeover"
|
||||
},
|
||||
"app.skins.skin-site-account.description": {
|
||||
"message": "Select a skin below and apply it directly to {player}. The skin site session remains available while you move between launcher pages."
|
||||
"message": "Select a skin below and apply it directly to {player}."
|
||||
},
|
||||
"app.skins.skin-site-account.empty-description": {
|
||||
"message": "This skin site account does not have an available player profile yet."
|
||||
|
||||
@ -1,4 +1,21 @@
|
||||
{
|
||||
"app.instance-player.title": { "message": "选择实例玩家" },
|
||||
"app.instance-player.remember": { "message": "选择后,此实例将一直使用该玩家。可在实例设置中切换。" },
|
||||
"app.instance-player.restore": { "message": "请重新登录 {name};要更换玩家,请前往实例设置。" },
|
||||
"app.instance-player.loading": { "message": "正在读取已登录的玩家…" },
|
||||
"app.instance-player.licensed": { "message": "正版玩家" },
|
||||
"app.instance-player.skin": { "message": "皮肤站玩家" },
|
||||
"app.instance-player.offline": { "message": "离线玩家" },
|
||||
"app.instance-player.empty": { "message": "当前账号没有可用的皮肤站玩家,可在皮肤站创建玩家。" },
|
||||
"app.instance-player.sign-in": { "message": "请登录账号后选择玩家。" },
|
||||
"app.instance-player.retry": { "message": "重试" },
|
||||
"app.instance-player.skin-login": { "message": "登录皮肤站" },
|
||||
"app.instance-player.or": { "message": "或者" },
|
||||
"app.instance-player.use-microsoft": { "message": "使用正版账号" },
|
||||
"app.instance-player.saving": { "message": "正在保存实例玩家…" },
|
||||
"app.instance-player.setting": { "message": "实例玩家" },
|
||||
"app.instance-player.first-launch": { "message": "首次启动时选择玩家" },
|
||||
"app.instance-player.change": { "message": "切换实例玩家" },
|
||||
"app.hosted-mods.title": { "message": "正在更新服务器 Mod" },
|
||||
"app.hosted-mods.description": { "message": "所有更新安装完成后将继续启动游戏。" },
|
||||
"app.hosted-mods.complete": { "message": "下载完成" },
|
||||
@ -5947,7 +5964,7 @@
|
||||
"message": "切换主页样式"
|
||||
},
|
||||
"app.onboarding.instance-actions.description": {
|
||||
"message": "从实例页头部启动、停止、修复、配置、导出或打开这个实例。"
|
||||
"message": "从这里启动或配置实例。首次启动选择玩家后会自动记住,之后可在实例设置中切换。"
|
||||
},
|
||||
"app.onboarding.instance-actions.title": {
|
||||
"message": "主操作台"
|
||||
@ -7792,7 +7809,7 @@
|
||||
"message": "小鬼当家"
|
||||
},
|
||||
"app.skins.skin-site-account.description": {
|
||||
"message": "在下方选择皮肤后,可直接应用到 {player}。切换启动器页面时,皮肤站登录状态也会保持。"
|
||||
"message": "在下方选择皮肤后,可直接应用到 {player}。"
|
||||
},
|
||||
"app.skins.skin-site-account.empty-description": {
|
||||
"message": "这个皮肤站账号暂时没有可用的玩家角色。"
|
||||
|
||||
@ -7483,7 +7483,7 @@
|
||||
"message": "小鬼當家"
|
||||
},
|
||||
"app.skins.skin-site-account.description": {
|
||||
"message": "在下方選擇皮膚後,可直接套用到 {player}。切換啟動器頁面時,皮膚站登入狀態也會保持。"
|
||||
"message": "在下方選擇皮膚後,可直接套用到 {player}。"
|
||||
},
|
||||
"app.skins.skin-site-account.empty-description": {
|
||||
"message": "這個皮膚站帳號暫時沒有可用的玩家角色。"
|
||||
|
||||
@ -231,8 +231,7 @@ const messages = defineMessages({
|
||||
},
|
||||
skinSiteManagementDescription: {
|
||||
id: 'app.skins.skin-site-account.description',
|
||||
defaultMessage:
|
||||
'Select a skin below and apply it directly to {player}. The skin site session remains available while you move between launcher pages.',
|
||||
defaultMessage: 'Select a skin below and apply it directly to {player}.',
|
||||
},
|
||||
skinSiteMojangDescription: {
|
||||
id: 'app.skins.skin-site-account.mojang-description',
|
||||
|
||||
@ -10,7 +10,7 @@ import {
|
||||
import { onUnmounted, shallowRef } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { NewInstanceImage } from '@/assets/icons'
|
||||
import instanceThinkingImage from '@/assets/illustrations/instance-thinking.png'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
|
||||
import { instance_listener } from '@/helpers/events.js'
|
||||
@ -73,11 +73,15 @@ onUnmounted(() => {
|
||||
<template v-if="instances && instances.length > 0">
|
||||
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
|
||||
</template>
|
||||
<div v-else class="no-instance flex flex-col items-center justify-center h-full gap-3">
|
||||
<div class="icon">
|
||||
<NewInstanceImage />
|
||||
</div>
|
||||
<h3>{{ formatMessage(messages.noInstances) }}</h3>
|
||||
<div v-else class="flex flex-col items-center justify-center h-full gap-3">
|
||||
<img
|
||||
:src="instanceThinkingImage"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-40 w-40 object-contain"
|
||||
draggable="false"
|
||||
/>
|
||||
<h3 class="m-0">{{ formatMessage(messages.noInstances) }}</h3>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
data-onboarding-id="create-instance"
|
||||
@ -91,19 +95,3 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.no-instance {
|
||||
p,
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
svg {
|
||||
width: 10rem;
|
||||
height: 10rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -111,6 +111,9 @@ fn main() {
|
||||
"poll_device_login",
|
||||
"begin_yggdrasil_login",
|
||||
"finish_yggdrasil_login",
|
||||
"login_skin_site_player",
|
||||
"get_instance_player",
|
||||
"set_instance_player",
|
||||
"list_yggdrasil_saved_logins",
|
||||
"get_yggdrasil_password",
|
||||
"set_yggdrasil_password",
|
||||
|
||||
@ -31,6 +31,9 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
get_default_user,
|
||||
set_default_user,
|
||||
get_users,
|
||||
get_instance_player,
|
||||
set_instance_player,
|
||||
login_skin_site_player,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@ -318,6 +321,33 @@ pub async fn finish_yggdrasil_login(
|
||||
|
||||
const YGGDRASIL_SAVED_LOGINS_KEY: &str = "yggdrasil-saved-logins";
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_instance_player(
|
||||
instance_id: String,
|
||||
) -> Result<Option<minecraft_auth::InstancePlayer>> {
|
||||
Ok(minecraft_auth::get_instance_player(&instance_id).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_instance_player(
|
||||
instance_id: String,
|
||||
player: minecraft_auth::InstancePlayer,
|
||||
) -> Result<()> {
|
||||
Ok(minecraft_auth::set_instance_player(&instance_id, player).await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn login_skin_site_player(
|
||||
token: String,
|
||||
player_id: uuid::Uuid,
|
||||
user_id: String,
|
||||
) -> Result<Credentials> {
|
||||
Ok(
|
||||
minecraft_auth::login_skin_site_player(&token, player_id, &user_id)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct SavedYggdrasilLogin {
|
||||
pub api_root: String,
|
||||
@ -443,22 +473,24 @@ 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::<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}"
|
||||
);
|
||||
Ok(Vec::new())
|
||||
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}"
|
||||
);
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => Ok(Vec::new()),
|
||||
Err(error) => Err(keyring_error(error)),
|
||||
}
|
||||
|
||||
@ -462,6 +462,7 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
|
||||
})
|
||||
.transpose()?,
|
||||
launch_overrides: Some(InstanceLaunchOverridesPatch {
|
||||
player: None,
|
||||
instance_mode: None,
|
||||
java_path: edit_instance.java_path,
|
||||
extra_launch_args: edit_instance.extra_launch_args,
|
||||
|
||||
@ -354,7 +354,8 @@ unsafe extern "system" fn maximize_if_owned_by_process(
|
||||
_: windows::Win32::Foundation::LPARAM,
|
||||
) -> windows::core::BOOL {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetWindowThreadProcessId, IsWindowVisible, SW_MAXIMIZE, SetForegroundWindow, ShowWindow,
|
||||
GetWindowThreadProcessId, IsWindowVisible, SW_MAXIMIZE,
|
||||
SetForegroundWindow, ShowWindow,
|
||||
};
|
||||
use windows::core::BOOL;
|
||||
|
||||
|
||||
@ -148,7 +148,6 @@ pub fn run() -> Result<(), String> {
|
||||
let arguments = parse_arguments()?;
|
||||
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
|
||||
let window = WindowBuilder::new()
|
||||
|
||||
.with_title("Starlight Launcher")
|
||||
.with_inner_size(LogicalSize::new(940.0, 620.0))
|
||||
.with_min_inner_size(LogicalSize::new(940.0, 620.0))
|
||||
|
||||
@ -78,19 +78,32 @@ async fn run_with_extra_launch_args_inner(
|
||||
let _hosted_guard =
|
||||
crate::pack::hosted::prepare_launch(instance_id, offline_mode).await?;
|
||||
let state = State::get().await?;
|
||||
let launch_preparation_timeout =
|
||||
let context =
|
||||
crate::state::instances::commands::get_instance_launch_context(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.await?;
|
||||
let launch_preparation_timeout = context
|
||||
.as_ref()
|
||||
.and_then(|context| context.launch_overrides.launch_preparation_timeout)
|
||||
.unwrap_or(DEFAULT_LAUNCH_PREPARATION_TIMEOUT)
|
||||
.clamp(
|
||||
MIN_LAUNCH_PREPARATION_TIMEOUT,
|
||||
MAX_LAUNCH_PREPARATION_TIMEOUT,
|
||||
);
|
||||
let default_account = if offline_mode {
|
||||
let saved_player = context
|
||||
.as_ref()
|
||||
.and_then(|context| context.launch_overrides.player.as_ref());
|
||||
let default_account = if let Some(player) = saved_player {
|
||||
if offline_mode
|
||||
&& player.account_type
|
||||
!= crate::state::MinecraftAccountType::Offline
|
||||
{
|
||||
return Err(crate::ErrorKind::InputError("当前实例绑定的是在线玩家,请恢复网络后启动,或在实例设置中手动切换玩家".into()).as_error());
|
||||
}
|
||||
Credentials::for_instance_player(player, &state.pool).await?
|
||||
} else if offline_mode {
|
||||
Credentials::get_offline_credential(&state.pool)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
|
||||
@ -7,6 +7,7 @@ use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::State;
|
||||
pub use crate::state::InstancePlayer;
|
||||
pub use crate::state::YggdrasilLoginResult;
|
||||
use crate::state::{
|
||||
Credentials, MinecraftAccountType, MinecraftLoginFlow, MinecraftProfile,
|
||||
@ -172,6 +173,72 @@ pub fn normalize_yggdrasil_api_root(api_root: &str) -> crate::Result<String> {
|
||||
crate::state::normalize_api_root(api_root)
|
||||
}
|
||||
|
||||
pub async fn get_instance_player(
|
||||
instance_id: &str,
|
||||
) -> crate::Result<Option<crate::state::InstancePlayer>> {
|
||||
let state = State::get().await?;
|
||||
let context =
|
||||
crate::state::instances::commands::get_instance_launch_context(
|
||||
instance_id,
|
||||
&state.pool,
|
||||
)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("Unknown instance".into()).as_error()
|
||||
})?;
|
||||
Ok(context.launch_overrides.player)
|
||||
}
|
||||
|
||||
pub async fn set_instance_player(
|
||||
instance_id: &str,
|
||||
player: crate::state::InstancePlayer,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
let accounts = Credentials::get_all_without_refresh(&state.pool).await?;
|
||||
let account = accounts.get(&player.id).ok_or_else(|| {
|
||||
crate::ErrorKind::InputError("所选玩家未登录,请重新登录该账号".into())
|
||||
.as_error()
|
||||
})?;
|
||||
if account.account_type != player.account_type
|
||||
|| player.skin_site_user.as_ref().is_some_and(|user| {
|
||||
account.yggdrasil.as_ref().is_none_or(|ygg| {
|
||||
ygg.login != *user
|
||||
|| ygg.api_root != "https://skin.starlight.cool/yggdrasil"
|
||||
})
|
||||
})
|
||||
{
|
||||
return Err(
|
||||
crate::ErrorKind::InputError("玩家身份不匹配".into()).as_error()
|
||||
);
|
||||
}
|
||||
drop(account);
|
||||
crate::state::edit_instance(
|
||||
instance_id,
|
||||
crate::state::EditInstance {
|
||||
launch_overrides: Some(
|
||||
crate::state::InstanceLaunchOverridesPatch {
|
||||
player: Some(player),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn login_skin_site_player(
|
||||
token: &str,
|
||||
player_id: uuid::Uuid,
|
||||
user_id: &str,
|
||||
) -> crate::Result<Credentials> {
|
||||
let state = State::get().await?;
|
||||
crate::state::login_skin_site_player(token, player_id, user_id, &state.pool)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_default_user(
|
||||
offline_mode: bool,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
|
||||
mod progress;
|
||||
mod tagged;
|
||||
mod transport;
|
||||
use crate::{
|
||||
State,
|
||||
state::{
|
||||
@ -32,6 +33,7 @@ use tokio::{
|
||||
const API: &str = "https://skin.starlight.cool/starlight/mod/packs";
|
||||
const BINDING: &str = ".starlight-pack.json";
|
||||
const JOURNAL: &str = ".starlight-pack-pending.json";
|
||||
const MAX_HOSTED_CONCURRENT_FILES: usize = 8;
|
||||
static GATES: LazyLock<dashmap::DashMap<String, Arc<Mutex<()>>>> =
|
||||
LazyLock::new(dashmap::DashMap::new);
|
||||
static SESSION: Mutex<Option<DownloadSession>> = Mutex::const_new(None);
|
||||
@ -230,11 +232,42 @@ fn validate(files: &[PackFile]) -> crate::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
/// Reject symlinks and junctions in every existing ancestor, including internal state files.
|
||||
#[cfg(windows)]
|
||||
fn filesystem_path(path: &Path) -> PathBuf {
|
||||
use std::ffi::OsString;
|
||||
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
||||
|
||||
if !path.is_absolute() {
|
||||
return path.to_path_buf();
|
||||
}
|
||||
|
||||
let path = path.as_os_str().encode_wide().collect::<Vec<_>>();
|
||||
const BACKSLASH: u16 = b'\\' as u16;
|
||||
const QUESTION_MARK: u16 = b'?' as u16;
|
||||
if path.starts_with(&[BACKSLASH, BACKSLASH, QUESTION_MARK, BACKSLASH]) {
|
||||
return PathBuf::from(OsString::from_wide(&path));
|
||||
}
|
||||
|
||||
let mut extended = "\\\\?\\".encode_utf16().collect::<Vec<_>>();
|
||||
if path.starts_with(&[BACKSLASH, BACKSLASH]) {
|
||||
extended.extend("UNC\\".encode_utf16());
|
||||
extended.extend_from_slice(&path[2..]);
|
||||
} else {
|
||||
extended.extend_from_slice(&path);
|
||||
}
|
||||
PathBuf::from(OsString::from_wide(&extended))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn filesystem_path(path: &Path) -> PathBuf {
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
fn target(root: &Path, relative: &str) -> crate::Result<PathBuf> {
|
||||
let mut path = root.to_path_buf();
|
||||
for part in relative.split('/') {
|
||||
path.push(part);
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
match std::fs::symlink_metadata(filesystem_path(&path)) {
|
||||
Ok(meta) => {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
@ -250,21 +283,32 @@ fn target(root: &Path, relative: &str) -> crate::Result<PathBuf> {
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (),
|
||||
Err(e) => return Err(e.into()),
|
||||
Err(e) => {
|
||||
return Err(
|
||||
crate::util::io::IOError::with_path(e, &path).into()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(path)
|
||||
// Windows' legacy Win32 path parser reports ERROR_PATH_NOT_FOUND once a
|
||||
// perfectly valid pack path exceeds MAX_PATH. Extended-length paths keep
|
||||
// deep KubeJS/data-pack trees addressable without changing their layout.
|
||||
Ok(filesystem_path(&path))
|
||||
}
|
||||
async fn hash(path: &Path) -> crate::Result<Option<String>> {
|
||||
let mut input = match tokio::fs::File::open(path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
Err(e) => {
|
||||
return Err(crate::util::io::IOError::with_path(e, path).into());
|
||||
}
|
||||
};
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = vec![0; 64 * 1024];
|
||||
loop {
|
||||
let n = input.read(&mut buffer).await?;
|
||||
let n = input.read(&mut buffer).await.map_err(|error| {
|
||||
crate::util::io::IOError::with_path(error, path)
|
||||
})?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
@ -276,10 +320,11 @@ async fn read_json<T: serde::de::DeserializeOwned>(
|
||||
root: &Path,
|
||||
name: &str,
|
||||
) -> crate::Result<Option<T>> {
|
||||
match tokio::fs::read(target(root, name)?).await {
|
||||
let path = target(root, name)?;
|
||||
match tokio::fs::read(&path).await {
|
||||
Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
Err(e) => Err(crate::util::io::IOError::with_path(e, &path).into()),
|
||||
}
|
||||
}
|
||||
fn write_json<T: Serialize>(
|
||||
@ -289,10 +334,31 @@ fn write_json<T: Serialize>(
|
||||
) -> crate::Result<()> {
|
||||
use std::io::Write;
|
||||
let dest = target(root, name)?;
|
||||
let mut file = tempfile::NamedTempFile::new_in(root)?;
|
||||
file.write_all(&serde_json::to_vec(value)?)?;
|
||||
file.as_file().sync_all()?;
|
||||
file.persist(dest).map_err(|e| e.error)?;
|
||||
let mut file = tempfile::NamedTempFile::new_in(root).map_err(|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(error, root))
|
||||
.with_context(format!("创建 {name} 的临时文件失败"))
|
||||
})?;
|
||||
let temporary = file.path().to_path_buf();
|
||||
file.write_all(&serde_json::to_vec(value)?)
|
||||
.map_err(|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(
|
||||
error, &temporary,
|
||||
))
|
||||
.with_context(format!("写入 {name} 的临时文件失败"))
|
||||
})?;
|
||||
file.as_file().sync_all().map_err(|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(
|
||||
error, &temporary,
|
||||
))
|
||||
.with_context(format!("同步 {name} 的临时文件失败"))
|
||||
})?;
|
||||
file.persist(&dest).map_err(|error| {
|
||||
crate::Error::from(error.error).with_context(format!(
|
||||
"提交 {name} 失败;临时文件:{};目标文件:{}",
|
||||
temporary.display(),
|
||||
dest.display()
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
async fn authorization() -> crate::Result<String> {
|
||||
@ -330,18 +396,22 @@ async fn request_authorized<T: serde::de::DeserializeOwned>(
|
||||
auth: &str,
|
||||
) -> crate::Result<T> {
|
||||
ensure_session(auth).await?;
|
||||
let response = configured_client()
|
||||
.await?
|
||||
.get(format!("{API}{suffix}"))
|
||||
.header(reqwest::header::AUTHORIZATION, auth)
|
||||
.header(reqwest::header::CACHE_CONTROL, "no-cache")
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await?;
|
||||
if matches!(response.status().as_u16(), 401 | 403) {
|
||||
return Err(invalid(
|
||||
"StarLight 登录凭据已失效或无权限,请重新登录后重试",
|
||||
));
|
||||
let client = configured_client().await?;
|
||||
let response =
|
||||
transport::metadata_request(&client, &format!("{API}{suffix}"), auth)
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
let message = transport::response_failure(
|
||||
&client,
|
||||
response,
|
||||
"https://skin.starlight.cool/starlight/user",
|
||||
suffix,
|
||||
auth,
|
||||
)
|
||||
.await;
|
||||
ensure_session(auth).await?;
|
||||
tracing::warn!("{message}");
|
||||
return Err(invalid(message));
|
||||
}
|
||||
let data: Response<T> = response.error_for_status()?.json().await?;
|
||||
ensure_session(auth).await?;
|
||||
@ -433,7 +503,13 @@ async fn apply_files(
|
||||
) -> crate::Result<()> {
|
||||
for action in actions {
|
||||
let live = target(root, &action.path)?;
|
||||
if hash(&live).await? != action.old_hash {
|
||||
if hash(&live).await.map_err(|error| {
|
||||
error.with_context(format!(
|
||||
"检查整合包目标文件失败:{}",
|
||||
live.display()
|
||||
))
|
||||
})? != action.old_hash
|
||||
{
|
||||
return Err(invalid(format!(
|
||||
"File changed during sync: {}",
|
||||
action.path
|
||||
@ -442,16 +518,82 @@ async fn apply_files(
|
||||
if action.old_hash.is_some() {
|
||||
let backup =
|
||||
target(root, &format!("{}/{}", backup_dir, action.path))?;
|
||||
tokio::fs::create_dir_all(backup.parent().unwrap()).await?;
|
||||
tokio::fs::rename(&live, backup).await?;
|
||||
let backup_parent = backup.parent().unwrap();
|
||||
tokio::fs::create_dir_all(backup_parent).await.map_err(
|
||||
|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(
|
||||
error,
|
||||
backup_parent,
|
||||
))
|
||||
.with_context(format!(
|
||||
"创建整合包备份目录失败:{}",
|
||||
backup_parent.display()
|
||||
))
|
||||
},
|
||||
)?;
|
||||
tokio::fs::rename(&live, &backup).await.map_err(|error| {
|
||||
crate::Error::from(error).with_context(format!(
|
||||
"备份整合包文件失败;源文件:{};目标文件:{}",
|
||||
live.display(),
|
||||
backup.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
if let Some(next) = &action.next_hash {
|
||||
tokio::fs::create_dir_all(live.parent().unwrap()).await?;
|
||||
let staged =
|
||||
tempfile::NamedTempFile::new_in(live.parent().unwrap())?;
|
||||
tokio::fs::copy(target(cache, next)?, staged.path()).await?;
|
||||
staged.as_file().sync_all()?;
|
||||
staged.persist(&live).map_err(|e| e.error)?;
|
||||
let live_parent = live.parent().unwrap();
|
||||
tokio::fs::create_dir_all(live_parent)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(
|
||||
error,
|
||||
live_parent,
|
||||
))
|
||||
.with_context(format!(
|
||||
"创建整合包目标目录失败:{}",
|
||||
live_parent.display()
|
||||
))
|
||||
})?;
|
||||
let staged = tempfile::NamedTempFile::new_in(live_parent).map_err(
|
||||
|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(
|
||||
error,
|
||||
live_parent,
|
||||
))
|
||||
.with_context(format!(
|
||||
"创建整合包临时文件失败:{}",
|
||||
live.display()
|
||||
))
|
||||
},
|
||||
)?;
|
||||
let staged_path = staged.path().to_path_buf();
|
||||
let cache_object = target(cache, next)?;
|
||||
tokio::fs::copy(&cache_object, &staged_path)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::Error::from(error).with_context(format!(
|
||||
"复制整合包缓存文件失败;缓存文件:{};临时文件:{};目标文件:{}",
|
||||
cache_object.display(),
|
||||
staged_path.display(),
|
||||
live.display()
|
||||
))
|
||||
})?;
|
||||
staged.as_file().sync_all().map_err(|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(
|
||||
error,
|
||||
&staged_path,
|
||||
))
|
||||
.with_context(format!(
|
||||
"同步整合包临时文件失败:{}",
|
||||
staged_path.display()
|
||||
))
|
||||
})?;
|
||||
staged.persist(&live).map_err(|error| {
|
||||
crate::Error::from(error.error).with_context(format!(
|
||||
"提交整合包文件失败;临时文件:{};目标文件:{}",
|
||||
staged_path.display(),
|
||||
live.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@ -975,7 +1117,7 @@ async fn synchronize_with_progress(
|
||||
let concurrency = crate::api::settings::get()
|
||||
.await?
|
||||
.effective_max_concurrent_downloads()
|
||||
.clamp(1, 16);
|
||||
.clamp(1, MAX_HOSTED_CONCURRENT_FILES);
|
||||
let current = std::sync::Mutex::new(BTreeMap::<String, u64>::new());
|
||||
let completed = AtomicUsize::new(0);
|
||||
progress.download(
|
||||
@ -1038,6 +1180,10 @@ async fn synchronize_with_progress(
|
||||
};
|
||||
let mut request =
|
||||
DownloadRequest::new(&url, ResourceClass::Modpack)
|
||||
// The batch already downloads several files in
|
||||
// parallel. Do not let an HTTP/1 fallback multiply
|
||||
// every large file into another four connections.
|
||||
.with_http1_segmented_download(false)
|
||||
.with_integrity(Integrity {
|
||||
size: Some(file_size),
|
||||
sha256: Some(file.sha256),
|
||||
@ -1047,14 +1193,34 @@ async fn synchronize_with_progress(
|
||||
request = request
|
||||
.with_header("Authorization", auth.clone());
|
||||
}
|
||||
download_to_path(
|
||||
request,
|
||||
let first_result = download_to_path(
|
||||
request.clone(),
|
||||
&object,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
Some(&mut on_progress),
|
||||
)
|
||||
.await?;
|
||||
.await;
|
||||
if let Err(error) = first_result {
|
||||
tracing::warn!(
|
||||
path = %file_path,
|
||||
%error,
|
||||
"Parallel StarLight file download failed; retrying after the batch load has eased"
|
||||
);
|
||||
ensure_session(auth).await?;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(
|
||||
750,
|
||||
))
|
||||
.await;
|
||||
download_to_path(
|
||||
request,
|
||||
&object,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
Some(&mut on_progress),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let completed_files =
|
||||
completed.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let completed_bytes = {
|
||||
@ -1158,12 +1324,33 @@ async fn synchronize_with_progress(
|
||||
}
|
||||
ensure_session(&auth).await?;
|
||||
progress.update(0, 0, "正在应用更新", true);
|
||||
apply_files(&root, &cache, &journal.backup, &journal.actions).await?;
|
||||
apply_files(&root, &cache, &journal.backup, &journal.actions)
|
||||
.await
|
||||
.map_err(|error| error.with_context(format!(
|
||||
"应用 StarLight 整合包文件失败;实例目录:{}",
|
||||
root.display()
|
||||
)))?;
|
||||
progress.update(0, 0, "正在完成安装", true);
|
||||
write_json(&root, BINDING, &Binding { publication: publication.clone(), files, sync_marker: Some(sync_marker), resolved_external })?;
|
||||
write_json(&root, BINDING, &Binding { publication: publication.clone(), files, sync_marker: Some(sync_marker), resolved_external })
|
||||
.map_err(|error| error.with_context(format!(
|
||||
"写入 StarLight 整合包绑定信息失败;实例目录:{}",
|
||||
root.display()
|
||||
)))?;
|
||||
crate::instance::edit(instance_id, EditInstance { install_stage: Some(InstanceInstallStage::Installed), ..Default::default() }).await?;
|
||||
crate::instance::sync_content_files(instance_id).await?;
|
||||
tokio::fs::remove_file(target(&root, JOURNAL)?).await?;
|
||||
crate::instance::sync_content_files(instance_id).await.map_err(|error| {
|
||||
error.with_context(format!(
|
||||
"扫描 StarLight 整合包内容失败;实例目录:{}",
|
||||
root.display()
|
||||
))
|
||||
})?;
|
||||
let journal_path = target(&root, JOURNAL)?;
|
||||
tokio::fs::remove_file(&journal_path).await.map_err(|error| {
|
||||
crate::Error::from(crate::util::io::IOError::with_path(
|
||||
error,
|
||||
&journal_path,
|
||||
))
|
||||
.with_context("清理 StarLight 整合包安装日志失败")
|
||||
})?;
|
||||
Ok::<_, crate::Error>(())
|
||||
}.await;
|
||||
if let Err(error) = result {
|
||||
|
||||
@ -184,6 +184,41 @@ mod tests {
|
||||
assert!(root.path().join("mods/personal.jar").is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identical_tagged_mod_keeps_the_modpack_file() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let original = PackFile {
|
||||
mod_ids: vec![],
|
||||
external: None,
|
||||
path: "mods/example-from-pack.jar".into(),
|
||||
sha256: "a".repeat(64),
|
||||
size: 10,
|
||||
force: true,
|
||||
preserve: false,
|
||||
};
|
||||
let mut files = vec![original.clone()];
|
||||
let mut sources = BTreeMap::from([(
|
||||
original.path.clone(),
|
||||
"https://example.invalid/pack-file".into(),
|
||||
)]);
|
||||
let mut tagged = manifest();
|
||||
tagged.replaces.push(original.path.clone());
|
||||
|
||||
let actions = merge(
|
||||
root.path(),
|
||||
&root.path().join("cache"),
|
||||
&mut files,
|
||||
&mut sources,
|
||||
&tagged,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(actions.is_empty());
|
||||
assert_eq!(files, vec![original]);
|
||||
assert!(!sources.contains_key("mods/example.starlight.jar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_mod_jar_cannot_be_partially_replaced() {
|
||||
assert!(is_managed_file(&manifest().files[0].pack_file()));
|
||||
@ -266,10 +301,33 @@ pub(super) async fn merge(
|
||||
if ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut replaced: HashSet<String> =
|
||||
manifest.replaces.iter().cloned().collect();
|
||||
|
||||
// The modpack is the installation baseline. A tagged snapshot with the
|
||||
// same content already passes verification, even when the skin site uses
|
||||
// a canonical filename. Keep the pack's file and only replace it when the
|
||||
// tagged JAR is actually different.
|
||||
let satisfied_pack_paths: HashSet<String> = manifest
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|tagged| {
|
||||
files
|
||||
.iter()
|
||||
.find(|file| {
|
||||
is_mod_path(&file.path) && file.sha256 == tagged.sha256
|
||||
})
|
||||
.map(|file| file.path.clone())
|
||||
})
|
||||
.collect();
|
||||
let mut replaced: HashSet<String> = manifest
|
||||
.replaces
|
||||
.iter()
|
||||
.filter(|path| !satisfied_pack_paths.contains(*path))
|
||||
.cloned()
|
||||
.collect();
|
||||
for file in files.iter().filter(|file| is_mod_path(&file.path)) {
|
||||
if replaced.contains(&file.path) {
|
||||
if satisfied_pack_paths.contains(&file.path)
|
||||
|| replaced.contains(&file.path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let object = target(cache, &file.sha256)?;
|
||||
@ -284,6 +342,11 @@ pub(super) async fn merge(
|
||||
}
|
||||
files.retain(|file| !replaced.contains(&file.path));
|
||||
for file in &manifest.files {
|
||||
if files.iter().any(|existing| {
|
||||
is_mod_path(&existing.path) && existing.sha256 == file.sha256
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if files
|
||||
.iter()
|
||||
.any(|existing| existing.path.eq_ignore_ascii_case(&file.path))
|
||||
@ -299,6 +362,11 @@ pub(super) async fn merge(
|
||||
);
|
||||
files.push(file.pack_file());
|
||||
}
|
||||
let desired_paths: HashSet<_> = files
|
||||
.iter()
|
||||
.filter(|file| is_mod_path(&file.path))
|
||||
.map(|file| file.path.to_ascii_lowercase())
|
||||
.collect();
|
||||
let mut duplicate_actions = Vec::new();
|
||||
let mods_dir = target(root, "mods")?;
|
||||
if mods_dir.is_dir() {
|
||||
@ -309,7 +377,9 @@ pub(super) async fn merge(
|
||||
continue;
|
||||
};
|
||||
let path = format!("mods/{name}");
|
||||
if !is_mod_path(&path) || manifest.contains(&path) {
|
||||
if !is_mod_path(&path)
|
||||
|| desired_paths.contains(&path.to_ascii_lowercase())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let local = target(root, &path)?;
|
||||
|
||||
228
packages/app-lib/src/api/pack/hosted/transport.rs
Normal file
228
packages/app-lib/src/api/pack/hosted/transport.rs
Normal file
@ -0,0 +1,228 @@
|
||||
use reqwest::{Client, Response, header};
|
||||
|
||||
pub(super) async fn metadata_request(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
auth: &str,
|
||||
) -> Result<Response, reqwest::Error> {
|
||||
client
|
||||
.get(url)
|
||||
.query(&[("starlight_request", uuid::Uuid::new_v4().to_string())])
|
||||
.header(header::AUTHORIZATION, auth)
|
||||
.header(header::CACHE_CONTROL, "no-cache, no-store")
|
||||
.header(header::PRAGMA, "no-cache")
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn bounded_json(mut response: Response) -> Option<serde_json::Value> {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.ok()? {
|
||||
if bytes.len() + chunk.len() > 16_384 {
|
||||
return None;
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
pub(super) async fn response_failure(
|
||||
client: &Client,
|
||||
response: Response,
|
||||
user_url: &str,
|
||||
suffix: &str,
|
||||
auth: &str,
|
||||
) -> String {
|
||||
let status = response.status().as_u16();
|
||||
let trace = response
|
||||
.headers()
|
||||
.get("eo-log-uuid")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.filter(|value| {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == b'-')
|
||||
})
|
||||
.map(|value| format!(";CDN 请求编号 {value}"))
|
||||
.unwrap_or_default();
|
||||
let context = format!("(整合包 HTTP {status};接口 {suffix}{trace})");
|
||||
if let Some(body) = bounded_json(response).await {
|
||||
if let Some(message) = body["errorMessage"]
|
||||
.as_str()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
{
|
||||
let token = auth.strip_prefix("Bearer ").unwrap_or(auth);
|
||||
let message = if token.is_empty() {
|
||||
message.to_owned()
|
||||
} else {
|
||||
message.replace(token, "[凭据已隐藏]")
|
||||
};
|
||||
let message: String = message
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.take(500)
|
||||
.collect();
|
||||
return format!("StarLight 服务端返回:{message}{context}");
|
||||
}
|
||||
}
|
||||
if !matches!(status, 401 | 403) {
|
||||
return format!("StarLight 服务端请求失败{context}");
|
||||
}
|
||||
let detail = match metadata_request(client, user_url, auth).await {
|
||||
Ok(user_response) => {
|
||||
let user_status = user_response.status().as_u16();
|
||||
if user_status == 200 {
|
||||
let valid_user =
|
||||
bounded_json(user_response).await.is_some_and(|body| {
|
||||
body["payload"]["uuid"]
|
||||
.as_str()
|
||||
.is_some_and(|id| !id.is_empty())
|
||||
&& body["payload"]["username"].is_string()
|
||||
});
|
||||
if valid_user {
|
||||
"皮肤站登录有效,但整合包接口返回错误,且没有提供具体原因。请用请求编号查询服务端或 CDN 日志".to_owned()
|
||||
} else {
|
||||
"整合包接口拒绝访问,用户接口返回内容异常,无法确认登录状态"
|
||||
.to_owned()
|
||||
}
|
||||
} else if matches!(user_status, 401 | 403) {
|
||||
format!(
|
||||
"整合包和用户接口均拒绝登录凭据(用户接口 HTTP {user_status})。请重新登录;若仍失败,请检查服务端鉴权及 CDN 配置"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"整合包接口拒绝访问,暂时无法复核登录状态(用户接口 HTTP {user_status})"
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(_) => "整合包接口拒绝访问,用户接口连接失败,暂时无法复核登录状态"
|
||||
.to_owned(),
|
||||
};
|
||||
format!("{detail}{context}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_auth_failure_verifies_same_credential_without_exposing_it()
|
||||
{
|
||||
for (pack_status, user_status, body, expected) in [
|
||||
(
|
||||
401,
|
||||
200,
|
||||
r#"{"payload":{"uuid":"owner","username":"Player"}}"#,
|
||||
"皮肤站登录有效",
|
||||
),
|
||||
(403, 401, "{}", "均拒绝登录凭据"),
|
||||
(401, 200, "<html>Proxy error</html>", "用户接口返回内容异常"),
|
||||
] {
|
||||
let listener =
|
||||
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
let mut requests = Vec::new();
|
||||
for (status, body) in [(pack_status, ""), (user_status, body)] {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
request.push(socket.read_u8().await.unwrap());
|
||||
}
|
||||
requests.push(String::from_utf8(request).unwrap());
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nEO-LOG-UUID: trace-123\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
requests
|
||||
});
|
||||
let client = Client::builder().no_proxy().build().unwrap();
|
||||
let auth = "Bearer test-secret-never-display";
|
||||
let response =
|
||||
metadata_request(&client, &format!("{base}/default"), auth)
|
||||
.await
|
||||
.unwrap();
|
||||
let message = response_failure(
|
||||
&client,
|
||||
response,
|
||||
&format!("{base}/user"),
|
||||
"/default",
|
||||
auth,
|
||||
)
|
||||
.await;
|
||||
assert!(message.contains(expected), "{message}");
|
||||
assert!(message.contains(&format!("整合包 HTTP {pack_status}")));
|
||||
assert!(message.contains("trace-123"));
|
||||
assert!(!message.contains("test-secret"));
|
||||
let requests = server.await.unwrap();
|
||||
for (request, path) in requests.iter().zip(["/default", "/user"]) {
|
||||
assert!(
|
||||
request
|
||||
.starts_with(&format!("GET {path}?starlight_request="))
|
||||
);
|
||||
let headers = request.to_ascii_lowercase();
|
||||
assert!(headers.contains(
|
||||
"authorization: bearer test-secret-never-display\r\n"
|
||||
));
|
||||
assert!(
|
||||
headers.contains("cache-control: no-cache, no-store\r\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_business_errors_preserve_server_reason_without_login_probe()
|
||||
{
|
||||
for status in [400, 403, 409, 500] {
|
||||
let listener =
|
||||
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
while !request.ends_with(b"\r\n\r\n") {
|
||||
request.push(socket.read_u8().await.unwrap());
|
||||
}
|
||||
let body = r#"{"error":"IllegalState","errorMessage":"整合包 Mod 标签已删除,请管理员重新设置 test-secret-never-display"}"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
});
|
||||
let client = Client::builder().no_proxy().build().unwrap();
|
||||
let auth = "Bearer test-secret-never-display";
|
||||
let response = metadata_request(
|
||||
&client,
|
||||
&format!("{base}/tagged-mods/2"),
|
||||
auth,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let message = response_failure(
|
||||
&client,
|
||||
response,
|
||||
&format!("{base}/user"),
|
||||
"/tagged-mods/2",
|
||||
auth,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
message.contains("整合包 Mod 标签已删除,请管理员重新设置"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(message.contains("/tagged-mods/2"));
|
||||
assert!(message.contains(&format!("HTTP {status}")));
|
||||
assert!(!message.contains("test-secret"));
|
||||
assert!(!message.contains("请重新登录"));
|
||||
server.await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -142,6 +142,7 @@ pub(crate) async fn import_axolotl(
|
||||
loader_version: Some(config.content_set.loader_version.clone()),
|
||||
}),
|
||||
launch_overrides: Some(InstanceLaunchOverridesPatch {
|
||||
player: None,
|
||||
instance_mode: Some(crate::state::InstanceMode::Local),
|
||||
java_path: Some(config.launch_overrides.java_path.clone()),
|
||||
extra_launch_args: Some(
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
pub(crate) mod archive_util;
|
||||
pub mod detect;
|
||||
pub mod import;
|
||||
pub mod hosted;
|
||||
pub mod import;
|
||||
pub mod install_from;
|
||||
pub(crate) mod install_hmcl;
|
||||
pub(crate) mod install_mcbbs;
|
||||
|
||||
@ -16,7 +16,10 @@ mod tests {
|
||||
fn user_agent_is_unique_and_contains_no_contact_information() {
|
||||
let user_agent = user_agent("1.2.3", "windows");
|
||||
|
||||
assert_eq!(user_agent, "garbage-human-studio/starlight/1.2.3 (windows)");
|
||||
assert_eq!(
|
||||
user_agent,
|
||||
"garbage-human-studio/starlight/1.2.3 (windows)"
|
||||
);
|
||||
assert!(!user_agent.contains("ghs.red"));
|
||||
assert!(!user_agent.contains("http"));
|
||||
assert!(!user_agent.contains('@'));
|
||||
|
||||
@ -249,7 +249,11 @@ impl std::fmt::Display for Error {
|
||||
|
||||
impl Error {
|
||||
pub(crate) fn with_context(mut self, context: impl Into<String>) -> Self {
|
||||
self.context = Some(context.into());
|
||||
let context = context.into();
|
||||
self.context = Some(match self.context.take() {
|
||||
Some(existing) => format!("{existing}\n{context}"),
|
||||
None => context,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
|
||||
@ -22,17 +22,12 @@ const DEFAULT_CONSOLE_COLUMNS: usize = 80;
|
||||
#[cfg(debug_assertions)]
|
||||
const CONSOLE_TRUNCATION_MARKER: &str = "... [console output truncated]";
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
const LAUNCHER_LOG_MAX_BYTES: u64 = 10 * 1024 * 1024;
|
||||
#[cfg(not(debug_assertions))]
|
||||
const LAUNCHER_WARN_ERROR_MAX_BYTES: u64 = 30 * 1024 * 1024;
|
||||
#[cfg(not(debug_assertions))]
|
||||
const LAUNCHER_LOG_MAX_FILES: usize = 5;
|
||||
#[cfg(not(debug_assertions))]
|
||||
const LAUNCHER_LOG_MAX_AGE: std::time::Duration =
|
||||
std::time::Duration::from_secs(3 * 24 * 60 * 60);
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
#[derive(Clone)]
|
||||
struct RotatingLogWriter {
|
||||
state: std::sync::Arc<std::sync::Mutex<RotatingLogState>>,
|
||||
@ -40,7 +35,6 @@ struct RotatingLogWriter {
|
||||
warn_error_max_bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
struct RotatingLogState {
|
||||
logs_dir: std::path::PathBuf,
|
||||
session_name: String,
|
||||
@ -51,7 +45,6 @@ struct RotatingLogState {
|
||||
max_age: std::time::Duration,
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
impl RotatingLogWriter {
|
||||
fn new(
|
||||
logs_dir: std::path::PathBuf,
|
||||
@ -120,7 +113,6 @@ impl RotatingLogWriter {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
impl RotatingLogState {
|
||||
fn write_event(
|
||||
&mut self,
|
||||
@ -157,7 +149,6 @@ impl RotatingLogState {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
fn rotating_log_path(
|
||||
logs_dir: &std::path::Path,
|
||||
session_name: &str,
|
||||
@ -170,7 +161,6 @@ fn rotating_log_path(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
fn open_log_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
@ -178,7 +168,6 @@ fn open_log_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
|
||||
.open(path)
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
fn cleanup_launcher_logs(
|
||||
logs_dir: &std::path::Path,
|
||||
max_files: usize,
|
||||
@ -194,7 +183,6 @@ fn cleanup_launcher_logs(
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
fn cleanup_launcher_logs_at(
|
||||
logs_dir: &std::path::Path,
|
||||
max_files: usize,
|
||||
@ -244,7 +232,6 @@ fn cleanup_launcher_logs_at(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
fn launcher_log_is_expired(
|
||||
created: std::time::SystemTime,
|
||||
now: std::time::SystemTime,
|
||||
@ -253,14 +240,12 @@ fn launcher_log_is_expired(
|
||||
now.duration_since(created).is_ok_and(|age| age > max_age)
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
struct LogEventWriter {
|
||||
writer: RotatingLogWriter,
|
||||
buffer: Vec<u8>,
|
||||
max_file_bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
impl LogEventWriter {
|
||||
fn commit(&mut self) -> std::io::Result<()> {
|
||||
if self.buffer.is_empty() {
|
||||
@ -272,7 +257,6 @@ impl LogEventWriter {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
impl std::io::Write for LogEventWriter {
|
||||
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
|
||||
self.buffer.extend_from_slice(buffer);
|
||||
@ -285,14 +269,12 @@ impl std::io::Write for LogEventWriter {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
impl Drop for LogEventWriter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.commit();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RotatingLogWriter {
|
||||
type Writer = LogEventWriter;
|
||||
|
||||
@ -314,7 +296,6 @@ impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RotatingLogWriter {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, not(debug_assertions)))]
|
||||
impl RotatingLogWriter {
|
||||
fn event_writer(&self, max_file_bytes: u64) -> LogEventWriter {
|
||||
LogEventWriter {
|
||||
@ -627,11 +608,35 @@ impl std::io::Write for TruncatedConsoleWriter {
|
||||
}
|
||||
}
|
||||
|
||||
// Handling for the live development logging
|
||||
// This will log to the console, and will not log to a file
|
||||
// Handling for live development logging. Keep the compact console output, but
|
||||
// also write the full event stream so exported error reports contain the
|
||||
// session that produced the error.
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn start_logger(_app_identifier: &str) -> Option<()> {
|
||||
pub fn start_logger(app_identifier: &str) -> Option<()> {
|
||||
use crate::prelude::DirectoryInfo;
|
||||
use chrono::Local;
|
||||
use tracing_subscriber::fmt::time::ChronoLocal;
|
||||
use tracing_subscriber::prelude::*;
|
||||
let logs_dir = DirectoryInfo::launcher_logs_dir_path(app_identifier)
|
||||
.or_else(|| {
|
||||
eprintln!("Could not resolve launcher logs directory");
|
||||
None
|
||||
})?;
|
||||
let session_name =
|
||||
format!("session_{}", Local::now().format("%Y%m%d_%H%M%S"));
|
||||
let file_writer = RotatingLogWriter::new(
|
||||
logs_dir,
|
||||
session_name,
|
||||
LAUNCHER_LOG_MAX_BYTES,
|
||||
LAUNCHER_WARN_ERROR_MAX_BYTES,
|
||||
LAUNCHER_LOG_MAX_FILES,
|
||||
LAUNCHER_LOG_MAX_AGE,
|
||||
)
|
||||
.map_err(|error| {
|
||||
eprintln!("Could not start launcher log writer: {error}");
|
||||
error
|
||||
})
|
||||
.ok()?;
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| {
|
||||
tracing_subscriber::EnvFilter::new("theseus=info,theseus_gui=info")
|
||||
@ -646,6 +651,12 @@ pub fn start_logger(_app_identifier: &str) -> Option<()> {
|
||||
stdout: std::io::stdout(),
|
||||
}
|
||||
}))
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(file_writer)
|
||||
.with_ansi(false)
|
||||
.with_timer(ChronoLocal::rfc_3339()),
|
||||
)
|
||||
.with(filter)
|
||||
.with(tracing_error::ErrorLayer::default())
|
||||
.init();
|
||||
|
||||
@ -515,19 +515,18 @@ fn app_db_backup_dir_for(db_path: &Path) -> crate::Result<PathBuf> {
|
||||
))
|
||||
})?;
|
||||
|
||||
let backup_dir = base.join("Backups").join("app-db");
|
||||
match db_path
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|name| name.to_str())
|
||||
{
|
||||
Some("beta") | Some("release") => Ok(backup_dir.join(
|
||||
db_path
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.expect("database channel directory has a name"),
|
||||
)),
|
||||
_ => Ok(backup_dir),
|
||||
Ok(default_app_db_backup_dir(base))
|
||||
}
|
||||
|
||||
fn default_app_db_backup_dir(database_dir: &Path) -> PathBuf {
|
||||
match database_dir.file_name().and_then(|name| name.to_str()) {
|
||||
Some(channel @ ("beta" | "release")) => database_dir
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""))
|
||||
.join("Backups")
|
||||
.join("app-db")
|
||||
.join(channel),
|
||||
_ => database_dir.join("Backups").join("app-db"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -653,6 +652,21 @@ async fn create_sqlite_snapshot(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recovery_and_update_share_the_channel_backup_directory() {
|
||||
for channel in ["release", "beta"] {
|
||||
let settings = Path::new("launcher-settings");
|
||||
assert_eq!(
|
||||
default_app_db_backup_dir(&settings.join(channel)),
|
||||
settings.join("Backups").join("app-db").join(channel)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
default_app_db_backup_dir(Path::new("legacy-settings")),
|
||||
Path::new("legacy-settings").join("Backups").join("app-db")
|
||||
);
|
||||
}
|
||||
|
||||
async fn create_test_app_db(path: &Path, marker: &str) {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
|
||||
@ -54,6 +54,7 @@ pub struct EditInstance {
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct InstanceLaunchOverridesPatch {
|
||||
pub player: Option<crate::state::InstancePlayer>,
|
||||
pub instance_mode: Option<crate::state::InstanceMode>,
|
||||
#[serde(
|
||||
default,
|
||||
@ -373,6 +374,9 @@ fn apply_launch_overrides_patch(
|
||||
if let Some(mode) = patch.instance_mode {
|
||||
overrides.instance_mode = Some(mode);
|
||||
}
|
||||
if let Some(player) = patch.player {
|
||||
overrides.player = Some(player);
|
||||
}
|
||||
if let Some(timeout) = patch.launch_preparation_timeout {
|
||||
overrides.launch_preparation_timeout = timeout;
|
||||
}
|
||||
|
||||
@ -14,8 +14,19 @@ pub enum InstanceMode {
|
||||
Local,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstancePlayer {
|
||||
pub id: uuid::Uuid,
|
||||
pub name: String,
|
||||
pub account_type: crate::state::MinecraftAccountType,
|
||||
#[serde(default)]
|
||||
pub skin_site_user: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct InstanceLaunchOverrides {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub player: Option<InstancePlayer>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub instance_mode: Option<InstanceMode>,
|
||||
pub instance_id: String,
|
||||
@ -34,6 +45,7 @@ pub struct InstanceLaunchOverrides {
|
||||
impl InstanceLaunchOverrides {
|
||||
pub fn empty(instance_id: String) -> Self {
|
||||
Self {
|
||||
player: None,
|
||||
instance_mode: None,
|
||||
instance_id,
|
||||
java_path: None,
|
||||
@ -55,6 +67,8 @@ impl InstanceLaunchOverrides {
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub(crate) struct InstanceLaunchOverridesData {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub player: Option<InstancePlayer>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub instance_mode: Option<InstanceMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@ -83,6 +97,7 @@ impl InstanceLaunchOverridesData {
|
||||
instance_id: String,
|
||||
) -> InstanceLaunchOverrides {
|
||||
InstanceLaunchOverrides {
|
||||
player: self.player,
|
||||
instance_mode: self.instance_mode,
|
||||
instance_id,
|
||||
java_path: self.java_path,
|
||||
@ -101,6 +116,7 @@ impl InstanceLaunchOverridesData {
|
||||
impl From<&InstanceLaunchOverrides> for InstanceLaunchOverridesData {
|
||||
fn from(overrides: &InstanceLaunchOverrides) -> Self {
|
||||
Self {
|
||||
player: overrides.player.clone(),
|
||||
instance_mode: overrides.instance_mode,
|
||||
java_path: overrides.java_path.clone(),
|
||||
extra_launch_args: overrides.extra_launch_args.clone(),
|
||||
@ -154,4 +170,35 @@ mod tests {
|
||||
);
|
||||
assert!(serde_json::from_str::<InstanceMode>("\"invalid\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_player_survives_configuration_round_trip() {
|
||||
let mut original = InstanceLaunchOverrides::empty("one".into());
|
||||
let id = uuid::Uuid::new_v4();
|
||||
original.player = Some(InstancePlayer {
|
||||
id,
|
||||
name: "SavedPlayer".into(),
|
||||
account_type: crate::state::MinecraftAccountType::Yggdrasil,
|
||||
skin_site_user: Some("owner".into()),
|
||||
});
|
||||
let encoded = serde_json::to_string(
|
||||
&InstanceLaunchOverridesData::from(&original),
|
||||
)
|
||||
.unwrap();
|
||||
let decoded: InstanceLaunchOverridesData =
|
||||
serde_json::from_str(&encoded).unwrap();
|
||||
let saved = decoded.into_launch_overrides("one".into()).player.unwrap();
|
||||
assert_eq!(saved.id, id);
|
||||
assert_eq!(saved.skin_site_user.as_deref(), Some("owner"));
|
||||
assert_eq!(
|
||||
saved.account_type,
|
||||
crate::state::MinecraftAccountType::Yggdrasil
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<InstanceLaunchOverridesData>("{}")
|
||||
.unwrap()
|
||||
.player
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -611,6 +611,7 @@ where
|
||||
}
|
||||
|
||||
let launch_overrides = InstanceLaunchOverrides {
|
||||
player: None,
|
||||
instance_mode: None,
|
||||
instance_id: instance_id.clone(),
|
||||
java_path: input.java_path,
|
||||
|
||||
@ -798,6 +798,39 @@ impl Credentials {
|
||||
Self::get_active_with_refresh(exec, true).await
|
||||
}
|
||||
|
||||
pub async fn for_instance_player(
|
||||
player: &crate::state::InstancePlayer,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<Self> {
|
||||
let accounts = Self::get_all_without_refresh(exec).await?;
|
||||
let mut account = accounts
|
||||
.remove(&player.id)
|
||||
.map(|(_, account)| account)
|
||||
.ok_or_else(|| {
|
||||
ErrorKind::InputError(format!(
|
||||
"实例玩家 {} 已退出登录,请重新登录该玩家或在实例设置中切换",
|
||||
player.name
|
||||
))
|
||||
.as_error()
|
||||
})?;
|
||||
if account.account_type != player.account_type
|
||||
|| player.skin_site_user.as_ref().is_some_and(|user| {
|
||||
account.yggdrasil.as_ref().is_none_or(|ygg| {
|
||||
ygg.login != *user
|
||||
|| ygg.api_root
|
||||
!= "https://skin.starlight.cool/yggdrasil"
|
||||
})
|
||||
})
|
||||
{
|
||||
return Err(ErrorKind::InputError(
|
||||
"实例玩家身份不匹配,请在实例设置中重新选择".into(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
account.refresh(exec).await?;
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
pub async fn get_active_without_refresh(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<Option<Self>> {
|
||||
@ -900,6 +933,21 @@ impl Credentials {
|
||||
pub async fn upsert(
|
||||
&self,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<()> {
|
||||
self.upsert_inner(exec, false).await
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_preserving_selection(
|
||||
&self,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<()> {
|
||||
self.upsert_inner(exec, true).await
|
||||
}
|
||||
|
||||
async fn upsert_inner(
|
||||
&self,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
preserve_selection: bool,
|
||||
) -> crate::Result<()> {
|
||||
let profile = self.maybe_online_profile().await;
|
||||
let expires = self.expires.timestamp();
|
||||
@ -922,7 +970,7 @@ impl Credentials {
|
||||
.as_ref()
|
||||
.map_or("", |account| account.client_token.as_str());
|
||||
|
||||
if self.active {
|
||||
if self.active && !preserve_selection {
|
||||
sqlx::query!(
|
||||
"
|
||||
UPDATE minecraft_users
|
||||
@ -943,7 +991,7 @@ impl Credentials {
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
ON CONFLICT (uuid) DO UPDATE SET
|
||||
active = $2,
|
||||
active = CASE WHEN $12 THEN minecraft_users.active ELSE $2 END,
|
||||
username = $3,
|
||||
account_type = $4,
|
||||
access_token = $5,
|
||||
@ -956,7 +1004,7 @@ impl Credentials {
|
||||
",
|
||||
)
|
||||
.bind(uuid)
|
||||
.bind(self.active)
|
||||
.bind(self.active && !preserve_selection)
|
||||
.bind(&profile.name)
|
||||
.bind(account_type)
|
||||
.bind(&self.access_token)
|
||||
@ -966,6 +1014,7 @@ impl Credentials {
|
||||
.bind(yggdrasil_server_name)
|
||||
.bind(yggdrasil_login)
|
||||
.bind(yggdrasil_client_token)
|
||||
.bind(preserve_selection)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
@ -1042,6 +1091,75 @@ impl Serialize for Credentials {
|
||||
mod offline_account_tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_player_never_falls_back_to_global_account() {
|
||||
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect("sqlite::memory:")
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::migrate!().run(&pool).await.unwrap();
|
||||
let first = Credentials::offline("First").unwrap();
|
||||
let second = Credentials::offline("Second").unwrap();
|
||||
first.upsert(&pool).await.unwrap();
|
||||
let mut refreshed = Credentials::offline("First").unwrap();
|
||||
refreshed.active = false;
|
||||
refreshed.upsert_preserving_selection(&pool).await.unwrap();
|
||||
assert_eq!(
|
||||
Credentials::get_active_without_refresh(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.offline_profile
|
||||
.id,
|
||||
first.offline_profile.id
|
||||
);
|
||||
second.upsert(&pool).await.unwrap();
|
||||
first.upsert_preserving_selection(&pool).await.unwrap();
|
||||
let binding = crate::state::InstancePlayer {
|
||||
id: first.offline_profile.id,
|
||||
name: "First".into(),
|
||||
account_type: MinecraftAccountType::Offline,
|
||||
skin_site_user: None,
|
||||
};
|
||||
assert_eq!(
|
||||
Credentials::get_active_without_refresh(&pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.offline_profile
|
||||
.id,
|
||||
second.offline_profile.id
|
||||
);
|
||||
assert_eq!(
|
||||
Credentials::for_instance_player(&binding, &pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.offline_profile
|
||||
.id,
|
||||
first.offline_profile.id
|
||||
);
|
||||
let mismatched = crate::state::InstancePlayer {
|
||||
account_type: MinecraftAccountType::Microsoft,
|
||||
..binding.clone()
|
||||
};
|
||||
assert!(
|
||||
Credentials::for_instance_player(&mismatched, &pool)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
sqlx::query("DELETE FROM minecraft_users WHERE uuid = ?")
|
||||
.bind(binding.id.to_string())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
Credentials::for_instance_player(&binding, &pool)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_java_compatible_offline_uuid() {
|
||||
let credentials = Credentials::offline("Notch").unwrap();
|
||||
|
||||
@ -199,7 +199,8 @@ pub async fn begin_yggdrasil_login(
|
||||
.await?;
|
||||
|
||||
// 收集本次登录可用的所有角色;优先使用 availableProfiles,回退到 selectedProfile。
|
||||
let mut profiles: Vec<YggdrasilProfile> = response.available_profiles.clone();
|
||||
let mut profiles: Vec<YggdrasilProfile> =
|
||||
response.available_profiles.clone();
|
||||
if profiles.is_empty() {
|
||||
if let Some(selected) = response.selected_profile.clone() {
|
||||
profiles.push(selected);
|
||||
@ -607,10 +608,122 @@ fn create_credentials(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login_skin_site_player(
|
||||
token: &str,
|
||||
player_id: Uuid,
|
||||
user_id: &str,
|
||||
exec: impl sqlx::Executor<'_, Database = Sqlite> + Copy,
|
||||
) -> crate::Result<Credentials> {
|
||||
#[derive(Deserialize)]
|
||||
struct Envelope {
|
||||
payload: SkinSiteLogin,
|
||||
}
|
||||
let client_token = Uuid::new_v4().to_string();
|
||||
let response = reqwest::Client::builder()
|
||||
.timeout(StdDuration::from_secs(30))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?
|
||||
.post("https://skin.starlight.cool/starlight/launcher/login")
|
||||
.bearer_auth(token)
|
||||
.json(&json!({ "playerId": player_id, "clientToken": client_token }))
|
||||
.send()
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(ErrorKind::InputError(format!(
|
||||
"皮肤站玩家登录失败(HTTP {}),请检查登录状态后重试",
|
||||
response.status().as_u16()
|
||||
))
|
||||
.as_error());
|
||||
}
|
||||
let login = response.json::<Envelope>().await?.payload;
|
||||
let credential =
|
||||
skin_site_credentials(login, player_id, user_id, &client_token)?;
|
||||
credential.upsert_preserving_selection(exec).await?;
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SkinSiteLogin {
|
||||
access_token: String,
|
||||
client_token: String,
|
||||
selected_profile: YggdrasilProfile,
|
||||
user_id: String,
|
||||
}
|
||||
|
||||
fn skin_site_credentials(
|
||||
login: SkinSiteLogin,
|
||||
player_id: Uuid,
|
||||
user_id: &str,
|
||||
client_token: &str,
|
||||
) -> crate::Result<Credentials> {
|
||||
if login.selected_profile.id != player_id
|
||||
|| login.user_id != user_id
|
||||
|| login.client_token != client_token
|
||||
|| login.access_token.is_empty()
|
||||
{
|
||||
return Err(ErrorKind::InputError(
|
||||
"皮肤站返回的玩家身份不匹配,请重试".into(),
|
||||
)
|
||||
.as_error());
|
||||
}
|
||||
let mut credential = create_credentials(
|
||||
login.selected_profile,
|
||||
login.access_token,
|
||||
login.client_token,
|
||||
YggdrasilMetadata {
|
||||
api_root: "https://skin.starlight.cool/yggdrasil".into(),
|
||||
server_name: "StarLight".into(),
|
||||
raw: String::new(),
|
||||
},
|
||||
user_id,
|
||||
);
|
||||
credential.active = false;
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn skin_site_login_validates_identity_and_keeps_global_selection() {
|
||||
let id = Uuid::new_v4();
|
||||
let response = || SkinSiteLogin {
|
||||
access_token: "game-token".into(),
|
||||
client_token: "client".into(),
|
||||
selected_profile: YggdrasilProfile {
|
||||
id,
|
||||
name: "Player".into(),
|
||||
},
|
||||
user_id: "owner".into(),
|
||||
};
|
||||
let credentials =
|
||||
skin_site_credentials(response(), id, "owner", "client").unwrap();
|
||||
assert!(!credentials.active);
|
||||
assert_eq!(credentials.account_type, MinecraftAccountType::Yggdrasil);
|
||||
assert_eq!(
|
||||
credentials.yggdrasil.unwrap().api_root,
|
||||
"https://skin.starlight.cool/yggdrasil"
|
||||
);
|
||||
assert!(
|
||||
skin_site_credentials(
|
||||
response(),
|
||||
Uuid::new_v4(),
|
||||
"owner",
|
||||
"client"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
skin_site_credentials(response(), id, "other", "client").is_err()
|
||||
);
|
||||
assert!(
|
||||
skin_site_credentials(response(), id, "owner", "other-client")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_api_roots() {
|
||||
assert_eq!(
|
||||
|
||||
@ -105,7 +105,11 @@ const REASSIGNABLE_FIRST_BYTE_TIMEOUT: time::Duration =
|
||||
const MAX_DOWNLOAD_ATTEMPT_HISTORY: usize = 12;
|
||||
const MAX_DOWNLOAD_DIAGNOSTIC_BYTES: usize = 8 * 1024;
|
||||
const MAX_FAILURE_COOLDOWN: time::Duration = time::Duration::from_secs(1);
|
||||
const H2_FALLBACK_TTL: time::Duration = MAX_FAILURE_COOLDOWN;
|
||||
// Once an authority has returned a broken HTTP/2 body, keep subsequent file
|
||||
// retries on HTTP/1.1 for the rest of a typical install. A one-second memory
|
||||
// allowed longer batch downloads to fall straight back onto the same bad H2
|
||||
// connection between retry rounds.
|
||||
const H2_FALLBACK_TTL: time::Duration = time::Duration::from_secs(10 * 60);
|
||||
const TASK_PROBE_MAX_ROUTES: usize = 3;
|
||||
const MAX_TASK_PROBE_STATES: usize = 64;
|
||||
#[cfg(not(test))]
|
||||
@ -6495,7 +6499,10 @@ async fn download_to_path_inner(
|
||||
Ok(chunk) => chunk,
|
||||
Err(error) => {
|
||||
let decode_failure = error.is_decode();
|
||||
if is_h2_protocol_failure(&error)
|
||||
if (is_h2_protocol_failure(&error)
|
||||
|| (decode_failure
|
||||
&& http_version
|
||||
== reqwest::Version::HTTP_2))
|
||||
&& let Some(authority) =
|
||||
url_authority(&final_url)
|
||||
{
|
||||
|
||||
@ -56,7 +56,7 @@
|
||||
|
||||
管理员在「Mod 管理 → 管理整合包 → 整合包 Mod 标签」选择已有常用标签。标签按现有规则集合匹配 Mod,每次启动独立获取最新清单并检查标签文件 SHA-256;包标志相同也不跳过这一步。服务端保存内容快照,上传新 Mod 不会破坏正在下载的旧快照。设置审计保存于 `launcher_modpack_tag_audit`,快照登记于 `launcher_tagged_mod_object`,文件位于 `launcher-modpacks/tagged-mods`,不得公开挂载。
|
||||
|
||||
标签 Mod 优先于整合包中同标识的旧 Mod,安装路径为 `mods/{modId}.starlight.jar`。完整识别一个 JAR 声明的所有 Mod;部分覆盖多 Mod JAR 时明确报错,不擅自删除其他 Mod。相同标识的旧文件与新文件统一在下载校验完成后通过恢复日志替换。标签取消或 Mod 移出标签后,移除启动器管理的标签文件并恢复仍在整合包清单中的原版;用户其他 Mod 不作为删除目标。CurseForge 引用保存项目、文件及 Mod 身份,清理缓存后仍能使用原下载源恢复。
|
||||
整合包自带 Mod 是安装基线,之后再校验标签 Mod。标签快照与整合包 JAR 的 SHA-256 相同时保留整合包原文件和原文件名,不重复下载或改名;只有内容确实不同时,标签 Mod 才优先替换同标识的旧 Mod,安装路径为 `mods/{modId}.starlight.jar`。完整识别一个 JAR 声明的所有 Mod;部分覆盖多 Mod JAR 时明确报错,不擅自删除其他 Mod。相同标识且内容不同的旧文件与新文件统一在下载校验完成后通过恢复日志替换。标签取消或 Mod 移出标签后,移除启动器管理的标签文件并恢复仍在整合包清单中的原版;用户其他 Mod 不作为删除目标。CurseForge 引用保存项目、文件及 Mod 身份,清理缓存后仍能使用原下载源恢复。
|
||||
|
||||
标签下载使用启动器有效并发设置,上限 16 个文件,复用现有下载重试、连接管理与完整性校验。独立弹窗逐个显示文件字节进度,下载页同时显示这些任务;所有文件成功下载、替换后才允许启动,任一失败则保留错误并阻止启动。再次尝试会清理上一轮弹窗进度和失败任务。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user