Compare commits
8
Commits
171a19985b
...
0b30fc96a0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b30fc96a0 | ||
|
|
2a1dfa3c42 | ||
|
|
fbcaed528b | ||
|
|
9dd2b013af | ||
|
|
c50595b252 | ||
|
|
3471cd3da2 | ||
|
|
1593ac7a7c | ||
|
|
1fa56add19 |
No files matched your search
Generated
+13
-38
@@ -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",
|
||||
|
||||
@@ -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
@@ -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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": "彩蛋小游戏"
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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": "刷新連接狀態"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})()
|
||||
@@ -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=
|
||||
@@ -1,2 +0,0 @@
|
||||
.vercel
|
||||
.env*
|
||||
@@ -1,5 +0,0 @@
|
||||
.nuxt/
|
||||
.output/
|
||||
.wrangler/
|
||||
node_modules/
|
||||
coverage/
|
||||
@@ -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.
|
||||
@@ -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
Reference in new issue
Block a user