Compare commits

...
8 Commits
Author SHA1 Message Date
Disy 0b30fc96a0 fix: 复用皮肤站逻辑并稳定玩家头像
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-09-14 23:06:03 +08:00
Disy 2a1dfa3c42 feat: 添加获批整合包同步与 StarLight 实例模式 2026-09-14 22:59:16 +08:00
Disy fbcaed528b feat: 完善 StarLight 皮肤站集成 2026-09-14 11:29:48 +08:00
AxTps 9dd2b013af merge: 合并远端 main 分支 2026-09-13 22:16:12 +08:00
AxTps c50595b252 fix:正版登陆的文字显示问题 2026-09-13 22:16:07 +08:00
Disy 3471cd3da2 feat: 嵌入 StarLight 皮肤站并同步登录状态 2026-09-13 21:34:09 +08:00
Disy 1593ac7a7c refactor: 移除遥测并修复启动器流程 2026-09-13 20:32:57 +08:00
Disy 1fa56add19 feat: 添加星光矿域彩蛋 2026-09-13 20:32:57 +08:00
220 changed files with 6766 additions and 13269 deletions

No files matched your search

Generated
+13 -38
View File
@@ -106,7 +106,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.19",
"utoipa",
"uuid 1.24.0",
"uuid",
]
[[package]]
@@ -707,7 +707,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
dependencies = [
"byteorder",
"fnv",
"uuid 1.24.0",
"uuid",
]
[[package]]
@@ -1431,21 +1431,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "discord-rich-presence"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90c55d69cab17c19677ce3a5f8face993a9e6eaf847fecac3547f3a3ff4a2494"
dependencies = [
"log",
"serde",
"serde_derive",
"serde_json",
"serde_repr",
"thiserror 2.0.19",
"uuid 0.8.2",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
@@ -3570,7 +3555,7 @@ dependencies = [
"portable-atomic",
"smallvec",
"tagptr",
"uuid 1.24.0",
"uuid",
]
[[package]]
@@ -5300,7 +5285,7 @@ dependencies = [
"serde",
"serde_json",
"url",
"uuid 1.24.0",
"uuid",
]
[[package]]
@@ -5904,7 +5889,7 @@ dependencies = [
"tokio-stream",
"tracing",
"url",
"uuid 1.24.0",
"uuid",
]
[[package]]
@@ -5984,7 +5969,7 @@ dependencies = [
"stringprep",
"thiserror 2.0.19",
"tracing",
"uuid 1.24.0",
"uuid",
"whoami",
]
@@ -6022,7 +6007,7 @@ dependencies = [
"stringprep",
"thiserror 2.0.19",
"tracing",
"uuid 1.24.0",
"uuid",
"whoami",
]
@@ -6048,7 +6033,7 @@ dependencies = [
"thiserror 2.0.19",
"tracing",
"url",
"uuid 1.24.0",
"uuid",
]
[[package]]
@@ -6454,7 +6439,7 @@ dependencies = [
"thiserror 2.0.19",
"time",
"url",
"uuid 1.24.0",
"uuid",
"walkdir",
]
@@ -6762,7 +6747,7 @@ dependencies = [
"toml 1.1.3+spec-1.1.0",
"url",
"urlpattern",
"uuid 1.24.0",
"uuid",
"walkdir",
]
@@ -6833,7 +6818,6 @@ dependencies = [
"data-url",
"derive_more",
"dirs",
"discord-rich-presence",
"dotenvy",
"dunce",
"either",
@@ -6901,7 +6885,7 @@ dependencies = [
"tracing-subscriber",
"url",
"urlencoding",
"uuid 1.24.0",
"uuid",
"webpki-roots",
"whoami",
"windows 0.61.3",
@@ -6967,7 +6951,7 @@ dependencies = [
"trash",
"url",
"urlencoding",
"uuid 1.24.0",
"uuid",
"windows 0.61.3",
"zip 6.0.0",
]
@@ -7691,15 +7675,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "uuid"
version = "1.24.0"
@@ -8868,7 +8843,7 @@ dependencies = [
"tokio",
"tracing",
"uds_windows",
"uuid 1.24.0",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.4",
"zbus_macros",
-1
View File
@@ -41,7 +41,6 @@ dashmap = "6.1.0"
data-url = "0.3.2"
derive_more = "2.1.1"
dirs = "6.0.0"
discord-rich-presence = "1.0.0"
dotenvy = "0.15.7"
dunce = "1.0.5"
either = "1.15.0"
+10 -105
View File
@@ -3,7 +3,6 @@ import { AuthFeature, TauriModrinthClient, VerboseLoggingFeature } from '@modrin
import {
ChangeSkinIcon,
CompassIcon,
GlobeIcon,
DownloadIcon,
ExternalIcon,
FlaskConicalIcon,
@@ -57,13 +56,13 @@ import SymlinkMethodCards from '@modrinth/ui/src/components/flows/drop/SymlinkMe
import { useQuery } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { hideAllPoppers } from 'floating-vue'
import { listen } from '@tauri-apps/api/event'
import { Effect, getCurrentWindow } from '@tauri-apps/api/window'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { openUrl } from '@tauri-apps/plugin-opener'
import { type as getOsType } from '@tauri-apps/plugin-os'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { hideAllPoppers } from 'floating-vue'
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { type RouteLocationNormalizedLoaded, RouterView, useRoute, useRouter } from 'vue-router'
@@ -85,20 +84,19 @@ import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerMod
import JavaDownloadConfirmationModal from '@/components/ui/modal/JavaDownloadConfirmationModal.vue'
import ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import ModpackInstallModal from '@/components/ui/modal/ModpackInstallModal.vue'
import PrivacyConsentModal from '@/components/ui/modal/PrivacyConsentModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import NavButton from '@/components/ui/NavButton.vue'
import NavRail from '@/components/ui/NavRail.vue'
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 WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { useDropImport } from '@/composables/useDropImport'
import { minecraftLaunchErrorKey } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { AxolotlBrandConfig, config, getOfficialLabrinthBaseUrl } from '@/config'
import { trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { configureCurseForgeManualDownloadWatcher } from '@/helpers/curseforge'
@@ -119,16 +117,13 @@ import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
import {
get as getSettings,
getLastBrowseContentProjectType,
getPrivacySettings,
getUpdateChannel,
getUpdatePreferences,
isBrowseContentProjectType,
type PrivacySettings,
savePrivacySettings,
set as setSettings,
} from '@/helpers/settings.ts'
import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts'
import { get_opening_command, initialize_state, set_discord_activity } from '@/helpers/state'
import { get_opening_command, initialize_state } from '@/helpers/state'
import {
areUpdatesEnabled,
backupAppDbForUpdate,
@@ -369,8 +364,6 @@ watch(
)
const stateInitialized = ref(false)
const privacyConsentModal = ref<InstanceType<typeof PrivacyConsentModal>>()
const privacyConsentPending = ref(false)
const closeChoiceModal = ref<InstanceType<typeof NewModal>>()
const closeChoiceOpen = ref(false)
const closeChoiceRemember = ref(false)
@@ -737,10 +730,6 @@ const messages = defineMessages({
id: 'app.navigation.skin-selector',
defaultMessage: 'Skin selector',
},
starlightSkin: {
id: 'app.navigation.starlight-skin',
defaultMessage: '斯达莱特',
},
library: {
id: 'app.navigation.library',
defaultMessage: 'Library',
@@ -765,9 +754,9 @@ const messages = defineMessages({
id: 'app.account.signed-in-as',
defaultMessage: 'Signed in as',
},
playingAs: {
id: 'app.minecraft.playing-as',
defaultMessage: 'Playing as',
userInformation: {
id: 'app.minecraft.user-information',
defaultMessage: 'User information',
},
collapseSidebar: {
id: 'app.sidebar.collapse',
@@ -1055,9 +1044,6 @@ async function setupApp() {
theme,
accent_color,
locale,
telemetry,
telemetry_consent_version,
discord_rpc,
collapsed_navigation,
hide_nametag_skins_page,
advanced_rendering,
@@ -1101,7 +1087,6 @@ async function setupApp() {
const dev = await isDev()
isDevEnvironment.value = dev
if (!onboarded && route.path !== '/') await router.replace('/')
privacyConsentPending.value = telemetry_consent_version < 1
showOnboarding.value = false
onboardingSettings.value = initialSettings
@@ -1132,16 +1117,7 @@ async function setupApp() {
themeStore.devMode = developer_mode
themeStore.featureFlags = feature_flags
stateInitialized.value = true
if (privacyConsentPending.value) {
await nextTick()
privacyConsentModal.value?.show({
telemetry,
discord_rpc,
consent_version: telemetry_consent_version,
})
} else {
showOnboarding.value = !onboarded
}
showOnboarding.value = !onboarded
void reconcileMojangAuthSourceAtStartup().catch(handleError)
isMaximized.value = await getCurrentWindow().isMaximized()
@@ -1274,42 +1250,7 @@ async function closeOnboardingSettings() {
}
async function scheduleStartupDialogs() {
if (!stateInitialized.value || privacyConsentPending.value || showOnboarding.value) return
}
async function handlePrivacyConsentSaved(privacy: PrivacySettings) {
privacyConsentPending.value = false
if (onboardingSettings.value) {
onboardingSettings.value.telemetry = privacy.telemetry
onboardingSettings.value.discord_rpc = privacy.discord_rpc
onboardingSettings.value.telemetry_consent_version = privacy.consent_version
}
if (!onboardingSettings.value?.onboarded) {
startOnboarding('main')
} else {
await scheduleStartupDialogs()
}
}
async function previewPrivacyConsentModal() {
try {
const current = await getPrivacySettings()
const privacy = await savePrivacySettings({
telemetry: false,
discord_rpc: current.discord_rpc,
consent_version: 0,
})
privacyConsentPending.value = true
if (onboardingSettings.value) {
onboardingSettings.value.telemetry = privacy.telemetry
onboardingSettings.value.discord_rpc = privacy.discord_rpc
onboardingSettings.value.telemetry_consent_version = privacy.consent_version
}
await nextTick()
privacyConsentModal.value?.show(privacy)
} catch (error) {
handleError(error)
}
if (!stateInitialized.value || showOnboarding.value) return
}
provide('replayOnboarding', replayOnboarding)
@@ -1320,7 +1261,6 @@ provide(
)
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
provide('showLauncherPopup', (_request: unknown) => {})
provide('previewPrivacyConsentModal', previewPrivacyConsentModal)
const stateFailed = ref(false)
stateInitialization
@@ -1429,9 +1369,6 @@ loading.setEnabled(false)
let initialLoadToken = loading.begin()
let routerToken = null
let suspenseToken = null
let lastDiscordActivity = null
let discordActivityUpdate = Promise.resolve()
let suspensePending = false
const sidebarOverlayScrollbarsOptions = Object.freeze({
@@ -1447,31 +1384,11 @@ router.beforeEach(() => {
routerToken = loading.begin()
})
function syncDiscordActivity(to: RouteLocationNormalizedLoaded) {
const activity =
typeof to.meta.discordActivity === 'string' ? to.meta.discordActivity : 'Idling...'
if (activity === lastDiscordActivity) return
lastDiscordActivity = activity
discordActivityUpdate = discordActivityUpdate
.then(() => set_discord_activity(activity))
.catch((error) => {
if (lastDiscordActivity === activity) lastDiscordActivity = null
console.error('Failed to update Discord activity', error)
})
}
router.afterEach((to, from, failure) => {
hideAllPoppers()
if (!failure) void invoke('lightweight_mode_set_route', { route: to.fullPath })
trackEvent('PageView', {
path: to.path,
fromPath: from.path,
failed: failure,
})
if (!failure) {
void directLinkSync?.()
if (stateInitialized.value) syncDiscordActivity(to)
}
setTimeout(() => {
if (!suspensePending && stateInitialized.value) {
@@ -1508,7 +1425,6 @@ watch(
stateInitialized,
(ready) => {
if (ready) {
syncDiscordActivity(router.currentRoute.value)
if (initialLoadToken) {
loading.end(initialLoadToken)
initialLoadToken = null
@@ -1624,7 +1540,6 @@ const dropImport = useDropImport({
onSkinsPage,
onSchematicWorkshopPage,
isSchematicFile,
trackEvent,
router,
})
@@ -1836,9 +1751,6 @@ async function handleCommand(e) {
} else {
await install_create_modpack_instance(location).catch(handleError)
}
trackEvent('InstanceCreate', {
source: 'CreationModalFileDrop',
})
}
} else if (e.event === 'LaunchInstance') {
const instance = await getInstance(e.id).catch(() => null)
@@ -2240,6 +2152,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<template>
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<SkinSiteSessionFrame v-if="stateInitialized" />
<div id="teleports"></div>
<div
v-if="stateInitialized && themeStore.customBackgroundPath && !themeStore.transparentBackground"
@@ -2321,13 +2234,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<ChangeSkinIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.starlightSkin)"
data-onboarding-id="nav-starlight-skin"
to="/starlight-skin"
>
<GlobeIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.library)"
data-onboarding-id="nav-library"
@@ -2553,7 +2459,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }">
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.playingAs) }}
{{ formatMessage(messages.userInformation) }}
</h3>
<suspense>
<AccountsCard ref="accounts" />
@@ -2577,7 +2483,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
/>
<MinecraftCrashModal ref="minecraftCrashModal" @error="handleError" />
<JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" />
<PrivacyConsentModal ref="privacyConsentModal" @saved="handlePrivacyConsentSaved" />
<NewModal
ref="closeChoiceModal"
:header="formatMessage(messages.closeLauncherTitle)"
@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<title>幸运四叶草</title>
<path
d="M33 34C39.5 38.5 44.5 46 47 56"
stroke="var(--clover-edge, #16883F)"
stroke-width="5.5"
stroke-linecap="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
transform="rotate(90 32 32)"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
transform="rotate(180 32 32)"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<path
d="M32 32C24 29 17 24 17 17.5C17 12.2 21 8 26 8C28.8 8 31 9.7 32 12.2C33 9.7 35.2 8 38 8C43 8 47 12.2 47 17.5C47 24 40 29 32 32Z"
transform="rotate(270 32 32)"
fill="var(--clover-leaf, #42C95A)"
stroke="var(--clover-edge, #16883F)"
stroke-width="2.2"
stroke-linejoin="round"
/>
<circle cx="32" cy="32" r="3.7" fill="var(--clover-edge, #16883F)" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -28,7 +28,6 @@ import LegacyProjectCard from '@/components/ui/LegacyProjectCard.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { install_duplicate_instance } from '@/helpers/install'
import { kill, remove, run } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process.js'
@@ -158,17 +157,9 @@ const handleOptionsClick = async (args) => {
})
if (!handled) handleSevereError(err, { instanceId: args.item.id })
})
trackEvent('InstanceStart', {
loader: args.item.loader,
game_version: args.item.game_version,
})
break
case 'stop':
await kill(args.item.id).catch(handleError)
trackEvent('InstanceStop', {
loader: args.item.loader,
game_version: args.item.game_version,
})
break
case 'add_content':
await router.push({
@@ -18,7 +18,6 @@ import { computed, ref, watch } from 'vue'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import {
type DailyPlaytime,
type DailyPlaytimeEntry,
@@ -200,11 +199,6 @@ function selectDay(dateKey: string) {
async function playInstance(instance: GameInstance) {
try {
await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeCalendar',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
@@ -216,11 +210,6 @@ async function playInstance(instance: GameInstance) {
async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeCalendar',
})
}
watch(() => anchor.value.getTime(), refreshPlaytime, { immediate: true })
@@ -1,35 +1,70 @@
<script setup lang="ts">
import { ExternalIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import LuckyCloverIcon from '@/assets/icons/lucky-clover.svg'
import {
openSkinSiteLogin,
requestSkinSiteLuck,
skinSiteUser,
} from '@/composables/skin-site-session'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
widgetTitle: { id: 'app.home.skin-site.title', defaultMessage: 'StarLight skin site' },
luckyIndex: { id: 'app.home.skin-site.lucky-index', defaultMessage: 'Lucky index' },
jump: { id: 'app.home.skin-site.jump', defaultMessage: 'Jump to StarLight skin site' },
widgetTitle: { id: 'app.home.luck.title', defaultMessage: 'Daily luck index' },
getLuck: { id: 'app.home.luck.get', defaultMessage: 'Get with one click' },
loginToGet: { id: 'app.home.luck.login-to-get', defaultMessage: 'Sign in to get it' },
loading: { id: 'app.home.luck.loading', defaultMessage: 'Asking the little sprite...' },
error: {
id: 'app.home.luck.error',
defaultMessage: 'The little sprite did not answer. Please try again later.',
},
outOf: { id: 'app.home.luck.out-of', defaultMessage: 'out of 100' },
})
const SKIN_PROFILE_URL = 'https://skin.starlight.cool/profile'
const SKIN_HOME_URL = 'https://skin.starlight.cool/'
const luck = ref<number | null>(null)
const loading = ref(false)
const failed = ref(false)
const waitingForLogin = ref(false)
const signedIn = computed(() => Boolean(skinSiteUser.value))
async function openProfile() {
async function fetchLuck() {
if (loading.value) return
loading.value = true
failed.value = false
try {
await openUrl(SKIN_PROFILE_URL)
} catch (error) {
handleError(error)
luck.value = await requestSkinSiteLuck()
} catch {
failed.value = true
} finally {
loading.value = false
}
}
async function openSkinSite() {
try {
await openUrl(SKIN_HOME_URL)
} catch (error) {
handleError(error)
function handlePrimaryAction() {
if (signedIn.value) {
void fetchLuck()
return
}
waitingForLogin.value = true
openSkinSiteLogin()
}
watch(
() => skinSiteUser.value?.uuid,
(uuid, previousUuid) => {
if (uuid === previousUuid) return
luck.value = null
failed.value = false
if (uuid && waitingForLogin.value) {
waitingForLogin.value = false
void fetchLuck()
} else if (!uuid) {
waitingForLogin.value = false
}
},
)
</script>
<template>
@@ -37,32 +72,72 @@ async function openSkinSite() {
class="flex min-w-0 flex-col gap-3 border-0 border-b-[1px] border-solid border-[--brand-gradient-border] p-4"
>
<div class="flex items-center gap-2">
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
<LuckyCloverIcon class="lucky-clover size-5 shrink-0" aria-hidden="true" />
<h2 class="m-0 truncate text-lg">
{{ formatMessage(messages.widgetTitle) }}
</h2>
</div>
<div class="flex flex-col gap-2">
<button
type="button"
class="flex w-full cursor-pointer items-center justify-between rounded-lg border border-[--brand-gradient-border] bg-transparent px-3 py-2 text-left transition-colors hover:bg-button-bg"
@click="openProfile"
>
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.luckyIndex) }}
</span>
<ExternalIcon class="size-4 shrink-0 text-secondary" aria-hidden="true" />
</button>
<div class="flex flex-col gap-3">
<div v-if="loading" class="luck-feedback" role="status" aria-live="polite">
<LuckyCloverIcon
class="lucky-clover lucky-clover-loading size-7 shrink-0"
aria-hidden="true"
/>
<span>{{ formatMessage(messages.loading) }}</span>
</div>
<div v-else-if="luck !== null" class="luck-feedback" aria-live="polite">
<strong class="text-3xl leading-none text-contrast">{{ luck }}</strong>
<span class="text-xs text-secondary">{{ formatMessage(messages.outOf) }}</span>
</div>
<p v-else-if="failed" class="m-0 text-sm leading-5 text-secondary" role="alert">
{{ formatMessage(messages.error) }}
</p>
<button
type="button"
class="flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-[--brand-gradient] px-3 py-2 font-semibold text-[var(--color-contrast)] transition-opacity hover:opacity-90"
@click="openSkinSite"
>
<ExternalIcon class="size-4 shrink-0" aria-hidden="true" />
<span>{{ formatMessage(messages.jump) }}</span>
</button>
<ButtonStyled color="brand" class="w-full">
<button type="button" class="w-full" :disabled="loading" @click="handlePrimaryAction">
{{ formatMessage(signedIn ? messages.getLuck : messages.loginToGet) }}
</button>
</ButtonStyled>
</div>
</section>
</template>
<style scoped>
.lucky-clover {
--clover-leaf: color-mix(in srgb, #42c95a 88%, var(--color-brand));
--clover-edge: color-mix(in srgb, #16883f 88%, var(--color-brand));
}
.luck-feedback {
display: flex;
min-height: 2.75rem;
align-items: center;
justify-content: center;
gap: 0.5rem;
font-weight: 600;
color: var(--color-secondary);
text-align: center;
}
.lucky-clover-loading {
transform-origin: center;
animation: clover-wait 850ms ease-in-out infinite;
}
@keyframes clover-wait {
0%,
100% {
transform: translateY(1px) rotate(-5deg) scale(0.96);
}
50% {
transform: translateY(-2px) rotate(6deg) scale(1.04);
}
}
@media (prefers-reduced-motion: reduce) {
.lucky-clover-loading {
animation: none;
}
}
</style>
@@ -25,7 +25,6 @@ import HomeGreeting from '@/components/home/HomeGreeting.vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
@@ -126,11 +125,6 @@ async function playInstance() {
loading.value = true
try {
await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeMinimal',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
@@ -149,11 +143,6 @@ async function stopInstance() {
await kill(instance.id).catch(handleError)
running.value = false
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeMinimal',
})
}
async function installInstance() {
@@ -22,7 +22,6 @@ import { computed, ref } from 'vue'
import type { HomeWidgetSize } from '@/components/home/home-dashboard'
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import {
@@ -107,11 +106,6 @@ async function joinServer(world: ServerWorld & WorldWithInstance, instance: Game
try {
await start_join_server(world.instance_id, world.address)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedServer',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
@@ -125,11 +119,6 @@ async function joinServer(world: ServerWorld & WorldWithInstance, instance: Game
async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedServer',
})
}
async function unpinServer(world: ServerWorld & WorldWithInstance) {
@@ -7,7 +7,6 @@ import { getHomeWidgetCardDensity, type HomeWidgetSize } from '@/components/home
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill, run } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import {
@@ -73,11 +72,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
try {
await start_join_singleplayer_world(world.instance_id, world.path)
playingWorldKey.value = key
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedWorld',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
@@ -92,11 +86,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
async function playInstance(instance: GameInstance) {
try {
await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedWorld',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
@@ -109,11 +98,6 @@ async function playInstance(instance: GameInstance) {
async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError)
playingWorldKey.value = null
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomePinnedWorld',
})
}
</script>
@@ -15,7 +15,6 @@ import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtim
import InstanceItem from '@/components/ui/world/InstanceItem.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill, run } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import {
@@ -117,11 +116,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
await start_join_singleplayer_world(world.instance_id, world.path)
}
playingWorldKey.value = key
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeRecentWorld',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
@@ -136,11 +130,6 @@ async function joinWorld(world: WorldWithInstance, instance: GameInstance) {
async function playInstance(instance: GameInstance) {
try {
await run(instance.id)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeRecentWorld',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.id,
@@ -153,11 +142,6 @@ async function playInstance(instance: GameInstance) {
async function stopInstance(instance: GameInstance) {
await kill(instance.id).catch(handleError)
playingWorldKey.value = null
trackEvent('InstanceStop', {
loader: instance.loader,
game_version: instance.game_version,
source: 'HomeRecentWorld',
})
}
</script>
@@ -28,7 +28,6 @@ import type { HomeWidgetPlacement, HomeWidgetSize } from '@/components/home/home
import { useHomeDashboardRuntime } from '@/components/home/home-dashboard-runtime'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { kill, run } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import {
@@ -205,11 +204,6 @@ async function playInstance(targetInstance: GameInstance) {
starting.value = true
try {
await run(targetInstance.id)
trackEvent('InstanceStart', {
loader: targetInstance.loader,
game_version: targetInstance.game_version,
source: 'HomeInstanceWidget',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: targetInstance.id,
@@ -230,11 +224,6 @@ async function playWorld() {
} else {
await start_join_singleplayer_world(instance.value.id, world.value.path)
}
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: 'HomeShortcutWidget',
})
} catch (error) {
const handled = await handleMinecraftLaunchError(error, {
instance_id: instance.value.id,
@@ -0,0 +1,214 @@
<template>
<InstanceModeSettings :instance-id="instanceId" :disabled="syncing" class="mb-5" />
<div v-if="modeQuery.data.value === 'local'" class="flex flex-col gap-3">
<h2 class="m-0">{{ formatMessage(messages.localTitle) }}</h2>
<p class="m-0 text-secondary">{{ formatMessage(messages.localPacks) }}</p>
<div class="flex flex-wrap gap-3">
<ButtonStyled
><button type="button" @click="router.push('/browse/modpack')">
{{ formatMessage(messages.browse) }}
</button></ButtonStyled
>
<ButtonStyled
><button type="button" @click="importLocal">
{{ formatMessage(messages.importLocal) }}
</button></ButtonStyled
>
</div>
</div>
<template v-if="modeQuery.data.value === 'starlight'">
<section class="flex flex-col gap-4" :aria-busy="loading || syncing">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0">{{ formatMessage(messages.title) }}</h2>
<p>{{ formatMessage(messages.description) }}</p>
</div>
<ButtonStyled
><button type="button" :disabled="loading || syncing" @click="load">
{{ formatMessage(messages.refresh) }}
</button></ButtonStyled
>
</div>
<p v-if="loading" role="status">{{ formatMessage(messages.loading) }}</p>
<p v-if="error" role="alert" class="text-red">{{ error }}</p>
<p v-if="binding">
{{
formatMessage(messages.bound, {
name: binding.publication.manifest.name,
version: binding.publication.manifest.version,
})
}}
</p>
<p v-if="syncing" role="status">{{ formatMessage(messages.syncing) }}</p>
<div v-if="result" role="status" class="rounded-xl bg-bg-raised p-4">
<p>
{{
formatMessage(messages.complete, {
version: result.version,
count: result.changedFiles,
size: (result.downloadedBytes / 1048576).toFixed(2),
})
}}
</p>
<details v-if="result.preservedFiles.length">
<summary>
{{ formatMessage(messages.preserved, { count: result.preservedFiles.length }) }}
</summary>
<ul>
<li v-for="path in result.preservedFiles" :key="path">{{ path }}</li>
</ul>
</details>
</div>
<p v-if="!loading && ready && !catalog.length">{{ formatMessage(messages.empty) }}</p>
<article
v-for="pack in catalog"
:key="pack.packId"
class="flex flex-wrap items-center justify-between gap-4 rounded-xl bg-bg-raised p-4"
>
<div>
<h3 class="m-0">{{ pack.manifest.name }}</h3>
<p class="mb-0">
{{ pack.manifest.version }} · Minecraft {{ pack.manifest.runtime.gameVersion }} ·
{{ pack.manifest.runtime.loader }}
</p>
</div>
<ButtonStyled color="brand"
><button
type="button"
:disabled="
!ready ||
syncing ||
loading ||
(!!binding && binding.publication.packId !== pack.packId)
"
@click="sync(pack.packId)"
>
{{ formatMessage(binding ? messages.update : messages.install) }}
</button></ButtonStyled
>
</article>
</section>
</template>
</template>
<script setup lang="ts">
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { inject, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
import { useInstanceMode } from '@/composables/useInstanceMode'
import {
type HostedBinding,
hostedBinding,
hostedCatalog,
type HostedPublication,
hostedSync,
type HostedSyncResult,
} from '@/helpers/hosted-packs'
const props = defineProps<{ instanceId: string }>()
const modeQuery = useInstanceMode(() => props.instanceId)
const router = useRouter()
const showCreation = inject<
(options: { skipSetupType: boolean; initialMode: 'import'; instanceMode: 'local' }) => void
>('showCreationModalWithOptions')
function importLocal() {
showCreation?.({ skipSetupType: true, initialMode: 'import', instanceMode: 'local' })
}
const { formatMessage } = useVIntl()
const messages = defineMessages({
localTitle: { id: 'app.instance-mode.local-packs-title', defaultMessage: 'Local modpacks' },
localPacks: {
id: 'app.instance-mode.local-packs',
defaultMessage:
'Choose any modpack to install as a local instance. Existing files and worlds are kept when you switch to Local; StarLight changes will no longer be synchronized.',
},
browse: { id: 'app.instance-mode.browse', defaultMessage: 'Browse modpacks' },
importLocal: { id: 'app.instance-mode.import', defaultMessage: 'Import as a local instance' },
title: { id: 'app.hosted-packs.title', defaultMessage: 'Published modpacks' },
description: {
id: 'app.hosted-packs.description',
defaultMessage:
'Install an approved modpack into this instance. Future online launches automatically download changed files. Use a separate empty instance for each pack; saves and personal settings are preserved.',
},
refresh: { id: 'app.hosted-packs.refresh', defaultMessage: 'Refresh' },
loading: { id: 'app.hosted-packs.loading', defaultMessage: 'Loading published modpacks…' },
bound: { id: 'app.hosted-packs.bound', defaultMessage: 'Installed: {name} · {version}' },
syncing: {
id: 'app.hosted-packs.syncing',
defaultMessage: 'Comparing files and synchronizing changes…',
},
complete: {
id: 'app.hosted-packs.complete',
defaultMessage: 'Synced to {version}. Changed {count} files; downloaded {size} MiB.',
},
preserved: {
id: 'app.hosted-packs.preserved',
defaultMessage: 'Preserved {count} locally modified or personal files',
},
empty: {
id: 'app.hosted-packs.empty',
defaultMessage: 'No modpacks have been approved for publication yet.',
},
update: { id: 'app.hosted-packs.update', defaultMessage: 'Synchronize now' },
install: { id: 'app.hosted-packs.install', defaultMessage: 'Install and enable automatic sync' },
})
const catalog = ref<HostedPublication[]>([])
const binding = ref<HostedBinding | null>(null)
const result = ref<HostedSyncResult | null>(null)
const loading = ref(false)
const syncing = ref(false)
const ready = ref(false)
const error = ref('')
let generation = 0
async function load() {
if (modeQuery.data.value !== 'starlight') return
const current = ++generation
const instanceId = props.instanceId
loading.value = true
ready.value = false
error.value = ''
try {
const [packs, installed] = await Promise.all([hostedCatalog(), hostedBinding(instanceId)])
if (current !== generation) return
catalog.value = packs
binding.value = installed
ready.value = true
} catch (cause) {
if (current === generation) error.value = String(cause)
} finally {
if (current === generation) loading.value = false
}
}
async function sync(packId: string) {
if (syncing.value || !ready.value || modeQuery.data.value !== 'starlight') return
const instanceId = props.instanceId
syncing.value = true
error.value = ''
result.value = null
try {
const completed = await hostedSync(instanceId, packId)
if (props.instanceId !== instanceId) return
result.value = completed
await load()
} catch (cause) {
if (props.instanceId === instanceId) error.value = String(cause)
} finally {
syncing.value = false
}
}
watch(
() => [props.instanceId, modeQuery.data.value] as const,
() => {
generation++
loading.value = false
ready.value = false
error.value = ''
catalog.value = []
binding.value = null
result.value = null
void load()
},
{ immediate: true },
)
</script>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { useId } from 'vue'
import type { InstanceMode } from '@/helpers/hosted-packs'
defineProps<{ modelValue?: InstanceMode; disabled?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: InstanceMode] }>()
const group = useId()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.instance-mode.title', defaultMessage: 'Instance type' },
starlight: { id: 'app.instance-mode.starlight', defaultMessage: 'StarLight instance' },
local: { id: 'app.instance-mode.local', defaultMessage: 'Local instance' },
starlightDescription: {
id: 'app.instance-mode.starlight-description',
defaultMessage:
'Automatically syncs changes published by the StarLight server. Required for playing on StarLight. Select and install the official modpack in Mod management before your first launch.',
},
localDescription: {
id: 'app.instance-mode.local-description',
defaultMessage:
'Does not sync StarLight server changes. Skips sync checks for faster startup and lets you choose your own modpacks. Best for third-party servers and personal single-player worlds.',
},
})
</script>
<template>
<fieldset class="m-0 flex flex-col gap-3 border-0 p-0" :disabled="disabled">
<legend class="mb-3 text-lg font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</legend>
<label
v-for="mode in ['starlight', 'local'] as const"
:key="mode"
class="flex items-start gap-3 rounded-xl border border-solid border-divider p-4"
:class="[
modelValue === mode ? 'bg-surface-3' : 'bg-surface-1',
disabled ? 'opacity-60' : 'cursor-pointer',
]"
>
<input
type="radio"
class="mt-1"
:name="group"
:value="mode"
:checked="modelValue === mode"
@change="emit('update:modelValue', mode)"
/>
<span class="flex flex-col gap-1"
><span class="font-semibold text-contrast">{{ formatMessage(messages[mode]) }}</span
><span class="text-sm text-secondary">{{
formatMessage(
mode === 'starlight' ? messages.starlightDescription : messages.localDescription,
)
}}</span></span
>
</label>
</fieldset>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, watch } from 'vue'
import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
import { useInstanceMode, useSetInstanceMode } from '@/composables/useInstanceMode'
import type { InstanceMode } from '@/helpers/hosted-packs'
const props = defineProps<{ instanceId: string; disabled?: boolean }>()
const query = useInstanceMode(() => props.instanceId)
const save = useSetInstanceMode()
const { formatMessage } = useVIntl()
const messages = defineMessages({
saving: { id: 'app.instance-mode.saving', defaultMessage: 'Saving instance type…' },
loading: { id: 'app.instance-mode.loading', defaultMessage: 'Loading instance type…' },
retry: { id: 'app.instance-mode.retry', defaultMessage: 'Retry' },
})
const saving = computed(
() => save.isPending.value && save.variables.value?.instanceId === props.instanceId,
)
const selection = computed(() => (saving.value ? save.variables.value?.mode : query.data.value))
const failure = computed(
() =>
query.error.value ??
(save.variables.value?.instanceId === props.instanceId ? save.error.value : null),
)
function select(mode: InstanceMode) {
if (props.disabled || saving.value || !query.data.value || query.data.value === mode) return
save.mutate({ instanceId: props.instanceId, mode })
}
function retry() {
if (props.disabled || saving.value) return
if (query.error.value) void query.refetch()
else if (save.variables.value?.instanceId === props.instanceId) save.mutate(save.variables.value)
}
watch(
() => props.instanceId,
() => save.reset(),
)
</script>
<template>
<div class="flex flex-col gap-3">
<InstanceModeOptions
:model-value="selection"
:disabled="disabled || !query.data.value || query.isPending.value || saving"
@update:model-value="select"
/>
<p v-if="query.isPending.value || saving" class="m-0 text-secondary" role="status">
{{ formatMessage(saving ? messages.saving : messages.loading) }}
</p>
<div v-if="failure" role="alert" class="flex items-center gap-3">
<span>{{ String(failure) }}</span
><ButtonStyled
><button
type="button"
:disabled="disabled || query.isFetching.value || saving"
@click="retry"
>
{{ formatMessage(messages.retry) }}
</button></ButtonStyled
>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<template>
<div
class="management-switch relative isolate mb-4 flex w-full max-w-[26rem] rounded-xl bg-surface-1 p-1"
:data-packs="selected === 'packs'"
role="group"
:aria-label="formatMessage(messages.label)"
>
<span
class="selection absolute inset-y-1 left-1 -z-10 w-[calc(50%_-_4px)] rounded-lg bg-surface-3 shadow-sm"
aria-hidden="true"
/>
<button
class="flex-1 cursor-pointer border-0 bg-transparent px-4 py-2.5 font-semibold text-base aria-pressed:text-contrast"
type="button"
:aria-pressed="selected === 'mods'"
@click="select('mods')"
>
{{ formatMessage(messages.mods) }}
</button>
<button
class="flex-1 cursor-pointer border-0 bg-transparent px-4 py-2.5 font-semibold text-base aria-pressed:text-contrast"
type="button"
:aria-pressed="selected === 'packs'"
@click="select('packs')"
>
{{ formatMessage(messages.packs) }}
</button>
</div>
<div v-show="selected === 'mods'"><slot /></div>
<div v-if="openedPacks" v-show="selected === 'packs'"><slot name="packs" /></div>
</template>
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
label: { id: 'app.hosted-packs.management', defaultMessage: 'Content management' },
mods: { id: 'app.hosted-packs.mods', defaultMessage: 'Manage mods' },
packs: { id: 'app.hosted-packs.packs', defaultMessage: 'Manage modpacks' },
})
const key = 'starlight:instance:mod-management-tab'
function initial(): 'mods' | 'packs' {
try {
return localStorage.getItem(key) === 'packs' ? 'packs' : 'mods'
} catch {
return 'mods'
}
}
const selected = ref(initial())
const openedPacks = ref(selected.value === 'packs')
function select(value: 'mods' | 'packs') {
selected.value = value
if (value === 'packs') openedPacks.value = true
try {
localStorage.setItem(key, value)
} catch {
/* Selection remains usable without storage. */
}
}
</script>
<style scoped>
.selection {
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
[data-packs='true'] .selection {
transform: translateX(100%);
}
@media (prefers-reduced-motion: reduce) {
.selection {
transition: none;
}
}
</style>
@@ -1,10 +1,106 @@
<template>
<div v-if="skinSiteUser" class="flex items-center gap-3 mt-2 p-3 rounded-xl bg-button-bg">
<Avatar :src="axolotlLogo" size="36px" />
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-contrast">{{ skinSiteUser.username }}</span>
<span class="text-secondary text-xs">{{ formatMessage(messages.skinSiteSignedIn) }}</span>
</div>
</div>
<p v-else-if="skinSiteStatus === 'checking'" class="text-sm text-secondary">
{{ formatMessage(messages.skinSiteChecking) }}
</p>
<p v-else-if="skinSiteStatus === 'error'" class="text-sm text-secondary">
{{ formatMessage(messages.skinSiteSyncError) }}
</p>
<div
v-if="skinSiteUser"
class="mt-2 overflow-hidden rounded-xl border border-solid border-surface-5 bg-button-bg"
>
<div class="flex items-center justify-between gap-3 px-3 py-2">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.skinSitePlayers, { count: skinSitePlayers.length }) }}
</span>
<button
v-if="skinSitePlayersStatus === 'error'"
type="button"
class="button-base flex cursor-pointer items-center gap-1 border-0 bg-transparent p-1 text-xs text-secondary hover:text-brand"
@click="refreshSkinSitePlayers()"
>
<RefreshCwIcon class="h-4 w-4" />
{{ formatMessage(messages.retrySkinSitePlayers) }}
</button>
<SpinnerIcon
v-else-if="skinSitePlayersStatus === 'checking'"
class="h-4 w-4 animate-spin text-secondary"
/>
</div>
<div v-if="skinSitePlayers.length > 0" class="border-0 border-t border-solid border-surface-5">
<button
v-for="player in skinSitePlayers"
:key="player.uuid"
type="button"
class="flex w-full cursor-pointer items-center gap-2 border-0 border-b border-solid border-surface-5 px-3 py-2 text-left transition-colors last:border-b-0 hover:bg-button-hover"
:class="selectedSkinSitePlayerId === player.uuid ? 'bg-button-hover' : 'bg-transparent'"
:aria-pressed="selectedSkinSitePlayerId === player.uuid"
@click="setSkinSitePlayer(player.uuid)"
>
<Avatar
v-if="player.skinState === 'ready' && player.headDataUrl"
:src="player.headDataUrl"
size="32px"
pixelated
:unframed-natural-width="36"
/>
<div
v-else
class="h-8 w-8 shrink-0 rounded-sm border-2 border-dashed border-secondary/45 bg-surface-5/50 text-secondary"
style="
background-image:
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor);
background-position:
6px 8px,
22px 8px,
8px 20px;
background-repeat: no-repeat;
background-size:
4px 4px,
4px 4px,
16px 2px;
"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate text-sm text-primary">{{ player.name }}</span>
</button>
</div>
<p v-else-if="skinSitePlayersStatus === 'ready'" class="m-0 px-3 pb-3 text-sm text-secondary">
{{ formatMessage(messages.noSkinSitePlayers) }}
</p>
<p v-else-if="skinSitePlayersStatus === 'error'" class="m-0 px-3 pb-3 text-sm text-secondary">
{{ formatMessage(messages.skinSitePlayersError) }}
</p>
</div>
<ButtonStyled v-if="accounts.length > 0 && !offline && !skinSiteUser" color="brand">
<button class="mt-2 w-full" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
<LogInIcon />
{{ formatMessage(messages.signInToStarlight) }}
</button>
</ButtonStyled>
<div
v-if="offline"
class="flex flex-col gap-1 bg-highlight-orange border border-solid border-orange rounded-xl p-3 mt-2"
>
<span class="font-semibold text-contrast">{{ formatMessage(messages.offlineMode) }}</span>
<span class="text-sm text-secondary">{{ formatMessage(messages.offlineModeDescription) }}</span>
<span class="text-sm text-secondary">
{{
formatMessage(
browserOffline
? messages.offlineModeNoInternetDescription
: messages.offlineModeServerUnavailableDescription,
)
}}
</span>
<ButtonStyled>
<button class="mt-1" :disabled="refreshingNetwork" @click="refreshNetworkStatus()">
<SpinnerIcon v-if="refreshingNetwork" class="animate-spin" />
@@ -17,18 +113,18 @@
v-if="accounts.length === 0"
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
>
<span>{{ formatMessage(messages.notSignedIn) }}</span>
<ButtonStyled v-if="!offline" color="brand">
<button color="primary" :disabled="loginDisabled" @click="login()">
<span v-if="skinSiteStatus === 'signed-out'">{{ formatMessage(messages.notSignedIn) }}</span>
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
<button color="primary" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
<LogInIcon v-if="!loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
{{ formatMessage(messages.signInToMinecraft) }}
{{ formatMessage(messages.signInToStarlight) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="!offline">
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
<button :disabled="loginDisabled" @click="login()">
<PlusIcon />
{{ formatMessage(messages.addThirdPartyAccount) }}
{{ formatMessage(messages.addMicrosoftAccount) }}
</button>
</ButtonStyled>
</div>
@@ -142,109 +238,10 @@
{{ formatMessage(messages.addMicrosoftAccount) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
<PlusIcon />
{{ formatMessage(messages.addThirdPartyAccount) }}
</button>
</ButtonStyled>
</div>
</div>
</Accordion>
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
<ModalWrapper ref="yggdrasilAccountModal" :header="formatMessage(messages.thirdPartyModalTitle)">
<div class="flex min-w-[24rem] flex-col gap-4">
<p class="m-0 text-secondary">{{ formatMessage(messages.thirdPartyModalDescription) }}</p>
<div v-if="savedYggdrasilLogins.length > 0" class="flex flex-col gap-2">
<span class="font-semibold">{{ formatMessage(messages.savedLogins) }}</span>
<div
v-for="savedLogin in savedYggdrasilLogins"
:key="`${savedLogin.api_root}:${savedLogin.login}`"
class="flex items-center gap-1 rounded-xl bg-surface-3 p-1"
>
<button
class="flex min-w-0 flex-grow flex-col items-start border-0 bg-transparent px-3 py-2 text-left cursor-pointer"
:disabled="loginDisabled"
@click="selectSavedYggdrasilLogin(savedLogin)"
>
<span class="w-full truncate font-semibold text-primary">{{ savedLogin.login }}</span>
<span class="w-full truncate text-xs text-secondary">{{ savedLogin.api_root }}</span>
</button>
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
<button
v-tooltip="formatMessage(messages.removeSavedLogin)"
:disabled="loginDisabled"
@click="removeSavedYggdrasilLogin(savedLogin)"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.apiRootLabel) }}
<StyledInput
v-model="yggdrasilApiRoot"
:disabled="true"
readonly
:placeholder="formatMessage(messages.apiRootPlaceholder)"
inputmode="url"
@blur="loadRememberedYggdrasilPassword()"
/>
</label>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.accountLabel) }}
<StyledInput
v-model="yggdrasilLogin"
:disabled="loginDisabled"
:placeholder="formatMessage(messages.accountPlaceholder)"
autocomplete="username"
@blur="loadRememberedYggdrasilPassword()"
/>
</label>
<label class="flex flex-col gap-2 font-semibold">
{{ formatMessage(messages.passwordLabel) }}
<StyledInput
v-model="yggdrasilPassword"
type="password"
:disabled="loginDisabled"
autocomplete="current-password"
@keyup.enter="addYggdrasilAccount()"
/>
</label>
<Checkbox
v-model="rememberYggdrasilPassword"
:disabled="loginDisabled"
:label="formatMessage(messages.rememberPassword)"
/>
<div class="input-group push-right">
<ButtonStyled>
<button :disabled="loginDisabled" @click="yggdrasilAccountModal?.hide()">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="loginDisabled || !yggdrasilFormValid" @click="addYggdrasilAccount()">
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
<LogInIcon v-else />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="yggdrasilProfileModal" :header="formatMessage(messages.selectProfileTitle)">
<div class="flex min-w-[22rem] flex-col gap-2">
<p class="m-0 mb-2 text-secondary">{{ formatMessage(messages.selectProfileDescription) }}</p>
<ButtonStyled v-for="profile in pendingYggdrasilProfiles" :key="profile.id" class="w-full">
<button :disabled="loginDisabled" @click="selectYggdrasilProfile(profile.id)">
<SpinnerIcon v-if="loginDisabled" class="animate-spin" />
<RadioButtonIcon v-else />
{{ profile.name }}
</button>
</ButtonStyled>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
@@ -262,36 +259,35 @@ import {
Accordion,
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { listen } from '@tauri-apps/api/event'
import type { Ref } from 'vue'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import axolotlLogo from '@/assets/netherstar.png'
import steveSkinTexture from '@/assets/skins/steve.png?inline'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import {
openSkinSiteLogin,
requestSkinSitePlayers,
selectedSkinSitePlayerId,
selectSkinSitePlayer,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { compareMinecraftAccounts } from '@/helpers/accounts'
import { trackEvent } from '@/helpers/analytics'
import {
begin_yggdrasil_login,
delete_yggdrasil_password,
finish_yggdrasil_login,
get_default_user,
get_yggdrasil_password,
list_yggdrasil_saved_logins,
login as loginToMinecraft,
remove_user,
set_default_user,
set_yggdrasil_password,
users,
} from '@/helpers/auth'
import { process_listener } from '@/helpers/events'
@@ -299,12 +295,22 @@ import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins'
import { get_available_skins } from '@/helpers/skins'
import { handleSevereError } from '@/store/error.js'
import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const { offline, refreshBrowserOffline } = useNetworkStatus()
const { browserOffline, offline, refreshBrowserOffline } = useNetworkStatus()
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
const themeStore = useTheming()
async function goToSkinSiteLogin() {
openSkinSiteLogin()
// The login action must reveal the iframe even if Minimal Home was selected.
themeStore.homeLayout = 'standard'
await router.push('/').catch(handleError)
}
const refreshingNetwork = ref(false)
/**
@@ -348,20 +354,6 @@ type MinecraftCredential = {
}
}
type YggdrasilProfile = {
id: string
name: string
}
type SavedYggdrasilLogin = {
api_root: string
login: string
}
type YggdrasilLoginResult =
| { status: 'complete'; credentials: MinecraftCredential }
| { status: 'select_profile'; flow_id: string; profiles: YggdrasilProfile[] }
const STARLIGHT_YGGDRASIL_API_ROOT = 'https://skin.starlight.cool/yggdrasil'
const accounts: Ref<MinecraftCredential[]> = ref([])
@@ -376,34 +368,13 @@ let refreshGeneration = 0
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
let defaultUserUpdateQueue = Promise.resolve()
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
const yggdrasilAccountModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
const yggdrasilProfileModal = ref<InstanceType<typeof ModalWrapper> | null>(null)
const yggdrasilApiRoot = ref(STARLIGHT_YGGDRASIL_API_ROOT)
const yggdrasilLogin = ref('')
const yggdrasilPassword = ref('')
const rememberYggdrasilPassword = ref(true)
const savedYggdrasilLogins = ref<SavedYggdrasilLogin[]>([])
const pendingYggdrasilFlowId = ref<string | undefined>()
const pendingYggdrasilProfiles = ref<YggdrasilProfile[]>([])
const yggdrasilFormValid = computed(
() =>
yggdrasilApiRoot.value.trim().length > 0 &&
yggdrasilLogin.value.trim().length > 0 &&
yggdrasilPassword.value.length > 0,
)
function createSkinHeadDataUrl(textureUrl: string) {
const escapedTextureUrl = textureUrl
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 8 8" shape-rendering="crispEdges"><image href="${escapedTextureUrl}" x="-8" y="-8" width="64" height="64" style="image-rendering:pixelated"/><image href="${escapedTextureUrl}" x="-40" y="-8" width="64" height="64" style="image-rendering:pixelated"/></svg>`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
async function refreshSkinSitePlayers() {
await requestSkinSitePlayers().catch(() => {})
}
const defaultSteveHeadUrl = createSkinHeadDataUrl(steveSkinTexture)
function setSkinSitePlayer(playerId: string) {
selectSkinSitePlayer(playerId)
}
const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
const HEAD_REFRESH_CONTINUOUS_DELAY = 60_000
@@ -648,6 +619,7 @@ function persistDefaultUser(userId: string) {
async function setAccount(account: MinecraftCredential) {
const userId = account.profile.id
refreshGeneration += 1
selectSkinSitePlayer(null)
defaultUser.value = userId
equippedSkin.value = null
@@ -657,6 +629,16 @@ async function setAccount(account: MinecraftCredential) {
if (defaultUser.value === userId) notifyAccountChange()
}
watch(
[skinSitePlayers, defaultUser],
([availablePlayers, selectedLocalUser]) => {
if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
selectSkinSitePlayer(availablePlayers[0].uuid)
}
},
{ immediate: true },
)
async function login() {
if (offline.value) return
loginDisabled.value = true
@@ -678,7 +660,6 @@ async function onMicrosoftLogin(account: MinecraftCredential) {
loginDisabled.value = true
try {
await setAccount(account)
trackEvent('AccountLogIn')
} catch (error) {
handleSevereError(error)
} finally {
@@ -686,187 +667,6 @@ async function onMicrosoftLogin(account: MinecraftCredential) {
}
}
async function showYggdrasilAccountModal() {
yggdrasilApiRoot.value = STARLIGHT_YGGDRASIL_API_ROOT
yggdrasilLogin.value = ''
yggdrasilPassword.value = ''
rememberYggdrasilPassword.value = true
pendingYggdrasilFlowId.value = undefined
pendingYggdrasilProfiles.value = []
await loadSavedYggdrasilLogins()
yggdrasilAccountModal.value?.show()
}
async function loadSavedYggdrasilLogins() {
const storedLogins = await list_yggdrasil_saved_logins().catch(handleError)
const savedLogins: SavedYggdrasilLogin[] = Array.isArray(storedLogins)
? [...storedLogins].filter(
(savedLogin) => savedLogin.api_root.replace(/\/+$/, '') === STARLIGHT_YGGDRASIL_API_ROOT,
)
: []
const savedLoginKeys = new Set(
savedLogins.map((savedLogin) => `${savedLogin.api_root}\n${savedLogin.login}`),
)
for (const account of accounts.value) {
if (
!account.yggdrasil ||
account.yggdrasil.api_root.replace(/\/+$/, '') !== STARLIGHT_YGGDRASIL_API_ROOT
)
continue
const savedLogin = {
api_root: account.yggdrasil.api_root,
login: account.yggdrasil.login,
}
const key = `${savedLogin.api_root}\n${savedLogin.login}`
if (savedLoginKeys.has(key)) continue
try {
const password = await get_yggdrasil_password(savedLogin.api_root, savedLogin.login)
if (!password) continue
await set_yggdrasil_password(savedLogin.api_root, savedLogin.login, password)
savedLogins.push(savedLogin)
savedLoginKeys.add(key)
} catch {
continue
}
}
savedYggdrasilLogins.value = savedLogins.sort((left, right) =>
left.login.localeCompare(right.login),
)
}
async function selectSavedYggdrasilLogin(savedLogin: SavedYggdrasilLogin) {
if (loginDisabled.value) return
loginDisabled.value = true
try {
const password = await get_yggdrasil_password(savedLogin.api_root, savedLogin.login)
if (!password) {
await delete_yggdrasil_password(savedLogin.api_root, savedLogin.login)
await loadSavedYggdrasilLogins()
return
}
yggdrasilApiRoot.value = savedLogin.api_root
yggdrasilLogin.value = savedLogin.login
yggdrasilPassword.value = password
rememberYggdrasilPassword.value = true
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function removeSavedYggdrasilLogin(savedLogin: SavedYggdrasilLogin) {
if (loginDisabled.value) return
loginDisabled.value = true
try {
await delete_yggdrasil_password(savedLogin.api_root, savedLogin.login)
savedYggdrasilLogins.value = savedYggdrasilLogins.value.filter(
(entry) => entry.api_root !== savedLogin.api_root || entry.login !== savedLogin.login,
)
if (
yggdrasilApiRoot.value === savedLogin.api_root &&
yggdrasilLogin.value === savedLogin.login
) {
yggdrasilPassword.value = ''
rememberYggdrasilPassword.value = false
}
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function loadRememberedYggdrasilPassword() {
if (
!rememberYggdrasilPassword.value ||
!yggdrasilApiRoot.value.trim() ||
!yggdrasilLogin.value.trim() ||
yggdrasilPassword.value
)
return
try {
const password = await get_yggdrasil_password(
yggdrasilApiRoot.value.trim(),
yggdrasilLogin.value.trim(),
)
if (password) yggdrasilPassword.value = password
} catch {
return
}
}
async function persistYggdrasilPasswordPreference() {
try {
if (rememberYggdrasilPassword.value) {
await set_yggdrasil_password(
yggdrasilApiRoot.value.trim(),
yggdrasilLogin.value.trim(),
yggdrasilPassword.value,
)
} else {
await delete_yggdrasil_password(yggdrasilApiRoot.value.trim(), yggdrasilLogin.value.trim())
}
} catch (error) {
handleError(error as Error)
}
}
async function addYggdrasilAccount() {
if (!yggdrasilFormValid.value || loginDisabled.value) return
if (yggdrasilApiRoot.value.trim().replace(/\/+$/, '') !== STARLIGHT_YGGDRASIL_API_ROOT) return
loginDisabled.value = true
try {
const result = (await begin_yggdrasil_login(
yggdrasilApiRoot.value.trim(),
yggdrasilLogin.value.trim(),
yggdrasilPassword.value,
)) as YggdrasilLoginResult
if (result.status === 'complete') {
await persistYggdrasilPasswordPreference()
yggdrasilAccountModal.value?.hide()
await setAccount(result.credentials)
trackEvent('YggdrasilAccountAdd')
} else {
pendingYggdrasilFlowId.value = result.flow_id
pendingYggdrasilProfiles.value = result.profiles
yggdrasilAccountModal.value?.hide()
yggdrasilProfileModal.value?.show()
}
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function selectYggdrasilProfile(profileId: string) {
if (!pendingYggdrasilFlowId.value || loginDisabled.value) return
loginDisabled.value = true
try {
const account = (await finish_yggdrasil_login(
pendingYggdrasilFlowId.value,
profileId,
)) as MinecraftCredential
await persistYggdrasilPasswordPreference()
yggdrasilProfileModal.value?.hide()
await setAccount(account)
trackEvent('YggdrasilAccountAdd')
} catch (error) {
handleError(error as Error)
} finally {
loginDisabled.value = false
}
}
async function logout(account: MinecraftCredential) {
await remove_user(account.profile.id).catch(handleError)
await refreshValues()
@@ -875,7 +675,6 @@ async function logout(account: MinecraftCredential) {
} else {
notifyAccountChange()
}
trackEvent('AccountLogOut')
}
async function copyAccountUuid(account: MinecraftCredential) {
@@ -902,14 +701,47 @@ onUnmounted(() => {
})
const messages = defineMessages({
skinSiteSignedIn: {
id: 'minecraft-account.skin-site.signed-in',
defaultMessage: 'Signed in to StarLight Skin Site',
},
skinSiteChecking: {
id: 'minecraft-account.skin-site.checking',
defaultMessage: 'Checking skin site session…',
},
skinSiteSyncError: {
id: 'minecraft-account.skin-site.sync-error',
defaultMessage: 'Could not verify the skin site session. Retrying automatically.',
},
skinSitePlayers: {
id: 'minecraft-account.skin-site.players',
defaultMessage: 'Skin site players ({count})',
},
retrySkinSitePlayers: {
id: 'minecraft-account.skin-site.players.retry',
defaultMessage: 'Retry',
},
noSkinSitePlayers: {
id: 'minecraft-account.skin-site.players.empty',
defaultMessage: 'No player profiles are attached to this skin site account.',
},
skinSitePlayersError: {
id: 'minecraft-account.skin-site.players.error',
defaultMessage: 'Could not refresh the player list. Previously loaded players are kept.',
},
offlineMode: {
id: 'minecraft-account.offline-mode',
defaultMessage: 'Offline mode',
},
offlineModeDescription: {
id: 'minecraft-account.offline-mode.description',
offlineModeNoInternetDescription: {
id: 'minecraft-account.offline-mode.description.no-internet',
defaultMessage:
'Only offline accounts are available. You can launch fully downloaded instances.',
'It looks like this device may not be connected to the internet. You can currently only launch fully downloaded instances and will most likely be unable to connect to StarLight servers. Check your network connection. If you are using a proxy, try disabling or enabling it, then refresh the connection status below.',
},
offlineModeServerUnavailableDescription: {
id: 'minecraft-account.offline-mode.description.server-unavailable',
defaultMessage:
"Your internet connection is working, but StarLight's authentication server cannot be reached. The StarLight server may be undergoing maintenance, or your proxy may be misconfigured. If you are using a proxy, try disabling or enabling it, then refresh the connection status below. If that does not help, contact a server administrator in the StarLight community group to confirm the maintenance status.",
},
refreshNetworkStatus: {
id: 'minecraft-account.offline-mode.refresh',
@@ -921,11 +753,11 @@ const messages = defineMessages({
},
addMicrosoftAccount: {
id: 'minecraft-account.add-microsoft-account',
defaultMessage: 'Add Microsoft account',
defaultMessage: 'Add your own Minecraft account',
},
addThirdPartyAccount: {
id: 'minecraft-account.add-third-party-account',
defaultMessage: 'Add third-party account',
signInToStarlight: {
id: 'minecraft-account.sign-in-starlight',
defaultMessage: 'Sign in to StarLight Skin Site',
},
thirdPartyAccount: {
id: 'minecraft-account.third-party-account',
@@ -935,58 +767,6 @@ const messages = defineMessages({
id: 'minecraft-account.third-party-badge',
defaultMessage: 'StarLight skin',
},
thirdPartyModalTitle: {
id: 'minecraft-account.third-party-modal.title',
defaultMessage: 'Sign in with StarLight skin',
},
thirdPartyModalDescription: {
id: 'minecraft-account.third-party-modal.description',
defaultMessage: 'Sign in with your StarLight skin account.',
},
apiRootLabel: {
id: 'minecraft-account.third-party-modal.api-root',
defaultMessage: 'StarLight skin API address',
},
apiRootPlaceholder: {
id: 'minecraft-account.third-party-modal.api-root-placeholder',
defaultMessage: 'https://skin.starlight.cool/yggdrasil',
},
accountLabel: {
id: 'minecraft-account.third-party-modal.account',
defaultMessage: 'Account or email',
},
accountPlaceholder: {
id: 'minecraft-account.third-party-modal.account-placeholder',
defaultMessage: 'Enter your account or email',
},
passwordLabel: {
id: 'minecraft-account.third-party-modal.password',
defaultMessage: 'Password',
},
rememberPassword: {
id: 'minecraft-account.third-party-modal.remember-password',
defaultMessage: 'Save this login on this device',
},
savedLogins: {
id: 'minecraft-account.third-party-modal.saved-logins',
defaultMessage: 'Saved logins',
},
removeSavedLogin: {
id: 'minecraft-account.third-party-modal.remove-saved-login',
defaultMessage: 'Remove saved login',
},
signInButton: {
id: 'minecraft-account.third-party-modal.sign-in',
defaultMessage: 'Sign in',
},
selectProfileTitle: {
id: 'minecraft-account.third-party-profile.title',
defaultMessage: 'Select a profile',
},
selectProfileDescription: {
id: 'minecraft-account.third-party-profile.description',
defaultMessage: 'Choose the Minecraft profile to use with this account.',
},
offlineAccount: {
id: 'minecraft-account.offline-account',
defaultMessage: 'Offline Minecraft account',
@@ -1069,10 +849,6 @@ const messages = defineMessages({
id: 'minecraft-account.label',
defaultMessage: 'Minecraft account',
},
signInToMinecraft: {
id: 'minecraft-account.sign-in',
defaultMessage: 'Sign in to Minecraft',
},
loginTrouble: {
id: 'minecraft-login.trouble',
defaultMessage: 'Having trouble?',
@@ -212,7 +212,6 @@ import { useRoute, useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener, process_listener } from '@/helpers/events'
import { get_many as getInstances } from '@/helpers/instance'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
@@ -437,11 +436,6 @@ const stop = async (process: RunningProcess) => {
try {
await killProcess(process.uuid).catch(handleError)
trackEvent('InstanceStop', {
loader: process.instance.loader,
game_version: process.instance.game_version,
source: 'AppBar',
})
} catch (e) {
console.error(e)
}
@@ -23,7 +23,6 @@ import { computed, ref } from 'vue'
import { ChatIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { AxolotlBrandConfig } from '@/config'
import { trackEvent } from '@/helpers/analytics'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { install_existing_instance } from '@/helpers/install'
import { cancel_directory_change } from '@/helpers/settings.ts'
@@ -241,7 +240,6 @@ async function loginMinecraft() {
await set_default_user(loggedIn.profile.id).catch(handleError)
}
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
loadingMinecraft.value = false
errorModal.value.hide()
} catch (err) {
@@ -23,7 +23,6 @@ import { useRouter } from 'vue-router'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
@@ -121,11 +120,6 @@ const play = async (e, context) => {
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
})
.finally(() => {
trackEvent('InstanceStart', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
})
loading.value = false
}
@@ -136,11 +130,6 @@ const stop = async (e, context) => {
await kill(props.instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
}
const repair = async (e) => {
@@ -59,7 +59,6 @@ import {
import { onUnmounted, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { java_discovery_listener } from '@/helpers/events'
import { find_filtered_jres } from '@/helpers/jre.js'
@@ -131,9 +130,5 @@ const emit = defineEmits(['submit'])
function setJavaInstall(javaInstall) {
emit('submit', javaInstall)
detectJavaModal.value.hide()
trackEvent('JavaAutoDetect', {
path: javaInstall.path,
version: javaInstall.version,
})
}
</script>
@@ -112,7 +112,6 @@ import { computed, ref, watch } from 'vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import useJavaTest from '@/composables/useJavaTest'
import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
@@ -196,9 +195,9 @@ const recommendedInstalled = computed(() => {
let hasInitialized = false
async function runTest(path) {
await testJavaInstallation(path, testVersion.value, true)
await testJavaInstallation(path, testVersion.value)
if (props.version != null) {
await recommendedJavaTest.testJavaInstallation(path, props.version, false)
await recommendedJavaTest.testJavaInstallation(path, props.version)
}
}
@@ -212,9 +211,9 @@ watch(
(newPath) => {
if (newPath) {
if (!hasInitialized) {
testJavaInstallation(newPath, testVersion.value, false)
testJavaInstallation(newPath, testVersion.value)
if (props.version != null) {
recommendedJavaTest.testJavaInstallation(newPath, props.version, false)
recommendedJavaTest.testJavaInstallation(newPath, props.version)
}
hasInitialized = true
} else {
@@ -242,9 +241,6 @@ async function handleJavaFileInput() {
}
}
trackEvent('JavaManualSelect', {
version: props.version,
})
commitSelection(result)
}
@@ -279,7 +275,6 @@ async function reinstallJava() {
}
}
trackEvent('JavaReInstall', { path: path, version: props.version })
commitSelection(result)
runTest(result.path)
} finally {
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { skinSiteFrameUrl } from '@/composables/skin-site-session'
const { formatMessage } = useVIntl()
const messages = defineMessages({
frameTitle: {
id: 'app.starlight-skin.frame-title',
defaultMessage: 'StarLight Skin Site',
},
})
</script>
<template>
<iframe
:src="skinSiteFrameUrl"
:title="formatMessage(messages.frameTitle)"
class="block h-full min-h-0 w-full border-0"
/>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
} from '@/composables/skin-site-session'
const frame = ref<HTMLIFrameElement>()
let lastMessage = 0
let expiryTimer: ReturnType<typeof setInterval> | undefined
function connect() {
const contentWindow = frame.value?.contentWindow ?? null
setSkinSiteFrame(contentWindow)
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
}
function receive(event: MessageEvent) {
if (receiveSkinSiteMessage(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
}
onMounted(() => {
window.addEventListener('message', receive)
connect()
expiryTimer = setInterval(() => {
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
}, 10_000)
})
onUnmounted(() => {
window.removeEventListener('message', receive)
clearInterval(expiryTimer)
setSkinSiteFrame(null)
resetSkinSiteSession()
})
</script>
<template>
<iframe
ref="frame"
:src="`${SKIN_SITE_ORIGIN}/`"
title="StarLight Skin Site session"
aria-hidden="true"
tabindex="-1"
class="pointer-events-none fixed -left-[10000px] top-0 h-px w-px opacity-0"
@load="connect"
/>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { Avatar } from '@modrinth/ui'
import { onScopeDispose, ref } from 'vue'
defineProps<{ src: string; name: string; href?: string }>()
const emit = defineEmits<{ activate: [] }>()
const holding = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
let origin = { x: 0, y: 0 }
let suppressClick = false
function cancel() {
clearTimeout(timer)
timer = undefined
holding.value = false
}
function begin() {
cancel()
suppressClick = false
holding.value = true
timer = setTimeout(() => {
cancel()
suppressClick = true
emit('activate')
}, 800)
}
function pointerDown(event: PointerEvent) {
if (event.button !== 0 || !event.isPrimary) return
origin = { x: event.clientX, y: event.clientY }
begin()
}
function pointerMove(event: PointerEvent) {
if (Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 8) cancel()
}
function click(event: MouseEvent) {
if (!suppressClick) return
event.preventDefault()
event.stopPropagation()
suppressClick = false
}
function keydown(event: KeyboardEvent) {
if (event.code === 'Space') {
event.preventDefault()
if (!event.repeat) begin()
}
}
window.addEventListener('blur', cancel)
onScopeDispose(() => {
cancel()
window.removeEventListener('blur', cancel)
})
</script>
<template>
<a
:href="href"
target="_blank"
rel="noopener noreferrer"
class="mine-avatar"
:class="{ holding }"
@pointerdown="pointerDown"
@pointermove="pointerMove"
@pointerup="cancel"
@pointercancel="cancel"
@pointerleave="cancel"
@blur="cancel"
@click="click"
@contextmenu.prevent
@dragstart.prevent
@keydown="keydown"
@keyup.space.prevent="cancel"
>
<Avatar :src="src" :alt="name" size="2.5rem" circle no-shadow loading="lazy" />
</a>
</template>
<style scoped>
.mine-avatar {
display: inline-flex;
flex-shrink: 0;
border-radius: 50%;
user-select: none;
touch-action: manipulation;
}
.mine-avatar.holding {
outline: 2px solid var(--color-brand);
outline-offset: 3px;
}
.mine-avatar:focus-visible {
outline: 2px solid var(--color-contrast);
outline-offset: 3px;
}
</style>
@@ -0,0 +1,323 @@
<script setup lang="ts">
import { NewButton as Button, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onMounted, onScopeDispose, ref } from 'vue'
import type { Puzzle } from './engine'
import { messages } from './messages'
const props = defineProps<{
puzzle: Puzzle
revealed: boolean[]
selected: number
playing: boolean
lost: boolean
mistake: number
zoom: number
}>()
const emit = defineEmits<{
paint: [index: number]
'update:zoom': [value: number]
}>()
const { formatMessage } = useVIntl()
const viewport = ref<HTMLElement>()
const view = ref({ left: 0, top: 0, width: 0, height: 0 })
const large = computed(() => props.puzzle.difficulty !== 'easy')
const visibleCells = computed(() =>
props.puzzle.answer.flatMap((color, i) =>
props.revealed[i] || props.lost ? [{ color, i }] : [],
),
)
const letter = (color: number) => String.fromCharCode(65 + color)
function updateView() {
const el = viewport.value
if (!el) return
const size = props.puzzle.size
view.value = {
left: (el.scrollLeft / el.scrollWidth) * size,
top: (el.scrollTop / el.scrollHeight) * size,
width: Math.min(size, (el.clientWidth / el.scrollWidth) * size),
height: Math.min(size, (el.clientHeight / el.scrollHeight) * size),
}
}
async function setView(left: number, top: number) {
await nextTick()
viewport.value?.scrollTo(left, top)
updateView()
}
async function centerFirstClue() {
await nextTick()
const el = viewport.value
if (!el || !large.value) return setView(0, 0)
const i = props.revealed.findIndex(Boolean)
await setView(
((i % props.puzzle.size) + 0.5) * (40 * props.zoom + 3) - el.clientWidth / 2,
(Math.floor(i / props.puzzle.size) + 0.5) * (40 * props.zoom + 3) - el.clientHeight / 2,
)
}
function navigate(event: MouseEvent) {
const rect = (event.currentTarget as SVGElement).getBoundingClientRect()
const el = viewport.value
if (!el) return
void setView(
((event.clientX - rect.left) / rect.width) * el.scrollWidth - el.clientWidth / 2,
((event.clientY - rect.top) / rect.height) * el.scrollHeight - el.clientHeight / 2,
)
}
async function changeZoom(value: number) {
const el = viewport.value
if (!el) return
const zoom = Math.max(0.7, Math.min(1.6, Math.round(value * 10) / 10))
const ratio = (40 * zoom + 3) / (40 * props.zoom + 3)
const x = (el.scrollLeft + el.clientWidth / 2) * ratio - el.clientWidth / 2
const y = (el.scrollTop + el.clientHeight / 2) * ratio - el.clientHeight / 2
emit('update:zoom', zoom)
await setView(x, y)
}
function onWheel(event: WheelEvent) {
if (!large.value || !event.ctrlKey) return
event.preventDefault()
void changeZoom(props.zoom + (event.deltaY < 0 ? 0.1 : -0.1))
}
function label(index: number) {
const values = {
row: Math.floor(index / props.puzzle.size) + 1,
column: (index % props.puzzle.size) + 1,
}
return props.revealed[index] || props.lost
? formatMessage(messages.revealed, {
...values,
color: letter(props.puzzle.answer[index]),
number: props.puzzle.numbers[index],
})
: formatMessage(messages.hidden, values)
}
let observer: ResizeObserver | undefined
onMounted(() => {
observer = new ResizeObserver(updateView)
if (viewport.value) observer.observe(viewport.value)
})
onScopeDispose(() => observer?.disconnect())
defineExpose({
centerFirstClue,
setView,
getView: () => ({ left: viewport.value?.scrollLeft ?? 0, top: viewport.value?.scrollTop ?? 0 }),
})
</script>
<template>
<div class="mine-navigation" :class="{ 'mine-large': large }">
<div ref="viewport" class="mine-viewport" @scroll.passive="updateView" @wheel="onWheel">
<div
class="mine-grid"
:class="{ 'mine-grid-small': !large }"
:style="{ '--mine-size': puzzle.size, '--mine-cell-size': `${40 * zoom}px` }"
:aria-label="formatMessage(messages.board, { size: puzzle.size })"
>
<button
v-for="(_, i) in puzzle.answer"
:key="i"
type="button"
class="mine-cell"
:class="{
'mine-open': revealed[i] || lost,
'mine-answer': lost && !revealed[i],
'mine-mistake': mistake === i,
'mine-sampled': revealed[i] && selected === puzzle.answer[i],
}"
:style="
revealed[i] || lost
? { '--mine-cell-color': `var(--mine-color-${puzzle.answer[i]})` }
: undefined
"
:disabled="!playing"
:aria-label="label(i)"
@click="emit('paint', i)"
>
<template v-if="revealed[i] || lost">
<span>{{ puzzle.numbers[i] }}</span
><small>{{ letter(puzzle.answer[i]) }}</small>
</template>
</button>
</div>
</div>
<div v-if="large" class="mine-overview">
<svg
class="mine-map"
:viewBox="`0 0 ${puzzle.size} ${puzzle.size}`"
role="img"
:aria-label="formatMessage(messages.map)"
@click="navigate"
>
<rect width="100%" height="100%" fill="var(--surface-1)" />
<rect
v-for="cell in visibleCells"
:key="cell.i"
:x="cell.i % puzzle.size"
:y="Math.floor(cell.i / puzzle.size)"
width="1"
height="1"
:fill="`var(--mine-color-${cell.color})`"
/>
<rect
:x="view.left"
:y="view.top"
:width="view.width"
:height="view.height"
fill="none"
stroke="var(--color-contrast)"
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
</svg>
<div class="mine-zoom">
<Button
size="sm"
:disabled="zoom <= 0.7"
:aria-label="formatMessage(messages.zoomOut)"
@click="changeZoom(zoom - 0.1)"
>−</Button
>
<span>{{ Math.round(zoom * 100) }}%</span>
<Button
size="sm"
:disabled="zoom >= 1.6"
:aria-label="formatMessage(messages.zoomIn)"
@click="changeZoom(zoom + 0.1)"
>+</Button
>
</div>
</div>
</div>
</template>
<style scoped>
.mine-navigation {
min-width: 0;
display: grid;
gap: var(--gap-md);
}
.mine-large {
grid-template-columns: minmax(0, 1fr) 7rem;
}
.mine-viewport {
overflow: auto;
max-height: min(52vh, 32rem);
min-width: 0;
background: var(--surface-1);
border-radius: var(--radius-sm);
overscroll-behavior: contain;
}
.mine-grid {
display: grid;
grid-template-columns: repeat(var(--mine-size), var(--mine-cell-size));
/* Scrolling and minimap coordinates use a fixed square pitch at every zoom level. */
grid-auto-rows: var(--mine-cell-size);
gap: 3px;
width: max-content;
padding: 3px;
}
.mine-grid-small {
grid-template-columns: repeat(var(--mine-size), minmax(0, 1fr));
grid-auto-rows: auto;
width: min(100%, 26rem, 48vh);
margin: auto;
box-sizing: border-box;
}
.mine-cell {
position: relative;
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 1;
padding: 0;
border: 1px solid color-mix(in srgb, var(--mine-ink) 38%, var(--surface-5));
border-radius: var(--radius-sm);
background: var(--surface-4);
color: var(--color-contrast);
font: inherit;
font-weight: 700;
cursor: crosshair;
user-select: none;
touch-action: manipulation;
}
.mine-cell:hover:not(:disabled) {
background: var(--surface-5);
border-color: var(--mine-ink);
}
.mine-cell:focus-visible {
outline: 2px solid var(--color-contrast);
outline-offset: -3px;
z-index: 1;
}
.mine-open {
background: color-mix(in srgb, var(--mine-cell-color) 34%, var(--surface-2));
border-color: var(--mine-cell-color);
cursor: pointer;
}
.mine-open:hover:not(:disabled) {
background: color-mix(in srgb, var(--mine-cell-color) 50%, var(--surface-2));
}
.mine-sampled {
box-shadow: inset 0 0 0 1px var(--mine-cell-color);
}
.mine-cell small {
position: absolute;
right: 3px;
bottom: 1px;
font-size: 0.5rem;
line-height: 1;
}
.mine-answer {
opacity: 0.55;
}
.mine-cell:disabled {
cursor: default;
}
.mine-mistake {
opacity: 1;
outline: 3px solid var(--color-red);
outline-offset: -3px;
}
.mine-overview {
display: flex;
flex-direction: column;
gap: var(--gap-md);
align-items: center;
}
.mine-map {
display: block;
width: 7rem;
height: auto;
aspect-ratio: 1;
border: 1px solid var(--surface-5);
border-radius: var(--radius-sm);
cursor: crosshair;
}
.mine-zoom {
display: flex;
align-items: center;
gap: var(--gap-xs);
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
@media (max-width: 600px) {
.mine-large {
grid-template-columns: minmax(0, 1fr);
}
.mine-overview {
flex-direction: row;
justify-content: space-between;
}
.mine-map {
width: 4.5rem;
}
}
</style>
@@ -0,0 +1,482 @@
<script setup lang="ts">
import './palette.css'
import { NewButton as Button, NewModal, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
import ColorMineBoard from './ColorMineBoard.vue'
import { type Difficulty, LEVELS, type Puzzle } from './engine'
import { messages } from './messages'
import {
parseSave,
readBest,
recordBest,
SAVE_KEY,
type SavedGame,
saveGame,
validateResume,
} from './storage'
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const board = ref<InstanceType<typeof ColorMineBoard>>()
const prompt = ref<HTMLElement>()
const puzzle = shallowRef<Puzzle>()
const revealed = ref<boolean[]>([])
const selected = ref(-1)
const mistake = ref(-1)
const elapsed = ref(0)
const zoom = ref(1)
const best = ref<number | null>(null)
const difficulty = ref<Difficulty>('easy')
const levels = Object.keys(LEVELS) as Difficulty[]
const screen = ref<'loading' | 'board' | 'saved' | 'error'>('loading')
const result = ref<'playing' | 'lost' | 'won'>('playing')
const confirmation = ref<'leave' | 'replace' | null>(null)
const saved = shallowRef<SavedGame | null>(null)
const storageError = ref(false)
const recordError = ref(false)
const needsColor = ref(false)
let nextDifficulty: Difficulty = 'easy'
let worker: Worker | undefined
let active = false
let closing = false
let started = false
let heldView = { left: 0, top: 0 }
let lastTick = performance.now()
const count = computed(() => revealed.value.filter(Boolean).length)
const letter = (color: number) => String.fromCharCode(65 + color)
const timeText = (time: number) => {
const seconds = Math.floor(time / 1000)
return `${Math.floor(seconds / 60)
.toString()
.padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`
}
const status = computed(() => {
if (result.value === 'won') return formatMessage(messages.won)
if (result.value === 'lost' && puzzle.value)
return formatMessage(messages.lost, {
row: Math.floor(mistake.value / puzzle.value.size) + 1,
column: (mistake.value % puzzle.value.size) + 1,
selected: letter(selected.value),
correct: letter(puzzle.value.answer[mistake.value]),
})
return selected.value < 0 || needsColor.value
? formatMessage(messages.choose)
: formatMessage(messages.selected, { color: letter(selected.value) })
})
function tick() {
const now = performance.now()
if (
active &&
started &&
screen.value === 'board' &&
result.value === 'playing' &&
!confirmation.value &&
!document.hidden
) {
elapsed.value += now - lastTick
}
lastTick = now
}
let timer: number | undefined
function visibilityChanged() {
lastTick = performance.now()
}
document.addEventListener('visibilitychange', visibilityChanged)
async function show() {
if (active || closing) return
active = true
lastTick = performance.now()
timer = window.setInterval(tick, 250)
modal.value?.show()
loadSaved()
}
function loadSaved() {
storageError.value = false
saved.value = null
try {
saved.value = parseSave(localStorage.getItem(SAVE_KEY))
if (saved.value) {
difficulty.value = saved.value.difficulty
screen.value = 'saved'
} else start(difficulty.value)
} catch {
screen.value = 'saved'
storageError.value = true
}
}
function start(level: Difficulty, resume?: SavedGame) {
worker?.terminate()
confirmation.value = null
storageError.value = false
recordError.value = false
screen.value = 'loading'
difficulty.value = level
started = false
try {
const pending = new Worker(new URL('./generator.worker.ts', import.meta.url), {
type: 'module',
})
worker = pending
pending.onerror = () => {
if (worker !== pending) return
pending.terminate()
worker = undefined
screen.value = 'error'
}
pending.onmessage = async (event: MessageEvent<{ puzzle?: Puzzle; error?: boolean }>) => {
if (worker !== pending || !active) return
pending.terminate()
worker = undefined
if (!event.data.puzzle) {
screen.value = 'error'
return
}
const generated = event.data.puzzle
if (resume) {
try {
validateResume(resume, generated)
// Consume the save before playing so losing cannot reload an older, safe state.
localStorage.removeItem(SAVE_KEY)
} catch {
screen.value = 'saved'
storageError.value = true
return
}
}
saved.value = null
puzzle.value = generated
revealed.value = resume?.revealed.slice() ?? generated.clues.slice()
selected.value = resume?.selected ?? -1
elapsed.value = resume?.elapsed ?? 0
started = elapsed.value > 0
lastTick = performance.now()
zoom.value = resume?.zoom ?? 1
mistake.value = -1
needsColor.value = false
result.value = 'playing'
screen.value = 'board'
try {
best.value = readBest(localStorage, level)
} catch {
best.value = null
}
await nextTick()
if (resume) await board.value?.setView(resume.left, resume.top)
else await board.value?.centerFirstClue()
}
pending.postMessage({
seed: resume?.seed ?? crypto.getRandomValues(new Uint32Array(1))[0],
difficulty: level,
})
} catch {
worker?.terminate()
worker = undefined
screen.value = 'error'
}
}
function paint(index: number) {
const current = puzzle.value
if (!current || confirmation.value || result.value !== 'playing') return
if (revealed.value[index]) {
selected.value = current.answer[index]
needsColor.value = false
return
}
if (selected.value < 0) {
needsColor.value = true
return
}
tick()
started = true
if (selected.value !== current.answer[index]) {
mistake.value = index
result.value = 'lost'
return
}
revealed.value[index] = true
if (revealed.value.every(Boolean)) {
result.value = 'won'
try {
best.value = recordBest(localStorage, current.difficulty, elapsed.value)
} catch {
recordError.value = true
}
}
}
async function ask(kind: 'leave' | 'replace') {
tick()
heldView = board.value?.getView() ?? { left: 0, top: 0 }
confirmation.value = kind
await nextTick()
prompt.value?.focus()
}
function requestNew(level: Difficulty = difficulty.value) {
nextDifficulty = level
if (screen.value === 'board' && result.value === 'playing') void ask('replace')
else start(level)
}
function requestClose() {
if (confirmation.value) {
confirmation.value = null
lastTick = performance.now()
void nextTick(() => board.value?.setView(heldView.left, heldView.top))
return
}
if (screen.value === 'board' && result.value === 'playing') void ask('leave')
else void close()
}
async function close() {
if (closing) return
closing = true
active = false
window.clearInterval(timer)
timer = undefined
worker?.terminate()
worker = undefined
await modal.value?.hide()
confirmation.value = null
puzzle.value = undefined
closing = false
}
async function saveAndLeave() {
if (!puzzle.value) return
try {
saveGame(localStorage, {
version: 1,
seed: puzzle.value.seed,
difficulty: puzzle.value.difficulty,
revealed: revealed.value.slice(),
selected: selected.value,
elapsed: elapsed.value,
zoom: zoom.value,
left: heldView.left,
top: heldView.top,
})
await close()
} catch {
storageError.value = true
}
}
function discardSaved() {
try {
localStorage.removeItem(SAVE_KEY)
saved.value = null
start(difficulty.value)
} catch {
storageError.value = true
}
}
function keydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
requestClose()
}
}
onScopeDispose(() => {
worker?.terminate()
window.clearInterval(timer)
document.removeEventListener('visibilitychange', visibilityChanged)
})
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="56rem"
max-width="56rem"
:closable="false"
:close-on-esc="false"
actions-divider
@keydown.stop="keydown"
>
<div
class="color-mine"
:style="{
'--mine-ink': selected >= 0 ? `var(--mine-color-${selected})` : 'var(--color-brand)',
}"
>
<div v-if="confirmation" ref="prompt" class="mine-prompt" tabindex="-1" role="alert">
<h3>
{{
formatMessage(confirmation === 'leave' ? messages.leaveTitle : messages.replaceTitle)
}}
</h3>
<p>
{{ formatMessage(confirmation === 'leave' ? messages.leaveText : messages.replaceText) }}
</p>
</div>
<div v-if="screen === 'board' && puzzle" v-show="!confirmation" class="mine-play">
<div class="mine-toolbar">
<div class="mine-levels">
<Button
v-for="level in levels"
:key="level"
size="sm"
:type="difficulty === level ? 'outlined' : 'base'"
:aria-pressed="difficulty === level"
@click="level !== difficulty && requestNew(level)"
>{{ formatMessage(messages[level]) }}</Button
>
</div>
<span class="mine-time">{{ timeText(elapsed) }}</span>
</div>
<div class="mine-frame">
<p class="mine-status" role="status">{{ status }}</p>
<ColorMineBoard
ref="board"
v-model:zoom="zoom"
:puzzle="puzzle"
:revealed="revealed"
:selected="selected"
:playing="result === 'playing'"
:lost="result === 'lost'"
:mistake="mistake"
@paint="paint"
/>
</div>
<div class="mine-stats">
<span>{{ formatMessage(messages.progress, { count, total: revealed.length }) }}</span>
<span v-if="best !== null">{{
formatMessage(messages.best, { time: timeText(best) })
}}</span>
</div>
<p v-if="result === 'lost'" class="mine-meta">{{ formatMessage(messages.review) }}</p>
<p v-if="recordError" role="alert">{{ formatMessage(messages.recordError) }}</p>
<details class="mine-rules">
<summary>{{ formatMessage(messages.rulesTitle) }}</summary>
<p>{{ formatMessage(messages.rules) }}</p>
</details>
</div>
<p v-else-if="screen === 'loading'" role="status">{{ formatMessage(messages.loading) }}</p>
<div v-else-if="screen === 'saved'" class="mine-prompt">
<h3>{{ formatMessage(messages.savedTitle) }}</h3>
<p v-if="saved">
{{ formatMessage(messages[saved.difficulty]) }} · {{ timeText(saved.elapsed) }}
</p>
</div>
<p v-else role="alert">{{ formatMessage(messages.generateError) }}</p>
<p v-if="storageError" role="alert">{{ formatMessage(messages.storageError) }}</p>
</div>
<template #actions>
<div class="mine-actions">
<template v-if="confirmation">
<Button @click="requestClose">{{ formatMessage(messages.continue) }}</Button>
<Button v-if="confirmation === 'leave'" @click="close">{{
formatMessage(messages.discard)
}}</Button>
<Button
type="colored"
color="brand"
@click="confirmation === 'leave' ? saveAndLeave() : start(nextDifficulty)"
>
{{
formatMessage(confirmation === 'leave' ? messages.saveLeave : messages.newGame)
}}</Button
>
</template>
<template v-else>
<Button @click="requestClose">{{ formatMessage(messages.close) }}</Button>
<template v-if="screen === 'saved'">
<Button @click="discardSaved">{{ formatMessage(messages.discard) }}</Button>
<Button
type="colored"
color="brand"
@click="saved ? start(saved.difficulty, saved) : loadSaved()"
>
{{ formatMessage(saved ? messages.resume : messages.retry) }}</Button
>
</template>
<Button
v-else-if="screen !== 'loading'"
:type="result === 'playing' && screen === 'board' ? 'base' : 'colored'"
color="brand"
@click="screen === 'error' && saved ? start(saved.difficulty, saved) : requestNew()"
>
{{ formatMessage(screen === 'error' ? messages.retry : messages.newGame) }}</Button
>
</template>
</div>
</template>
</NewModal>
</template>
<style scoped>
.color-mine {
display: flex;
flex-direction: column;
gap: var(--gap-md);
min-width: 0;
}
.mine-play {
display: flex;
flex-direction: column;
gap: var(--gap-md);
min-width: 0;
}
.mine-toolbar,
.mine-levels,
.mine-stats,
.mine-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--gap-sm);
}
.mine-time {
margin-left: auto;
font-variant-numeric: tabular-nums;
font-weight: 700;
color: var(--color-contrast);
}
.mine-meta,
.mine-stats,
.mine-rules {
color: var(--color-secondary);
font-size: 0.875rem;
}
.mine-stats {
justify-content: space-between;
}
.mine-frame {
border: 2px solid var(--mine-ink);
border-radius: var(--radius-md);
padding: var(--gap-md);
background: var(--surface-2);
}
.mine-status {
margin: 0 0 var(--gap-md);
color: var(--color-contrast);
font-size: 0.875rem;
}
.mine-actions {
justify-content: flex-end;
}
.mine-prompt h3,
.mine-prompt p {
margin: 0 0 var(--gap-md);
}
.mine-rules summary {
cursor: pointer;
}
.mine-rules p {
line-height: 1.6;
margin-bottom: 0;
}
</style>
@@ -0,0 +1,110 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { type Board, createEngine, type Difficulty, generate, LEVELS } from './engine.ts'
function connected(board: Board) {
const { cross } = createEngine(board.size, board.colors)
for (let color = 0; color < board.colors; color++) {
const source = board.answer.indexOf(color)
if (source < 0) return false
const visited = new Set([source])
const pending = [source]
while (pending.length) {
for (const neighbor of cross[pending.pop()!]) {
if (board.answer[neighbor] === color && !visited.has(neighbor)) {
visited.add(neighbor)
pending.push(neighbor)
}
}
}
if (visited.size !== board.answer.filter((c) => c === color).length) return false
}
return true
}
for (const difficulty of Object.keys(LEVELS) as Difficulty[]) {
test(`${difficulty}: 20 seeds have connected colors, correct numbers and a complete no-guess path`, () => {
for (let seed = 1; seed <= 20; seed++) {
const puzzle = generate(seed, difficulty)
const engine = createEngine(puzzle.size, puzzle.colors)
assert.ok(connected(puzzle))
assert.ok(puzzle.clues.some((v) => !v))
assert.ok(
puzzle.clues.filter(Boolean).length >=
Math.ceil(puzzle.size ** 2 * LEVELS[difficulty].density),
)
for (let color = 0; color < puzzle.colors; color++) {
assert.ok(puzzle.clues.some((v, i) => v && puzzle.answer[i] === color))
}
assert.deepEqual(
puzzle.numbers,
puzzle.answer.map(
(color, i) => engine.around[i].filter((j) => puzzle.answer[j] === color).length,
),
)
assert.ok(engine.solve(puzzle, puzzle.clues).every(Boolean))
if (difficulty === 'hard') {
for (let i = 0; i < puzzle.clues.length; i++) {
if (
!puzzle.clues[i] ||
puzzle.clues.filter((v, j) => v && puzzle.answer[j] === puzzle.answer[i]).length <= 1
)
continue
const fewer = puzzle.clues.slice()
fewer[i] = false
assert.ok(
engine.solve(puzzle, fewer).some((v) => !v),
`Redundant clue at ${i}, seed ${seed}`,
)
}
}
}
})
}
test('seeded generation is deterministic for saved games', () => {
assert.deepEqual(generate(4294967295, 'normal'), generate(4294967295, 'normal'))
})
test('deductions agree with exhaustive valid 3 × 3 boards; unrevealed numbers never leak', () => {
const engine = createEngine(3, 2)
const boards: Board[] = []
for (let bits = 1; bits < 511; bits++) {
const answer = Array.from({ length: 9 }, (_, i) => (bits >> i) & 1)
const board = {
size: 3,
colors: 2,
answer,
numbers: answer.map((c, i) => engine.around[i].filter((j) => answer[j] === c).length),
}
if (connected(board)) boards.push(board)
}
for (const board of boards) {
const revealed = board.answer.map((color, i) => i === board.answer.indexOf(color))
const masks = engine.infer(board, revealed)
const candidates = boards.filter((candidate) =>
revealed.every(
(open, i) =>
!open ||
(candidate.answer[i] === board.answer[i] && candidate.numbers[i] === board.numbers[i]),
),
)
for (const candidate of candidates) {
candidate.answer.forEach((color, i) => assert.ok(masks[i] & (1 << color)))
}
const hiddenScrambled = {
...board,
answer: board.answer.map((c, i) => (revealed[i] ? c : 1 - c)),
numbers: board.numbers.map((n, i) => (revealed[i] ? n : 99)),
}
assert.deepEqual(engine.infer(hiddenScrambled, revealed), masks)
}
})
test('diagonal contact does not connect a color', () => {
assert.equal(
connected({ size: 2, colors: 2, answer: [0, 1, 1, 0], numbers: [1, 1, 1, 1] }),
false,
)
})
@@ -0,0 +1,275 @@
export const LEVELS = {
easy: { size: 10, colors: 4, density: 0.35 },
normal: { size: 25, colors: 6, density: 0.15 },
hard: { size: 60, colors: 10, density: 0 },
} as const
export type Difficulty = keyof typeof LEVELS
export interface Board {
size: number
colors: number
answer: number[]
numbers: number[]
}
export interface Puzzle extends Board {
clues: boolean[]
seed: number
difficulty: Difficulty
}
const singleton = (mask: number) => mask > 0 && (mask & (mask - 1)) === 0
export const colorOf = (mask: number) => 31 - Math.clz32(mask)
function randomSource(seed: number) {
return () => {
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function shuffle<T>(items: T[], random: () => number) {
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1))
;[items[i], items[j]] = [items[j], items[i]]
}
return items
}
export function createEngine(size: number, colors: number) {
const count = size * size
const all = (1 << colors) - 1
const indices = Array.from({ length: count }, (_, i) => i)
const around = indices.map((i) => {
const neighbors: number[] = []
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const x = (i % size) + dx
const y = Math.floor(i / size) + dy
if ((dx || dy) && x >= 0 && x < size && y >= 0 && y < size) {
neighbors.push(y * size + x)
}
}
}
return neighbors
})
const distance = (a: number, b: number) =>
Math.abs((a % size) - (b % size)) + Math.abs(Math.floor(a / size) - Math.floor(b / size))
const cross = around.map((neighbors, i) => neighbors.filter((j) => distance(i, j) === 1))
function grow(random: () => number): Board {
const answer: number[] = Array(count).fill(-1)
const areas: number[] = Array(colors).fill(0)
const seeds: number[] = []
for (let color = 0; color < colors; color++) {
const candidates = shuffle(
indices.filter((i) => answer[i] < 0),
random,
).slice(0, 80)
if (color) {
candidates.sort(
(a, b) =>
Math.min(...seeds.map((s) => distance(b, s))) -
Math.min(...seeds.map((s) => distance(a, s))),
)
}
const seed = candidates[0]
answer[seed] = color
areas[color]++
seeds.push(seed)
}
const frontier: number[][] = Array.from({ length: colors }, () => [])
const queued = Array.from({ length: colors }, () => new Uint8Array(count))
function extend(i: number, color: number) {
for (const j of cross[i]) {
if (answer[j] < 0 && !queued[color][j]) {
frontier[color].push(j)
queued[color][j] = 1
}
}
}
seeds.forEach(extend)
let left = count - colors
while (left) {
const available = areas.map((_, c) => c).filter((c) => frontier[c].length)
available.sort((a, b) => areas[a] - areas[b])
if (!available.length) throw new Error('Incomplete region growth')
const color = available[Math.floor(random() * Math.min(2, available.length))]
const list = frontier[color]
const position = Math.floor(random() * list.length)
const i = list[position]
list[position] = list[list.length - 1]
list.pop()
if (answer[i] >= 0) continue
answer[i] = color
areas[color]++
left--
extend(i, color)
}
return {
size,
colors,
answer,
numbers: answer.map((color, i) => around[i].filter((j) => answer[j] === color).length),
}
}
// Only revealed cells supply numbers. Deduced cells become new clues after a safe paint.
function propagate(board: Board, revealed: boolean[], masks: number[]) {
let changed = true
while (changed) {
changed = false
for (const i of indices) {
if (!revealed[i]) continue
const bit = 1 << board.answer[i]
const target = board.numbers[i]
let sure = 0
const optional: number[] = []
for (const j of around[i]) {
if (masks[j] === bit) sure++
else if (masks[j] & bit) optional.push(j)
}
if (sure > target || sure + optional.length < target) throw new Error('Contradictory clue')
if (sure === target || sure + optional.length === target) {
for (const j of optional) {
masks[j] = sure === target ? masks[j] & ~bit : bit
changed = true
}
}
}
}
}
function connectivity(masks: number[]) {
let changed = false
for (let color = 0; color < colors; color++) {
const bit = 1 << color
const source = masks.indexOf(bit)
if (source < 0) continue
// Iterative Tarjan traversal avoids a recursive stack overflow on the 60 × 60 board.
const order = new Int32Array(count)
const low = new Int32Array(count)
const parent = new Int32Array(count).fill(-1)
const terminals = new Int32Array(count)
const edge = new Uint8Array(count)
const stack = [source]
let time = 1
order[source] = low[source] = terminals[source] = 1
while (stack.length) {
const u = stack[stack.length - 1]
if (edge[u] < cross[u].length) {
const v = cross[u][edge[u]++]
if (!(masks[v] & bit)) continue
if (!order[v]) {
parent[v] = u
order[v] = low[v] = ++time
terminals[v] = masks[v] === bit ? 1 : 0
stack.push(v)
} else if (v !== parent[u]) low[u] = Math.min(low[u], order[v])
} else {
stack.pop()
const p = parent[u]
if (p < 0) continue
low[p] = Math.min(low[p], low[u])
terminals[p] += terminals[u]
// A bridge must be this color if its removal separates two known same-color cells.
if (p !== source && low[u] >= order[p] && terminals[u] > 0 && masks[p] !== bit) {
masks[p] = bit
changed = true
}
}
}
for (const i of indices) {
if (masks[i] & bit && !order[i]) {
masks[i] &= ~bit
changed = true
}
}
}
if (masks.some((mask) => mask === 0)) throw new Error('Empty color domain')
return changed
}
function infer(board: Board, revealed: boolean[]) {
const masks = board.answer.map((color, i) => (revealed[i] ? 1 << color : all))
do {
propagate(board, revealed, masks)
} while (connectivity(masks))
return masks
}
function solve(board: Board, clues: boolean[]) {
const revealed = clues.slice()
const masks = board.answer.map((color, i) => (revealed[i] ? 1 << color : all))
while (true) {
propagate(board, revealed, masks)
const next = indices.filter((i) => !revealed[i] && singleton(masks[i]))
if (!next.length) {
if (!connectivity(masks)) return revealed
continue
}
for (const i of next) {
if (colorOf(masks[i]) !== board.answer[i]) throw new Error('Unsound deduction')
revealed[i] = true
}
}
}
return { around, cross, grow, infer, solve }
}
export function generate(seed: number, difficulty: Difficulty): Puzzle {
const level = LEVELS[difficulty]
const random = randomSource(seed)
const engine = createEngine(level.size, level.colors)
const board = engine.grow(random)
const indices = board.answer.map((_, i) => i)
const clues: boolean[] = indices.map(() => false)
for (let color = 0; color < level.colors; color++) {
const cells = shuffle(
indices.filter((i) => board.answer[i] === color),
random,
)
cells.sort((a, b) => board.numbers[b] - board.numbers[a])
clues[cells[0]] = true
}
let solved = engine.solve(board, clues)
while (solved.some((value) => !value)) {
const cells = shuffle(
indices.filter((i) => !solved[i]),
random,
)
cells.sort(
(a, b) =>
engine.around[b].filter((j) => !solved[j]).length -
engine.around[a].filter((j) => !solved[j]).length,
)
clues[cells[0]] = true
solved = engine.solve(board, clues)
}
// Greedy irredundancy under these deduction rules, not a claim of global minimum clue count.
for (const i of shuffle(
indices.filter((i) => clues[i]),
random,
)) {
if (indices.filter((j) => clues[j] && board.answer[j] === board.answer[i]).length <= 1) continue
clues[i] = false
if (engine.solve(board, clues).some((value) => !value)) clues[i] = true
}
const target = Math.ceil(indices.length * level.density)
const pools = Array.from({ length: level.colors }, (_, color) =>
shuffle(
indices.filter((i) => board.answer[i] === color && !clues[i]),
random,
),
)
let total = clues.filter(Boolean).length
while (total < target) {
for (const pool of pools) {
if (pool.length && total < target) {
clues[pool.pop()!] = true
total++
}
}
}
return { ...board, clues, seed, difficulty }
}
@@ -0,0 +1,9 @@
import { type Difficulty, generate } from './engine'
self.onmessage = (event: MessageEvent<{ seed: number; difficulty: Difficulty }>) => {
try {
self.postMessage({ puzzle: generate(event.data.seed, event.data.difficulty) })
} catch {
self.postMessage({ error: true })
}
}
@@ -0,0 +1,93 @@
import { defineMessages } from '@modrinth/ui'
export const messages = defineMessages({
title: { id: 'app.easteregg.color-mine.title', defaultMessage: 'Starlight Mine: Chromatic Realms' },
easy: { id: 'app.easteregg.color-mine.easy', defaultMessage: 'Easy' },
normal: { id: 'app.easteregg.color-mine.normal', defaultMessage: 'Normal' },
hard: { id: 'app.easteregg.color-mine.hard', defaultMessage: 'Hard' },
newGame: { id: 'app.easteregg.color-mine.newGame', defaultMessage: 'New board' },
close: { id: 'app.easteregg.color-mine.close', defaultMessage: 'Close' },
choose: {
id: 'app.easteregg.color-mine.choose',
defaultMessage: 'Click a revealed tile to sample its color, then paint a gray tile.',
},
selected: {
id: 'app.easteregg.color-mine.selected',
defaultMessage: 'Color {color} selected · Click a colored tile to change your brush.',
},
rulesTitle: { id: 'app.easteregg.color-mine.rulesTitle', defaultMessage: 'How to play' },
rules: {
id: 'app.easteregg.color-mine.rules',
defaultMessage:
'Numbers count same-color tiles in the eight surrounding spaces, excluding the tile itself. Each color forms one connected region using only horizontal and vertical edges. Sample a revealed tile to select a brush; a correct paint reveals a new number, but one wrong paint ends the game. Every board has a step-by-step deduction path without guessing.',
},
loading: {
id: 'app.easteregg.color-mine.loading',
defaultMessage: 'Generating regions and checking the deduction path…',
},
generateError: {
id: 'app.easteregg.color-mine.generateError',
defaultMessage: 'Could not generate this board. Try again.',
},
retry: { id: 'app.easteregg.color-mine.retry', defaultMessage: 'Try again' },
progress: {
id: 'app.easteregg.color-mine.progress',
defaultMessage: '{count} / {total} revealed',
},
best: { id: 'app.easteregg.color-mine.best', defaultMessage: 'Best · {time}' },
won: { id: 'app.easteregg.color-mine.won', defaultMessage: 'All colors restored!' },
lost: {
id: 'app.easteregg.color-mine.lost',
defaultMessage:
'Wrong color at row {row}, column {column}: selected {selected}, correct {correct}. Game over.',
},
review: {
id: 'app.easteregg.color-mine.review',
defaultMessage: 'Unrevealed answers are now shown faded for review.',
},
leaveTitle: { id: 'app.easteregg.color-mine.leaveTitle', defaultMessage: 'Leave this board?' },
leaveText: {
id: 'app.easteregg.color-mine.leaveText',
defaultMessage: 'Save to continue later, or discard this unfinished board.',
},
saveLeave: { id: 'app.easteregg.color-mine.saveLeave', defaultMessage: 'Save and leave' },
discard: { id: 'app.easteregg.color-mine.discard', defaultMessage: 'Discard' },
continue: { id: 'app.easteregg.color-mine.continue', defaultMessage: 'Keep playing' },
replaceTitle: {
id: 'app.easteregg.color-mine.replaceTitle',
defaultMessage: 'Start a new board?',
},
replaceText: {
id: 'app.easteregg.color-mine.replaceText',
defaultMessage: 'The current board will be discarded.',
},
savedTitle: {
id: 'app.easteregg.color-mine.savedTitle',
defaultMessage: 'An unfinished board is saved',
},
resume: { id: 'app.easteregg.color-mine.resume', defaultMessage: 'Resume saved game' },
storageError: {
id: 'app.easteregg.color-mine.storageError',
defaultMessage:
'Could not read or write the saved game. Your current board has been kept. Retry or explicitly discard it.',
},
recordError: {
id: 'app.easteregg.color-mine.recordError',
defaultMessage: 'Finished, but the best time could not be saved.',
},
zoomIn: { id: 'app.easteregg.color-mine.zoomIn', defaultMessage: 'Zoom in' },
zoomOut: { id: 'app.easteregg.color-mine.zoomOut', defaultMessage: 'Zoom out' },
map: { id: 'app.easteregg.color-mine.map', defaultMessage: 'Board overview. Click to navigate.' },
board: {
id: 'app.easteregg.color-mine.board',
defaultMessage: '{size} by {size} color deduction board',
},
hidden: {
id: 'app.easteregg.color-mine.hidden',
defaultMessage: 'Row {row}, column {column}, unrevealed',
},
revealed: {
id: 'app.easteregg.color-mine.revealed',
defaultMessage: 'Row {row}, column {column}, color {color}, {number} same-color neighbors',
},
})
@@ -0,0 +1,13 @@
/* Semantic colors stay identifiable when the launcher's theme/accent changes. */
.color-mine {
--mine-color-0: var(--color-red);
--mine-color-1: #d7ac37;
--mine-color-2: var(--color-green);
--mine-color-3: var(--color-blue);
--mine-color-4: var(--color-purple);
--mine-color-5: var(--color-orange);
--mine-color-6: #97ba35;
--mine-color-7: #22b6b8;
--mine-color-8: var(--color-pink);
--mine-color-9: #8999ac;
}
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { generate } from './engine.ts'
import {
parseSave,
readBest,
recordBest,
SAVE_KEY,
type SavedGame,
saveGame,
validateResume,
} from './storage.ts'
const puzzle = generate(123, 'easy')
const saved: SavedGame = {
version: 1,
seed: puzzle.seed,
difficulty: puzzle.difficulty,
revealed: puzzle.clues.slice(),
selected: -1,
elapsed: 4321,
zoom: 1,
left: 12,
top: 34,
}
function memoryStorage() {
const values = new Map<string, string>()
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value)
},
removeItem: (key: string) => {
values.delete(key)
},
}
}
test('save round trip keeps board progress, brush, timer and viewport', () => {
const storage = memoryStorage()
saveGame(storage, saved)
assert.deepEqual(parseSave(storage.getItem(SAVE_KEY)), saved)
validateResume(saved, puzzle)
assert.equal(parseSave(null), null)
})
test('malformed and incompatible saves are rejected without overwriting data', () => {
for (const bad of [
null,
{},
{ ...saved, version: 2 },
{ ...saved, difficulty: '__proto__' },
{ ...saved, revealed: [true] },
{ ...saved, elapsed: -1 },
{ ...saved, selected: 15 },
{ ...saved, zoom: 5 },
{ ...saved, seed: 1.2 },
{ ...saved, top: -1 },
{ ...saved, revealed: saved.revealed.map(() => true) },
]) {
assert.throws(() => parseSave(JSON.stringify(bad)))
}
assert.throws(() => parseSave('{invalid'))
assert.throws(() => validateResume({ ...saved, seed: 9 }, puzzle))
assert.throws(() =>
validateResume({ ...saved, revealed: saved.revealed.map(() => false) }, puzzle),
)
const storage = memoryStorage()
saveGame(storage, saved)
assert.throws(() => saveGame(storage, { ...saved, elapsed: -1 }))
assert.deepEqual(parseSave(storage.getItem(SAVE_KEY)), saved)
})
test('storage failures are surfaced so the UI can keep the active board', () => {
assert.throws(() =>
saveGame(
{
...memoryStorage(),
setItem: () => {
throw new Error('Quota exceeded')
},
},
saved,
),
)
})
test('best times are independent per difficulty and only improve', () => {
const storage = memoryStorage()
assert.equal(readBest(storage, 'easy'), null)
assert.equal(recordBest(storage, 'easy', 1000), 1000)
assert.equal(recordBest(storage, 'easy', 2000), 1000)
assert.equal(recordBest(storage, 'easy', 500), 500)
assert.equal(recordBest(storage, 'hard', 9000), 9000)
assert.equal(readBest(storage, 'easy'), 500)
assert.throws(() => recordBest(storage, 'easy', -1))
})
@@ -0,0 +1,93 @@
import { type Difficulty, LEVELS, type Puzzle } from './engine.ts'
export const SAVE_KEY = 'starlight.color-mine.save.v1'
const BEST_KEY = 'starlight.color-mine.best.v1'
export interface SavedGame {
version: 1
seed: number
difficulty: Difficulty
revealed: boolean[]
selected: number
elapsed: number
zoom: number
left: number
top: number
}
type StoragePort = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
const finite = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value)
export function parseSave(raw: string | null): SavedGame | null {
if (raw === null) return null
const value = JSON.parse(raw) as Partial<SavedGame> | null
if (
!value ||
value.version !== 1 ||
!value.difficulty ||
!Object.keys(LEVELS).includes(value.difficulty)
) {
throw new Error('Invalid saved game')
}
const level = LEVELS[value.difficulty]
if (
!finite(value.seed) ||
!Number.isInteger(value.seed) ||
value.seed < 0 ||
value.seed > 0xffffffff ||
!Array.isArray(value.revealed) ||
value.revealed.length !== level.size ** 2 ||
value.revealed.some((v) => typeof v !== 'boolean') ||
value.revealed.every(Boolean) ||
!finite(value.selected) ||
!Number.isInteger(value.selected) ||
value.selected < -1 ||
value.selected >= level.colors ||
!finite(value.elapsed) ||
value.elapsed < 0 ||
!finite(value.zoom) ||
value.zoom < 0.7 ||
value.zoom > 1.6 ||
!finite(value.left) ||
value.left < 0 ||
!finite(value.top) ||
value.top < 0
)
throw new Error('Invalid saved game')
return value as SavedGame
}
export function validateResume(saved: SavedGame, puzzle: Puzzle) {
if (
saved.seed !== puzzle.seed ||
saved.difficulty !== puzzle.difficulty ||
puzzle.clues.some((clue, i) => clue && !saved.revealed[i]) ||
(saved.selected >= 0 &&
!saved.revealed.some((open, i) => open && puzzle.answer[i] === saved.selected))
) {
throw new Error('Saved game does not match puzzle')
}
}
export function saveGame(storage: StoragePort, saved: SavedGame) {
const raw = JSON.stringify(saved)
parseSave(raw)
storage.setItem(SAVE_KEY, raw)
}
export function readBest(storage: StoragePort, difficulty: Difficulty): number | null {
const raw = storage.getItem(BEST_KEY)
if (!raw) return null
const value: unknown = JSON.parse(raw)
if (!value || typeof value !== 'object') return null
const time = (value as Record<string, unknown>)[difficulty]
return finite(time) && time >= 0 ? time : null
}
export function recordBest(storage: StoragePort, difficulty: Difficulty, elapsed: number) {
if (!finite(elapsed) || elapsed < 0) throw new Error('Invalid time')
const best = Object.fromEntries(
Object.keys(LEVELS).map((key) => [key, readBest(storage, key as Difficulty)]),
)
best[difficulty] = Math.min(best[difficulty] ?? Infinity, elapsed)
storage.setItem(BEST_KEY, JSON.stringify(best))
return best[difficulty]
}
@@ -14,7 +14,6 @@ import { computed, ref } from 'vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { list } from '@/helpers/instance'
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
@@ -91,7 +90,6 @@ defineExpose({
instances.value = instanceValues
modal.value.show()
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
},
})
@@ -102,11 +100,6 @@ async function addServer(instance) {
instance.added = true
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
trackEvent('AddServerToInstance', {
server_name: serverName.value,
instance_name: instance.name,
source: 'AddServerToInstanceModal',
})
} catch (err) {
handleError(err)
}
@@ -17,8 +17,8 @@ import { computed, type Ref, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { install_duplicate_instance } from '@/helpers/install'
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
import { injectInstanceSettings } from '@/providers/instance-settings'
@@ -49,10 +49,6 @@ const installing = computed(() => instance.value.install_stage !== 'installed')
async function duplicateInstance() {
await install_duplicate_instance(instance.value.id).catch(handleError)
trackEvent('InstanceDuplicate', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
}
function formatReleaseChannelLabel(channel: ReleaseChannel) {
@@ -104,7 +100,6 @@ watch(selectedReleaseChannel, async (channel, previousChannel) => {
async function resetIcon() {
icon.value = undefined
await edit_icon(instance.value.id, null).catch(handleError)
trackEvent('InstanceRemoveIcon')
}
async function setIcon() {
@@ -116,7 +111,6 @@ async function setIcon() {
icon.value = picked.path
try {
await edit_icon(instance.value.id, picked.path)
trackEvent('InstanceSetIcon')
} catch (error) {
icon.value = previousIcon
handleError(error)
@@ -253,10 +247,6 @@ async function removeInstance() {
removing.value = true
const path = instance.value.id
trackEvent('InstanceRemove', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
await router.push({ path: '/' })
await remove(path).catch(handleError)
@@ -390,6 +380,7 @@ const messages = defineMessages({
</script>
<template>
<InstanceModeSettings class="mb-6" :instance-id="instance.id" />
<ConfirmDeleteInstanceModal
ref="deleteConfirmModal"
:symlink-target="instance.symlink_target"
@@ -22,7 +22,6 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache'
import { type CurseForgeFile, updateManagedCurseForgeModpack } from '@/helpers/curseforge'
import {
@@ -333,10 +332,6 @@ provideInstallationSettings({
afterSave: async () => {
debug('afterSave: installing', { instanceId: instance.value.id })
await install_existing_instance(instance.value.id, false).catch(handleError)
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('afterSave: done')
},
@@ -345,40 +340,27 @@ provideInstallationSettings({
repairing.value = true
await install_existing_instance(instance.value.id, true).catch(handleError)
repairing.value = false
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('repair: done')
},
async reinstallModpack() {
debug('reinstallModpack: called', { instanceId: instance.value.id })
reinstalling.value = true
let shouldTrack = false
try {
if (isImportedModpack.value) {
shouldTrack = await installLocalModpackFromPicker()
await installLocalModpackFromPicker()
} else if (isCurseForgeLinkedModpack.value) {
const fileId = Number(instance.value.link?.version_id)
if (!Number.isFinite(fileId)) {
throw new Error('Invalid CurseForge file ID')
}
await updateManagedCurseForgeModpack(instance.value.id, fileId).catch(handleError)
shouldTrack = true
} else {
await update_repair_modrinth(instance.value.id).catch(handleError)
shouldTrack = true
}
} finally {
reinstalling.value = false
}
if (shouldTrack) {
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
}
debug('reinstallModpack: done')
},
@@ -386,13 +368,7 @@ provideInstallationSettings({
debug('swapModpack: called', { instanceId: instance.value.id })
reinstalling.value = true
try {
const installed = await installLocalModpackFromPicker()
if (installed) {
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
}
await installLocalModpackFromPicker()
} finally {
reinstalling.value = false
}
@@ -1,145 +0,0 @@
<script setup lang="ts">
import { ExternalIcon, ShieldIcon, SpinnerIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
NewModal,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
import { type PrivacySettings, savePrivacySettings } from '@/helpers/settings'
const emit = defineEmits<{
saved: [privacy: PrivacySettings]
}>()
const CONSENT_VERSION = 1
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = ref<InstanceType<typeof NewModal>>()
const telemetry = ref(true)
const discordRpc = ref(true)
const saving = ref(false)
const messages = defineMessages({
title: {
id: 'app.privacy-consent.title',
defaultMessage: 'Privacy & security',
},
intro: {
id: 'app.privacy-consent.intro',
defaultMessage:
'Choose what Starlight may send or display. Nothing is sent until you confirm these choices.',
},
telemetry: {
id: 'app.privacy-consent.telemetry',
defaultMessage: 'Allow anonymous telemetry',
},
telemetryDescription: {
id: 'app.privacy-consent.telemetry-description',
defaultMessage:
'Helps count opted-in installations and daily active users. Full Minecraft logs and account credentials are never uploaded.',
},
discordRpc: {
id: 'app.privacy-consent.discord-rpc',
defaultMessage: 'Discord Rich Presence',
},
discordRpcDescription: {
id: 'app.privacy-consent.discord-rpc-description',
defaultMessage:
'Shows your current launcher or game activity in Discord when Discord is running.',
},
privacyPolicy: {
id: 'app.privacy-consent.privacy-policy',
defaultMessage: 'Read the privacy policy',
},
continue: {
id: 'app.privacy-consent.continue',
defaultMessage: 'Save and continue',
},
})
function show(current: PrivacySettings) {
telemetry.value = true
discordRpc.value = current.discord_rpc
modal.value?.show()
}
async function save() {
if (saving.value) return
saving.value = true
try {
const privacy = await savePrivacySettings({
telemetry: telemetry.value,
discord_rpc: discordRpc.value,
consent_version: CONSENT_VERSION,
})
modal.value?.hide()
emit('saved', privacy)
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" :closable="false" max-width="600px">
<div class="flex flex-col gap-6">
<div class="flex items-start gap-3">
<ShieldIcon class="mt-0.5 size-6 shrink-0 text-brand" />
<p class="m-0 leading-relaxed text-primary">
{{ formatMessage(messages.intro) }}
</p>
</div>
<div class="flex items-center justify-between gap-5">
<div class="min-w-0">
<label for="consent-telemetry" class="font-semibold text-contrast">
{{ formatMessage(messages.telemetry) }}
</label>
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.telemetryDescription) }}
</p>
</div>
<Toggle id="consent-telemetry" v-model="telemetry" :disabled="saving" />
</div>
<div class="flex items-center justify-between gap-5">
<div class="min-w-0">
<label for="consent-discord-rpc" class="font-semibold text-contrast">
{{ formatMessage(messages.discordRpc) }}
</label>
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.discordRpcDescription) }}
</p>
</div>
<Toggle id="consent-discord-rpc" v-model="discordRpc" :disabled="saving" />
</div>
</div>
<template #actions>
<div class="flex items-center justify-between gap-4">
<ButtonStyled type="transparent">
<button type="button" :disabled="saving" @click="openUrl('https://axlmc.org/privacy')">
<ExternalIcon />
{{ formatMessage(messages.privacyPolicy) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="saving" @click="save">
<SpinnerIcon v-if="saving" class="animate-spin" />
{{ formatMessage(messages.continue) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
@@ -110,7 +110,8 @@ const { formatMessage } = useVIntl()
color: var(--color-contrast);
font-size: 4.5rem;
font-weight: 800;
line-height: 1;
// Keep descenders inside the line box clipped by the wordmark reveal animation.
line-height: normal;
letter-spacing: 0;
white-space: nowrap;
animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
@@ -204,19 +204,11 @@ export const onboardingMessages = defineMessages({
defaultMessage:
'Choose how content downloads and installs, from download sources to safety checks.',
},
privacyTitle: {
id: 'app.onboarding.privacy.title',
defaultMessage: 'Your data, your call',
},
privacyDescription: {
id: 'app.onboarding.privacy.description',
defaultMessage:
'Manage anonymous telemetry, Discord Rich Presence, and the Minecraft log analysis service whenever you need to.',
},
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
updatesDescription: {
id: 'app.onboarding.updates.description',
defaultMessage: 'Choose when Starlight checks for updates and whether it installs them for you.',
defaultMessage:
'Choose when Starlight checks for updates and whether it installs them for you.',
},
clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' },
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
@@ -248,6 +240,15 @@ export const onboardingMessages = defineMessages({
defaultMessage: 'Click Create new instance to continue',
},
creationTitle: { id: 'app.onboarding.creation.title', defaultMessage: 'Pick your route' },
instanceModeTitle: {
id: 'app.onboarding.instance-mode.title',
defaultMessage: 'Choose your instance type',
},
instanceModeDescription: {
id: 'app.onboarding.instance-mode.description',
defaultMessage:
'Choose StarLight to play on the StarLight server and automatically sync its approved updates. Local instances skip StarLight sync, start faster, and let you use your own modpacks for other servers or single-player. You can change this later in instance settings.',
},
creationDescription: {
id: 'app.onboarding.creation.description',
defaultMessage:
@@ -690,6 +691,12 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
),
control('create-instance', '/create'),
),
inspect(
'creation-instance-mode',
'creation-instance-mode',
onboardingMessages.instanceModeTitle,
onboardingMessages.instanceModeDescription,
),
step(
'creation-flow',
'activate',
@@ -1,20 +1,15 @@
<script setup lang="ts">
import {
ChevronDownIcon,
ExternalIcon,
ScaleIcon,
UsersIcon,
} from '@modrinth/assets'
import { ChevronDownIcon, ExternalIcon, ScaleIcon, UsersIcon } from '@modrinth/assets'
import { Avatar, defineMessages, NewButton as Button, useVIntl } from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { openUrl } from '@tauri-apps/plugin-opener'
import { inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
import { defineAsyncComponent, inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
import ColorMineAvatar from '@/components/ui/easteregg/color-mine/ColorMineAvatar.vue'
import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue'
import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue'
import { AxolotlBrandConfig } from '@/config'
import { contributors, teamMembers, type TeamMember } from '@/data/about'
import { contributors, type TeamMember, teamMembers } from '@/data/about'
import AboutScene from '../AboutScene.vue'
import { type AboutMemberExperience, getAboutMemberExperience } from './about-member-experiences'
@@ -24,7 +19,7 @@ const version = await getVersion()
const experienceHost = ref<HTMLElement>()
const activeMemberExperience = shallowRef<AboutMemberExperience>()
const pressingMemberName = ref<string>()
let longPressTimer: ReturnType<typeof window.setTimeout> | undefined
let longPressTimer: number | undefined
let pressStart = { x: 0, y: 0 }
let suppressNextMemberClick = false
const replayOnboarding = inject<(mode: 'main' | 'instance') => Promise<void>>('replayOnboarding')
@@ -78,6 +73,24 @@ function closeMemberExperience() {
const gameModal = ref<InstanceType<typeof EasterEggGameModal> | null>(null)
const contributorsModal = ref<InstanceType<typeof EasterEggContributorsModal> | null>(null)
const ColorMineModal = defineAsyncComponent(
() => import('@/components/ui/easteregg/color-mine/ColorMineModal.vue'),
)
const colorMineModal = ref<{ show: () => void }>()
const colorMineMounted = ref(false)
const colorMinePending = ref(false)
function openColorMine() {
colorMineMounted.value = true
if (colorMineModal.value) colorMineModal.value.show()
else colorMinePending.value = true
}
function colorMineReady() {
if (!colorMinePending.value) return
colorMinePending.value = false
colorMineModal.value?.show()
}
let typedBuffer = ''
const secretCodes = ['starlight']
@@ -166,7 +179,8 @@ const messages = defineMessages({
},
attribution: {
id: 'app.settings.about.attribution',
defaultMessage: 'Starlight Launcher is a modified version of the Axolotl Launcher, which is based on the open-source Modrinth codebase.',
defaultMessage:
'Starlight Launcher is a modified version of the Axolotl Launcher, which is based on the open-source Modrinth codebase.',
},
notAffiliated: {
id: 'app.settings.about.not-affiliated',
@@ -194,7 +208,6 @@ const messages = defineMessages({
defaultMessage: '{count, plural, one {# contributor} other {# contributors}}',
},
})
</script>
<template>
@@ -237,7 +250,6 @@ const messages = defineMessages({
</p>
</section>
<section>
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
<ScaleIcon class="size-5 text-secondary" />
@@ -288,33 +300,56 @@ const messages = defineMessages({
{{ formatMessage(messages.developmentTeam) }}
</h3>
<div class="grid gap-3 sm:grid-cols-2">
<a
v-for="member in teamMembers"
:key="member.name"
:href="member.url ?? undefined"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
@pointerdown="startMemberLongPress(member, $event)"
@pointermove="moveMemberLongPress"
@pointerup="cancelMemberLongPress"
@pointerleave="cancelMemberLongPress"
@click="handleMemberClick"
@contextmenu="handleMemberContextMenu(member, $event)"
>
<Avatar
:src="member.avatarUrl"
:alt="member.name"
size="2.5rem"
circle
no-shadow
loading="lazy"
/>
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">
{{ member.name }}
</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
<template v-for="member in teamMembers" :key="member.name">
<div
v-if="member.name === 'Disy920'"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
>
<ColorMineAvatar
:src="member.avatarUrl"
:name="member.name"
:href="member.url"
@activate="openColorMine"
/>
<a
:href="member.url"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 flex-1 items-center gap-3"
>
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">{{
member.name
}}</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
</div>
<a
v-else
:href="member.url ?? undefined"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-3 transition-colors hover:bg-surface-5"
@pointerdown="startMemberLongPress(member, $event)"
@pointermove="moveMemberLongPress"
@pointerup="cancelMemberLongPress"
@pointerleave="cancelMemberLongPress"
@click="handleMemberClick"
@contextmenu="handleMemberContextMenu(member, $event)"
>
<Avatar
:src="member.avatarUrl"
:alt="member.name"
size="2.5rem"
circle
no-shadow
loading="lazy"
/>
<span class="min-w-0 flex-1 truncate font-semibold text-contrast">
{{ member.name }}
</span>
<ExternalIcon v-if="member.url" class="size-4 shrink-0 text-secondary" />
</a>
</template>
</div>
</section>
<details class="group pt-4 about-settings-details">
@@ -360,6 +395,7 @@ const messages = defineMessages({
</div>
<EasterEggGameModal ref="gameModal" />
<ColorMineModal v-if="colorMineMounted" ref="colorMineModal" @vue:mounted="colorMineReady" />
<EasterEggContributorsModal ref="contributorsModal" @open-game="onEasterEggOpenGame" />
</template>
@@ -21,7 +21,6 @@ import JetBrainsLogo from '@/assets/java-vendors/jetbrains.png'
import MicrosoftLogo from '@/assets/java-vendors/microsoft.png'
import OracleLogo from '@/assets/java-vendors/oracle.png'
import SapLogo from '@/assets/java-vendors/sap.png'
import { trackEvent } from '@/helpers/analytics'
import { download_java, list_java_feed_vendors, list_java_feed_versions } from '@/helpers/jre'
const { handleError } = injectNotificationManager()
@@ -117,7 +116,6 @@ function backToVendors() {
async function downloadVersion(info) {
downloading.value = info.major_version
trackEvent('JavaDownload', { vendor: info.vendor, version: info.major_version })
modal.value?.hide()
const job = await download_java(info.vendor, info.major_version).catch(handleError)
@@ -23,7 +23,6 @@ const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const isDevEnvironment = await isDev()
const previewMinecraftCrashModal = inject<() => void>('previewMinecraftCrashModal')
const previewPrivacyConsentModal = inject<() => Promise<void>>('previewPrivacyConsentModal')
const messages = defineMessages({
resetToDefault: {
id: 'app.settings.feature-flags.reset-to-default',
@@ -53,10 +52,6 @@ const messages = defineMessages({
id: 'app.settings.about.preview-minecraft-crash-modal',
defaultMessage: 'Preview Minecraft crash window',
},
previewPrivacyConsentModal: {
id: 'app.settings.about.preview-privacy-consent-modal',
defaultMessage: 'Preview privacy & security modal',
},
})
const settings = ref(await getSettings())
@@ -127,9 +122,6 @@ watch(
<Button type="base" @click="previewMinecraftCrashModal?.()">
<WrenchIcon /> {{ formatMessage(messages.previewMinecraftCrashModal) }}
</Button>
<Button type="base" @click="previewPrivacyConsentModal?.()">
<WrenchIcon /> {{ formatMessage(messages.previewPrivacyConsentModal) }}
</Button>
</div>
</SettingsSection>
</template>
@@ -19,7 +19,6 @@ import MemoryAllocationDisplay from '@/components/ui/MemoryAllocationDisplay.vue
import DownloadJavaModal from '@/components/ui/settings/DownloadJavaModal.vue'
import InstalledJavaModal from '@/components/ui/settings/InstalledJavaModal.vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { trackEvent } from '@/helpers/analytics'
import { collectGcContext } from '@/helpers/gc/context'
import { wait_for_install_job } from '@/helpers/install'
import { getJavaArgumentPresets } from '@/helpers/java-argument-presets'
@@ -249,7 +248,6 @@ async function runScan(exhaustive) {
scanning.value = true
scanMode.value = 'quick'
trackEvent('JavaQuickScan', { source: 'settings' })
try {
await find_filtered_jres(null, false, true, false).catch(handleError)
} finally {
@@ -262,7 +260,6 @@ async function confirmDeepScan() {
showDeepScanConfirm.value = false
scanning.value = true
scanMode.value = 'deep'
trackEvent('JavaDeepScan', { source: 'settings' })
try {
await find_filtered_jres(null, true, true, true).catch(handleError)
} finally {
@@ -280,7 +277,6 @@ async function handleManualAdd() {
if (!javaInfo) return
await set_java_version(javaInfo).catch(handleError)
trackEvent('JavaManualSelect', { path: filePath })
}
async function onJavaDownloaded(job) {
@@ -1,156 +0,0 @@
<script setup lang="ts">
import { defineMessages, injectNotificationManager, Toggle, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { getPrivacySettings, setDiscordRpcEnabled, setTelemetryEnabled } from '@/helpers/settings'
import SettingsRow from './SettingsRow.vue'
import SettingsSaveStatus from './SettingsSaveStatus.vue'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const privacy = ref(await getPrivacySettings())
const telemetrySaving = ref(false)
const discordSaving = ref(false)
const lastSaveState = ref<'idle' | 'saved' | 'error'>('idle')
const retrySave = ref<(() => void) | undefined>()
const messages = defineMessages({
telemetry: {
id: 'app.settings.privacy.telemetry',
defaultMessage: 'Allow telemetry',
},
telemetryDescription: {
id: 'app.settings.privacy.telemetry-description',
defaultMessage:
'Send one anonymous daily activity signal to improve usage statistics. Minecraft logs and account credentials are never uploaded.',
},
discordRpc: {
id: 'app.settings.privacy.discord-rpc',
defaultMessage: 'Discord Rich Presence',
},
discordRpcDescription: {
id: 'app.settings.privacy.discord-rpc-description',
defaultMessage: 'Show your current launcher or game activity in Discord.',
},
dataHandling: {
id: 'app.settings.privacy.data-handling',
defaultMessage:
'Telemetry uses a random installation identifier and sends only a daily activity signal. Turning telemetry off clears pending data immediately.',
},
})
const saveStatus = computed(() => {
if (telemetrySaving.value || discordSaving.value) return 'saving'
return lastSaveState.value
})
async function updateTelemetry(value: boolean) {
if (telemetrySaving.value) return
const previous = privacy.value.telemetry
privacy.value.telemetry = value
telemetrySaving.value = true
lastSaveState.value = 'idle'
retrySave.value = undefined
try {
const saved = await setTelemetryEnabled(value)
privacy.value.telemetry = saved.telemetry
privacy.value.consent_version = saved.consent_version
lastSaveState.value = 'saved'
} catch (error) {
privacy.value.telemetry = previous
retrySave.value = () => void updateTelemetry(value)
lastSaveState.value = 'error'
handleError(error)
} finally {
telemetrySaving.value = false
}
}
async function updateDiscordRpc(value: boolean) {
if (discordSaving.value) return
const previous = privacy.value.discord_rpc
privacy.value.discord_rpc = value
discordSaving.value = true
lastSaveState.value = 'idle'
retrySave.value = undefined
try {
const saved = await setDiscordRpcEnabled(value)
privacy.value.discord_rpc = saved.discord_rpc
lastSaveState.value = 'saved'
} catch (error) {
privacy.value.discord_rpc = previous
retrySave.value = () => void updateDiscordRpc(value)
lastSaveState.value = 'error'
handleError(error)
} finally {
discordSaving.value = false
}
}
</script>
<template>
<div class="flex w-full flex-col gap-4">
<header class="settings-page-header">
<SettingsSaveStatus :status="saveStatus" :retry="retrySave" />
</header>
<div class="settings-page-card">
<SettingsRow>
<template #label>
<span id="settings-target-privacy-telemetry" tabindex="-1">
{{ formatMessage(messages.telemetry) }}
</span>
</template>
<template #description>{{ formatMessage(messages.telemetryDescription) }}</template>
<template #control>
<Toggle
id="privacy-telemetry"
:model-value="privacy.telemetry"
:disabled="telemetrySaving"
@update:model-value="(value) => updateTelemetry(!!value)"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-privacy-discord-rpc" tabindex="-1">
{{ formatMessage(messages.discordRpc) }}
</span>
</template>
<template #description>{{ formatMessage(messages.discordRpcDescription) }}</template>
<template #control>
<Toggle
id="privacy-discord-rpc"
:model-value="privacy.discord_rpc"
:disabled="discordSaving"
@update:model-value="(value) => updateDiscordRpc(!!value)"
/>
</template>
</SettingsRow>
</div>
<p class="settings-page-note">{{ formatMessage(messages.dataHandling) }}</p>
</div>
</template>
<style scoped>
.settings-page-card {
overflow: hidden;
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
border-radius: var(--radius-md);
background: var(--surface-2);
}
.settings-page-header {
display: flex;
min-height: 0;
justify-content: flex-end;
}
.settings-page-note {
margin: 0;
color: var(--color-secondary);
font-size: 0.8125rem;
line-height: 1.5;
}
</style>
@@ -10,7 +10,6 @@ export type SettingsCategoryId =
| 'content-downloads'
| 'network-multiplayer'
| 'storage-backups'
| 'privacy-data'
| 'updates'
| 'about'
| 'feature-flags'
@@ -106,15 +105,6 @@ export const settingsCategoryDefinitions: SettingsCategoryDefinition[] = [
group: 'data-privacy',
onboardingId: 'settings-tab-storage-backups',
},
{
id: 'privacy-data',
name: defineMessage({
id: 'app.settings.tabs.privacy-data',
defaultMessage: 'Privacy & data sharing',
}),
group: 'data-privacy',
onboardingId: 'settings-tab-privacy-data',
},
{
id: 'updates',
name: defineMessage({ id: 'app.settings.tabs.updates', defaultMessage: 'Updates' }),
@@ -72,10 +72,6 @@ const categoryContent: Record<SettingsCategoryId, Pick<SettingsCategory, 'icon'
icon: ArchiveIcon,
content: defineAsyncComponent(() => import('./StorageBackupSettings.vue')),
},
'privacy-data': {
icon: ShieldIcon,
content: defineAsyncComponent(() => import('./PrivacySettings.vue')),
},
updates: {
icon: RefreshCwIcon,
content: defineAsyncComponent(() => import('./UpdateSettings.vue')),
@@ -221,18 +221,6 @@ export const settingsSearchEntries: SettingsSearchEntry[] = [
label: message('app.crash-analysis.ai.settings.title', 'Crash AI explanation'),
keywords: [message('app.settings.tabs.launch-defaults', 'Launch & instance defaults')],
},
{
id: 'privacy-telemetry',
categoryId: 'privacy-data',
targetId: 'settings-target-privacy-telemetry',
label: message('app.settings.privacy.telemetry', 'Telemetry'),
},
{
id: 'privacy-discord-rpc',
categoryId: 'privacy-data',
targetId: 'settings-target-privacy-discord-rpc',
label: message('app.settings.privacy.discord-rpc', 'Discord rich presence'),
},
{
id: 'java-installations',
categoryId: 'java-performance',
@@ -28,7 +28,6 @@ const settingsComponentFiles = {
'content-downloads': ['./AppearanceSettings.vue', './ResourceManagementSettings.vue'],
'network-multiplayer': ['./ResourceManagementSettings.vue', './MultiplayerSettings.vue'],
'storage-backups': ['./ResourceManagementSettings.vue', './StorageSettings.vue'],
'privacy-data': ['./PrivacySettings.vue'],
updates: ['./UpdateSettings.vue'],
about: ['./AboutSettings.vue'],
'feature-flags': ['./FeatureFlagSettings.vue'],
@@ -169,7 +168,7 @@ test('developer-only settings stay out of the normal search categories', () => {
'content-downloads',
'network-multiplayer',
])
assert.deepEqual(categoriesForGroup('data-privacy'), ['storage-backups', 'privacy-data'])
assert.deepEqual(categoriesForGroup('data-privacy'), ['storage-backups'])
assert.deepEqual(categoriesForGroup('support'), ['updates', 'about'])
assert.deepEqual(categoriesForGroup('developer'), [])
assert.deepEqual(categoriesForGroup('developer', true), ['feature-flags'])
@@ -84,6 +84,7 @@ const props = defineProps<{
isSkinActive: (skin: Skin) => boolean
isAddSkinButtonDragActive: boolean
readOnly?: boolean
manageSavedSkins?: boolean
activeTab?: 'saved' | 'default'
}>()
@@ -182,7 +183,9 @@ const sections = computed<SkinSection[]>(() => {
const draggableSavedSkins = ref<Skin[]>([])
const isDraggingSavedSkin = ref(false)
const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1)
const canReorderSavedSkins = computed(
() => props.manageSavedSkins !== false && draggableSavedSkins.value.length > 1,
)
const fixedSavedSkins = computed(() => props.savedSkins.filter((skin) => !canDragSavedSkin(skin)))
const sectionLayouts = computed(() => {
@@ -486,7 +489,7 @@ defineExpose({ getAddSkinButtonElement })
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<template v-if="!readOnly && manageSavedSkins !== false" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
@@ -526,7 +529,7 @@ defineExpose({ getAddSkinButtonElement })
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<template v-if="!readOnly && manageSavedSkins !== false" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
@@ -568,7 +571,7 @@ defineExpose({ getAddSkinButtonElement })
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template #overlay-buttons>
<template v-if="manageSavedSkins !== false" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
@@ -26,7 +26,6 @@ import { useRouter } from 'vue-router'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { get_project } from '@/helpers/cache'
import { process_listener } from '@/helpers/events'
import { kill, run } from '@/helpers/instance'
@@ -108,11 +107,6 @@ const play = async (event: MouseEvent) => {
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
})
.finally(() => {
trackEvent('InstanceStart', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: 'InstanceItem',
})
})
emit('play')
loading.value = false
@@ -122,11 +116,6 @@ const stop = async (event: MouseEvent) => {
event?.stopPropagation()
loading.value = true
await kill(props.instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: 'InstanceItem',
})
emit('stop')
loading.value = false
}
@@ -0,0 +1,313 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
openSkinSiteLogin,
receiveSkinSiteMessage,
requestSkinSiteLuck,
requestSkinSitePlayers,
requestSkinSiteSkinUpdate,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteFrameUrl,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from './skin-site-session.ts'
test('skin site session accepts only the embedded origin and window, and redirects after verification', () => {
const frame = {} as Window
const message = (status: string, user?: unknown) =>
({
origin: SKIN_SITE_ORIGIN,
source: frame,
data: { type: 'starlight-skin-session', status, user },
}) as MessageEvent
resetSkinSiteSession()
openSkinSiteLogin()
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/login`)
const signedIn = message('signed-in', {
uuid: 'test-user',
username: '测试用户',
jwt: 'must-not-copy',
})
assert.equal(
receiveSkinSiteMessage({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame),
false,
)
assert.equal(receiveSkinSiteMessage(signedIn, {} as Window), false)
assert.equal(receiveSkinSiteMessage(message('signed-in', {}), frame), false)
assert.equal(skinSiteUser.value, null)
assert.equal(receiveSkinSiteMessage(signedIn, frame), true)
assert.deepEqual(skinSiteUser.value, { uuid: 'test-user', username: '测试用户' })
assert.equal(skinSiteStatus.value, 'signed-in')
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/profile`)
assert.equal(receiveSkinSiteMessage(message('checking'), frame), true)
assert.equal(skinSiteUser.value, null)
receiveSkinSiteMessage(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
assert.deepEqual(skinSiteUser.value, { uuid: 'second', username: '另一个账号' })
receiveSkinSiteMessage(message('signed-out'), frame)
assert.equal(skinSiteUser.value, null)
assert.equal(skinSiteStatus.value, 'signed-out')
receiveSkinSiteMessage(message('error'), frame)
assert.equal(skinSiteStatus.value, 'error')
resetSkinSiteSession()
})
test('luck requests use only the connected skin site frame and validate its result', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
const sessionMessage = {
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'lucky-user', username: 'Lucky' },
},
} as MessageEvent
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(sessionMessage, frame)
const automaticPlayersRequest = sent.at(-1)?.data as { type: string; requestId: string }
assert.equal(automaticPlayersRequest.type, 'starlight-skin-players-request')
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticPlayersRequest.requestId,
ok: true,
players: [],
},
} as MessageEvent,
frame,
)
const result = requestSkinSiteLuck()
assert.equal(sent.at(-1)?.targetOrigin, SKIN_SITE_ORIGIN)
const request = sent.at(-1)?.data as { type: string; requestId: string }
assert.equal(request.type, 'starlight-skin-luck-request')
assert.match(request.requestId, /^skin-luck-\d+-\d+$/)
assert.equal(
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-luck-result',
requestId: request.requestId,
ok: true,
luck: 88,
},
} as MessageEvent,
frame,
),
true,
)
assert.equal(await result, 88)
setSkinSiteFrame(null)
resetSkinSiteSession()
})
test('player requests validate identities and replace the collection atomically', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'skin-user', username: 'Skin User' },
},
} as MessageEvent,
frame,
)
const automaticRequest = sent.at(-1)?.data as { requestId: string }
assert.equal(skinSitePlayersStatus.value, 'checking')
assert.equal(
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'ready',
headDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
skinDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
model: 'slim',
},
{
uuid: 'fedcba9876543210fedcba9876543210',
name: 'NoSkinPlayer',
isMojang: false,
skinState: 'empty',
},
{ uuid: 'invalid', name: 'Ignored', isMojang: false },
],
},
} as MessageEvent,
frame,
),
true,
)
assert.deepEqual(skinSitePlayers.value, [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'ready',
headDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
skinDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
model: 'slim',
},
{
uuid: 'fedcba9876543210fedcba9876543210',
name: 'NoSkinPlayer',
isMojang: false,
skinState: 'empty',
},
])
assert.equal(skinSitePlayersStatus.value, 'ready')
const transientFailure = requestSkinSitePlayers()
const transientFailureRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: transientFailureRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne Renamed',
isMojang: false,
skinState: 'error',
},
],
},
} as MessageEvent,
frame,
)
assert.equal((await transientFailure)[0].skinState, 'ready')
assert.equal(skinSitePlayers.value[0].name, 'PlayerOne Renamed')
assert.equal(skinSitePlayers.value[0].headDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
const retry = requestSkinSitePlayers()
const retryRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: retryRequest.requestId,
ok: true,
players: [],
},
} as MessageEvent,
frame,
)
assert.deepEqual(await retry, [])
assert.deepEqual(skinSitePlayers.value, [])
setSkinSiteFrame(null)
resetSkinSiteSession()
})
test('skin updates are restricted to known non-Mojang players and validated results', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'skin-user', username: 'Skin User' },
},
} as MessageEvent,
frame,
)
const automaticRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'empty',
},
],
},
} as MessageEvent,
frame,
)
const result = requestSkinSiteSkinUpdate(
'0123456789abcdef0123456789abcdef',
'data:image/png;base64,SEVBRERBVEE=',
'slim',
)
const request = sent.at(-1)?.data as { type: string; requestId: string; playerId: string }
assert.equal(request.type, 'starlight-skin-update-request')
assert.equal(request.playerId, '0123456789abcdef0123456789abcdef')
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: { type: 'starlight-skin-update-result', requestId: request.requestId, ok: true },
} as MessageEvent,
frame,
)
await result
await assert.rejects(
requestSkinSiteSkinUpdate(
'fedcba9876543210fedcba9876543210',
'data:image/png;base64,SEVBRERBVEE=',
'default',
),
)
setSkinSiteFrame(null)
resetSkinSiteSession()
})
@@ -0,0 +1,357 @@
import { readonly, ref } from 'vue'
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
export type SkinSiteUser = { uuid: string; username: string }
export type SkinSiteStatus = 'checking' | 'signed-out' | 'signed-in' | 'error'
export type SkinSitePlayer = {
uuid: string
name: string
isMojang: boolean
skinState: 'ready' | 'empty' | 'error'
headDataUrl?: string
skinDataUrl?: string
model?: 'default' | 'slim'
}
export type SkinSitePlayersStatus = 'idle' | 'checking' | 'ready' | 'error'
const user = ref<SkinSiteUser | null>(null)
const status = ref<SkinSiteStatus>('signed-out')
const players = ref<SkinSitePlayer[]>([])
const playersStatus = ref<SkinSitePlayersStatus>('idle')
const selectedPlayerId = ref<string | null>(null)
const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`)
let connectedFrame: Window | null = null
let luckRequestSequence = 0
let playersRequestSequence = 0
let skinUpdateRequestSequence = 0
type PendingLuckRequest = {
resolve: (luck: number) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingLuckRequests = new Map<string, PendingLuckRequest>()
type PendingPlayersRequest = {
resolve: (players: SkinSitePlayer[]) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingPlayersRequests = new Map<string, PendingPlayersRequest>()
type PendingSkinUpdateRequest = {
resolve: () => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingSkinUpdateRequests = new Map<string, PendingSkinUpdateRequest>()
function isSafePngDataUrl(value: unknown, maxLength: number) {
return (
typeof value === 'string' &&
value.length <= maxLength &&
/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i.test(value)
)
}
function hasSafeSkinHead(player: SkinSitePlayer) {
if (!['ready', 'empty', 'error'].includes(player.skinState)) return false
if (player.skinState !== 'ready') return player.headDataUrl === undefined
return (
isSafePngDataUrl(player.headDataUrl, 100_000) &&
(player.skinDataUrl === undefined || isSafePngDataUrl(player.skinDataUrl, 2_000_000)) &&
(player.model === undefined || player.model === 'default' || player.model === 'slim')
)
}
export const skinSiteUser = readonly(user)
export const skinSiteStatus = readonly(status)
export const skinSitePlayers = readonly(players)
export const skinSitePlayersStatus = readonly(playersStatus)
export const selectedSkinSitePlayerId = readonly(selectedPlayerId)
export const skinSiteFrameUrl = readonly(frameUrl)
function rejectPendingLuckRequests(message: string) {
for (const request of pendingLuckRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
}
pendingLuckRequests.clear()
}
function rejectPendingPlayersRequests(message: string) {
for (const request of pendingPlayersRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
}
pendingPlayersRequests.clear()
}
function rejectPendingSkinUpdateRequests(message: string) {
for (const request of pendingSkinUpdateRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
}
pendingSkinUpdateRequests.clear()
}
export function setSkinSiteFrame(frame: Window | null) {
if (connectedFrame === frame) return
rejectPendingLuckRequests('The skin site connection changed.')
rejectPendingPlayersRequests('The skin site connection changed.')
rejectPendingSkinUpdateRequests('The skin site connection changed.')
connectedFrame = frame
}
export function selectSkinSitePlayer(playerId: string | null) {
if (playerId === null) {
selectedPlayerId.value = null
return
}
if (players.value.some((player) => player.uuid === playerId)) selectedPlayerId.value = playerId
}
export function openSkinSiteLogin() {
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
}
export function resetSkinSiteSession() {
rejectPendingLuckRequests('The skin site session ended.')
rejectPendingPlayersRequests('The skin site session ended.')
rejectPendingSkinUpdateRequests('The skin site session ended.')
user.value = null
status.value = 'signed-out'
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
export function requestSkinSiteSkinUpdate(
playerId: string,
textureDataUrl: string,
model: 'default' | 'slim',
) {
const player = players.value.find((candidate) => candidate.uuid === playerId)
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before changing a skin.'))
}
if (!player || player.isMojang) {
return Promise.reject(new Error('This skin site player cannot be changed.'))
}
if (!isSafePngDataUrl(textureDataUrl, 2_000_000)) {
return Promise.reject(new Error('The selected skin texture is invalid or too large.'))
}
const requestId = `skin-update-${Date.now()}-${++skinUpdateRequestSequence}`
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingSkinUpdateRequests.delete(requestId)
reject(new Error('The skin site update timed out.'))
}, 20_000)
pendingSkinUpdateRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{
type: 'starlight-skin-update-request',
requestId,
playerId,
textureDataUrl,
model,
},
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingSkinUpdateRequests.delete(requestId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function requestSkinSitePlayers() {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before requesting players.'))
}
playersStatus.value = 'checking'
const requestId = `skin-players-${Date.now()}-${++playersRequestSequence}`
return new Promise<SkinSitePlayer[]>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingPlayersRequests.delete(requestId)
playersStatus.value = 'error'
reject(new Error('The skin site player request timed out.'))
}, 30_000)
pendingPlayersRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-skin-players-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingPlayersRequests.delete(requestId)
playersStatus.value = 'error'
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function requestSkinSiteLuck() {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before requesting luck.'))
}
const requestId = `skin-luck-${Date.now()}-${++luckRequestSequence}`
return new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingLuckRequests.delete(requestId)
reject(new Error('The skin site luck request timed out.'))
}, 12_000)
pendingLuckRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-skin-luck-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingLuckRequests.delete(requestId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null) {
if (!frame || event.source !== frame || event.origin !== SKIN_SITE_ORIGIN) return false
const data = event.data
if (!data || typeof data !== 'object') return false
if (data.type === 'starlight-skin-players-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingPlayersRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingPlayersRequests.delete(data.requestId)
if (data.ok === true && Array.isArray(data.players)) {
const nextPlayers = data.players.filter(
(player: unknown): player is SkinSitePlayer =>
typeof player === 'object' &&
player !== null &&
typeof (player as SkinSitePlayer).uuid === 'string' &&
/^[0-9a-f-]{32,36}$/i.test((player as SkinSitePlayer).uuid) &&
typeof (player as SkinSitePlayer).name === 'string' &&
(player as SkinSitePlayer).name.length > 0 &&
(player as SkinSitePlayer).name.length <= 64 &&
typeof (player as SkinSitePlayer).isMojang === 'boolean' &&
hasSafeSkinHead(player as SkinSitePlayer),
)
const previousPlayers = new Map(players.value.map((player) => [player.uuid, player]))
players.value = nextPlayers.map((player) => {
const previous = previousPlayers.get(player.uuid)
return player.skinState === 'error' && previous?.skinState === 'ready'
? { ...player, ...previous, name: player.name, isMojang: player.isMojang }
: player
})
if (
selectedPlayerId.value &&
!nextPlayers.some((player) => player.uuid === selectedPlayerId.value)
) {
selectedPlayerId.value = null
}
playersStatus.value = 'ready'
pending.resolve(players.value)
} else {
playersStatus.value = 'error'
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site returned an invalid player list.',
),
)
}
return true
}
if (data.type === 'starlight-skin-luck-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingLuckRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingLuckRequests.delete(data.requestId)
if (
data.ok === true &&
typeof data.luck === 'number' &&
Number.isFinite(data.luck) &&
data.luck >= 0 &&
data.luck <= 100
) {
pending.resolve(data.luck)
} else {
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site returned an invalid luck result.',
),
)
}
return true
}
if (data.type === 'starlight-skin-update-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingSkinUpdateRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingSkinUpdateRequests.delete(data.requestId)
if (data.ok === true) pending.resolve()
else {
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site rejected the skin update.',
),
)
}
return true
}
if (data.type !== 'starlight-skin-session') return false
if (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return false
let refreshPlayers = false
if (data.status === 'signed-in') {
if (
!data.user ||
typeof data.user.uuid !== 'string' ||
typeof data.user.username !== 'string' ||
!data.user.uuid ||
!data.user.username ||
data.user.username.length > 256
)
return false
if (user.value?.uuid && user.value.uuid !== data.user.uuid) {
rejectPendingPlayersRequests('The skin site account changed.')
rejectPendingSkinUpdateRequests('The skin site account changed.')
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
user.value = { uuid: data.user.uuid, username: data.user.username }
if ([`${SKIN_SITE_ORIGIN}/`, `${SKIN_SITE_ORIGIN}/login`].includes(frameUrl.value)) {
frameUrl.value = `${SKIN_SITE_ORIGIN}/profile`
}
refreshPlayers = true
} else {
rejectPendingLuckRequests('The skin site session is unavailable.')
rejectPendingPlayersRequests('The skin site session is unavailable.')
rejectPendingSkinUpdateRequests('The skin site session is unavailable.')
user.value = null
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
status.value = data.status
if (refreshPlayers) void requestSkinSitePlayers().catch(() => {})
return true
}
@@ -114,8 +114,6 @@ export interface DropImportOptions {
onSchematicWorkshopPage: ComputedRef<boolean>
/** Check if path is a schematic file */
isSchematicFile: (path: string) => boolean
/** Track analytics event */
trackEvent: (name: string, properties?: Record<string, unknown>) => void
/** Route to push */
router: Router
}
@@ -135,7 +133,6 @@ export interface DropImportOptions {
* onSkinsPage,
* onSchematicWorkshopPage,
* isSchematicFile,
* trackEvent,
* router,
* route,
* })
@@ -151,7 +148,6 @@ export function useDropImport(options: DropImportOptions) {
onSkinsPage,
onSchematicWorkshopPage,
isSchematicFile,
trackEvent,
router,
} = options
@@ -906,7 +902,6 @@ export function useDropImport(options: DropImportOptions) {
clearDropProcessingNotification()
await installModpackFromPath(filePath, fileName, { persistUntilDone: true })
trackEvent('InstanceCreate', { source: 'DropConfirmModpack' })
await router.push('/library')
return
}
@@ -0,0 +1,27 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, type MaybeRefOrGetter, toValue } from 'vue'
import { getInstanceMode, type InstanceMode, setInstanceMode } from '@/helpers/hosted-packs'
export const instanceModeKey = (instanceId: string) => ['instance-mode', instanceId] as const
export function useInstanceMode(instanceId: MaybeRefOrGetter<string>) {
return useQuery({
queryKey: computed(() => instanceModeKey(toValue(instanceId))),
queryFn: () => getInstanceMode(toValue(instanceId)),
enabled: computed(() => !!toValue(instanceId)),
staleTime: 30_000,
})
}
export function useSetInstanceMode() {
const client = useQueryClient()
return useMutation({
mutationFn: ({ instanceId, mode }: { instanceId: string; mode: InstanceMode }) =>
setInstanceMode(instanceId, mode),
onMutate: ({ instanceId }) => client.cancelQueries({ queryKey: instanceModeKey(instanceId) }),
onSuccess: (_, { instanceId, mode }) => client.setQueryData(instanceModeKey(instanceId), mode),
onError: (_, { instanceId }) =>
client.invalidateQueries({ queryKey: instanceModeKey(instanceId) }),
})
}
@@ -1,6 +1,5 @@
import { ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { get_jre, test_jre } from '@/helpers/jre.js'
export default function useJavaTest() {
@@ -8,7 +7,7 @@ export default function useJavaTest() {
const javaTestResult = ref<boolean | null>(null)
let testDebounceTimer: ReturnType<typeof setTimeout> | null = null
async function runJavaTest(path: string, version: number | null, track = true) {
async function runJavaTest(path: string, version: number | null) {
if (testDebounceTimer) {
clearTimeout(testDebounceTimer)
testDebounceTimer = null
@@ -28,10 +27,6 @@ export default function useJavaTest() {
javaTestResult.value = false
}
testingJava.value = false
if (track) {
trackEvent('JavaTest', { path, success: javaTestResult.value })
}
}
function testJavaInstallationDebounced(path: string, version: number | null, delay = 600) {
@@ -43,8 +38,8 @@ export default function useJavaTest() {
testDebounceTimer = setTimeout(() => runJavaTest(path, version, false), delay)
}
async function testJavaInstallation(path: string, version: number | null, track = false) {
await runJavaTest(path, version, track)
async function testJavaInstallation(path: string, version: number | null) {
await runJavaTest(path, version)
}
return {
@@ -1,75 +0,0 @@
interface InstanceProperties {
loader: string
game_version: string
}
interface ProjectProperties extends InstanceProperties {
id: string
project_type: string
}
type AnalyticsEventMap = {
Launched: { version: string; dev: boolean; onboarded: boolean }
PageView: { path: string; fromPath: string; failed: unknown }
InstanceCreate: { source: string }
InstanceCreateStart: { source: string }
InstanceStart: InstanceProperties & { source: string }
InstanceStop: Partial<InstanceProperties> & { source?: string }
InstanceDuplicate: InstanceProperties
InstanceRepair: InstanceProperties
InstanceSetIcon: Record<string, never>
InstanceRemoveIcon: Record<string, never>
InstanceUpdateAll: InstanceProperties & { count: number; selected: boolean }
InstanceProjectUpdate: InstanceProperties & { id: string; name: string; project_type: string }
InstanceProjectDisable: InstanceProperties & {
id: string
name: string
project_type: string
disabled: boolean
}
InstanceProjectRemove: InstanceProperties & { id: string; name: string; project_type: string }
ProjectInstall: ProjectProperties & { version_id: string; title: string; source: string }
ProjectInstallStart: { source: string }
PackInstall: { id: string; version_id: string; title: string; source: string }
PackInstallStart: Record<string, never>
AccountLogIn: { source?: string }
AccountLogOut: Record<string, never>
JavaTest: { path: string; success: boolean }
JavaManualSelect: { version: string }
JavaAutoDetect: { path: string; version: string }
GalleryImageNext: { project_id: string; url: string }
GalleryImagePrevious: { project_id: string; url: unknown }
GalleryImageExpand: { project_id: string; url: string }
}
export type AnalyticsEvent = keyof AnalyticsEventMap
let optedIn = false
let debugEnabled = false
export const initAnalytics = () => {
optedIn = true
}
export const debugAnalytics = () => {
debugEnabled = true
}
export const optOutAnalytics = () => {
optedIn = false
}
export const optInAnalytics = () => {
optedIn = true
}
type OptionalArgs<T> = Record<string, never> extends T ? [properties?: T] : [properties: T]
export const trackEvent = <E extends AnalyticsEvent>(
eventName: E,
...args: OptionalArgs<AnalyticsEventMap[E]>
) => {
if (optedIn && debugEnabled) {
console.debug('[Axolotl telemetry disabled]', eventName, args[0])
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ import { invoke } from '@tauri-apps/api/core'
// }
/**
* Check if the authentication servers are reachable, throwing an exception if
* not reachable.
* Check if the StarLight authentication server is reachable, throwing an
* exception if it is not reachable.
*/
export async function check_reachable() {
await invoke('plugin:auth|check_reachable')
@@ -0,0 +1,33 @@
import { invoke } from '@tauri-apps/api/core'
export type InstanceMode = 'starlight' | 'local'
export const getInstanceMode = (instanceId: string) =>
invoke<InstanceMode>('plugin:install|hosted_instance_mode', { instanceId })
export const setInstanceMode = (instanceId: string, mode: InstanceMode) =>
invoke<void>('plugin:install|hosted_set_instance_mode', { instanceId, mode })
export interface HostedPublication {
packId: string
releaseId: number
manifest: {
name: string
version: string
format: string
runtime: { gameVersion: string; loader: string; loaderVersion: string | null }
files: { path: string; size: number; sha256: string }[]
}
}
export interface HostedBinding {
publication: HostedPublication
}
export interface HostedSyncResult {
version: string
downloadedBytes: number
changedFiles: number
preservedFiles: string[]
}
export const hostedCatalog = () => invoke<HostedPublication[]>('plugin:install|hosted_catalog')
export const hostedBinding = (instanceId: string) =>
invoke<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
export const hostedSync = (instanceId: string, packId: string) =>
invoke<HostedSyncResult>('plugin:install|hosted_sync', { instanceId, packId })
+1
View File
@@ -32,6 +32,7 @@ export interface InstallModpackPreview {
}
export interface InstallCreateInstanceRequest {
instanceMode?: 'starlight' | 'local'
name: string
gameVersion: string
loader: InstanceLoader
@@ -652,10 +652,6 @@ export async function update_project(instanceId: string, projectPath: string): P
return await invoke('plugin:instance|instance_update_project', { instanceId, projectPath })
}
// Add a project to an instance from a version
// Returns a path to the new project file
export type DownloadReason = 'standalone' | 'dependency' | 'modpack' | 'update'
export interface ResolutionPreferences {
game_versions?: string[]
loaders?: string[]
@@ -691,14 +687,10 @@ export interface ResolveContentPlan {
export async function add_project_from_version(
instanceId: string,
versionId: string,
reason: DownloadReason,
dependentOnVersionId?: string,
): Promise<string> {
return await invoke('plugin:instance|instance_add_project_from_version', {
instanceId,
versionId,
reason,
dependentOnVersionId,
})
}
-25
View File
@@ -175,9 +175,6 @@ export type AppSettings = {
home_widgets: HomeDashboardConfig | null
terracotta_public_nodes: string[]
telemetry: boolean
telemetry_consent_version: number
discord_rpc: boolean
onboarded: boolean
onboarding_version: number
onboarding_instance_tour_completed: boolean
@@ -207,12 +204,6 @@ export type AppSettings = {
version: number
}
export type PrivacySettings = {
telemetry: boolean
discord_rpc: boolean
consent_version: number
}
type LegacyMirrorSettings = {
use_minecraft_mirror?: boolean
use_modrinth_mirror?: boolean
@@ -301,22 +292,6 @@ export async function cancel_directory_change(): Promise<void> {
return await invoke('plugin:settings|cancel_directory_change')
}
export async function getPrivacySettings(): Promise<PrivacySettings> {
return await invoke('plugin:settings|privacy_get')
}
export async function savePrivacySettings(privacy: PrivacySettings): Promise<PrivacySettings> {
return await invoke('plugin:settings|privacy_set', { privacy })
}
export async function setTelemetryEnabled(enabled: boolean): Promise<PrivacySettings> {
return await invoke('plugin:settings|telemetry_set', { enabled })
}
export async function setDiscordRpcEnabled(enabled: boolean): Promise<PrivacySettings> {
return await invoke('plugin:settings|discord_rpc_set', { enabled })
}
export async function getProxyConfig(): Promise<ProxyConfig> {
return await invoke('plugin:settings|proxy_get')
}
+4
View File
@@ -118,6 +118,10 @@ export async function get_available_skins(): Promise<Skin[]> {
return invoke('plugin:minecraft-skins|get_available_skins', {})
}
export async function get_default_skins(): Promise<Skin[]> {
return invoke('plugin:minecraft-skins|get_default_skins', {})
}
export async function add_and_equip_custom_skin(
textureBlob: Uint8Array,
variant: SkinModel,
-4
View File
@@ -44,10 +44,6 @@ export async function initialize_state() {
return await invoke<void>('initialize_state')
}
export async function set_discord_activity(activity: string) {
return await invoke<void>('set_discord_activity', { activity })
}
// Gets active progress bars
export async function progress_bars_list() {
return await invoke<Record<string, LoadingBar>>('plugin:utils|progress_bars_list')
@@ -1,7 +0,0 @@
import { invoke } from '@tauri-apps/api/core'
export function installTelemetryHandlers(): void {
window.addEventListener('online', () => {
void invoke('plugin:telemetry|notify_online').catch(() => undefined)
})
}
-2
View File
@@ -216,8 +216,6 @@ type AppSettings = {
close_behavior: 'ask' | 'close' | 'lightweight'
home_widgets: import('@/components/home/home-dashboard').HomeDashboardConfig | null
telemetry: boolean
discord_rpc: boolean
developer_mode: boolean
onboarded: boolean
+185 -58
View File
@@ -1,4 +1,158 @@
{
"app.onboarding.instance-mode.title": { "message": "Choose your instance type" },
"app.onboarding.instance-mode.description": { "message": "Choose StarLight to play on the StarLight server and automatically sync its approved updates. Local instances skip StarLight sync, start faster, and let you use your own modpacks for other servers or single-player. You can change this later in instance settings." },
"app.instance-mode.title": { "message": "Instance type" },
"app.instance-mode.starlight": { "message": "StarLight instance" },
"app.instance-mode.local": { "message": "Local instance" },
"app.instance-mode.starlight-description": { "message": "Automatically syncs changes published by the StarLight server. Required for playing on StarLight. Select and install the official modpack in Mod management before your first launch." },
"app.instance-mode.local-description": { "message": "Does not sync StarLight server changes. Skips sync checks for faster startup and lets you choose your own modpacks. Best for third-party servers and personal single-player worlds." },
"app.instance-mode.saving": { "message": "Saving instance type…" },
"app.instance-mode.loading": { "message": "Loading instance type…" },
"app.instance-mode.retry": { "message": "Retry" },
"app.instance-mode.local-packs-title": { "message": "Local modpacks" },
"app.instance-mode.local-packs": { "message": "Choose any modpack to install as a local instance. Existing files and worlds are kept when you switch to Local; StarLight changes will no longer be synchronized." },
"app.instance-mode.browse": { "message": "Browse modpacks" },
"app.instance-mode.import": { "message": "Import as a local instance" },
"app.hosted-packs.title": {"message": "Published modpacks"},
"app.hosted-packs.description": {"message": "Install an approved modpack into this instance. Future online launches automatically download changed files. Use a separate empty instance for each pack; saves and personal settings are preserved."},
"app.hosted-packs.refresh": {"message": "Refresh"},
"app.hosted-packs.loading": {"message": "Loading published modpacks…"},
"app.hosted-packs.bound": {"message": "Installed: {name} · {version}"},
"app.hosted-packs.syncing": {"message": "Comparing files and synchronizing changes…"},
"app.hosted-packs.complete": {"message": "Synced to {version}. Changed {count} files; downloaded {size} MiB."},
"app.hosted-packs.preserved": {"message": "Preserved {count} locally modified or personal files"},
"app.hosted-packs.empty": {"message": "No modpacks have been approved for publication yet."},
"app.hosted-packs.update": {"message": "Synchronize now"},
"app.hosted-packs.install": {"message": "Install and enable automatic sync"},
"app.hosted-packs.management": {"message": "Content management"},
"app.hosted-packs.mods": {"message": "Manage mods"},
"app.hosted-packs.packs": {"message": "Manage modpacks"},
"app.home.luck.title": { "message": "Daily luck index" },
"app.home.luck.get": { "message": "Get with one click" },
"app.home.luck.login-to-get": { "message": "Sign in to get it" },
"app.home.luck.loading": { "message": "Asking the little sprite..." },
"app.home.luck.error": {
"message": "The little sprite did not answer. Please try again later."
},
"app.home.luck.out-of": { "message": "out of 100" },
"minecraft-account.skin-site.signed-in": { "message": "Signed in to StarLight Skin Site" },
"minecraft-account.skin-site.checking": { "message": "Checking skin site session…" },
"minecraft-account.skin-site.sync-error": {
"message": "Could not verify the skin site session. Retrying automatically."
},
"minecraft-account.skin-site.players": { "message": "Skin site players ({count})" },
"minecraft-account.skin-site.players.retry": { "message": "Retry" },
"minecraft-account.skin-site.players.empty": {
"message": "No player profiles are attached to this skin site account."
},
"minecraft-account.skin-site.players.error": {
"message": "Could not refresh the player list. Previously loaded players are kept."
},
"app.easteregg.color-mine.title": {
"message": "Starlight Mine: Chromatic Realms"
},
"app.easteregg.color-mine.easy": {
"message": "Easy"
},
"app.easteregg.color-mine.normal": {
"message": "Normal"
},
"app.easteregg.color-mine.hard": {
"message": "Hard"
},
"app.easteregg.color-mine.newGame": {
"message": "New board"
},
"app.easteregg.color-mine.close": {
"message": "Close"
},
"app.easteregg.color-mine.choose": {
"message": "Click a revealed tile to sample its color, then paint a gray tile."
},
"app.easteregg.color-mine.selected": {
"message": "Color {color} selected · Click a colored tile to change your brush."
},
"app.easteregg.color-mine.rulesTitle": {
"message": "How to play"
},
"app.easteregg.color-mine.rules": {
"message": "Numbers count same-color tiles in the eight surrounding spaces, excluding the tile itself. Each color forms one connected region using only horizontal and vertical edges. Sample a revealed tile to select a brush; a correct paint reveals a new number, but one wrong paint ends the game. Every board has a step-by-step deduction path without guessing."
},
"app.easteregg.color-mine.loading": {
"message": "Generating regions and checking the deduction path…"
},
"app.easteregg.color-mine.generateError": {
"message": "Could not generate this board. Try again."
},
"app.easteregg.color-mine.retry": {
"message": "Try again"
},
"app.easteregg.color-mine.progress": {
"message": "{count} / {total} revealed"
},
"app.easteregg.color-mine.best": {
"message": "Best · {time}"
},
"app.easteregg.color-mine.won": {
"message": "All colors restored!"
},
"app.easteregg.color-mine.lost": {
"message": "Wrong color at row {row}, column {column}: selected {selected}, correct {correct}. Game over."
},
"app.easteregg.color-mine.review": {
"message": "Unrevealed answers are now shown faded for review."
},
"app.easteregg.color-mine.leaveTitle": {
"message": "Leave this board?"
},
"app.easteregg.color-mine.leaveText": {
"message": "Save to continue later, or discard this unfinished board."
},
"app.easteregg.color-mine.saveLeave": {
"message": "Save and leave"
},
"app.easteregg.color-mine.discard": {
"message": "Discard"
},
"app.easteregg.color-mine.continue": {
"message": "Keep playing"
},
"app.easteregg.color-mine.replaceTitle": {
"message": "Start a new board?"
},
"app.easteregg.color-mine.replaceText": {
"message": "The current board will be discarded."
},
"app.easteregg.color-mine.savedTitle": {
"message": "An unfinished board is saved"
},
"app.easteregg.color-mine.resume": {
"message": "Resume saved game"
},
"app.easteregg.color-mine.storageError": {
"message": "Could not read or write the saved game. Your current board has been kept. Retry or explicitly discard it."
},
"app.easteregg.color-mine.recordError": {
"message": "Finished, but the best time could not be saved."
},
"app.easteregg.color-mine.zoomIn": {
"message": "Zoom in"
},
"app.easteregg.color-mine.zoomOut": {
"message": "Zoom out"
},
"app.easteregg.color-mine.map": {
"message": "Board overview. Click to navigate."
},
"app.easteregg.color-mine.board": {
"message": "{size} by {size} color deduction board"
},
"app.easteregg.color-mine.hidden": {
"message": "Row {row}, column {column}, unrevealed"
},
"app.easteregg.color-mine.revealed": {
"message": "Row {row}, column {column}, color {color}, {number} same-color neighbors"
},
"app.account.signed-in-as": {
"message": "Signed in as"
},
@@ -5174,8 +5328,8 @@
"app.minecraft-crash.view-mod-changes": {
"message": "View Mod changes"
},
"app.minecraft.playing-as": {
"message": "Playing as"
"app.minecraft.user-information": {
"message": "User information"
},
"app.modal.install-to-play.content-required": {
"message": "Content required"
@@ -5807,12 +5961,6 @@
"app.onboarding.mascot-alt": {
"message": "Starlight guide"
},
"app.onboarding.privacy.description": {
"message": "Manage anonymous telemetry, Discord Rich Presence, and the Minecraft log analysis service whenever you need to."
},
"app.onboarding.privacy.title": {
"message": "Your data, your call"
},
"app.onboarding.resources.description": {
"message": "Choose how content downloads and installs, from download sources to safety checks."
},
@@ -5858,30 +6006,6 @@
"app.onboarding.welcome.title": {
"message": "Everything is ready"
},
"app.privacy-consent.continue": {
"message": "Save and continue"
},
"app.privacy-consent.discord-rpc": {
"message": "Discord Rich Presence"
},
"app.privacy-consent.discord-rpc-description": {
"message": "Shows your current launcher or game activity in Discord when Discord is running."
},
"app.privacy-consent.intro": {
"message": "Choose what Starlight may send or display. Nothing is sent until you confirm these choices."
},
"app.privacy-consent.privacy-policy": {
"message": "Read the privacy policy"
},
"app.privacy-consent.telemetry": {
"message": "Allow anonymous telemetry"
},
"app.privacy-consent.telemetry-description": {
"message": "Helps count opted-in installations and daily active users. Full Minecraft logs and account credentials are never uploaded."
},
"app.privacy-consent.title": {
"message": "Privacy & security"
},
"app.project.curseforge.loading": {
"message": "Loading CurseForge project…"
},
@@ -6701,9 +6825,6 @@
"app.settings.about.preview-minecraft-crash-modal": {
"message": "Preview Minecraft crash window"
},
"app.settings.about.preview-privacy-consent-modal": {
"message": "Preview privacy & security modal"
},
"app.settings.about.product-title": {
"message": "About {productName}"
},
@@ -6965,21 +7086,6 @@
"app.settings.java.view-installed": {
"message": "View installed Java"
},
"app.settings.privacy.data-handling": {
"message": "Telemetry uses a random installation identifier and sends only a daily activity signal. Turning telemetry off clears pending data immediately."
},
"app.settings.privacy.discord-rpc": {
"message": "Discord Rich Presence"
},
"app.settings.privacy.discord-rpc-description": {
"message": "Show your current launcher or game activity in Discord."
},
"app.settings.privacy.telemetry": {
"message": "Allow telemetry"
},
"app.settings.privacy.telemetry-description": {
"message": "Send one anonymous daily activity signal to improve usage statistics. Minecraft logs and account credentials are never uploaded."
},
"app.settings.resources.add-minecraft-directory": {
"message": "Add .minecraft directory"
},
@@ -7361,9 +7467,6 @@
"app.settings.tabs.network-multiplayer": {
"message": "Network & multiplayer"
},
"app.settings.tabs.privacy-data": {
"message": "Privacy & data sharing"
},
"app.settings.tabs.storage": {
"message": "Storage"
},
@@ -7691,6 +7794,27 @@
"app.skins.section.tiny-takeover": {
"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."
},
"app.skins.skin-site-account.empty-description": {
"message": "This skin site account does not have an available player profile yet."
},
"app.skins.skin-site-account.empty-title": {
"message": "No players available"
},
"app.skins.skin-site-account.loading-description": {
"message": "The launcher is synchronizing the players attached to your account."
},
"app.skins.skin-site-account.loading-title": {
"message": "Loading skin site players…"
},
"app.skins.skin-site-account.mojang-description": {
"message": "{player} is an official Minecraft player. Its skin remains managed by the official account provider."
},
"app.skins.skin-site-account.title": {
"message": "StarLight skin site player"
},
"app.skins.sign-in.axolotl-alt": {
"message": "Starlight Launcher"
},
@@ -10020,13 +10144,13 @@
"message": "World is in use"
},
"minecraft-account.add-microsoft-account": {
"message": "Add Microsoft account"
"message": "Add your own Minecraft account"
},
"minecraft-account.add-offline-account": {
"message": "Add offline account"
},
"minecraft-account.add-third-party-account": {
"message": "Add third-party account"
"minecraft-account.sign-in-starlight": {
"message": "Sign in to StarLight Skin Site"
},
"minecraft-account.copy-uuid": {
"message": "Copy UUID"
@@ -10088,8 +10212,11 @@
"minecraft-account.offline-mode": {
"message": "Offline mode"
},
"minecraft-account.offline-mode.description": {
"message": "Only offline accounts are available. You can launch fully downloaded instances."
"minecraft-account.offline-mode.description.no-internet": {
"message": "It looks like this device may not be connected to the internet. You can currently only launch fully downloaded instances and will most likely be unable to connect to StarLight servers. Check your network connection. If you are using a proxy, try disabling or enabling it, then refresh the connection status below."
},
"minecraft-account.offline-mode.description.server-unavailable": {
"message": "Your internet connection is working, but StarLight's authentication server cannot be reached. The StarLight server may be undergoing maintenance, or your proxy may be misconfigured. If you are using a proxy, try disabling or enabling it, then refresh the connection status below. If that does not help, contact a server administrator in the StarLight community group to confirm the maintenance status."
},
"minecraft-account.offline-mode.refresh": {
"message": "Refresh connection status"
+208 -68
View File
@@ -1,22 +1,190 @@
{
"app.settings.developer.announcement-preview": { "message": "公告样式预览" },
"app.onboarding.instance-mode.title": { "message": "选择实例类型" },
"app.onboarding.instance-mode.description": { "message": "游玩 StarLight 服务器请选择 StarLight 实例,它会自动同步管理员批准的更新。本地实例跳过 StarLight 同步,启动更快,可自选整合包,适合第三方服务器与单人游玩。之后也可以在实例设置中切换。" },
"app.instance-mode.title": { "message": "实例类型" },
"app.instance-mode.starlight": { "message": "StarLight 实例" },
"app.instance-mode.local": { "message": "本地实例" },
"app.instance-mode.starlight-description": { "message": "自动同步 StarLight 服务器推送的变更,游玩 StarLight 服务器必选。首次启动前,请在 Mod 管理中选择并安装官方整合包。" },
"app.instance-mode.local-description": { "message": "不会同步 StarLight 服务器的变更。跳过同步检查,启动更快,可自选整合包安装,更适合第三方服务器与本地个人游玩。" },
"app.instance-mode.saving": { "message": "正在保存实例类型…" },
"app.instance-mode.loading": { "message": "正在读取实例类型…" },
"app.instance-mode.retry": { "message": "重试" },
"app.instance-mode.local-packs-title": { "message": "本地整合包" },
"app.instance-mode.local-packs": { "message": "可以自选整合包并安装为本地实例。切换为本地后保留现有游戏文件和存档,之后不再同步 StarLight 的变更。" },
"app.instance-mode.browse": { "message": "浏览整合包" },
"app.instance-mode.import": { "message": "导入为本地实例" },
"app.hosted-packs.management": {"message": "内容管理"},
"app.hosted-packs.mods": {"message": "管理 Mod"},
"app.hosted-packs.packs": {"message": "管理整合包"},
"app.hosted-packs.title": {"message": "已发布整合包"},
"app.hosted-packs.description": {"message": "将审核通过的整合包安装到当前实例。此后联网启动时自动下载变动文件。每个整合包请使用单独的空实例;存档和个人设置会保留。"},
"app.hosted-packs.refresh": {"message": "刷新"},
"app.hosted-packs.loading": {"message": "正在获取已发布整合包…"},
"app.hosted-packs.bound": {"message": "已安装:{name} · {version}"},
"app.hosted-packs.syncing": {"message": "正在比对文件并同步变动…"},
"app.hosted-packs.complete": {"message": "已同步至 {version},变更 {count} 个文件,下载 {size} MiB。"},
"app.hosted-packs.preserved": {"message": "保留了 {count} 个本地修改或个人文件"},
"app.hosted-packs.empty": {"message": "暂无审核通过的整合包。"},
"app.hosted-packs.update": {"message": "立即同步"},
"app.hosted-packs.install": {"message": "安装并启用自动同步"},
"app.home.luck.title": { "message": "每日幸运指数" },
"app.home.luck.get": { "message": "一键获取" },
"app.home.luck.login-to-get": { "message": "登录后获取" },
"app.home.luck.loading": { "message": "正在询问小精灵..." },
"app.home.luck.error": { "message": "小精灵暂时没有回应,请稍后重试。" },
"app.home.luck.out-of": { "message": "满分 100" },
"minecraft-account.skin-site.signed-in": { "message": "已登录 StarLight皮肤站" },
"minecraft-account.skin-site.checking": { "message": "正在验证皮肤站登录状态…" },
"minecraft-account.skin-site.sync-error": {
"message": "暂时无法验证皮肤站登录状态,将自动重试。"
},
"minecraft-account.skin-site.players": { "message": "皮肤站玩家({count})" },
"minecraft-account.skin-site.players.retry": { "message": "重试" },
"minecraft-account.skin-site.players.empty": { "message": "这个皮肤站账号还没有玩家。" },
"minecraft-account.skin-site.players.error": {
"message": "暂时无法刷新玩家列表,已保留上一次结果。"
},
"app.easteregg.color-mine.title": {
"message": "星光矿井:彩域"
},
"app.easteregg.color-mine.easy": {
"message": "简单"
},
"app.easteregg.color-mine.normal": {
"message": "普通"
},
"app.easteregg.color-mine.hard": {
"message": "困难"
},
"app.easteregg.color-mine.newGame": {
"message": "新的一局"
},
"app.easteregg.color-mine.close": {
"message": "关闭"
},
"app.easteregg.color-mine.choose": {
"message": "先点击已揭示的色块取色,再给灰格染色。"
},
"app.easteregg.color-mine.selected": {
"message": "已取色 {color} · 点击其他色块切换画笔。"
},
"app.easteregg.color-mine.rulesTitle": {
"message": "玩法说明"
},
"app.easteregg.color-mine.rules": {
"message": "数字表示周围八格中同色格的数量,不含自身。每种颜色必须组成一个上下左右连通的区域,斜角不算连接。点击已揭示的格子取色,染对会显示新数字,染错则本局结束。每局均经过逐步推理验证,无需猜测。"
},
"app.easteregg.color-mine.loading": {
"message": "正在生成色域并校验推理路径…"
},
"app.easteregg.color-mine.generateError": {
"message": "棋盘生成失败,请重试。"
},
"app.easteregg.color-mine.retry": {
"message": "重试"
},
"app.easteregg.color-mine.progress": {
"message": "已揭示 {count} / {total}"
},
"app.easteregg.color-mine.best": {
"message": "最佳 · {time}"
},
"app.easteregg.color-mine.won": {
"message": "全部色域已还原!"
},
"app.easteregg.color-mine.lost": {
"message": "第 {row} 行、第 {column} 列染错了:选择了 {selected},正确颜色为 {correct}。本局结束。"
},
"app.easteregg.color-mine.review": {
"message": "未揭示的答案已淡色显示,可查看棋盘复盘。"
},
"app.easteregg.color-mine.leaveTitle": {
"message": "要离开当前棋盘吗?"
},
"app.easteregg.color-mine.leaveText": {
"message": "可以保存,下次从这里继续;也可以放弃本局。"
},
"app.easteregg.color-mine.saveLeave": {
"message": "保存并离开"
},
"app.easteregg.color-mine.discard": {
"message": "放弃本局"
},
"app.easteregg.color-mine.continue": {
"message": "继续这局"
},
"app.easteregg.color-mine.replaceTitle": {
"message": "开始新的一局?"
},
"app.easteregg.color-mine.replaceText": {
"message": "当前这局的进度将被放弃。"
},
"app.easteregg.color-mine.savedTitle": {
"message": "有一局尚未完成的棋盘"
},
"app.easteregg.color-mine.resume": {
"message": "继续上次的棋盘"
},
"app.easteregg.color-mine.storageError": {
"message": "无法读取或保存存档,当前棋盘已保留。可重试,或选择放弃存档。"
},
"app.easteregg.color-mine.recordError": {
"message": "已完成,但最佳成绩未能保存。"
},
"app.easteregg.color-mine.zoomIn": {
"message": "放大"
},
"app.easteregg.color-mine.zoomOut": {
"message": "缩小"
},
"app.easteregg.color-mine.map": {
"message": "棋盘总览,点击定位"
},
"app.easteregg.color-mine.board": {
"message": "{size} × {size} 彩色推理棋盘"
},
"app.easteregg.color-mine.hidden": {
"message": "第 {row} 行,第 {column} 列,未揭示"
},
"app.easteregg.color-mine.revealed": {
"message": "第 {row} 行,第 {column} 列,颜色 {color},周围同色 {number} 格"
},
"app.settings.developer.announcement-preview": {
"message": "公告样式预览"
},
"app.settings.developer.announcement-preview-description": {
"message": "使用本地示例预览真实公告组件,不请求远端公告,也不改变真实公告的已读状态。"
},
"app.settings.developer.preview-with-action": { "message": "显示可选的外部链接按钮" },
"app.settings.developer.preview-announcement-modal": { "message": "预览启动弹窗" },
"app.settings.developer.preview-announcement-popup": { "message": "预览 Popup 通知" },
"app.remote-announcements.preview-title": { "message": "公告样式预览" },
"app.settings.developer.preview-with-action": {
"message": "显示可选的外部链接按钮"
},
"app.settings.developer.preview-announcement-modal": {
"message": "预览启动弹窗"
},
"app.settings.developer.preview-announcement-popup": {
"message": "预览 Popup 通知"
},
"app.remote-announcements.preview-title": {
"message": "公告样式预览"
},
"app.remote-announcements.preview-summary": {
"message": "这是一条本地预览通知,点击“查看公告”可查看完整 Markdown 正文。"
},
"app.remote-announcements.preview-action": { "message": "访问官网" },
"app.remote-announcements.preview-action": {
"message": "访问官网"
},
"app.remote-announcements.preview-content": {
"message": "## 公告预览\n\n这是**示例正文**,不是已发布公告。\n\n- 支持标题、列表和链接\n- 默认自带关闭按钮\n\n> 预览不会影响真实公告的已读状态。\n\n| 类型 | 展示方式 |\n| --- | --- |\n| 启动弹窗 | 完整 Markdown 正文 |\n| Popup 通知 | 先显示摘要,再查看全文 |\n\n[访问官网](https://axlmc.org)"
},
"app.remote-announcements.view": { "message": "查看公告" },
"app.remote-announcements.unread": { "message": "未读" },
"app.remote-announcements.read-all": { "message": "将所有公告标为已读" },
"app.remote-announcements.view": {
"message": "查看公告"
},
"app.remote-announcements.unread": {
"message": "未读"
},
"app.remote-announcements.read-all": {
"message": "将所有公告标为已读"
},
"app.account.signed-in-as": {
"message": "登录身份:"
},
@@ -5246,8 +5414,8 @@
"app.minecraft-crash.view-mod-changes": {
"message": "查看 Mod 变化"
},
"app.minecraft.playing-as": {
"message": "当前游玩账号:"
"app.minecraft.user-information": {
"message": "用户信息"
},
"app.modal.install-to-play.content-required": {
"message": "需求内容"
@@ -5879,12 +6047,6 @@
"app.onboarding.mascot-alt": {
"message": "引导吉祥物"
},
"app.onboarding.privacy.description": {
"message": "匿名遥测、Discord 活动状态与 Minecraft 日志分析服务都可以在这里调整。"
},
"app.onboarding.privacy.title": {
"message": "你的数据,由你决定"
},
"app.onboarding.resources.description": {
"message": "选择内容的下载与安装方式,包括下载源和安全确认。"
},
@@ -5930,30 +6092,6 @@
"app.onboarding.welcome.title": {
"message": "一切就绪"
},
"app.privacy-consent.continue": {
"message": "保存并继续"
},
"app.privacy-consent.discord-rpc": {
"message": "Discord 活动状态"
},
"app.privacy-consent.discord-rpc-description": {
"message": "Discord 运行时显示你当前使用启动器或游玩的状态。"
},
"app.privacy-consent.intro": {
"message": "选择 Starlight 可以发送或展示的内容。确认前不会发送任何遥测。"
},
"app.privacy-consent.privacy-policy": {
"message": "阅读隐私政策"
},
"app.privacy-consent.telemetry": {
"message": "允许匿名遥测"
},
"app.privacy-consent.telemetry-description": {
"message": "用于统计选择加入的安装量和每日活跃用户。绝不上传完整 Minecraft 日志或账户凭据。"
},
"app.privacy-consent.title": {
"message": "隐私与安全"
},
"app.project.curseforge.loading": {
"message": "正在加载 CurseForge 项目…"
},
@@ -6746,9 +6884,6 @@
"app.settings.about.preview-minecraft-crash-modal": {
"message": "预览 Minecraft 崩溃窗口"
},
"app.settings.about.preview-privacy-consent-modal": {
"message": "预览隐私与安全弹窗"
},
"app.settings.about.product-title": {
"message": "关于 {productName}"
},
@@ -6995,21 +7130,6 @@
"app.settings.java.view-installed": {
"message": "查看已安装 Java"
},
"app.settings.privacy.data-handling": {
"message": "遥测使用随机安装标识,仅发送每日活跃信号。关闭遥测会立即清空待发送数据。"
},
"app.settings.privacy.discord-rpc": {
"message": "Discord 活动状态"
},
"app.settings.privacy.discord-rpc-description": {
"message": "在 Discord 中显示你当前使用启动器或游玩的状态。"
},
"app.settings.privacy.telemetry": {
"message": "允许遥测"
},
"app.settings.privacy.telemetry-description": {
"message": "发送匿名每日活跃信号以优化使用统计。绝不上传 Minecraft 日志或账户凭据。"
},
"app.settings.resources.app-cache": {
"message": "应用缓存"
},
@@ -7400,9 +7520,6 @@
"app.settings.tabs.network-multiplayer": {
"message": "网络与多人游戏"
},
"app.settings.tabs.privacy-data": {
"message": "隐私与数据共享"
},
"app.settings.tabs.resource-management": {
"message": "资源管理"
},
@@ -7748,6 +7865,27 @@
"app.skins.section.tiny-takeover": {
"message": "小鬼当家"
},
"app.skins.skin-site-account.description": {
"message": "在下方选择皮肤后,可直接应用到 {player}。切换启动器页面时,皮肤站登录状态也会保持。"
},
"app.skins.skin-site-account.empty-description": {
"message": "这个皮肤站账号暂时没有可用的玩家角色。"
},
"app.skins.skin-site-account.empty-title": {
"message": "暂无可用玩家"
},
"app.skins.skin-site-account.loading-description": {
"message": "启动器正在同步此账号绑定的玩家。"
},
"app.skins.skin-site-account.loading-title": {
"message": "正在加载皮肤站玩家……"
},
"app.skins.skin-site-account.mojang-description": {
"message": "{player} 是正版 Minecraft 玩家,其皮肤仍由正版账号服务管理。"
},
"app.skins.skin-site-account.title": {
"message": "StarLight 皮肤站玩家"
},
"app.skins.sign-in.axolotl-alt": {
"message": "Starlight Launcher"
},
@@ -10083,13 +10221,13 @@
"message": "世界正在使用中"
},
"minecraft-account.add-microsoft-account": {
"message": "添加 Microsoft 账户"
"message": "添加自己的正版账号"
},
"minecraft-account.add-offline-account": {
"message": "添加离线账户"
},
"minecraft-account.add-third-party-account": {
"message": "添加第三方账号"
"minecraft-account.sign-in-starlight": {
"message": "登录 StarLight皮肤站"
},
"minecraft-account.copy-uuid": {
"message": "复制 UUID"
@@ -10151,8 +10289,11 @@
"minecraft-account.offline-mode": {
"message": "离线模式"
},
"minecraft-account.offline-mode.description": {
"message": "仅可使用离线账号,并且只能启动已完整下载的实例。这通常是因为你的电脑无法连接到Mojang的认证服务器,请检查网络连接。如果你正在使用代理,请关闭或开启代理后点击下方按钮刷新连接状态。"
"minecraft-account.offline-mode.description.no-internet": {
"message": "看起来本机似乎并没有连接到互联网,您目前只能启动已完整下载的实例,且极可能无法连接到StarLight服务器,请检查网络连接。如果您正在使用代理,请关闭或开启代理后点击下方按钮刷新连接状态。"
},
"minecraft-account.offline-mode.description.server-unavailable": {
"message": "您目前网络正常,但无法连接到StarLight验证服务器;这可能是由于StarLight服务器正在维护,或者您配置的代理存在问题。如果您正在使用代理,请关闭或开启代理后点击下方按钮刷新连接状态;若该操作不生效,请前往StarLight交流群,联系服务器管理确认服务器的维护状态。"
},
"minecraft-account.offline-mode.refresh": {
"message": "刷新连接状态"
@@ -10309,6 +10450,5 @@
},
"app.settings.about.easteregg.game-title": {
"message": "彩蛋小游戏"
}
}
+181 -58
View File
@@ -1,4 +1,154 @@
{
"app.onboarding.instance-mode.title": { "message": "選擇實例類型" },
"app.onboarding.instance-mode.description": { "message": "遊玩 StarLight 伺服器請選擇 StarLight 實例,它會自動同步管理員核准的更新。本地實例跳過 StarLight 同步,啟動更快,可自行選擇整合包,適合第三方伺服器與單人遊玩。之後也可以在實例設定中切換。" },
"app.instance-mode.title": { "message": "實例類型" },
"app.instance-mode.starlight": { "message": "StarLight 實例" },
"app.instance-mode.local": { "message": "本機實例" },
"app.instance-mode.starlight-description": { "message": "自動同步 StarLight 伺服器推送的變更,遊玩 StarLight 伺服器必選。首次啟動前,請在 Mod 管理中選擇並安裝官方整合包。" },
"app.instance-mode.local-description": { "message": "不會同步 StarLight 伺服器的變更。略過同步檢查,啟動更快,可自行選擇整合包安裝,更適合第三方伺服器與本機個人遊玩。" },
"app.instance-mode.saving": { "message": "正在儲存實例類型…" },
"app.instance-mode.loading": { "message": "正在讀取實例類型…" },
"app.instance-mode.retry": { "message": "重試" },
"app.instance-mode.local-packs-title": { "message": "本機整合包" },
"app.instance-mode.local-packs": { "message": "可以自行選擇整合包並安裝為本機實例。切換為本機後保留現有遊戲檔案和存檔,之後不再同步 StarLight 的變更。" },
"app.instance-mode.browse": { "message": "瀏覽整合包" },
"app.instance-mode.import": { "message": "匯入為本機實例" },
"app.hosted-packs.management": {"message": "內容管理"},
"app.hosted-packs.mods": {"message": "管理 Mod"},
"app.hosted-packs.packs": {"message": "管理整合包"},
"app.hosted-packs.title": {"message": "已發布整合包"},
"app.hosted-packs.description": {"message": "將審核通過的整合包安裝到目前實例。之後連線啟動時自動下載變動檔案。每個整合包請使用獨立的空實例;存檔和個人設定會保留。"},
"app.hosted-packs.refresh": {"message": "重新整理"},
"app.hosted-packs.loading": {"message": "正在取得已發布整合包…"},
"app.hosted-packs.bound": {"message": "已安裝:{name} · {version}"},
"app.hosted-packs.syncing": {"message": "正在比對檔案並同步變動…"},
"app.hosted-packs.complete": {"message": "已同步至 {version},變更 {count} 個檔案,下載 {size} MiB。"},
"app.hosted-packs.preserved": {"message": "保留了 {count} 個本機修改或個人檔案"},
"app.hosted-packs.empty": {"message": "尚無審核通過的整合包。"},
"app.hosted-packs.update": {"message": "立即同步"},
"app.hosted-packs.install": {"message": "安裝並啟用自動同步"},
"app.home.luck.title": { "message": "每日幸運指數" },
"app.home.luck.get": { "message": "一鍵獲取" },
"app.home.luck.login-to-get": { "message": "登入後獲取" },
"app.home.luck.loading": { "message": "正在詢問小精靈..." },
"app.home.luck.error": { "message": "小精靈暫時沒有回應,請稍後重試。" },
"app.home.luck.out-of": { "message": "滿分 100" },
"minecraft-account.skin-site.signed-in": { "message": "已登入 StarLight皮膚站" },
"minecraft-account.skin-site.checking": { "message": "正在驗證皮膚站登入狀態…" },
"minecraft-account.skin-site.sync-error": {
"message": "暫時無法驗證皮膚站登入狀態,將自動重試。"
},
"minecraft-account.skin-site.players": { "message": "皮膚站玩家({count})" },
"minecraft-account.skin-site.players.retry": { "message": "重試" },
"minecraft-account.skin-site.players.empty": { "message": "這個皮膚站帳號還沒有玩家。" },
"minecraft-account.skin-site.players.error": {
"message": "暫時無法重新整理玩家清單,已保留上一次結果。"
},
"app.easteregg.color-mine.title": {
"message": "星光礦井:彩域"
},
"app.easteregg.color-mine.easy": {
"message": "簡單"
},
"app.easteregg.color-mine.normal": {
"message": "普通"
},
"app.easteregg.color-mine.hard": {
"message": "困難"
},
"app.easteregg.color-mine.newGame": {
"message": "新的一局"
},
"app.easteregg.color-mine.close": {
"message": "關閉"
},
"app.easteregg.color-mine.choose": {
"message": "先點擊已揭示的色塊取色,再為灰格染色。"
},
"app.easteregg.color-mine.selected": {
"message": "已取色 {color} · 點擊其他色塊切換畫筆。"
},
"app.easteregg.color-mine.rulesTitle": {
"message": "玩法說明"
},
"app.easteregg.color-mine.rules": {
"message": "數字表示周圍八格中同色格的數量,不含自身。每種顏色必須組成一個上下左右連通的區域,斜角不算連接。點擊已揭示的格子取色,染對會顯示新數字,染錯則本局結束。每局均經過逐步推理驗證,無需猜測。"
},
"app.easteregg.color-mine.loading": {
"message": "正在生成色域並校驗推理路徑…"
},
"app.easteregg.color-mine.generateError": {
"message": "棋盤生成失敗,請重試。"
},
"app.easteregg.color-mine.retry": {
"message": "重試"
},
"app.easteregg.color-mine.progress": {
"message": "已揭示 {count} / {total}"
},
"app.easteregg.color-mine.best": {
"message": "最佳 · {time}"
},
"app.easteregg.color-mine.won": {
"message": "全部色域已還原!"
},
"app.easteregg.color-mine.lost": {
"message": "第 {row} 行、第 {column} 列染錯了:選擇了 {selected},正確顏色為 {correct}。本局結束。"
},
"app.easteregg.color-mine.review": {
"message": "未揭示的答案已淡色顯示,可查看棋盤複盤。"
},
"app.easteregg.color-mine.leaveTitle": {
"message": "要離開目前棋盤嗎?"
},
"app.easteregg.color-mine.leaveText": {
"message": "可以儲存,下次從這裡繼續;也可以放棄本局。"
},
"app.easteregg.color-mine.saveLeave": {
"message": "儲存並離開"
},
"app.easteregg.color-mine.discard": {
"message": "放棄本局"
},
"app.easteregg.color-mine.continue": {
"message": "繼續這局"
},
"app.easteregg.color-mine.replaceTitle": {
"message": "開始新的一局?"
},
"app.easteregg.color-mine.replaceText": {
"message": "目前這局的進度將被放棄。"
},
"app.easteregg.color-mine.savedTitle": {
"message": "有一局尚未完成的棋盤"
},
"app.easteregg.color-mine.resume": {
"message": "繼續上次的棋盤"
},
"app.easteregg.color-mine.storageError": {
"message": "無法讀取或儲存存檔,目前棋盤已保留。可重試,或選擇放棄存檔。"
},
"app.easteregg.color-mine.recordError": {
"message": "已完成,但最佳成績未能儲存。"
},
"app.easteregg.color-mine.zoomIn": {
"message": "放大"
},
"app.easteregg.color-mine.zoomOut": {
"message": "縮小"
},
"app.easteregg.color-mine.map": {
"message": "棋盤總覽,點擊定位"
},
"app.easteregg.color-mine.board": {
"message": "{size} × {size} 彩色推理棋盤"
},
"app.easteregg.color-mine.hidden": {
"message": "第 {row} 行,第 {column} 列,未揭示"
},
"app.easteregg.color-mine.revealed": {
"message": "第 {row} 行,第 {column} 列,顏色 {color},周圍同色 {number} 格"
},
"app.account.signed-in-as": {
"message": "登入身分:"
},
@@ -5015,8 +5165,8 @@
"app.minecraft-crash.view-mod-changes": {
"message": "查看 Mod 變更"
},
"app.minecraft.playing-as": {
"message": "當前遊玩帳號:"
"app.minecraft.user-information": {
"message": "使用者資訊"
},
"app.modal.install-to-play.content-required": {
"message": "所需內容"
@@ -5618,12 +5768,6 @@
"app.onboarding.mascot-alt": {
"message": "引導吉祥物"
},
"app.onboarding.privacy.description": {
"message": "匿名遙測、Discord 活動狀態與 Minecraft 日誌分析服務都可以在這裡調整。"
},
"app.onboarding.privacy.title": {
"message": "你的資料,由你決定"
},
"app.onboarding.resources.description": {
"message": "調節下載、儲存和啟動器資源。給風扇一點尊嚴。"
},
@@ -5669,30 +5813,6 @@
"app.onboarding.welcome.title": {
"message": "一切就緒"
},
"app.privacy-consent.continue": {
"message": "儲存並繼續"
},
"app.privacy-consent.discord-rpc": {
"message": "Discord 活動狀態"
},
"app.privacy-consent.discord-rpc-description": {
"message": "Discord 執行時顯示你當前使用啟動器或遊玩的狀態。"
},
"app.privacy-consent.intro": {
"message": "選擇 Starlight 可以傳送或展示的內容。確認前不會傳送任何遙測。"
},
"app.privacy-consent.privacy-policy": {
"message": "閱讀隱私政策"
},
"app.privacy-consent.telemetry": {
"message": "允許匿名遙測"
},
"app.privacy-consent.telemetry-description": {
"message": "透過脫敏且限長的報告統計選擇加入的安裝量並排查啟動器錯誤。不會上傳完整 Minecraft 日誌或賬戶憑據。"
},
"app.privacy-consent.title": {
"message": "隱私與安全"
},
"app.project.curseforge.loading": {
"message": "正在載入 CurseForge 專案..."
},
@@ -6461,9 +6581,6 @@
"app.settings.about.preview-minecraft-crash-modal": {
"message": "預覽 Minecraft 崩潰窗口"
},
"app.settings.about.preview-privacy-consent-modal": {
"message": "預覽隱私與安全彈窗"
},
"app.settings.about.product-title": {
"message": "關於 {productName}"
},
@@ -6710,21 +6827,6 @@
"app.settings.java.view-installed": {
"message": "查看已安裝 Java"
},
"app.settings.privacy.data-handling": {
"message": "遙測使用隨機安裝標識。錯誤上下文離開裝置前會經過脫敏和長度限制。關閉遙測會立即清空待傳送報告。"
},
"app.settings.privacy.discord-rpc": {
"message": "Discord 活動狀態"
},
"app.settings.privacy.discord-rpc-description": {
"message": "在 Discord 中顯示你當前使用啟動器或遊玩的狀態。"
},
"app.settings.privacy.telemetry": {
"message": "允許遙測"
},
"app.settings.privacy.telemetry-description": {
"message": "傳送匿名每日活躍訊號和脫敏的啟動器錯誤報告。絕不上傳 Minecraft 日誌或賬戶憑據。"
},
"app.settings.resources.app-cache": {
"message": "應用程式快取"
},
@@ -7115,9 +7217,6 @@
"app.settings.tabs.network-multiplayer": {
"message": "網路與多人遊戲"
},
"app.settings.tabs.privacy-data": {
"message": "隱私與資料共享"
},
"app.settings.tabs.resource-management": {
"message": "資源管理"
},
@@ -7457,6 +7556,27 @@
"app.skins.section.tiny-takeover": {
"message": "小鬼當家"
},
"app.skins.skin-site-account.description": {
"message": "在下方選擇皮膚後,可直接套用到 {player}。切換啟動器頁面時,皮膚站登入狀態也會保持。"
},
"app.skins.skin-site-account.empty-description": {
"message": "這個皮膚站帳號暫時沒有可用的玩家角色。"
},
"app.skins.skin-site-account.empty-title": {
"message": "暫無可用玩家"
},
"app.skins.skin-site-account.loading-description": {
"message": "啟動器正在同步此帳號綁定的玩家。"
},
"app.skins.skin-site-account.loading-title": {
"message": "正在載入皮膚站玩家……"
},
"app.skins.skin-site-account.mojang-description": {
"message": "{player} 是正版 Minecraft 玩家,其皮膚仍由正版帳號服務管理。"
},
"app.skins.skin-site-account.title": {
"message": "StarLight 皮膚站玩家"
},
"app.skins.sign-in.axolotl-alt": {
"message": "Starlight Launcher"
},
@@ -9747,13 +9867,13 @@
"message": "世界正在使用中"
},
"minecraft-account.add-microsoft-account": {
"message": "新增 Microsoft 帳號"
"message": "新增自己的正版帳號"
},
"minecraft-account.add-offline-account": {
"message": "新增離線帳號"
},
"minecraft-account.add-third-party-account": {
"message": "新增第三方帳號"
"minecraft-account.sign-in-starlight": {
"message": "登入 StarLight皮膚站"
},
"minecraft-account.copy-uuid": {
"message": "複製 UUID"
@@ -9815,8 +9935,11 @@
"minecraft-account.offline-mode": {
"message": "離線模式"
},
"minecraft-account.offline-mode.description": {
"message": "僅可使用離線帳號,並且只能啟動已完整下載的實例。"
"minecraft-account.offline-mode.description.no-internet": {
"message": "看起來本機似乎並未連線到網際網路,您目前只能啟動已完整下載的實例,且極可能無法連線到 StarLight 伺服器,請檢查網路連線。如果您正在使用代理,請關閉或開啟代理後點擊下方按鈕重新整理連線狀態。"
},
"minecraft-account.offline-mode.description.server-unavailable": {
"message": "您目前網路正常,但無法連線到 StarLight 驗證伺服器;這可能是因為 StarLight 伺服器正在維護,或者您設定的代理存在問題。如果您正在使用代理,請關閉或開啟代理後點擊下方按鈕重新整理連線狀態;若該操作沒有作用,請前往 StarLight 交流群組,聯絡伺服器管理員確認維護狀態。"
},
"minecraft-account.offline-mode.refresh": {
"message": "刷新連接狀態"
-3
View File
@@ -9,7 +9,6 @@ import { createApp } from 'vue'
import App from '@/App.vue'
import { overlayScrollbarsDirective } from '@/directives/overlayScrollbars'
import { installTelemetryHandlers } from '@/helpers/telemetry'
import i18nPlugin from '@/plugins/i18n'
import i18nDebugPlugin from '@/plugins/i18n-debug'
import router from '@/routes'
@@ -18,8 +17,6 @@ const pinia = createPinia()
const app = createApp(App)
installTelemetryHandlers()
app.use(VueQueryPlugin)
app.use(router)
app.use(pinia)
+11 -3
View File
@@ -1,16 +1,20 @@
<script setup lang="ts">
import { FolderOpenIcon, LeftArrowIcon, SparklesIcon } from '@modrinth/assets'
import { BigOptionButton, Button, defineMessages, useVIntl } from '@modrinth/ui'
import { inject } from 'vue'
import { inject, ref } from 'vue'
import { useRouter } from 'vue-router'
import InstanceModeOptions from '@/components/instance/InstanceModeOptions.vue'
import type { InstanceMode } from '@/helpers/hosted-packs'
const { formatMessage } = useVIntl()
const router = useRouter()
const instanceMode = ref<InstanceMode>('local')
const showModal = inject<
(options?: {
skipSetupType?: boolean
initialMode?: 'custom' | 'import'
instanceMode?: InstanceMode
onBack?: () => void
}) => void
>('showCreationModalWithOptions')
@@ -60,6 +64,7 @@ function handleStartFresh() {
showModal?.({
skipSetupType: true,
initialMode: 'custom',
instanceMode: instanceMode.value,
onBack: () => router.push('/create'),
})
}
@@ -68,14 +73,15 @@ function handleImportExisting() {
showModal?.({
skipSetupType: true,
initialMode: 'import',
instanceMode: 'local',
onBack: () => router.push('/create'),
})
}
</script>
<template>
<div class="flex h-full w-full flex-col items-center justify-center p-6">
<div class="flex w-full max-w-2xl flex-col gap-6">
<div class="flex h-full w-full flex-col items-center overflow-y-auto p-6">
<div class="my-auto flex w-full max-w-2xl shrink-0 flex-col gap-6">
<div class="flex flex-col gap-2">
<h1 class="m-0 text-2xl font-bold text-contrast">
{{ formatMessage(messages.title) }}
@@ -85,6 +91,7 @@ function handleImportExisting() {
</p>
</div>
<InstanceModeOptions v-model="instanceMode" data-onboarding-id="creation-instance-mode" />
<div data-onboarding-id="creation-methods" class="flex flex-col gap-4 sm:flex-row">
<BigOptionButton
data-onboarding-id="creation-method-custom"
@@ -97,6 +104,7 @@ function handleImportExisting() {
<BigOptionButton
data-onboarding-id="creation-method-import"
v-if="instanceMode === 'local'"
:icon="FolderOpenIcon"
:title="formatMessage(messages.importTitle)"
:description="formatMessage(messages.importDescription)"
+3 -3
View File
@@ -137,11 +137,11 @@
</template>
</div>
<div
v-if="downloadTelemetry(job).length"
v-if="downloadDetails(job).length"
class="mt-1 flex flex-wrap items-center gap-2 text-sm text-secondary"
>
<template
v-for="(metric, index) in downloadTelemetry(job)"
v-for="(metric, index) in downloadDetails(job)"
:key="`${index}-${metric}`"
>
<BulletDivider v-if="index > 0" />
@@ -1058,7 +1058,7 @@ function totalRequiredFiles(job: InstallJobSnapshot) {
return Math.max(job.summary.files_total ?? 0, job.items.length, completed + missing)
}
function downloadTelemetry(job: InstallJobSnapshot) {
function downloadDetails(job: InstallJobSnapshot) {
const summary = job.summary
const metrics: string[] = []
if (summary.source && !isRecoveryValidation(job)) {
+18 -238
View File
@@ -1,14 +1,5 @@
<script setup lang="ts">
import {
CheckIcon,
GridIcon,
LayoutTemplateIcon,
MinimizeIcon,
MoveIcon,
PencilIcon,
PlusIcon,
RotateCounterClockwiseIcon,
} from '@modrinth/assets'
import { LayoutTemplateIcon, MinimizeIcon } from '@modrinth/assets'
import {
defineMessages,
injectNotificationManager,
@@ -18,20 +9,14 @@ import {
import { computed, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
createDefaultHomeDashboard,
createHomeDashboardSaveQueue,
type HomeDashboardConfig,
normalizeHomeDashboard,
} from '@/components/home/home-dashboard'
import { getActivePlayerName } from '@/components/home/home-utils'
import HomeDailyChallenge from '@/components/home/HomeDailyChallenge.vue'
import HomeDashboard from '@/components/home/HomeDashboard.vue'
import HomeInstancePickerModal from '@/components/home/HomeInstancePickerModal.vue'
import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
import HomeMinimal from '@/components/home/HomeMinimal.vue'
import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
import HomePlayInsights from '@/components/home/HomePlayInsights.vue'
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { get_default_user, users } from '@/helpers/auth'
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
@@ -41,7 +26,7 @@ import { get as getSettings, set as setSettings } from '@/helpers/settings'
import type { GameInstance } from '@/helpers/types'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/state'
import type { FeatureFlag, HomeLayout } from '@/store/theme'
import type { HomeLayout } from '@/store/theme'
const { handleError } = injectNotificationManager()
const route = useRoute()
@@ -66,58 +51,15 @@ const messages = defineMessages({
id: 'app.home.layout.toggle',
defaultMessage: 'Minimal Home',
},
switchToGridWidgetLayout: {
id: 'app.home.widgets.layout.switch-to-grid',
defaultMessage: 'Switch to grid widget layout',
},
switchToFreeWidgetLayout: {
id: 'app.home.widgets.layout.switch-to-free',
defaultMessage: 'Switch to free widget layout',
},
widgetLayoutToggle: {
id: 'app.home.widgets.layout.toggle',
defaultMessage: 'Widget layout mode',
},
resetWidgets: {
id: 'app.home.widgets.reset-confirm',
defaultMessage: 'Restore the default widget layout?',
},
customizeWidgets: {
id: 'app.home.widgets.customize',
defaultMessage: 'Customize widgets',
},
doneEditing: { id: 'app.home.widgets.done', defaultMessage: 'Finish editing' },
addWidget: { id: 'app.home.widgets.add', defaultMessage: 'Add widget' },
resetWidgetLayout: {
id: 'app.home.widgets.reset',
defaultMessage: 'Restore default widgets',
},
})
const recentProjectsInHomeFlag: FeatureFlag = 'worlds_in_home'
breadcrumbs.setRootContext({ name: formatMessage(messages.home), link: route.path })
const instances = ref<GameInstance[]>([])
const playerName = ref<string | null>(null)
const dashboardConfig = ref<HomeDashboardConfig | null>(null)
const dashboard = ref<InstanceType<typeof HomeDashboard>>()
const dashboardEditing = ref(false)
const instancePicker = ref<InstanceType<typeof HomeInstancePickerModal>>()
const isMinimal = computed(() => themeStore.homeLayout === 'minimal')
const isFreeWidgetLayout = computed(() => dashboardConfig.value?.layout === 'free')
const switchingLayout = ref(false)
const dashboardSaveQueue = createHomeDashboardSaveQueue(
async (config) => {
const settings = await getSettings()
settings.home_widgets = config
await setSettings(settings)
},
(config) => {
dashboardConfig.value = config
},
handleError,
)
const floatingControlsStyle = computed(() => ({
bottom: themeStore.getFeatureFlag('page_path') ? '3.5rem' : '1rem',
right: `calc(${pageContext.floatingActionBarOffsets?.right.value ?? '0px'} + 1rem)`,
@@ -162,38 +104,6 @@ async function fetchPlayerName() {
playerName.value = getActivePlayerName(selectedUser, accounts)
}
async function loadDashboardConfig() {
try {
const settings = await getSettings()
const normalized = normalizeHomeDashboard(settings.home_widgets)
if (normalized) {
dashboardConfig.value = normalized
return
}
const config = createDefaultHomeDashboard(themeStore.getFeatureFlag(recentProjectsInHomeFlag))
dashboardConfig.value = config
settings.home_widgets = config
await setSettings(settings)
} catch (error) {
dashboardConfig.value = createDefaultHomeDashboard(
themeStore.getFeatureFlag(recentProjectsInHomeFlag),
)
handleError(error)
}
}
function updateDashboardConfig(config: HomeDashboardConfig) {
const previous = dashboardConfig.value ?? config
dashboardConfig.value = config
void dashboardSaveQueue.enqueue(config, previous)
}
function resetDashboardConfig() {
if (!window.confirm(formatMessage(messages.resetWidgets))) return
updateDashboardConfig(createDefaultHomeDashboard())
}
async function selectMinimalInstance(instance: GameInstance) {
try {
const settings = await getSettings()
@@ -214,10 +124,8 @@ async function toggleHomeLayout() {
const previousLayout = themeStore.homeLayout
const nextLayout: HomeLayout = previousLayout === 'minimal' ? 'standard' : 'minimal'
const previousEditing = dashboardEditing.value
switchingLayout.value = true
themeStore.homeLayout = nextLayout
if (nextLayout === 'minimal') dashboardEditing.value = false
try {
const settings = await getSettings()
@@ -225,28 +133,14 @@ async function toggleHomeLayout() {
await setSettings(settings)
} catch (error) {
themeStore.homeLayout = previousLayout
dashboardEditing.value = previousEditing
handleError(error)
} finally {
switchingLayout.value = false
}
}
function toggleDashboardEditing() {
dashboardEditing.value = !dashboardEditing.value
}
function toggleWidgetLayout() {
dashboard.value?.setLayout(isFreeWidgetLayout.value ? 'grid' : 'free')
}
function openWidgetPicker() {
dashboard.value?.openWidgetPicker()
}
const instancesLoaded = await fetchInstances()
if (!instancesLoaded || instances.value.length > 0) void fetchPlayerName()
await loadDashboardConfig()
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, fetchInstances)
@@ -267,16 +161,8 @@ onUnmounted(() => {
:selected-instance-id="themeStore.minimalHomeInstanceId"
@select="selectMinimalInstance"
/>
<div class="min-h-full">
<HomeDashboard
v-if="!isMinimal && dashboardConfig"
ref="dashboard"
:config="dashboardConfig"
:instances="instances"
:player-name="playerName"
:editing="dashboardEditing"
@change="updateDashboardConfig"
/>
<div class="home-content" :class="{ 'is-minimal': isMinimal }">
<SkinSiteFrame v-if="!isMinimal" />
<HomeMinimal
v-else
@@ -288,71 +174,6 @@ onUnmounted(() => {
/>
</div>
<div class="home-floating-controls" :style="floatingControlsStyle">
<template v-if="!isMinimal">
<button
v-if="dashboardEditing"
v-tooltip="formatMessage(messages.addWidget)"
type="button"
class="home-floating-action"
:aria-label="formatMessage(messages.addWidget)"
@click="openWidgetPicker"
>
<PlusIcon />
</button>
<button
v-if="dashboardEditing"
v-tooltip="formatMessage(messages.resetWidgetLayout)"
type="button"
class="home-floating-action"
:aria-label="formatMessage(messages.resetWidgetLayout)"
@click="resetDashboardConfig"
>
<RotateCounterClockwiseIcon />
</button>
<button
v-tooltip="
formatMessage(dashboardEditing ? messages.doneEditing : messages.customizeWidgets)
"
data-onboarding-id="home-widget-customize"
type="button"
class="home-floating-action"
:class="{ 'is-active': dashboardEditing }"
:aria-label="
formatMessage(dashboardEditing ? messages.doneEditing : messages.customizeWidgets)
"
:aria-pressed="dashboardEditing"
@click="toggleDashboardEditing"
>
<CheckIcon v-if="dashboardEditing" />
<PencilIcon v-else />
</button>
<button
v-if="dashboardEditing"
v-tooltip="
formatMessage(
isFreeWidgetLayout
? messages.switchToGridWidgetLayout
: messages.switchToFreeWidgetLayout,
)
"
type="button"
role="switch"
class="home-layout-switch home-widget-layout-switch"
:class="{ 'is-free': isFreeWidgetLayout }"
:aria-checked="isFreeWidgetLayout"
:aria-label="formatMessage(messages.widgetLayoutToggle)"
@click="toggleWidgetLayout"
>
<span class="home-layout-switch-option home-widget-layout-grid" aria-hidden="true">
<GridIcon />
</span>
<span class="home-layout-switch-thumb" aria-hidden="true" />
<span class="home-layout-switch-option home-widget-layout-free" aria-hidden="true">
<MoveIcon />
</span>
</button>
<span class="home-floating-divider" aria-hidden="true" />
</template>
<button
v-tooltip="formatMessage(isMinimal ? messages.switchToInformation : messages.switchToMinimal)"
data-onboarding-id="home-layout-switch"
@@ -388,6 +209,17 @@ onUnmounted(() => {
</template>
<style scoped>
.home-content {
/* The embedded site owns scrolling within the launcher viewport. */
height: calc(100dvh - var(--top-bar-height));
min-width: 0;
}
.home-content.is-minimal {
height: auto;
min-height: calc(100dvh - var(--top-bar-height));
}
.home-floating-controls {
position: fixed;
z-index: 40;
@@ -406,57 +238,11 @@ onUnmounted(() => {
isolation: isolate;
}
.home-floating-action {
display: flex;
width: 2rem;
height: 2rem;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 9999px;
background: transparent;
color: var(--color-secondary);
cursor: pointer;
transition:
background-color 120ms ease,
color 120ms ease,
filter 150ms ease,
transform 150ms ease;
}
.home-floating-action:hover {
background: var(--color-button-bg);
color: var(--color-contrast);
}
.home-floating-action:active {
transform: scale(0.96);
}
.home-floating-action:focus-visible,
.home-layout-switch:focus-visible {
outline: none;
box-shadow: 0 0 0 4px var(--color-brand-shadow);
}
.home-floating-action.is-active {
background: var(--color-brand);
color: var(--color-accent-contrast);
}
.home-floating-action :deep(svg) {
width: 1rem;
height: 1rem;
}
.home-floating-divider {
width: 1px;
height: 1.25rem;
margin: 0 0.125rem;
background: var(--color-divider);
}
.home-layout-switch {
position: relative;
display: grid;
@@ -505,10 +291,6 @@ onUnmounted(() => {
transform: translateX(2rem);
}
.home-widget-layout-switch.is-free .home-layout-switch-thumb {
transform: translateX(2rem);
}
.home-layout-switch-option {
position: relative;
z-index: 1;
@@ -527,9 +309,7 @@ onUnmounted(() => {
}
.home-layout-switch:not(.is-minimal) .home-layout-switch-information,
.home-layout-switch.is-minimal .home-layout-switch-minimal,
.home-widget-layout-switch:not(.is-free) .home-widget-layout-grid,
.home-widget-layout-switch.is-free .home-widget-layout-free {
.home-layout-switch.is-minimal .home-layout-switch-minimal {
color: var(--color-accent-contrast);
}
</style>
+197 -29
View File
@@ -31,12 +31,23 @@ import { onBeforeRouteLeave, useRouter } from 'vue-router'
import type AccountsCard from '@/components/ui/AccountsCard.vue'
import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue'
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
import {
openSkinSiteLogin,
requestSkinSitePlayers,
requestSkinSiteSkinUpdate,
selectedSkinSitePlayerId,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { check_reachable, get_default_user, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts'
import {
determineModelType,
equip_skin,
filterDefaultSkins,
filterSavedSkins,
@@ -44,6 +55,7 @@ import {
flush_pending_skin_change_for_profile,
get_available_capes,
get_available_skins,
get_default_skins,
get_normalized_skin_texture,
normalize_skin_texture,
remove_custom_skin,
@@ -179,6 +191,22 @@ const messages = defineMessages({
id: 'app.skins.sign-in.button',
defaultMessage: 'Sign In',
},
loadingSkinSitePlayersTitle: {
id: 'app.skins.skin-site-account.loading-title',
defaultMessage: 'Loading skin site players…',
},
loadingSkinSitePlayersDescription: {
id: 'app.skins.skin-site-account.loading-description',
defaultMessage: 'The launcher is synchronizing the players attached to your account.',
},
noSkinSitePlayersTitle: {
id: 'app.skins.skin-site-account.empty-title',
defaultMessage: 'No players available',
},
noSkinSitePlayersDescription: {
id: 'app.skins.skin-site-account.empty-description',
defaultMessage: 'This skin site account does not have an available player profile yet.',
},
offlineCompatibility: {
id: 'app.skins.offline-account.compatibility',
defaultMessage:
@@ -197,6 +225,20 @@ const messages = defineMessages({
defaultMessage:
'Skins for this account are managed by its Yggdrasil provider. Open the provider website to change skins or capes.',
},
skinSiteManagementTitle: {
id: 'app.skins.skin-site-account.title',
defaultMessage: 'StarLight skin site player',
},
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.',
},
skinSiteMojangDescription: {
id: 'app.skins.skin-site-account.mojang-description',
defaultMessage:
'{player} is an official Minecraft player. Its skin remains managed by the official account provider.',
},
savedTab: {
id: 'app.skins.tabs.saved',
defaultMessage: 'Saved skins',
@@ -228,7 +270,16 @@ const currentUser = ref(undefined)
const currentUserId = ref<string | undefined>(undefined)
const currentAccountType = ref<'microsoft' | 'offline' | 'yggdrasil' | undefined>(undefined)
const username = computed(() => currentUser.value?.profile?.name ?? undefined)
const activeSkinSitePlayer = computed(() =>
selectedSkinSitePlayerId.value
? skinSitePlayers.value.find((player) => player.uuid === selectedSkinSitePlayerId.value)
: undefined,
)
const isSkinSiteProfile = computed(() => Boolean(activeSkinSitePlayer.value && skinSiteUser.value))
const hasCurrentProfile = computed(() => Boolean(currentUser.value || isSkinSiteProfile.value))
const username = computed(
() => activeSkinSitePlayer.value?.name ?? currentUser.value?.profile?.name ?? undefined,
)
const selectedSkin = ref<Skin | null>(null)
const isApplyingSkin = ref(false)
@@ -314,10 +365,16 @@ const capeTexture = computed(() => currentCape.value?.texture)
const skinVariant = computed(() => selectedSkin.value?.variant)
const skinNametag = computed(() => (themeStore.hideNametagSkinsPage ? undefined : username.value))
const isSkinManagementReadOnly = computed(
() =>
currentAccountType.value === 'yggdrasil' ||
(currentAccountType.value !== 'offline' &&
(offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value))),
() => {
if (isSkinSiteProfile.value) {
return activeSkinSitePlayer.value?.isMojang === true || skinSiteStatus.value !== 'signed-in'
}
return (
currentAccountType.value === 'yggdrasil' ||
(currentAccountType.value !== 'offline' &&
(offline.value || (authServerQuery.isError.value && !authServerQuery.isLoading.value)))
)
},
)
const hasPendingSkinChange = computed(
() => !skinsMatch(selectedSkin.value, originalSelectedSkin.value),
@@ -356,7 +413,7 @@ function confirmDeleteSkin(skin: Skin) {
}
async function deleteSkin() {
if (isSkinManagementReadOnly.value) return
if (isSkinManagementReadOnly.value || isSkinSiteProfile.value) return
const deletedSkin = skinToDelete.value
if (!deletedSkin) return
@@ -372,10 +429,17 @@ async function deleteSkin() {
}
async function loadCapes() {
if (isSkinSiteProfile.value) {
capes.value = []
return
}
const profileId = currentUserId.value
try {
capes.value = (await get_available_capes()) ?? []
const loadedCapes = (await get_available_capes()) ?? []
if (isSkinSiteProfile.value || currentUserId.value !== profileId) return
capes.value = loadedCapes
} catch (error) {
if (currentUser.value && error instanceof Error) {
if (hasCurrentProfile.value && error instanceof Error) {
handleError(error)
}
}
@@ -383,7 +447,36 @@ async function loadCapes() {
async function loadSkins() {
try {
if (isSkinSiteProfile.value) {
const player = activeSkinSitePlayer.value
if (!player) return
const playerId = player.uuid
const bundledSkins = (await get_default_skins()).map((skin) => ({
...skin,
is_equipped: false,
}))
if (activeSkinSitePlayer.value?.uuid !== playerId) return
const currentSkin: Skin | null =
player.skinState === 'ready' && player.skinDataUrl
? {
texture_key: `starlight:${player.uuid}:current`,
name: player.name,
variant: player.model === 'slim' ? 'SLIM' : 'CLASSIC',
texture: player.skinDataUrl,
source: 'custom_external',
is_equipped: true,
}
: null
skins.value = currentSkin ? [currentSkin, ...bundledSkins] : bundledSkins
generateSkinPreviews(skins.value, [])
selectedSkin.value = currentSkin
originalSelectedSkin.value = currentSkin
return
}
const profileId = currentUserId.value
const loadedSkins = (await get_available_skins()) ?? []
if (isSkinSiteProfile.value || currentUserId.value !== profileId) return
const loadedEquippedSkin = loadedSkins.find((s) => s.is_equipped)
const locallyKnownEquippedSkin =
originalSelectedSkin.value &&
@@ -404,7 +497,7 @@ async function loadSkins() {
selectedSkin.value = skins.value.find((s) => s.is_equipped) ?? null
originalSelectedSkin.value = selectedSkin.value
} catch (error) {
if (currentUser.value && error instanceof Error) {
if (hasCurrentProfile.value && error instanceof Error) {
handleError(error)
}
}
@@ -612,6 +705,7 @@ function updateLocalSkin(savedSkin: Skin, applied: boolean, previousSkin?: Skin)
}
async function reorderSavedSkins(orderedSkins: Skin[]) {
if (isSkinSiteProfile.value) return
const previousSkins = skins.value
const previousSelectedSkin = selectedSkin.value
const previousOriginalSelectedSkin = originalSelectedSkin.value
@@ -733,6 +827,19 @@ async function applySelectedSkin() {
isApplyingSkin.value = true
try {
if (isSkinSiteProfile.value) {
const player = activeSkinSitePlayer.value
if (!player) return
const textureDataUrl = await get_normalized_skin_texture(skinToApply)
await requestSkinSiteSkinUpdate(
player.uuid,
textureDataUrl,
skinToApply.variant === 'SLIM' ? 'slim' : 'default',
)
await requestSkinSitePlayers()
await loadSkins()
return
}
await equip_skin(skinToApply)
setLocallyEquippedSkin(skinToApply)
schedulePendingSkinRefresh()
@@ -776,7 +883,7 @@ async function loadCurrentUser() {
currentAccountType.value = selectedAccount?.account_type
currentUser.value = selectedAccount
} catch (e) {
handleError(e as Error)
if (!isSkinSiteProfile.value) handleError(e as Error)
currentUser.value = undefined
currentUserId.value = undefined
currentAccountType.value = undefined
@@ -794,14 +901,22 @@ watch(accountChangeRevision, (revision, previousRevision) => {
void refreshSelectedAccount()
})
watch(
() => [selectedSkinSitePlayerId.value, activeSkinSitePlayer.value?.skinDataUrl] as const,
() => {
void loadCapes()
void loadSkins()
},
)
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
return skinBlobUrlMap.get(key)
}
async function login() {
if (offline.value) return
accountsCard.value?.login()
async function loginToSkinSite() {
openSkinSiteLogin()
await router.push('/')
}
function openAddSkinFileBrowser() {
@@ -877,7 +992,6 @@ async function onAddSkinDrop(event: DragEvent) {
async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
if (isSkinManagementReadOnly.value) return
const fakeEvent = new MouseEvent('click')
const originalSkinTexUrl = `data:image/png;base64,` + arrayBufferToBase64(buffer)
try {
const skinTextureNormalized = await normalize_skin_texture(originalSkinTexUrl)
@@ -885,6 +999,24 @@ async function processSkinFileBuffer(buffer: Uint8Array | ArrayBuffer) {
original: originalSkinTexUrl,
normalized: `data:image/png;base64,` + arrayBufferToBase64(skinTextureNormalized),
}
if (isSkinSiteProfile.value) {
const variant = await determineModelType(skinTexUrl.normalized)
const pendingSkin: Skin = {
texture_key: `starlight-upload:${Date.now()}`,
name: username.value,
variant,
texture: skinTexUrl.normalized,
source: 'custom_external',
is_equipped: false,
}
skins.value = [pendingSkin, ...skins.value.filter((skin) => skin.source === 'default')]
selectedSkin.value = pendingSkin
skinListTab.value = 'saved'
generateSkinPreviews(skins.value, [])
return
}
const fakeEvent = new MouseEvent('click')
editSkinModal.value?.showNew(fakeEvent, skinTexUrl)
} catch (error) {
handleError(error as Error)
@@ -984,6 +1116,7 @@ onUnmounted(() => {
})
async function checkUserChanges() {
if (isSkinSiteProfile.value) return
try {
const defaultId = await get_default_user(offline.value)
if (defaultId !== currentUserId.value) {
@@ -1047,9 +1180,26 @@ await loadSkins()
</p>
</section>
</Teleport>
<Teleport v-if="isSkinSiteProfile" to="#sidebar-default-teleport-target">
<section class="p-4">
<h3 class="m-0 text-base font-semibold text-primary">
{{ formatMessage(messages.skinSiteManagementTitle) }}
</h3>
<p class="mb-0 mt-2 text-sm leading-6 text-secondary">
{{
formatMessage(
activeSkinSitePlayer?.isMojang
? messages.skinSiteMojangDescription
: messages.skinSiteManagementDescription,
{ player: activeSkinSitePlayer?.name ?? '' },
)
}}
</p>
</section>
</Teleport>
<div
v-if="currentUser"
v-if="hasCurrentProfile"
data-onboarding-id="skins-page"
class="skin-layout box-border min-h-full p-4"
>
@@ -1100,7 +1250,7 @@ await loadSkins()
</button>
</div>
<button
v-else
v-else-if="!isSkinSiteProfile"
class="flex h-10 min-w-0 cursor-pointer items-center justify-center gap-2 rounded-[14px] border-0 bg-surface-4 px-4 py-2.5 text-base font-semibold leading-5 shadow-md transition-[filter,transform] duration-200 enabled:hover:brightness-[--hover-brightness] enabled:focus-visible:brightness-[--hover-brightness] enabled:active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-5 [&>svg]:shrink-0"
:disabled="!selectedSkin || isSkinManagementReadOnly"
@click="(e: MouseEvent) => selectedSkin && editSkinModal?.show(e, selectedSkin)"
@@ -1125,7 +1275,7 @@ await loadSkins()
}
"
/>
<ButtonStyled color="brand">
<ButtonStyled v-if="!isSkinSiteProfile" color="brand">
<button @click="router.push('/lab/skin-editor')">
<PlusIcon />
{{ formatMessage(messages.createSkinButton) }}
@@ -1142,8 +1292,9 @@ await loadSkins()
:is-skin-active="isSkinActive"
:is-add-skin-button-drag-active="isAddSkinButtonDragActive"
:read-only="isSkinManagementReadOnly"
:manage-saved-skins="!isSkinSiteProfile"
@select="changeSkin"
@edit="(skin, event) => editSkinModal?.show(event, skin)"
@edit="(skin, event) => !isSkinSiteProfile && editSkinModal?.show(event, skin)"
@delete="confirmDeleteSkin"
@reorder-saved-skins="reorderSavedSkins"
@add-skin="openAddSkinFileBrowser"
@@ -1183,20 +1334,37 @@ await loadSkins()
<div class="flex flex-col gap-5">
<h1 class="text-3xl font-extrabold m-0">
{{ formatMessage(messages.signInTitle) }}
{{
formatMessage(
skinSiteUser
? skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
? messages.loadingSkinSitePlayersTitle
: messages.noSkinSitePlayersTitle
: messages.signInTitle,
)
}}
</h1>
<p class="text-lg m-0">
{{ formatMessage(messages.signInDescription) }}
{{
formatMessage(
skinSiteUser
? skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
? messages.loadingSkinSitePlayersDescription
: messages.noSkinSitePlayersDescription
: messages.signInDescription,
)
}}
</p>
<ButtonStyled
v-if="!offline"
v-show="accountsCard"
color="brand"
:disabled="accountsCard.loginDisabled"
>
<button :disabled="accountsCard.loginDisabled" @click="login">
<LogInIcon v-if="!accountsCard.loginDisabled" />
<SpinnerIcon v-else class="animate-spin" />
<SpinnerIcon
v-if="
skinSiteUser &&
(skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle')
"
class="h-8 w-8 animate-spin text-brand"
/>
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
<button @click="loginToSkinSite">
<LogInIcon />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
+3 -35
View File
@@ -1,41 +1,9 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'app.starlight-skin.title',
defaultMessage: '斯达莱特',
},
frameTitle: {
id: 'app.starlight-skin.frame-title',
defaultMessage: 'StarLight Skin Site',
},
})
const SKIN_SITE_URL = 'https://skin.starlight.cool/'
const loading = ref(true)
function onLoad() {
loading.value = false
}
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div v-if="loading" class="flex flex-1 items-center justify-center">
<span class="text-secondary">{{ formatMessage(messages.title) }}…</span>
</div>
<iframe
:src="SKIN_SITE_URL"
:title="formatMessage(messages.frameTitle)"
class="h-full min-h-0 w-full flex-1 border-0"
:class="loading ? 'hidden' : ''"
@load="onLoad"
/>
<div class="h-full min-h-0">
<SkinSiteFrame />
</div>
</template>
@@ -419,7 +419,6 @@ import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
import { useSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { instance_listener, process_listener } from '@/helpers/events'
import {
@@ -776,11 +775,6 @@ const startInstance = async (context: string) => {
loading.value = false
}
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: context,
})
}
const stopInstance = async (context: string) => {
@@ -790,11 +784,6 @@ const stopInstance = async (context: string) => {
playing.value = false
if (!instance.value) return
trackEvent('InstanceStop', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: context,
})
}
const handlePlayServer = async () => {
+216 -267
View File
@@ -1,215 +1,220 @@
<template>
<ReadyTransition :pending="loading">
<template #pending>
<LoadingIndicator class="pt-4" />
</template>
<CollapsibleAdmonition
v-if="postUpgradeNotice?.warnings.length"
v-model="postUpgradeNoticeExpanded"
type="warning"
class="mb-4"
>
<template #header>
<span class="inline-flex items-center gap-2">
{{ formatMessage(messages.postUpgradeNoticeTitle) }}
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">{{
postUpgradeNotice.warnings.length
}}</span>
</span>
<ModManagementSwitch>
<template #packs><HostedModpacks :instance-id="instance.id" /></template>
<ReadyTransition :pending="loading">
<template #pending>
<LoadingIndicator class="pt-4" />
</template>
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
<p class="m-0">{{ formatMessage(messages.postUpgradeNoticeBody) }}</p>
<div class="mt-3 flex justify-end">
<ButtonStyled color="orange" size="small">
<button type="button" @click="dismissPostUpgradeNotice">
{{ formatMessage(messages.ignoreAllPostUpgradeWarnings) }}
</button>
</ButtonStyled>
</div>
</div>
</CollapsibleAdmonition>
<CollapsibleAdmonition
v-if="skippedManualDownloads.length > 0"
v-model="manualWarningExpanded"
type="warning"
class="mb-4"
>
<template #header>
<span class="inline-flex items-center gap-2">
{{ formatMessage(messages.skippedFilesWarningTitle) }}
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">
{{ skippedManualDownloads.length }}
<CollapsibleAdmonition
v-if="postUpgradeNotice?.warnings.length"
v-model="postUpgradeNoticeExpanded"
type="warning"
class="mb-4"
>
<template #header>
<span class="inline-flex items-center gap-2">
{{ formatMessage(messages.postUpgradeNoticeTitle) }}
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">{{
postUpgradeNotice.warnings.length
}}</span>
</span>
</span>
</template>
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
<p class="m-0">{{ skippedFilesWarningBody }}</p>
<ul class="mb-0 mt-2 flex list-none flex-col gap-1 p-0">
<li
v-for="item in visibleSkippedManualDownloads"
:key="`${item.projectId}:${item.fileId}`"
class="min-w-0"
>
<button
class="inline-flex max-w-full cursor-pointer items-center gap-1 text-left font-semibold text-brand hover:underline"
@click="openManualCurseForgeDownload(item)"
>
<span class="truncate">{{ item.fileName }}</span>
<ExternalIcon class="size-4 shrink-0" />
</button>
</li>
</ul>
<p v-if="hiddenSkippedManualDownloadCount > 0" class="mb-0 mt-2 text-secondary">
{{
formatMessage(messages.skippedFilesWarningMore, {
count: hiddenSkippedManualDownloadCount,
})
}}
</p>
<div class="mt-3 flex justify-end">
<ButtonStyled color="orange" size="small">
<button @click="openManualCurseForgeResolver">
<FolderSearchIcon />
{{ formatMessage(messages.completeSkippedFiles) }}
</button>
</ButtonStyled>
</div>
</div>
</CollapsibleAdmonition>
<CollapsibleAdmonition
v-if="missingPackMembers.length > 0"
v-model="missingWarningExpanded"
type="warning"
class="mb-4"
>
<template #header>
<span class="inline-flex items-center gap-2">
{{ formatMessage(messages.missingFilesWarningTitle) }}
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">
{{ missingPackMembers.length }}
</span>
</span>
</template>
<div class="bg-bg-orange px-4 pb-4 pt-3">
<p class="m-0 text-sm leading-6 text-secondary">
{{ formatMessage(messages.missingFilesWarningBody) }}
</p>
<ul class="m-0 mt-2 flex max-h-64 list-none flex-col gap-1 overflow-y-auto p-0">
<li
v-for="item in missingPackMembers"
:key="item.memberId ?? item.expectedRelativePath"
class="flex min-h-14 min-w-0 items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-surface-5/40"
>
<FileIcon class="size-5 shrink-0 text-brand-orange" aria-hidden="true" />
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="truncate font-medium text-contrast" :title="item.expectedRelativePath">
{{ fileNameFromPath(item.expectedRelativePath) }}
</span>
<code class="truncate text-xs text-secondary" :title="item.expectedRelativePath">
{{ item.expectedRelativePath }}
</code>
</span>
<ButtonStyled size="small" type="highlight-colored-text" color="orange">
<button
type="button"
:disabled="!item.memberId || isInstanceBusy || isRestoringMissingPackMember(item)"
@click="restoreMissingPackMember(item)"
>
<SpinnerIcon
v-if="isRestoringMissingPackMember(item)"
class="animate-spin"
aria-hidden="true"
/>
<UndoIcon v-else aria-hidden="true" />
{{ formatMessage(messages.restoreMissingFile) }}
</template>
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
<p class="m-0">{{ formatMessage(messages.postUpgradeNoticeBody) }}</p>
<div class="mt-3 flex justify-end">
<ButtonStyled color="orange" size="small">
<button type="button" @click="dismissPostUpgradeNotice">
{{ formatMessage(messages.ignoreAllPostUpgradeWarnings) }}
</button>
</ButtonStyled>
</li>
</ul>
</div>
</CollapsibleAdmonition>
<CollapsibleAdmonition
v-if="contentWarnings.length > 0"
v-model="contentWarningExpanded"
type="warning"
class="mb-4"
>
<template #header>{{ formatMessage(messages.contentRefreshWarningTitle) }}</template>
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
<p class="m-0">{{ formatMessage(messages.contentRefreshWarningBody) }}</p>
</div>
</CollapsibleAdmonition>
<ContentPageLayout @visible-items="handleVisibleItems">
<template #modals>
<ContentToggleDependenciesModal ref="toggleDependenciesModal" />
<DependencyGraphModal
ref="dependencyGraphModal"
:instance-id="props.instance.id"
:instance-name="props.instance.name"
:instance-icon-url="localContentIconUrl(props.instance.icon_path)"
/>
</div>
</div>
</CollapsibleAdmonition>
<CollapsibleAdmonition
v-if="skippedManualDownloads.length > 0"
v-model="manualWarningExpanded"
type="warning"
class="mb-4"
>
<template #header>
<span class="inline-flex items-center gap-2">
{{ formatMessage(messages.skippedFilesWarningTitle) }}
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">
{{ skippedManualDownloads.length }}
</span>
</span>
</template>
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
<p class="m-0">{{ skippedFilesWarningBody }}</p>
<ul class="mb-0 mt-2 flex list-none flex-col gap-1 p-0">
<li
v-for="item in visibleSkippedManualDownloads"
:key="`${item.projectId}:${item.fileId}`"
class="min-w-0"
>
<button
class="inline-flex max-w-full cursor-pointer items-center gap-1 text-left font-semibold text-brand hover:underline"
@click="openManualCurseForgeDownload(item)"
>
<span class="truncate">{{ item.fileName }}</span>
<ExternalIcon class="size-4 shrink-0" />
</button>
</li>
</ul>
<p v-if="hiddenSkippedManualDownloadCount > 0" class="mb-0 mt-2 text-secondary">
{{
formatMessage(messages.skippedFilesWarningMore, {
count: hiddenSkippedManualDownloadCount,
})
}}
</p>
<div class="mt-3 flex justify-end">
<ButtonStyled color="orange" size="small">
<button @click="openManualCurseForgeResolver">
<FolderSearchIcon />
{{ formatMessage(messages.completeSkippedFiles) }}
</button>
</ButtonStyled>
</div>
</div>
</CollapsibleAdmonition>
<CollapsibleAdmonition
v-if="missingPackMembers.length > 0"
v-model="missingWarningExpanded"
type="warning"
class="mb-4"
>
<template #header>
<span class="inline-flex items-center gap-2">
{{ formatMessage(messages.missingFilesWarningTitle) }}
<span class="rounded-full bg-brand-orange/20 px-2 py-0.5 text-sm tabular-nums">
{{ missingPackMembers.length }}
</span>
</span>
</template>
<div class="bg-bg-orange px-4 pb-4 pt-3">
<p class="m-0 text-sm leading-6 text-secondary">
{{ formatMessage(messages.missingFilesWarningBody) }}
</p>
<ul class="m-0 mt-2 flex max-h-64 list-none flex-col gap-1 overflow-y-auto p-0">
<li
v-for="item in missingPackMembers"
:key="item.memberId ?? item.expectedRelativePath"
class="flex min-h-14 min-w-0 items-center gap-3 rounded-lg px-2 py-2 transition-colors hover:bg-surface-5/40"
>
<FileIcon class="size-5 shrink-0 text-brand-orange" aria-hidden="true" />
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="truncate font-medium text-contrast" :title="item.expectedRelativePath">
{{ fileNameFromPath(item.expectedRelativePath) }}
</span>
<code class="truncate text-xs text-secondary" :title="item.expectedRelativePath">
{{ item.expectedRelativePath }}
</code>
</span>
<ButtonStyled size="small" type="highlight-colored-text" color="orange">
<button
type="button"
:disabled="!item.memberId || isInstanceBusy || isRestoringMissingPackMember(item)"
@click="restoreMissingPackMember(item)"
>
<SpinnerIcon
v-if="isRestoringMissingPackMember(item)"
class="animate-spin"
aria-hidden="true"
/>
<UndoIcon v-else aria-hidden="true" />
{{ formatMessage(messages.restoreMissingFile) }}
</button>
</ButtonStyled>
</li>
</ul>
</div>
</CollapsibleAdmonition>
<CollapsibleAdmonition
v-if="contentWarnings.length > 0"
v-model="contentWarningExpanded"
type="warning"
class="mb-4"
>
<template #header>{{ formatMessage(messages.contentRefreshWarningTitle) }}</template>
<div class="border-0 border-t border-solid border-brand-orange/60 bg-bg-orange p-4">
<p class="m-0">{{ formatMessage(messages.contentRefreshWarningBody) }}</p>
</div>
</CollapsibleAdmonition>
<ContentPageLayout @visible-items="handleVisibleItems">
<template #modals>
<ContentToggleDependenciesModal ref="toggleDependenciesModal" />
<DependencyGraphModal
ref="dependencyGraphModal"
:instance-id="props.instance.id"
:instance-name="props.instance.name"
:instance-icon-url="localContentIconUrl(props.instance.icon_path)"
/>
<ShareModalWrapper
ref="shareModal"
:share-title="formatMessage(messages.shareTitle)"
:share-text="formatMessage(messages.shareText)"
:open-in-new-tab="false"
/>
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="displayedModpackProject?.title"
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
@update:enabled="handleModpackContentToggle"
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade"
:backup-tip="
[displayedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
.filter(Boolean)
.join(' ')
"
:symlink-target="props.instance.symlink_target"
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ContentUpdaterModal
v-if="updatingProject || updatingModpack"
ref="contentUpdaterModal"
:versions="updatingProjectVersions"
:current-game-version="instance.game_version"
:current-loader="instance.loader"
:current-version-id="
updatingModpack
? (instance.link?.version_id ?? '')
: (updatingProject?.version?.id ?? '')
"
:is-app="true"
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
:project-icon-url="
updatingModpack ? displayedModpackProject?.icon_url : updatingProject?.project?.icon_url
"
:project-name="
updatingModpack
? (displayedModpackProject?.title ?? formatMessage(commonMessages.modpackLabel))
: (updatingProject?.project?.title ?? updatingProject?.file_name)
"
:loading="loadingVersions"
:loading-changelog="loadingChangelog"
@update="handleModalUpdate"
@cancel="resetUpdateState"
@version-select="handleVersionSelect"
@version-hover="handleVersionHover"
/>
</template>
</ContentPageLayout>
</ReadyTransition>
<ShareModalWrapper
ref="shareModal"
:share-title="formatMessage(messages.shareTitle)"
:share-text="formatMessage(messages.shareText)"
:open-in-new-tab="false"
/>
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="displayedModpackProject?.title"
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
:enable-toggle="!props.isServerInstance"
:busy="isBulkOperating"
:get-overflow-options="getOverflowOptions"
@update:enabled="handleModpackContentToggle"
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
@bulk:disable="(items) => handleModpackContentBulkToggle(items, false)"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade"
:backup-tip="
[displayedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
.filter(Boolean)
.join(' ')
"
:symlink-target="props.instance.symlink_target"
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
<ExportModal v-if="projects.length > 0" ref="exportModal" :instance="instance" />
<ContentUpdaterModal
v-if="updatingProject || updatingModpack"
ref="contentUpdaterModal"
:versions="updatingProjectVersions"
:current-game-version="instance.game_version"
:current-loader="instance.loader"
:current-version-id="
updatingModpack
? (instance.link?.version_id ?? '')
: (updatingProject?.version?.id ?? '')
"
:is-app="true"
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
:project-icon-url="
updatingModpack
? displayedModpackProject?.icon_url
: updatingProject?.project?.icon_url
"
:project-name="
updatingModpack
? (displayedModpackProject?.title ?? formatMessage(commonMessages.modpackLabel))
: (updatingProject?.project?.title ?? updatingProject?.file_name)
"
:loading="loadingVersions"
:loading-changelog="loadingChangelog"
@update="handleModalUpdate"
@cancel="resetUpdateState"
@version-select="handleVersionSelect"
@version-hover="handleVersionHover"
/>
</template>
</ContentPageLayout>
</ReadyTransition>
</ModManagementSwitch>
</template>
<script setup lang="ts">
@@ -259,12 +264,13 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import DependencyGraphModal from '@/components/instance/dependencies/DependencyGraphModal.vue'
import HostedModpacks from '@/components/instance/HostedModpacks.vue'
import ModManagementSwitch from '@/components/instance/ModManagementSwitch.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import ContentToggleDependenciesModal from '@/components/ui/modal/ContentToggleDependenciesModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { postUpgradeNoticeQueryKey, usePostUpgradeNotice } from '@/composables/usePostUpgradeNotice'
import { useWorldDatapacks } from '@/composables/useWorldDatapacks'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
import { applyContentItemUpdates, matchesContentItem } from '@/helpers/content-item-state'
import { lookupContentWikiIds, translateContentItemTitles } from '@/helpers/content-search'
@@ -1402,8 +1408,7 @@ async function getUpdaterProjectVersions(
if (!versions) {
versions = (await get_project_versions(projectId).catch(() => null)) as
| Labrinth.Versions.v2.Version[]
| null
Labrinth.Versions.v2.Version[] | null
}
if (!versions && fetchError) {
@@ -1580,14 +1585,6 @@ async function toggleDisableMod(
const enabled = desiredEnabled ?? !mod.enabled
try {
await toggleWorldDatapackItem(mod, enabled)
trackEvent('InstanceProjectDisable', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
disabled: !enabled,
})
} catch (err) {
handleError(err as Error)
}
@@ -1645,15 +1642,6 @@ async function applyToggleDisableMod(mod: ContentItem, enabled: boolean) {
file_name: newFileName,
enabled: actualEnabled,
})
trackEvent('InstanceProjectDisable', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
disabled: !actualEnabled,
})
} catch (err) {
applyContentItemToggleState(mod, operation.originalFileName, originalFilePath, {
file_path: originalFilePath,
@@ -1733,14 +1721,6 @@ async function applyToggleDisableBatch(items: ContentItem[], enabled: boolean) {
)
for (const operation of operations) {
trackEvent('InstanceProjectDisable', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: operation.item.project?.id,
name: operation.item.project?.title ?? operation.originalFileName,
project_type: operation.item.project_type,
disabled: !enabled,
})
}
} catch (error) {
for (const operation of operations) {
@@ -1830,14 +1810,6 @@ async function removeMod(mod: ContentItem) {
await remove_content_entry(props.instance.id, contentId)
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
}
trackEvent('InstanceProjectRemove', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) {
handleError(err as Error)
} finally {
@@ -1998,14 +1970,6 @@ async function updateProject(mod: ContentItem) {
try {
await update_content_entry(props.instance.id, contentId)
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) {
handleError(err as Error)
throw err
@@ -2023,14 +1987,6 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
try {
await switch_content_entry_version(props.instance.id, contentId, version.id)
trackEvent('InstanceProjectUpdate', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) {
handleError(err as Error)
} finally {
@@ -2046,13 +2002,6 @@ async function handleRollbackContent(mod: ContentItem) {
try {
await rollback_project(props.instance.id, mod.file_path)
trackEvent('InstanceProjectRollback', {
loader: props.instance.loader,
game_version: props.instance.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) {
handleError(err as Error)
} finally {
@@ -2803,8 +2752,8 @@ provideContentManager({
const instanceLink = props.instance.link
const projectPath =
instanceLink?.type === 'curseforge_modpack'
? `/project/curseforge/${instanceLink.project_id}`
: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}`
? `/project/curseforge/${instanceLink.project_id}`
: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}`
return {
project: displayedModpackProject.value ?? linkedModpackProject.value,
@@ -204,7 +204,6 @@ import ConfirmRemoveWorldModal from '@/components/ui/world/modal/ConfirmRemoveWo
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3 } from '@/helpers/cache.js'
import { instance_listener } from '@/helpers/events'
import { get_game_versions } from '@/helpers/tags'
@@ -592,20 +591,10 @@ async function joinWorld(world: World) {
const managedProjectId = instance.value.link?.project_id
if (managedProjectId && isManagedServerWorld(world)) {
await playServerProject(managedProjectId).catch(handleJoinError)
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: 'WorldsPage',
})
startingInstance.value = false
return
}
await start_join_server(instance.value.id, world.address).catch(handleJoinError)
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: 'WorldsPage',
})
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError)
}
@@ -18,7 +18,6 @@ import {
} from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import {
type ProjectGalleryCaptionField,
projectGalleryTranslationSegmentId,
@@ -130,10 +129,6 @@ function viewImage(entry: GalleryEntry) {
zoomedIn.value = false
viewerModal.value?.show()
trackEvent('GalleryImageExpand', {
project_id: props.project.id,
url: entry.image.url,
})
}
function changeImage(offset: number) {
@@ -146,10 +141,6 @@ function changeImage(offset: number) {
selectedGalleryItem.value = filteredGallery.value[nextIndex]
zoomedIn.value = false
trackEvent(offset > 0 ? 'GalleryImageNext' : 'GalleryImagePrevious', {
project_id: props.project.id,
url: selectedGalleryItem.value.image.url,
})
}
function handleViewerHide() {
@@ -210,25 +210,6 @@
{{ installButtonLabel }}
</button>
</ButtonStyled>
<!-- 开服功能暂有问题,隐藏该按钮
<Transition name="start-server">
<ButtonStyled
v-if="serverCapableModpack"
key="modpack-start-server"
size="large"
type="outlined"
>
<button
v-tooltip="formatMessage(messages.startServer)"
type="button"
@click="openModpackServerFlow"
>
<ServerIcon />
{{ formatMessage(messages.startServer) }}
</button>
</ButtonStyled>
</Transition>
-->
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:tooltip="`More options`"
@@ -339,7 +320,6 @@
:translations="translations"
:translation-mode="translationMode"
:translation-style="translationStyle"
:start-server="(version) => openModpackServerFlow(version)"
/>
</template>
<template v-else> Project data couldn't not be loaded. </template>
@@ -408,7 +388,6 @@ import {
PlusIcon,
ReportIcon,
SearchIcon,
ServerIcon,
SpinnerIcon,
StopCircleIcon,
} from '@modrinth/assets'
@@ -424,7 +403,6 @@ import {
getLatestMatchingInstallVersion,
getTargetInstallPreferences,
injectNotificationManager,
injectPopupNotificationManager,
NavTabs,
OverflowMenu,
ProjectBackgroundGradient,
@@ -501,7 +479,6 @@ import UpgradeProjectReturnBar from './UpgradeProjectReturnBar.vue'
dayjs.extend(relativeTime)
const { addNotification, handleError } = injectNotificationManager()
const popupNotificationManager = injectPopupNotificationManager()
const { install: installVersion } = injectContentInstall()
const contentSelection = injectContentSelection()
const route = useRoute()
@@ -603,22 +580,6 @@ const messages = defineMessages({
id: 'project.actions.view-modpacks',
defaultMessage: 'View modpacks',
},
startServer: {
id: 'app.project.modpack-server.start',
defaultMessage: 'Start server',
},
serverCreated: {
id: 'app.project.modpack-server.created-title',
defaultMessage: 'Server created',
},
serverCreatedDescription: {
id: 'app.project.modpack-server.created-description',
defaultMessage: '{name} is ready. Configure and start it in Multiplayer.',
},
openServer: {
id: 'app.project.modpack-server.open',
defaultMessage: 'Open server',
},
})
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
@@ -642,15 +603,6 @@ const favoriteSaved = computed(() =>
data.value ? contentFavorites.isFavorite('modrinth', data.value.id) : false,
)
const serverCapableModpack = computed(
() => data.value?.project_type === 'modpack' && data.value.server_side !== 'unsupported',
)
async function openModpackServerFlow() {
// 服务器创建弹窗(CreateModpackServerModal)已在上游移除,此流程暂不可用。
// 如需恢复:重新实现该组件,并在此处调用其 show() 方法。
}
async function toggleFavorite() {
if (!data.value || !favoriteSupported.value || favoritePending.value) return
await contentFavorites
@@ -30,16 +30,6 @@
<CheckIcon v-else />
</button>
</ButtonStyled>
<!-- 开服功能暂有问题,隐藏该按钮
<ButtonStyled v-if="serverCapable && startServer" circular type="transparent">
<button
v-tooltip="formatMessage(messages.startServer)"
@click.stop="() => startServer(version)"
>
<ServerIcon />
</button>
</ButtonStyled>
-->
<ButtonStyled circular type="transparent">
<OverflowMenu
v-if="false"
@@ -87,7 +77,6 @@ import {
DownloadIcon,
ExternalIcon,
MoreVerticalIcon,
ServerIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
@@ -98,7 +87,7 @@ import {
ProjectPageVersions,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js'
@@ -117,10 +106,6 @@ const messages = defineMessages({
id: 'app.project.versions.add-to-another-instance',
defaultMessage: 'Add to another instance',
},
startServer: {
id: 'app.project.versions.start-server',
defaultMessage: 'Create server',
},
})
const props = defineProps({
@@ -152,16 +137,8 @@ const props = defineProps({
type: String,
default: null,
},
startServer: {
type: Function,
default: null,
},
})
const serverCapable = computed(
() => props.project?.project_type === 'modpack' && props.project?.server_side !== 'unsupported',
)
const { handleError } = injectNotificationManager()
const route = useRoute()
@@ -15,7 +15,6 @@ import type { Router } from 'vue-router'
import type ContentInstallPreviewModal from '@/components/ui/ContentInstallPreviewModal.vue'
import type { ContentInstallPreviewData } from '@/components/ui/ContentInstallPreviewModal.vue'
import type { ModpackInstallModalData } from '@/components/ui/modal/ModpackInstallModal.vue'
import { trackEvent } from '@/helpers/analytics'
import {
get_organization,
get_project,
@@ -868,12 +867,6 @@ export function createContentInstall(opts: {
})
removeInstallingItems(createdInstanceId, [project.id])
markInstanceContentChanged(createdInstanceId)
trackEvent('PackInstall', {
id: project.id,
version_id: version.id,
title: project.title,
source,
})
callback(version.id, [project.id])
} catch (err) {
debugState('createAndInstallCurseForgeModpack ERR', { err: String(err), createdInstanceId })
@@ -997,7 +990,6 @@ export function createContentInstall(opts: {
if (sessionId !== currentSessionId) return
contentInstallModalOpen = true
modalRef?.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
}
get_game_versions()
@@ -1068,7 +1060,6 @@ export function createContentInstall(opts: {
await nextTick()
contentInstallModalOpen = true
modalRef?.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
return sessionId
}
@@ -1688,15 +1679,6 @@ export function createContentInstall(opts: {
if (storeInstance) storeInstance.installing = true
try {
await queueCurrentCurseForgeWorld(selectedInstance)
trackEvent('ProjectInstall', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
id: currentProject.id,
version_id: currentWorldFileId ?? '',
project_type: 'world',
title: currentProject.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(currentWorldFileId ?? undefined, [currentProject.id])
hideContentInstallModal()
} catch (err) {
@@ -1758,15 +1740,6 @@ export function createContentInstall(opts: {
{ ...request, excluded_project_ids: excludedProjectIds },
{ title: currentProject.title, iconUrl: currentProject.icon_url },
)
trackEvent('ProjectInstall', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
id: currentProject.id,
version_id: version.id,
project_type: currentProject.project_type,
title: currentProject.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, [currentProject.id])
hideContentInstallModal()
} catch (err) {
@@ -1786,15 +1759,6 @@ export function createContentInstall(opts: {
settleCurrentCallback()
return
}
trackEvent('ProjectInstall', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
id: currentProject.id,
version_id: version.id,
project_type: currentProject.project_type,
title: currentProject.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, [currentProject.id])
hideContentInstallModal()
} catch (err) {
@@ -1830,15 +1794,6 @@ export function createContentInstall(opts: {
storeInstance.installed = primaryInstalled
storeInstance.installing = false
}
trackEvent('ProjectInstall', {
loader: selectedInstance.loader,
game_version: selectedInstance.game_version,
id: currentProject!.id,
version_id: version.id,
project_type: currentProject!.project_type,
title: currentProject!.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(primaryInstalled ? version.id : undefined, installedProjectIds)
} catch (err) {
if (storeInstance) storeInstance.installing = false
@@ -1878,7 +1833,6 @@ export function createContentInstall(opts: {
await nextTick()
incompatibilityWarningModalRef?.show(version.id)
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
}
async function handleIncompatibilityWarningInstall(version: Labrinth.Versions.v2.Version) {
@@ -1931,15 +1885,6 @@ export function createContentInstall(opts: {
incompatibilityWarningModalRef?.hide()
removeInstallingItems(instance.id, [project.id])
trackEvent('ProjectInstall', {
loader: instance.loader ?? '',
game_version: instance.game_version ?? '',
id: project.id,
version_id: version.id,
project_type: project.project_type,
title: project.title,
source: 'ProjectIncompatibilityWarningModal',
})
}
function handleIncompatibilityWarningCancel() {
@@ -2056,16 +2001,6 @@ export function createContentInstall(opts: {
},
{ title: currentProject!.title, iconUrl: currentProject!.icon_url },
)
trackEvent('InstanceCreate', { source: 'ProjectInstallModal' })
trackEvent('ProjectInstall', {
loader: data.loader,
game_version: data.gameVersion,
id: currentProject!.id,
version_id: version.id,
project_type: currentProject!.project_type,
title: currentProject!.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, [currentProject!.id])
hideContentInstallModal()
return
@@ -2085,16 +2020,6 @@ export function createContentInstall(opts: {
excludedCurseForgeProjectIds,
)
if (!job) return
trackEvent('InstanceCreate', { source: 'ProjectInstallModal' })
trackEvent('ProjectInstall', {
loader: data.loader,
game_version: data.gameVersion,
id: currentProject!.id,
version_id: version.id,
project_type: currentProject!.project_type,
title: currentProject!.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, [currentProject!.id])
hideContentInstallModal()
return
@@ -2131,18 +2056,6 @@ export function createContentInstall(opts: {
: `/instance/${encodeURIComponent(id)}`,
)
trackEvent('InstanceCreate', {
source: 'ProjectInstallModal',
})
trackEvent('ProjectInstall', {
loader: data.loader,
game_version: data.gameVersion,
id: currentProject!.id,
version_id: version.id,
project_type: currentProject!.project_type,
title: currentProject!.title,
source: 'ProjectInstallModal',
})
settleCurrentCallback(version.id, installedProjectIds)
modalRef?.hide()
@@ -2282,15 +2195,6 @@ export function createContentInstall(opts: {
{ ...installRequest, excluded_project_ids: excludedProjectIds },
{ title: project.title, iconUrl: project.icon_url },
)
trackEvent('ProjectInstall', {
loader: instance.loader,
game_version: instance.game_version,
id: project.id,
project_type: project.project_type,
version_id: version.id,
title: project.title,
source,
})
callback(version.id, [project.id])
} else {
await showIncompatibilityWarning(instance, project, projectVersions, version, callback)
@@ -2402,15 +2306,6 @@ export function createContentInstall(opts: {
if (isVersionCompatible(version, project, instance)) {
const job = await queueCurrentCurseForgeVersion(instance, project, version)
if (!job) return
trackEvent('ProjectInstall', {
loader: instance.loader,
game_version: instance.game_version,
id: project.id,
project_type: project.project_type,
version_id: version.id,
title: project.title,
source,
})
callback(version.id, [project.id])
} else {
await showIncompatibilityWarning(instance, project, versions, version, callback)
@@ -2457,7 +2352,6 @@ export function createContentInstall(opts: {
if (sessionId !== currentSessionId) return
contentInstallModalOpen = true
modalRef?.show()
trackEvent('ProjectInstallStart', { source: 'ProjectInstallModal' })
try {
const candidates = (await list()).filter(
@@ -2537,15 +2431,6 @@ export function createContentInstall(opts: {
const instance = await get(instanceId)
if (!instance) throw new Error(formatMessage(curseForgeWorldUnknownInstanceMessage))
await queueCurrentCurseForgeWorld(instance)
trackEvent('ProjectInstall', {
loader: instance.loader,
game_version: instance.game_version,
id: currentProject!.id,
version_id: file.id.toString(),
project_type: 'world',
title: currentProject!.title,
source,
})
callback(file.id.toString(), [currentProject!.id])
}
@@ -2700,12 +2585,6 @@ export function createContentInstall(opts: {
if (instanceId) {
createInstanceCallback(instanceId)
}
trackEvent('PackInstall', {
id: project.id,
version_id: versionId,
title: project.title,
source,
})
callback(versionId)
},
handleModpackInstallCancel() {
@@ -4,7 +4,6 @@ import { createContext } from '@modrinth/ui'
import { type Ref, ref } from 'vue'
import type { Router } from 'vue-router'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3, get_version } from '@/helpers/cache.js'
import {
install_create_instance,
@@ -163,11 +162,6 @@ export function createServerInstall(opts: {
action: async () => {
try {
await joinServer(project.id, serverAddress)
trackEvent('InstanceStart', {
loader: project.loader,
game_version: project.game_version,
source: 'ServerProject',
})
} catch (err) {
handleSevereError(err, { instanceId: project.id })
}
@@ -198,11 +192,6 @@ export function createServerInstall(opts: {
action: async () => {
try {
if (serverAddress) await start_join_server(instance.id, serverAddress)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'ServerProject',
})
} catch (err) {
handleSevereError(err, { instanceId: instance.id })
}
@@ -348,11 +337,6 @@ export function createServerInstall(opts: {
// Join server
try {
await joinServer(instance.id, serverAddress)
trackEvent('InstanceStart', {
loader: instance.loader,
game_version: instance.game_version,
source: 'ServerProject',
})
} catch (err) {
handleSevereError(err, { instanceId: instance.id })
}
@@ -13,7 +13,6 @@ import { useRouter } from 'vue-router'
import type UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_search_results } from '@/helpers/cache.js'
import { getCurseForgeFiles, hasCompatibleCurseForgeFile } from '@/helpers/curseforge'
import { install_job_listener } from '@/helpers/events.js'
@@ -113,16 +112,20 @@ export function setupCreationModal(
}
provide('showCreationModal', () => {
creationInstanceMode.value = 'local'
installationModal.value?.show()
})
const creationInstanceMode = ref<'starlight' | 'local'>('local')
provide(
'showCreationModalWithOptions',
(options?: {
skipSetupType?: boolean
initialMode?: 'custom' | 'import'
instanceMode?: 'starlight' | 'local'
onBack?: () => void
}) => {
creationInstanceMode.value = options?.instanceMode ?? 'local'
installationModal.value?.show(options)
},
)
@@ -143,10 +146,10 @@ export function setupCreationModal(
},
{ name },
).catch(handleError)
trackEvent('InstanceCreate', { source: 'CreationModalModpack' })
}
async function handleCreate(config: CreationFlowContextValue) {
const instanceMode = creationInstanceMode.value
try {
installationModal.value?.hide()
@@ -222,7 +225,6 @@ export function setupCreationModal(
handleError(error)
}
}
trackEvent('InstanceCreate', { source: 'CreationModalImport' })
return
}
@@ -238,7 +240,6 @@ export function setupCreationModal(
const splitPath = config.modpackFilePath.value.split(/[\\/]/)
const fileName = splitPath ? splitPath[splitPath.length - 1] : config.modpackFilePath.value
await installModpackFromPath(config.modpackFilePath.value, fileName)
trackEvent('InstanceCreate', { source: 'CreationModalModpackFile' })
return
}
@@ -266,7 +267,8 @@ export function setupCreationModal(
: gameRoot
: null
await install_create_instance({
const job = await install_create_instance({
instanceMode,
name,
gameVersion: config.selectedGameVersion.value!,
loader: loader as InstanceLoader,
@@ -280,10 +282,14 @@ export function setupCreationModal(
iconPath,
gameDirOverride,
}).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModal',
})
if (instanceMode === 'starlight' && job?.instance_id) {
try {
localStorage.setItem('starlight:instance:mod-management-tab', 'packs')
} catch {
/* Optional navigation preference. */
}
await router.push(`/instance/${encodeURIComponent(job.instance_id)}/`)
}
} catch (err) {
handleError(err as Error)
}
+6 -4
View File
@@ -320,6 +320,7 @@ fn main() {
.commands(&[
"get_available_capes",
"get_available_skins",
"get_default_skins",
"add_and_equip_custom_skin",
"equip_skin",
"remove_custom_skin",
@@ -360,6 +361,11 @@ fn main() {
"install_cancel_import_plan",
"install_duplicate_instance",
"install_existing_instance",
"hosted_catalog",
"hosted_binding",
"hosted_sync",
"hosted_instance_mode",
"hosted_set_instance_mode",
"install_pack_to_existing_instance",
"install_job_list",
"install_job_get",
@@ -509,10 +515,6 @@ fn main() {
.commands(&[
"settings_get",
"settings_set",
"privacy_get",
"privacy_set",
"telemetry_set",
"discord_rpc_set",
"download_engine_set",
"cancel_directory_change",
"proxy_get",
+2 -2
View File
@@ -35,7 +35,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
.build()
}
/// Checks if the authentication servers are reachable.
/// Checks if the StarLight authentication server is reachable.
#[tauri::command]
pub async fn check_reachable() -> Result<()> {
minecraft_auth::check_reachable().await?;
@@ -146,7 +146,7 @@ pub async fn login<R: Runtime>(
},
)?),
)
.title("Sign into Axolotl Launcher")
.title(format!("Sign into {}", theseus::brand::PRODUCT_NAME))
.always_on_top(true)
.center()
.initialization_script(help_bar_script)
+61 -2
View File
@@ -14,6 +14,11 @@ use uuid::Uuid;
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("install")
.invoke_handler(tauri::generate_handler![
hosted_catalog,
hosted_binding,
hosted_sync,
hosted_instance_mode,
hosted_set_instance_mode,
install_get_modpack_preview,
install_create_instance,
install_create_modpack_instance,
@@ -48,9 +53,47 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.build()
}
#[tauri::command]
pub async fn hosted_catalog() -> Result<Vec<theseus::pack::hosted::Publication>>
{
Ok(theseus::pack::hosted::catalog().await?)
}
#[tauri::command]
pub async fn hosted_instance_mode(
instance_id: String,
) -> Result<theseus::data::InstanceMode> {
Ok(theseus::pack::hosted::instance_mode(&instance_id).await?)
}
#[tauri::command]
pub async fn hosted_set_instance_mode(
instance_id: String,
mode: theseus::data::InstanceMode,
) -> Result<()> {
Ok(theseus::pack::hosted::set_instance_mode(&instance_id, mode).await?)
}
#[tauri::command]
pub async fn hosted_binding(
instance_id: String,
) -> Result<Option<theseus::pack::hosted::Binding>> {
Ok(theseus::pack::hosted::binding(&instance_id).await?)
}
#[tauri::command]
pub async fn hosted_sync(
instance_id: String,
pack_id: String,
) -> Result<theseus::pack::hosted::SyncResult> {
Ok(theseus::pack::hosted::synchronize(&instance_id, &pack_id).await?)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstallCreateInstanceRequest {
#[serde(default)]
pub instance_mode: theseus::data::InstanceMode,
pub name: String,
pub game_version: String,
pub loader: ModLoader,
@@ -97,7 +140,7 @@ pub async fn install_get_modpack_preview(
pub async fn install_create_instance(
request: InstallCreateInstanceRequest,
) -> Result<InstallJobSnapshot> {
Ok(theseus::install::create_instance_with_adjuncts(
let job = theseus::install::create_instance_with_adjuncts(
request.name.trim().to_string(),
request.game_version,
request.loader,
@@ -110,7 +153,23 @@ pub async fn install_create_instance(
},
request.game_dir_override,
)
.await?)
.await?;
if let Some(id) = &job.instance_id {
theseus::instance::edit(
id,
theseus::data::EditInstance {
launch_overrides: Some(
theseus::data::InstanceLaunchOverridesPatch {
instance_mode: Some(request.instance_mode),
..Default::default()
},
),
..Default::default()
},
)
.await?;
}
Ok(job)
}
#[tauri::command]
+4 -9
View File
@@ -6,7 +6,6 @@ use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use theseus::DownloadReason;
use theseus::data::{
AppliedContentSetPatch, ContentItem, Dependency,
EditInstance as CoreEditInstance, InstanceInstallCandidate,
@@ -463,6 +462,7 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
})
.transpose()?,
launch_overrides: Some(InstanceLaunchOverridesPatch {
instance_mode: None,
java_path: edit_instance.java_path,
extra_launch_args: edit_instance.extra_launch_args,
custom_env_vars: edit_instance.custom_env_vars,
@@ -1116,16 +1116,11 @@ pub async fn instance_update_project(
pub async fn instance_add_project_from_version(
instance_id: &str,
version_id: &str,
reason: DownloadReason,
dependent_on_version_id: Option<String>,
) -> Result<String> {
Ok(theseus::instance::add_project_from_version(
instance_id,
version_id,
reason,
dependent_on_version_id,
Ok(
theseus::instance::add_project_from_version(instance_id, version_id)
.await?,
)
.await?)
}
#[tauri::command]
+7
View File
@@ -10,6 +10,7 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![
get_available_capes,
get_available_skins,
get_default_skins,
add_and_equip_custom_skin,
equip_skin,
remove_custom_skin,
@@ -40,6 +41,12 @@ pub async fn get_available_skins() -> Result<Vec<Skin>> {
Ok(minecraft_skins::get_available_skins().await?)
}
/// `invoke('plugin:minecraft-skins|get_default_skins')`
#[tauri::command]
pub async fn get_default_skins() -> Result<Vec<Skin>> {
Ok(minecraft_skins::get_default_skins())
}
/// `invoke('plugin:minecraft-skins|add_and_equip_custom_skin', texture_blob, variant, cape)`
///
/// See also: [minecraft_skins::add_and_equip_custom_skin]
-1
View File
@@ -25,7 +25,6 @@ pub mod shortcuts;
pub mod storage;
pub mod system_accent;
pub mod tags;
pub mod telemetry;
pub mod terracotta;
pub mod translation;
pub mod utils;
-24
View File
@@ -8,10 +8,6 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
.invoke_handler(tauri::generate_handler![
settings_get,
settings_set,
privacy_get,
privacy_set,
telemetry_set,
discord_rpc_set,
download_engine_set,
cancel_directory_change,
proxy_get,
@@ -41,26 +37,6 @@ pub async fn settings_set(
Ok(())
}
#[tauri::command]
pub async fn privacy_get() -> Result<PrivacySettings> {
Ok(settings::get_privacy().await?)
}
#[tauri::command]
pub async fn privacy_set(privacy: PrivacySettings) -> Result<PrivacySettings> {
Ok(settings::set_privacy(privacy).await?)
}
#[tauri::command]
pub async fn telemetry_set(enabled: bool) -> Result<PrivacySettings> {
Ok(settings::set_telemetry(enabled).await?)
}
#[tauri::command]
pub async fn discord_rpc_set(enabled: bool) -> Result<PrivacySettings> {
Ok(settings::set_discord_rpc(enabled).await?)
}
#[tauri::command]
pub async fn download_engine_set(
engine: settings::DownloadEngine,
-10
View File
@@ -1,10 +0,0 @@
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("telemetry")
.invoke_handler(tauri::generate_handler![notify_online,])
.build()
}
#[tauri::command]
pub fn notify_online() {
theseus::telemetry::notify_online();
}
+7 -12
View File
@@ -389,16 +389,6 @@ async fn backup_app_db_for_update(
Ok(())
}
#[tauri::command]
async fn set_discord_activity(activity: String) -> api::Result<()> {
let state = State::get().await?;
state
.discord_rpc
.set_launcher_activity(&activity, true)
.await?;
Ok(())
}
// Should be call once Vue has mounted the app
#[tracing::instrument(skip_all)]
#[tauri::command]
@@ -698,6 +688,13 @@ fn main() {
);
builder = builder
.plugin(
tauri::plugin::Builder::<tauri::Wry>::new("skin-site-session")
.js_init_script_on_all_frames(include_str!(
"skin_site_bridge.js"
))
.build(),
)
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if let Some(payload) = args.get(1) {
tracing::info!("Handling deep link from arg {payload}");
@@ -823,7 +820,6 @@ fn main() {
.plugin(api::schematic_preview::init())
.plugin(api::shortcuts::init())
.plugin(api::tags::init())
.plugin(api::telemetry::init())
.plugin(api::translation::init())
.plugin(api::utils::init())
.plugin(api::cache::init())
@@ -851,7 +847,6 @@ fn main() {
copy_database_between_channels,
beta_database_exists,
backup_app_db_for_update,
set_discord_activity,
is_dev,
portable::is_portable_mode,
are_updates_enabled,
+495
View File
@@ -0,0 +1,495 @@
// Runs inside the embedded skin site only. The JWT never leaves that origin.
;(() => {
if (location.origin !== 'https://skin.starlight.cool' || window.parent === window) return
const launcherOrigins = new Set([
'http://localhost:5201',
'http://tauri.localhost',
'https://tauri.localhost',
'tauri://localhost',
])
let parentOrigin
let lastToken
let lastCheck = 0
let generation = 0
let pending
const pendingLuck = new Map()
const pendingPlayers = new Map()
const pendingSkinUpdates = new Map()
let knownPlayers = new Map()
let snapshot = { status: 'checking', user: null }
function publish(status, user = null) {
snapshot = { status, user }
if (parentOrigin) {
window.parent.postMessage({ type: 'starlight-skin-session', ...snapshot }, parentOrigin)
}
}
function publishLuck(requestId, result) {
if (!parentOrigin) return
window.parent.postMessage(
{ type: 'starlight-skin-luck-result', requestId, ...result },
parentOrigin,
)
}
function publishPlayers(requestId, result) {
if (!parentOrigin) return
window.parent.postMessage(
{ type: 'starlight-skin-players-result', requestId, ...result },
parentOrigin,
)
}
function publishSkinUpdate(requestId, result) {
if (!parentOrigin) return
window.parent.postMessage(
{ type: 'starlight-skin-update-result', requestId, ...result },
parentOrigin,
)
}
// Keep this geometry identical to the skin site's SkinRender.renderHead.
// In particular, the site does not infer texture dimensions before cropping.
// Rendering here keeps the source on the skin site's origin and only exposes
// the finished PNG to the launcher.
function renderPlayerHead(skinSource) {
return new Promise((resolve, reject) => {
const image = new Image()
const timeout = setTimeout(() => reject(new Error('Skin rendering timed out.')), 8_000)
image.crossOrigin = 'anonymous'
image.onerror = () => {
clearTimeout(timeout)
reject(new Error('Unable to load the skin texture.'))
}
image.onload = () => {
clearTimeout(timeout)
try {
const buffer = document.createElement('canvas')
buffer.width = 18
buffer.height = 18
const context = buffer.getContext('2d')
if (!context) throw new Error('Unable to create the skin renderer.')
context.imageSmoothingEnabled = false
context.drawImage(image, 8, 8, 8, 8, 1, 1, 16, 16)
context.globalCompositeOperation = 'source-over'
context.drawImage(image, 40, 8, 8, 8, 0, 0, 18, 18)
const output = document.createElement('canvas')
output.width = 36
output.height = 36
const outputContext = output.getContext('2d')
if (!outputContext) throw new Error('Unable to create the skin renderer.')
outputContext.imageSmoothingEnabled = false
outputContext.drawImage(buffer, 0, 0, 18, 18, 0, 0, 36, 36)
resolve(output.toDataURL('image/png'))
} catch (error) {
reject(error)
}
}
image.src = skinSource
})
}
function serializePlayerSkin(skinSource) {
return new Promise((resolve, reject) => {
const image = new Image()
const timeout = setTimeout(() => reject(new Error('Skin loading timed out.')), 8_000)
image.crossOrigin = 'anonymous'
image.onerror = () => {
clearTimeout(timeout)
reject(new Error('Unable to load the skin texture.'))
}
image.onload = () => {
clearTimeout(timeout)
try {
const width = image.naturalWidth || image.width
const height = image.naturalHeight || image.height
if (!width || !height) throw new Error('The skin texture is empty.')
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')
if (!context) throw new Error('Unable to create the skin renderer.')
context.imageSmoothingEnabled = false
context.drawImage(image, 0, 0)
const dataUrl = canvas.toDataURL('image/png')
if (dataUrl.length > 2_000_000) throw new Error('The skin texture is too large.')
resolve(dataUrl)
} catch (error) {
reject(error)
}
}
image.src = skinSource
})
}
function sleep(delay) {
return new Promise((resolve) => setTimeout(resolve, delay))
}
function pngDataUrlToBlob(dataUrl) {
if (
typeof dataUrl !== 'string' ||
dataUrl.length > 2_000_000 ||
!/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i.test(dataUrl)
)
throw new Error('Invalid skin texture.')
const bytes = atob(dataUrl.slice(dataUrl.indexOf(',') + 1))
const buffer = new Uint8Array(bytes.length)
for (let index = 0; index < bytes.length; index += 1) buffer[index] = bytes.charCodeAt(index)
return new Blob([buffer], { type: 'image/png' })
}
async function requestPlayers(requestId) {
if (pendingPlayers.has(requestId)) return
let token
try {
token = localStorage.getItem('loginToken') || ''
} catch {
publishPlayers(requestId, { ok: false, error: 'Unable to read the skin site session.' })
return
}
if (!token) {
publish('signed-out')
publishPlayers(requestId, { ok: false, error: 'Sign in to the skin site first.' })
return
}
const controller = new AbortController()
pendingPlayers.set(requestId, controller)
try {
const response = await fetch('/starlight/skin/player', {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
})
const body = await response.json().catch(() => null)
if (localStorage.getItem('loginToken') !== token) {
publishPlayers(requestId, { ok: false, error: 'The skin site account changed.' })
return
}
if (response.status === 401 || response.status === 403) publish('signed-out')
const rawPlayers = Array.isArray(body?.payload) ? body.payload : null
const players = rawPlayers?.slice(0, 100).flatMap((player) => {
if (
typeof player?.uuid !== 'string' ||
!/^[0-9a-f-]{32,36}$/i.test(player.uuid) ||
typeof player?.name !== 'string' ||
player.name.length < 1 ||
player.name.length > 64
)
return []
return [{ uuid: player.uuid, name: player.name, isMojang: player.isMojang === true }]
})
if (response.ok && players) {
knownPlayers = new Map(players.map((player) => [player.uuid, player]))
// Match the skin site: stagger texture requests by 150 ms so the
// service is not hit with a burst that can drop individual players.
const skinResults = await Promise.all(
players.map(async (player, index) => {
if (index > 0) await sleep(index * 150)
try {
const skinResponse = await fetch(
`/starlight/skin/player/skin/${encodeURIComponent(player.uuid)}`,
{
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
},
)
const skinBody = await skinResponse.json().catch(() => null)
if (
!skinResponse.ok ||
!skinBody?.payload ||
typeof skinBody.payload !== 'object'
)
return { player, skinData: null }
return { player, skinData: skinBody.payload }
} catch {
return { player, skinData: null }
}
}),
)
const playersWithSkins = await Promise.all(
skinResults.map(async ({ player, skinData }) => {
if (!skinData) return { ...player, skinState: 'error' }
try {
const skinSource = skinData.skin
if (typeof skinSource !== 'string' || !skinSource.trim()) {
return { ...player, skinState: 'empty' }
}
const headDataUrl = await renderPlayerHead(skinSource.trim())
const skinDataUrl = await serializePlayerSkin(skinSource.trim()).catch(
() => undefined,
)
const model = skinData.model === 'slim' ? 'slim' : 'default'
return { ...player, skinState: 'ready', headDataUrl, skinDataUrl, model }
} catch {
return { ...player, skinState: 'error' }
}
}),
)
if (localStorage.getItem('loginToken') !== token) {
publishPlayers(requestId, { ok: false, error: 'The skin site account changed.' })
return
}
publishPlayers(requestId, { ok: true, players: playersWithSkins })
} else {
publishPlayers(requestId, {
ok: false,
error:
typeof body?.errorMessage === 'string' && body.errorMessage
? body.errorMessage.slice(0, 300)
: 'The skin site returned an invalid player list.',
})
}
} catch {
publishPlayers(requestId, { ok: false, error: 'Unable to reach the player service.' })
} finally {
pendingPlayers.delete(requestId)
}
}
async function updatePlayerSkin(requestId, playerId, textureDataUrl, model) {
if (pendingSkinUpdates.has(requestId)) return
const player = knownPlayers.get(playerId)
if (!player || player.isMojang) {
publishSkinUpdate(requestId, {
ok: false,
error: 'This skin site player cannot be changed.',
})
return
}
let token
try {
token = localStorage.getItem('loginToken') || ''
} catch {
publishSkinUpdate(requestId, { ok: false, error: 'Unable to read the skin site session.' })
return
}
if (!token) {
publish('signed-out')
publishSkinUpdate(requestId, { ok: false, error: 'Sign in to the skin site first.' })
return
}
const controller = new AbortController()
pendingSkinUpdates.set(requestId, controller)
const timeout = setTimeout(() => controller.abort(), 18_000)
try {
const texture = pngDataUrlToBlob(textureDataUrl)
const form = new FormData()
form.append('file', texture, `${playerId}.png`)
form.append('model', model)
const response = await fetch(
`/starlight/skin/player/skin/${encodeURIComponent(playerId)}/SKIN`,
{
method: 'PUT',
headers: { Authorization: `Bearer ${token}` },
body: form,
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
},
)
const body = await response.json().catch(() => null)
if (localStorage.getItem('loginToken') !== token) {
publishSkinUpdate(requestId, { ok: false, error: 'The skin site account changed.' })
return
}
if (response.status === 401 || response.status === 403) publish('signed-out')
if (response.ok && body?.success !== false) publishSkinUpdate(requestId, { ok: true })
else {
publishSkinUpdate(requestId, {
ok: false,
error:
typeof body?.errorMessage === 'string' && body.errorMessage
? body.errorMessage.slice(0, 300)
: 'The skin site rejected the skin update.',
})
}
} catch (error) {
publishSkinUpdate(requestId, {
ok: false,
error: error instanceof Error ? error.message : 'Unable to reach the skin service.',
})
} finally {
clearTimeout(timeout)
pendingSkinUpdates.delete(requestId)
}
}
async function requestLuck(requestId) {
if (pendingLuck.has(requestId)) return
let token
try {
token = localStorage.getItem('loginToken') || ''
} catch {
publishLuck(requestId, { ok: false, error: 'Unable to read the skin site session.' })
return
}
if (!token) {
publish('signed-out')
publishLuck(requestId, { ok: false, error: 'Sign in to the skin site first.' })
return
}
const controller = new AbortController()
pendingLuck.set(requestId, controller)
const timeout = setTimeout(() => controller.abort(), 10_000)
try {
const response = await fetch('/starlight/luck', {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
})
const body = await response.json().catch(() => null)
if (localStorage.getItem('loginToken') !== token) {
publishLuck(requestId, { ok: false, error: 'The skin site account changed.' })
return
}
if (response.status === 401 || response.status === 403) publish('signed-out')
const luck = Number(body?.payload?.luck)
if (response.ok && Number.isFinite(luck) && luck >= 0 && luck <= 100) {
publishLuck(requestId, { ok: true, luck })
} else {
publishLuck(requestId, {
ok: false,
error:
typeof body?.errorMessage === 'string' && body.errorMessage
? body.errorMessage.slice(0, 300)
: 'The skin site returned an invalid luck result.',
})
}
} catch {
publishLuck(requestId, { ok: false, error: 'Unable to reach the luck service.' })
} finally {
clearTimeout(timeout)
pendingLuck.delete(requestId)
}
}
async function checkSession(force = false) {
if (!parentOrigin) return
let token
try {
token = localStorage.getItem('loginToken') || ''
} catch {
publish('error')
return
}
const changed = token !== lastToken
if (!changed && (pending || (!force && Date.now() - lastCheck < 60_000))) return
const revision = ++generation
pending?.abort()
pending = undefined
lastToken = token
if (changed) knownPlayers = new Map()
lastCheck = Date.now()
if (!token) {
publish('signed-out')
return
}
if (changed) publish('checking')
const controller = new AbortController()
pending = controller
const timeout = setTimeout(() => controller.abort(), 10_000)
try {
const response = await fetch('/starlight/user', {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
cache: 'no-store',
redirect: 'error',
})
const body = response.ok ? await response.json() : null
// A logout or account switch must win over an older in-flight response.
if (revision !== generation || localStorage.getItem('loginToken') !== token) return
if (response.status === 401 || response.status === 403 || body?.payload?.banned) {
publish('signed-out')
} else if (
response.ok &&
typeof body?.payload?.uuid === 'string' &&
typeof body.payload.username === 'string' &&
body.payload.username.length > 0
) {
publish('signed-in', { uuid: body.payload.uuid, username: body.payload.username })
} else {
publish('error')
}
} catch {
if (revision === generation) publish('error')
} finally {
clearTimeout(timeout)
if (revision === generation) pending = undefined
}
}
window.addEventListener('message', (event) => {
if (event.source !== window.parent || !launcherOrigins.has(event.origin)) return
if (event.data?.type === 'starlight-skin-session-connect') {
parentOrigin = event.origin
publish(snapshot.status, snapshot.user)
void checkSession(true)
return
}
if (
event.data?.type === 'starlight-skin-luck-request' &&
parentOrigin === event.origin &&
typeof event.data.requestId === 'string' &&
/^skin-luck-\d+-\d+$/.test(event.data.requestId)
) {
void requestLuck(event.data.requestId)
return
}
if (
event.data?.type === 'starlight-skin-players-request' &&
parentOrigin === event.origin &&
typeof event.data.requestId === 'string' &&
/^skin-players-\d+-\d+$/.test(event.data.requestId)
) {
void requestPlayers(event.data.requestId)
return
}
if (
event.data?.type === 'starlight-skin-update-request' &&
parentOrigin === event.origin &&
typeof event.data.requestId === 'string' &&
/^skin-update-\d+-\d+$/.test(event.data.requestId) &&
typeof event.data.playerId === 'string' &&
/^[0-9a-f-]{32,36}$/i.test(event.data.playerId) &&
(event.data.model === 'default' || event.data.model === 'slim')
) {
void updatePlayerSkin(
event.data.requestId,
event.data.playerId,
event.data.textureDataUrl,
event.data.model,
)
}
})
// Storage events cover other tabs; polling also covers same-document SPA login/logout.
window.addEventListener('storage', () => void checkSession())
let timer = setInterval(() => void checkSession(), 1000)
window.addEventListener('pagehide', () => {
clearInterval(timer)
++generation
pending?.abort()
pending = undefined
for (const controller of pendingLuck.values()) controller.abort()
pendingLuck.clear()
for (const controller of pendingPlayers.values()) controller.abort()
pendingPlayers.clear()
for (const controller of pendingSkinUpdates.values()) controller.abort()
pendingSkinUpdates.clear()
})
window.addEventListener('pageshow', (event) => {
if (!event.persisted) return
timer = setInterval(() => void checkSession(), 1000)
void checkSession(true)
})
})()
+359
View File
@@ -0,0 +1,359 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const test = require('node:test')
const source = fs.readFileSync(require('node:path').join(__dirname, 'skin_site_bridge.js'), 'utf8')
function harness(origin = 'https://skin.starlight.cool', skinSize = 64) {
let token = null
const listeners = {},
messages = [],
requests = [],
drawCalls = []
const skinWidth = typeof skinSize === 'number' ? skinSize : skinSize.width
const skinHeight = typeof skinSize === 'number' ? skinSize : skinSize.height
let tick
let result = async () => ({
ok: true,
status: 200,
json: async () => ({ payload: { uuid: 'one', username: 'One' } }),
})
const parent = {
postMessage(data, target) {
messages.push({ data, target })
},
}
const context = {
location: { origin },
window: {
parent,
addEventListener(type, callback) {
listeners[type] = callback
},
},
localStorage: {
getItem() {
return token
},
},
fetch(...args) {
requests.push(args)
return result(...args)
},
AbortController,
Blob,
Date,
FormData,
Uint8Array,
atob,
document: {
createElement() {
return {
getContext() {
return {
drawImage(...args) {
drawCalls.push(args)
},
}
},
toDataURL() {
return 'data:image/png;base64,SEVBRERBVEE='
},
}
},
},
Image: class {
naturalWidth = skinWidth
naturalHeight = skinHeight
set src(_value) {
queueMicrotask(() => this.onload?.())
}
},
setTimeout,
clearTimeout,
setInterval(callback) {
tick = callback
return 1
},
clearInterval() {},
}
vm.runInNewContext(source, context)
return {
drawCalls,
messages,
requests,
listeners,
parent,
token(value) {
token = value
},
result(value) {
result = value
},
async tick() {
tick?.()
await new Promise(setImmediate)
},
async connect(origin = 'http://localhost:5201', source = parent) {
listeners.message?.({ source, origin, data: { type: 'starlight-skin-session-connect' } })
await new Promise(setImmediate)
},
async message(data, origin = 'http://localhost:5201', source = parent) {
listeners.message?.({ source, origin, data })
await new Promise(setImmediate)
},
async waitForMessage(type, requestId, timeout = 2_000) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
const message = messages.find(
(entry) =>
entry.data?.type === type &&
(requestId === undefined || entry.data?.requestId === requestId),
)
if (message) return message
await new Promise((resolve) => setTimeout(resolve, 10))
}
throw new Error(`Timed out waiting for ${type}`)
},
}
}
test('bridge is restricted to the skin origin and an allowlisted parent', async () => {
assert.equal(harness('https://evil.example').listeners.message, undefined)
const h = harness()
h.token('test-token')
await h.connect('https://evil.example')
await h.connect('http://localhost:5201', {})
assert.equal(h.requests.length, 0)
assert.equal(h.messages.length, 0)
await h.connect()
assert.equal(h.requests[0][0], '/starlight/user')
assert.equal(h.requests[0][1].headers.Authorization, 'Bearer test-token')
assert.equal(h.requests[0][1].redirect, 'error')
assert.equal(h.messages.at(-1).data.status, 'signed-in')
assert.ok(h.messages.every((m) => m.target === 'http://localhost:5201'))
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
})
test('logout, invalid token, network error and account switching replace old state', async () => {
const h = harness()
await h.connect()
assert.equal(h.messages.at(-1).data.status, 'signed-out')
h.token('first')
await h.tick()
assert.equal(h.messages.at(-1).data.user.username, 'One')
h.result(async () => ({ ok: false, status: 401 }))
h.token('expired')
await h.tick()
assert.equal(h.messages.at(-1).data.status, 'signed-out')
h.result(async () => {
throw Error('offline')
})
h.token('network-error')
await h.tick()
assert.equal(h.messages.at(-1).data.status, 'error')
h.token(null)
await h.tick()
assert.equal(h.messages.at(-1).data.status, 'signed-out')
})
test('a delayed response cannot restore the user after logout', async () => {
const h = harness()
let finish
h.result(
() =>
new Promise((resolve) => {
finish = resolve
}),
)
h.token('first')
await h.connect()
h.token(null)
await h.tick()
finish({
ok: true,
status: 200,
json: async () => ({ payload: { uuid: 'one', username: 'One' } }),
})
await new Promise(setImmediate)
assert.equal(h.messages.at(-1).data.status, 'signed-out')
assert.ok(!h.messages.some((m) => m.data.status === 'signed-in'))
})
test('luck requests stay on the skin origin and return only the validated score', async () => {
const h = harness()
h.token('test-token')
await h.connect()
h.result(async () => ({
ok: true,
status: 200,
json: async () => ({ payload: { luck: 73 } }),
}))
await h.message({ type: 'starlight-skin-luck-request', requestId: 'skin-luck-1-1' })
const request = h.requests.at(-1)
assert.equal(request[0], '/starlight/luck')
assert.equal(request[1].headers.Authorization, 'Bearer test-token')
assert.equal(request[1].redirect, 'error')
const message = h.messages.at(-1)
assert.equal(message.target, 'http://localhost:5201')
assert.equal(message.data.type, 'starlight-skin-luck-result')
assert.equal(message.data.requestId, 'skin-luck-1-1')
assert.equal(message.data.ok, true)
assert.equal(message.data.luck, 73)
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
})
test('luck requests reject untrusted parents, missing sessions, and invalid scores', async () => {
const h = harness()
await h.connect()
const before = h.requests.length
await h.message(
{ type: 'starlight-skin-luck-request', requestId: 'skin-luck-2-1' },
'https://evil.example',
)
assert.equal(h.requests.length, before)
await h.message({ type: 'starlight-skin-luck-request', requestId: 'skin-luck-2-2' })
assert.equal(h.requests.length, before)
assert.equal(h.messages.at(-1).data.ok, false)
h.token('test-token')
h.result(async () => ({
ok: true,
status: 200,
json: async () => ({ payload: { luck: 101 } }),
}))
await h.message({ type: 'starlight-skin-luck-request', requestId: 'skin-luck-2-3' })
assert.equal(h.messages.at(-1).data.ok, false)
})
test('player requests return a sanitized complete player collection without exposing the token', async () => {
const h = harness()
h.token('test-token')
await h.connect()
h.result(async (url) => {
if (url.startsWith('/starlight/skin/player/skin/')) {
return {
ok: true,
status: 200,
json: async () => ({
payload: {
skin: url.endsWith('0123456789abcdef0123456789abcdef')
? '/textures/player-one.png'
: null,
},
}),
}
}
return {
ok: true,
status: 200,
json: async () => ({
payload: [
{ uuid: '0123456789abcdef0123456789abcdef', name: 'PlayerOne', isMojang: false },
{ uuid: 'fedcba9876543210fedcba9876543210', name: 'Official', isMojang: true },
{ uuid: 'bad', name: 'Ignored', isMojang: false },
],
}),
}
})
await h.message({ type: 'starlight-skin-players-request', requestId: 'skin-players-1-1' })
await h.waitForMessage('starlight-skin-players-result', 'skin-players-1-1')
const request = h.requests.find(([url]) => url === '/starlight/skin/player')
assert.equal(request[0], '/starlight/skin/player')
assert.equal(request[1].headers.Authorization, 'Bearer test-token')
assert.equal(
h.requests.filter(([url]) => url.startsWith('/starlight/skin/player/skin/')).length,
2,
)
const message = h.messages.at(-1)
assert.equal(message.data.type, 'starlight-skin-players-result')
assert.equal(message.data.ok, true)
assert.equal(message.data.players.length, 2)
assert.equal(message.data.players[1].isMojang, true)
assert.equal(message.data.players[0].skinState, 'ready')
assert.equal(message.data.players[0].headDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
assert.equal(message.data.players[0].skinDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
assert.equal(message.data.players[0].model, 'default')
assert.equal(message.data.players[1].skinState, 'empty')
assert.equal(message.data.players[1].headDataUrl, undefined)
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
})
test('skin updates upload a PNG to the selected skin-site player without exposing the token', async () => {
const h = harness()
h.token('test-token')
await h.connect()
h.result(async (url) => ({
ok: true,
status: 200,
json: async () => ({
payload:
url === '/starlight/skin/player'
? [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
},
]
: { skin: null },
}),
}))
await h.message({ type: 'starlight-skin-players-request', requestId: 'skin-players-2-1' })
h.result(async () => ({
ok: true,
status: 200,
json: async () => ({ success: true, payload: 'updated' }),
}))
await h.message({
type: 'starlight-skin-update-request',
requestId: 'skin-update-1-1',
playerId: '0123456789abcdef0123456789abcdef',
textureDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
model: 'slim',
})
const request = h.requests.at(-1)
assert.equal(
request[0],
'/starlight/skin/player/skin/0123456789abcdef0123456789abcdef/SKIN',
)
assert.equal(request[1].method, 'PUT')
assert.equal(request[1].headers.Authorization, 'Bearer test-token')
assert.equal(request[1].body.get('model'), 'slim')
assert.equal(request[1].body.get('file').type, 'image/png')
assert.equal(h.messages.at(-1).data.type, 'starlight-skin-update-result')
assert.equal(h.messages.at(-1).data.ok, true)
assert.ok(!JSON.stringify(h.messages).includes('test-token'))
})
test('skin rendering uses the skin site crop and preserves the source texture dimensions', async () => {
const h = harness('https://skin.starlight.cool', { width: 64, height: 128 })
h.token('test-token')
await h.connect()
h.result(async (url) => ({
ok: true,
status: 200,
json: async () => ({
payload:
url === '/starlight/skin/player'
? [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'HighResolutionPlayer',
isMojang: false,
},
]
: { skin: '/textures/hd.png', model: 'slim' },
}),
}))
await h.message({ type: 'starlight-skin-players-request', requestId: 'skin-players-3-1' })
const player = h.messages.at(-1).data.players[0]
assert.equal(player.skinState, 'ready')
assert.equal(player.headDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
assert.equal(player.skinDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
assert.equal(player.model, 'slim')
assert.deepEqual(h.drawCalls[0].slice(1), [8, 8, 8, 8, 1, 1, 16, 16])
assert.deepEqual(h.drawCalls[1].slice(1), [40, 8, 8, 8, 0, 0, 18, 18])
})
@@ -1,9 +0,0 @@
# Fictional local values only. Never commit a real Access token or audience.
TELEMETRY_ADMIN_MOCK_AUTH=true
TELEMETRY_ADMIN_MOCK_SCENARIO=normal
NUXT_ACCESS_TEAM_DOMAIN=example
NUXT_ACCESS_AUDIENCE=fixture-audience
# Cloudflare GraphQL Analytics API (system page account usage check, local dev only)
# CLOUDFLARE_ACCOUNT_ID=
# CLOUDFLARE_ANALYTICS_TOKEN=
-2
View File
@@ -1,2 +0,0 @@
.vercel
.env*
-5
View File
@@ -1,5 +0,0 @@
.nuxt/
.output/
.wrangler/
node_modules/
coverage/
-106
View File
@@ -1,106 +0,0 @@
# Starlight Telemetry Dashboard
Read-only administration UI for the existing `axolotl-telemetry` D1 database and
`axolotl-telemetry-errors` R2 bucket. It does not share routes or code with the public telemetry
collector.
## Local development
Mock authentication is accepted only when `NODE_ENV=development` and
`TELEMETRY_ADMIN_MOCK_AUTH=true` are both present.
Use the production D1 and R2 resources through Wrangler remote bindings while keeping authentication
local to the development server:
```powershell
$env:TELEMETRY_ADMIN_MOCK_AUTH='true'
$env:TELEMETRY_ADMIN_REMOTE_BINDINGS='true'
pnpm --filter @axolotl/telemetry-dashboard dev
```
Remote mode is read-only at the application layer: the Admin API issues only `SELECT` statements
and a single registered-object R2 `GET`. It never exposes Cloudflare credentials to the browser.
For deterministic fixture scenarios, leave `TELEMETRY_ADMIN_REMOTE_BINDINGS` unset:
```powershell
$env:TELEMETRY_ADMIN_MOCK_AUTH='true'
$env:TELEMETRY_ADMIN_MOCK_SCENARIO='normal'
pnpm --filter @axolotl/telemetry-dashboard dev
```
Supported deterministic scenarios are `normal`, `empty`, `api-error`, `no-sample`,
`budget-reached`, `unconfigured-auth`, and `forbidden`. All mock values are synthetic fixtures.
## Production deployment (Cloudflare Workers)
The dashboard runs on the `axolotl-telemetry-dashboard` Worker with the custom domain
`admin.axlmc.org`. It uses the native D1 and R2 bindings declared in `wrangler.toml`; no data
credentials are sent to the browser.
Do not deploy the dashboard until every item below is complete:
1. Create a GitHub OAuth App controlled by the `Axolotl-Launcher` organization. Use
`https://admin.axlmc.org` as its homepage and the callback URL shown by Cloudflare Zero Trust,
normally `https://<team-name>.cloudflareaccess.com/cdn-cgi/access/callback`.
2. Add GitHub as a Cloudflare Zero Trust identity provider using that Client ID and Client Secret.
Keep the Client Secret only in GitHub and Cloudflare.
3. Create a self-hosted Access application for `admin.axlmc.org` with an 8-hour session duration.
4. Create one Allow policy with `Include -> GitHub Organization -> Axolotl-Launcher`. Do not add a
bypass policy or a broader include rule.
5. Keep the Worker custom domain `admin.axlmc.org` bound to `axolotl-telemetry-dashboard`. The DNS
records for `axlmc.org` stay proxied through Cloudflare.
Build and deploy from `apps/telemetry-dashboard`:
```powershell
pnpm --filter @axolotl/telemetry-dashboard build
pnpm --filter @axolotl/telemetry-dashboard exec wrangler deploy
```
`nuxi build` uses Nitro's `cloudflare-module` preset by default and emits
`.output/server/index.mjs` plus `.output/public`, exactly what `wrangler.toml` references.
### Automatic deployment
`.github/workflows/deploy-telemetry-dashboard.yml` deploys on every push to `main` that touches
`apps/telemetry-dashboard/**`, and can also be triggered manually. It requires one repository
secret:
- `CLOUDFLARE_API_TOKEN`: an API token with Workers Scripts Edit and Workers Routes Edit
permission for the account that owns `axolotl-telemetry-dashboard`.
Do not add `[skip ci]` to commits that should reach production: GitHub Actions skips all workflows
for such commits, including this deployment.
### Cloudflare account usage check
The system page reports account-wide Workers, D1, and R2 usage through the GraphQL Analytics API.
Enable it once with two Worker secrets; secrets survive CI deploys because they live on the Worker,
not in the repository:
1. Create an API token at `dash.cloudflare.com` → My Profile → API Tokens with the
`Account Analytics: Read` permission for the account that owns the telemetry resources.
2. From `apps/telemetry-dashboard`, run
`pnpm exec wrangler secret put CLOUDFLARE_ANALYTICS_TOKEN` and paste that token.
3. Run `pnpm exec wrangler secret put CLOUDFLARE_ACCOUNT_ID` and paste the 32-character account ID.
The check stays fail-closed: without both values the row reports 尚未配置, and any dataset failure
only degrades the row. The token is used server-side for three read-only GraphQL queries per system
page load (Workers invocations 24h, D1 rows today, R2 operations 30d) and is never sent to the
browser.
### Unused Vercel target
An earlier Vercel project (`axolotl-telemetry-dashboard`) exists, but its `admin.axlmc.org` domain
was never pointed at Vercel and traffic is served by the Cloudflare Worker. The Nitro `vercel`
preset and `server/utils/vercel-data-source.ts` remain as an offline fallback and are not part of
the production path. Do not enable both targets for the same hostname.
Verify an unauthenticated request is intercepted by Cloudflare Access, then verify
`/api/admin/session` reports `dataSource: production` after authentication.
The server fails closed when Access or the remote data source configuration is absent. A production
build ignores the mock provider even if a mock environment variable is present. All Admin API D1
operations are restricted to `SELECT`/`WITH` queries, and R2 reads require an object key previously
registered in D1.
-6
View File
@@ -1,6 +0,0 @@
<template>
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff. Show more