Files
Starlight_Lancher_animation_up/apps/app-frontend/src/App.vue
Disy bc904065c3 feat: complete hosted mod sync and launcher interface updates
Add pack sync markers, tagged mod updates, parallel progress, JWT downloads and retry recovery. Include pending onboarding, about scene, compatibility data pack and download fixes.
2026-09-15 19:06:56 +08:00

3122 lines
90 KiB
Vue

<script setup lang="ts">
import { AuthFeature, TauriModrinthClient, VerboseLoggingFeature } from '@modrinth/api-client'
import {
ChangeSkinIcon,
CompassIcon,
DownloadIcon,
ExternalIcon,
FlaskConicalIcon,
FolderOpenIcon,
HomeIcon,
LeftArrowIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
PlusIcon,
RefreshCwIcon,
RightArrowIcon,
RotateCounterClockwiseIcon,
SettingsIcon,
SpinnerIcon,
UserIcon,
WorldIcon,
} from '@modrinth/assets'
import {
Admonition,
Avatar,
BigOptionButton,
ButtonStyled,
Checkbox,
clientInstallableLoaders,
commonMessages,
ContentInstallModal,
ContentUpdaterModal,
CreationFlowModal,
defineMessages,
I18nDebugPanel,
LoadingBar,
NewModal,
NotificationPanel,
OverflowMenu,
PopupNotificationPanel,
provideModalBehavior,
provideModrinthClient,
provideNotificationManager,
providePageContext,
providePopupNotificationManager,
useDebugLogger,
useFormatBytes,
useVIntl,
} from '@modrinth/ui'
import BatchScanOverlay from '@modrinth/ui/src/components/flows/drop/BatchScanOverlay.vue'
import ConfirmDropTypeModal from '@modrinth/ui/src/components/flows/drop/ConfirmDropTypeModal.vue'
import GenericContentInstallModal from '@modrinth/ui/src/components/flows/drop/GenericContentInstallModal.vue'
import LauncherImportModal from '@modrinth/ui/src/components/flows/drop/LauncherImportModal.vue'
import SymlinkMethodCards from '@modrinth/ui/src/components/flows/drop/SymlinkMethodCards.vue'
import { useQuery } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
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'
import InstanceExportModal from '@/components/lab/recipe-generator/InstanceExportModal.vue'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import AppActionBar from '@/components/ui/AppActionBar.vue'
import AxolotlLogo from '@/components/ui/AxolotlLogo.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import ContentInstallPreviewModal from '@/components/ui/ContentInstallPreviewModal.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import MinecraftCrashModal from '@/components/ui/MinecraftCrashModal.vue'
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
import CurseForgeManualDownloadsModal from '@/components/ui/modal/CurseForgeManualDownloadsModal.vue'
import TaggedModDownloadsModal from '@/components/instance/TaggedModDownloadsModal.vue'
import InstallToPlayModal from '@/components/ui/modal/InstallToPlayModal.vue'
import InstanceIconPickerModal from '@/components/ui/modal/InstanceIconPickerModal.vue'
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 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 { check_reachable } from '@/helpers/auth.js'
import { get_user, get_version } from '@/helpers/cache.js'
import { configureCurseForgeManualDownloadWatcher } from '@/helpers/curseforge'
import { DIRECT_LINKS_SYNCED_EVENT, syncConfiguredDirectLinks } from '@/helpers/direct-link-sync'
import { getMissingContentScannerSettings } from '@/helpers/downloads-scanner'
import { classifyDroppedItem } from '@/helpers/drop'
import {
command_listener,
drop_classify_progress_listener,
java_download_confirmation_listener,
warning_listener,
} from '@/helpers/events.js'
import { install_create_modpack_instance, install_get_modpack_preview } from '@/helpers/install'
import { type DirectLinkSyncReport, get as getInstance, run } from '@/helpers/instance'
import { reconcileMojangAuthSourceAtStartup } from '@/helpers/mojang-auth'
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
import { mergeUrlQuery, parseModrinthLink } from '@/helpers/project-links.ts'
import {
get as getSettings,
getLastBrowseContentProjectType,
getUpdateChannel,
getUpdatePreferences,
isBrowseContentProjectType,
set as setSettings,
} from '@/helpers/settings.ts'
import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts'
import { get_opening_command, initialize_state } from '@/helpers/state'
import {
areUpdatesEnabled,
backupAppDbForUpdate,
checkAppUpdate,
enqueueUpdateForInstallation,
exportErrorLogs,
getOS,
getUpdateSize,
isDev,
isElevated,
isNetworkMetered,
restartApp,
setRestartAfterPendingUpdate,
} from '@/helpers/utils.js'
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
import i18n, { resolveInitialLocale } from '@/i18n.config'
import {
appUpdateState,
downloadAvailableAppUpdate,
getNextAppUpdatePopupTime,
installAvailableAppUpdate,
markAppUpdateActionable,
markAppUpdatePopupShown,
openAppUpdateChangelog,
setAppUpdateActions,
} from '@/providers/app-update.ts'
import { createContentInstall, provideContentInstall } from '@/providers/content-install'
import { createContentSelection, provideContentSelection } from '@/providers/content-selection'
import { createDownloadManager, provideDownloadManager } from '@/providers/download-manager'
import {
provideAppUpdateDownloadProgress,
subscribeToDownloadProgress,
} from '@/providers/download-progress.ts'
import { createServerInstall, provideServerInstall } from '@/providers/server-install'
import { setupProviders } from '@/providers/setup'
import { setupAuthProvider } from '@/providers/setup/auth'
import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
import { useError } from '@/store/error.js'
import { useTheming } from '@/store/state'
import { get_available_capes, get_available_skins } from './helpers/skins'
import { AppNotificationManager } from './providers/app-notifications'
import { AppPopupNotificationManager } from './providers/app-popup-notifications'
const themeStore = useTheming()
const router = useRouter()
const route = useRoute()
const onSkinsPage = computed(() => route.path === '/skins')
const onSchematicWorkshopPage = computed(() => route.path === '/lab/schematic-preview')
const isSchematicFile = (path: string) => /\.(litematic|schematic|schem)$/i.test(path)
const APP_LEFT_NAV_WIDTH = '4rem'
const discoverContentPath = computed(() => {
const projectType = route.params.projectType
if (
!route.query.i &&
!route.query.sid &&
!route.query.wid &&
typeof projectType === 'string' &&
isBrowseContentProjectType(projectType)
) {
return `/browse/${projectType}`
}
return `/browse/${getLastBrowseContentProjectType()}`
})
function getPageTransitionKey(route: RouteLocationNormalizedLoaded) {
const transitionGroup = route.meta.pageTransitionGroup
if (typeof transitionGroup !== 'string') return route.fullPath
const routeId = route.params.id
return `${transitionGroup}:${Array.isArray(routeId) ? routeId.join('/') : (routeId ?? '')}`
}
const APP_SIDEBAR_WIDTH = 300
const credentials = ref()
const sidebarToggled = ref(getSidebarExpanded())
function toggleSidebar() {
sidebarToggled.value = !sidebarToggled.value
setSidebarExpanded(sidebarToggled.value)
}
const forceSidebar = computed(
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
)
const forceSidebarHidden = computed(() => route.path === '/settings')
const sidebarVisible = computed(
() => !forceSidebarHidden.value && (sidebarToggled.value || forceSidebar.value),
)
const customBackgroundStyle = computed(() => {
// A custom image would sit between the desktop and the UI, defeating the
// transparent window entirely, so the two are mutually exclusive.
if (themeStore.transparentBackground || !themeStore.customBackgroundPath) return undefined
return {
backgroundImage: `url("${convertFileSrc(themeStore.customBackgroundPath)}")`,
filter: `blur(${themeStore.customBackgroundBlur}px)`,
opacity: themeStore.customBackgroundOpacity / 100,
}
})
const notificationManager = new AppNotificationManager()
provideNotificationManager(notificationManager)
const { handleError, addNotification } = notificationManager
const downloadManager = createDownloadManager(handleError)
provideDownloadManager(downloadManager)
const contentSelection = createContentSelection({
addNotification,
handleError,
downloadManager,
})
provideContentSelection(contentSelection)
const popupNotificationManager = new AppPopupNotificationManager()
providePopupNotificationManager(popupNotificationManager)
const { addPopupNotification } = popupNotificationManager
const appVersion = getVersion()
const isBetaBuild = ref(false)
void appVersion
.then((version) => {
isBetaBuild.value = version.includes('-beta')
})
.catch((error) => {
console.warn('Failed to read the app version for the Beta label', error)
})
const tauriApiClient = new TauriModrinthClient({
userAgent: async () => AxolotlBrandConfig.userAgent(await appVersion, await getOsType()),
labrinthBaseUrl: config.labrinthBaseUrl,
features: [
...(AxolotlBrandConfig.capabilities.privateModrinthServices
? [
new AuthFeature({
token: async () => (await getCreds())?.session,
}),
]
: []),
new VerboseLoggingFeature(),
],
})
provideModrinthClient(tauriApiClient)
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
floatingActionBarOffsets: {
left: ref(APP_LEFT_NAV_WIDTH),
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
},
featureFlags: {
serverRamAsBytesAlwaysOn: computed(() =>
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
),
},
openExternalUrl: (url) => openUrl(url),
})
provideModalBehavior({
noblur: computed(() => !themeStore.advancedRendering),
})
const stateInitialization = initialize_state()
const {
instanceIconPickerModal,
installationModal,
unknownPackWarningModal,
fetchExistingInstanceNames,
handleCreate,
handleBrowseModpacks,
searchModpacks,
getProjectVersions,
hasCompatibleOptiFabric,
getLoaderManifest,
installModpackFromPath,
setModpackAlreadyInstalledModal,
handleModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance,
fileDrop,
} = setupProviders(notificationManager, popupNotificationManager, stateInitialization)
const { browserOffline, offline, setNetworkReachable } = useNetworkStatus()
const showOnboarding = ref(false)
const onboardingMode = ref('main')
const onboardingSettings = ref(null)
const onboardingReplay = ref(false)
const nativeDecorations = ref(false)
const os = ref('')
const isDevEnvironment = ref(false)
/**
* Acrylic is rendered by the Windows compositor behind the webview, so CSS
* cannot clip it. Keep the native rounded frame and hide its border while the
* CSS-drawn transparent-window border is active.
*/
async function applyWindowFrame() {
if (os.value !== 'Windows') return
try {
await invoke('set_transparent_window_frame', {
enabled: themeStore.transparentBackground,
})
} catch (error) {
console.warn('Failed to update transparent window frame', error)
}
}
watch(() => themeStore.transparentBackground, applyWindowFrame)
/**
* The frosted glass has to come from the compositor: a webview cannot reach the
* pixels behind its own window, so `backdrop-filter` can never blur the desktop.
* Acrylic blurs whatever sits behind the window, matching what the transparency
* already reveals; Mica would only sample the wallpaper and ignore other
* windows. Linux exposes no window effects at all.
*/
async function applyWindowEffects() {
if (os.value === 'Linux') return
try {
const window = getCurrentWindow()
if (!themeStore.transparentBackground || !themeStore.transparentBackgroundBlur) {
await window.clearEffects()
return
}
await window.setEffects({
effects: [os.value === 'MacOS' ? Effect.UnderWindowBackground : Effect.Acrylic],
})
} catch (error) {
console.warn('Failed to update window effects', error)
}
}
watch(
() => [themeStore.transparentBackground, themeStore.transparentBackgroundBlur],
applyWindowEffects,
)
const stateInitialized = ref(false)
const closeChoiceModal = ref<InstanceType<typeof NewModal>>()
const closeChoiceOpen = ref(false)
const closeChoiceRemember = ref(false)
const closeRequestInProgress = ref(false)
let allowWindowClose = false
let unlistenCloseRequested: (() => void) | undefined
let unlistenLightweightModeError: (() => void) | undefined
let unlistenSystemAccentColor: (() => void) | undefined
let maximizedStateTimer: ReturnType<typeof setTimeout> | undefined
let unlistenWindowResize: (() => void) | undefined
const minecraftCrashModal = ref()
const javaDownloadConfirmationModal = ref()
const isMaximized = ref(false)
const authUnreachableDebug = useDebugLogger('AuthReachableChecker')
const authServerQuery = useQuery({
queryKey: ['authServerReachability'],
enabled: computed(() => !browserOffline.value),
queryFn: async () => {
try {
await check_reachable()
setNetworkReachable(true)
authUnreachableDebug('Auth servers are reachable')
return true
} catch (error) {
setNetworkReachable(false)
throw error
}
},
refetchInterval: 5 * 60 * 1000, // 5 minutes
retry: false,
refetchOnWindowFocus: false,
})
const authUnreachable = computed(() => {
if (!offline.value && authServerQuery.isError.value && !authServerQuery.isLoading.value) {
console.warn('Failed to reach auth servers', authServerQuery.error.value)
return true
}
return false
})
const appUpdateDownload = {
progress: appUpdateState.progress,
version: ref(),
}
let unlistenUpdateDownload
const {
metered,
finishedDownloading,
downloading,
restarting,
availableUpdate,
updateSize,
updatesEnabled,
updatesPaused,
} = appUpdateState
let delayedUpdatePopupTimeout = null
async function checkUpdates() {
if (!(await areUpdatesEnabled())) {
console.log('Skipping update check as updates are disabled in this build or environment')
updatesEnabled.value = false
return
}
updatesEnabled.value = true
updatesPaused.value = (await getUpdatePreferences()).updatesPaused
if (updatesPaused.value) {
setTimeout(checkUpdates, 5 * 60 * 1000)
return
}
if (!offline.value) {
await performUpdateCheck().catch((error) => {
console.warn('Failed to check for launcher updates', error)
})
}
setTimeout(
() => {
checkUpdates()
},
5 /* min */ * 60 /* sec */ * 1000 /* ms */,
)
}
/**
* Keep browser/webview shortcuts from escaping the launcher UI. F12 remains
* available when the in-app developer mode is enabled so development tools
* can still be opened intentionally.
*/
function handleGlobalKeydown(event: KeyboardEvent) {
const key = event.key.toLowerCase()
const isFindShortcut = key === 'f' && (event.ctrlKey || event.metaKey)
const isBlockedDevtoolsShortcut = event.key === 'F12' && !themeStore.devMode
if (isFindShortcut || isBlockedDevtoolsShortcut) {
event.preventDefault()
event.stopPropagation()
}
}
onMounted(async () => {
unlistenLightweightModeError = await listen<string>('lightweight-mode-error', ({ payload }) => {
allowWindowClose = false
closeRequestInProgress.value = false
if (!closeChoiceOpen.value) {
closeChoiceOpen.value = true
closeChoiceRemember.value = false
closeChoiceModal.value?.show()
}
handleError(payload)
})
await useCheckDisableMouseover()
window.addEventListener('keydown', handleGlobalKeydown, true)
unlistenCloseRequested = await getCurrentWindow().onCloseRequested(handleCloseRequested)
document.querySelector('body').addEventListener('click', handleClick)
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, handleDirectLinkSyncReport)
checkUpdates()
void warnIfRunningElevated()
startDirectLinkSync()
})
let directLinkSync: (() => Promise<void>) | undefined
let stopDirectLinkSync: (() => void) | undefined
let directLinkSyncErrorSignature = ''
function handleDirectLinkSyncReport(event: Event) {
if (!(event instanceof CustomEvent)) return
const report = event.detail as DirectLinkSyncReport
if (!Array.isArray(report?.errors)) return
const details = report.errors.join('\n')
if (!details) {
directLinkSyncErrorSignature = ''
return
}
if (details === directLinkSyncErrorSignature) return
directLinkSyncErrorSignature = details
addNotification({
title: formatMessage(messages.directLinkSyncIssuesTitle),
text: details,
type: 'warning',
})
}
function startDirectLinkSync() {
const readRoots = () => {
try {
const parsed = JSON.parse(localStorage.getItem('axolotl-minecraft-directories') ?? '[]')
return Array.isArray(parsed)
? parsed.flatMap((value) => {
if (typeof value === 'string' && value.trim()) {
return [{ path: value, mode: 'isolated' as const }]
}
if (
value &&
typeof value === 'object' &&
typeof value.path === 'string' &&
value.path.trim()
) {
return [
{
path: value.path,
mode: value.mode === 'shared' ? ('shared' as const) : ('isolated' as const),
},
]
}
return []
})
: []
} catch {
return []
}
}
const sync = () => syncConfiguredDirectLinks(readRoots()).catch(handleError)
const handleWindowFocus = () => {
void sync()
}
directLinkSync = sync
window.addEventListener('focus', handleWindowFocus)
void sync()
stopDirectLinkSync = () => {
window.removeEventListener('focus', handleWindowFocus)
if (directLinkSync === sync) directLinkSync = undefined
if (stopDirectLinkSync) stopDirectLinkSync = undefined
}
}
onUnmounted(async () => {
if (maximizedStateTimer) clearTimeout(maximizedStateTimer)
unlistenWindowResize?.()
window.removeEventListener('keydown', handleGlobalKeydown, true)
unlistenCloseRequested?.()
unlistenLightweightModeError?.()
unlistenSystemAccentColor?.()
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
window.removeEventListener(DIRECT_LINKS_SYNCED_EVENT, handleDirectLinkSyncReport)
clearDelayedUpdatePopup()
stopDirectLinkSync?.()
await unlistenUpdateDownload?.()
downloadManager.dispose()
})
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
async function warnIfRunningElevated() {
if (await isElevated().catch(() => false)) {
addNotification({
title: formatMessage(messages.runningAsAdmin),
type: 'warning',
autoCloseMs: null,
})
}
}
async function onImportFileReceived({
file: _file,
filePath,
source: _source,
}: {
file: File | null
filePath: string | null
source: 'file-picker' | 'drag-drop'
}) {
if (!filePath) return
const fileName = filePath.split(/[/\\]/).pop() || 'file'
// ── Hide creation modal first ──
installationModal.value?.hide()
// ── Show "Processing..." (matches drag-drop behavior) ──
const processingNotify = addNotification({
title: formatMessage(messages.dropProcessing, { name: fileName }),
type: 'info',
autoCloseMs: null,
})
try {
// ── Classify the file (same entry point as drag-drop) ──
const classification = await classifyDroppedItem(filePath)
clearDropProcessingNotification()
notificationManager.removeNotification(processingNotify.id)
// ── Set drop state so handleDropConfirm can read it ──
dropClassification.value = classification
dropFilePath.value = classification.file_path ?? classification.base_path ?? filePath
dropFileName.value = fileName
// ── Unknown + nested archives → confirm unpacking first ──
if (
classification.item_type === 'unknown' &&
classification.reason?.toLowerCase().includes('nested')
) {
showNestedUnpackPrompt(classification)
return
}
// ── Unknown + extraction → force analysis prompt ──
if (
classification.item_type === 'unknown' &&
classification.reason?.toLowerCase().includes('extraction')
) {
showForceAnalysisPrompt(classification)
return
}
// ── Unknown (no extraction) → error ──
if (classification.item_type === 'unknown') {
addNotification({
title: formatMessage(messages.dropUnknownTitle),
text: unknownReasonMessage(classification.reason),
type: 'error',
})
return
}
// ── Known types → show the same confirm modal as drag-drop ──
confirmDropModal.value?.show()
} catch (e) {
notificationManager.removeNotification(processingNotify?.id)
addNotification({
title: formatMessage(messages.dropProcessFailedTitle),
text: e instanceof Error ? e.message : String(e),
type: 'error',
})
}
}
const messages = defineMessages({
updateInstalledToastTitle: {
id: 'app.update.complete-toast.title',
defaultMessage: 'Version {version} was successfully installed!',
},
updateInstalledToastText: {
id: 'app.update.complete-toast.text',
defaultMessage: 'Click here to view the changelog.',
},
directLinkSyncIssuesTitle: {
id: 'app.direct-link-sync.issues-title',
defaultMessage: 'External Minecraft instances need attention',
},
authUnreachableHeader: {
id: 'app.auth-servers.unreachable.header',
defaultMessage: 'Cannot reach authentication servers',
},
authUnreachableBody: {
id: 'app.auth-servers.unreachable.body',
defaultMessage:
'Minecraft authentication servers may be down right now. Check your internet connection and try again later.',
},
runningAsAdmin: {
id: 'app.warning.running-as-admin',
defaultMessage:
'Starlight is running as administrator. Drag-and-drop file import is disabled in this mode; please restart the launcher without administrator privileges.',
},
restarting: {
id: 'app.restarting',
defaultMessage: 'Restarting...',
},
closeLauncherTitle: {
id: 'app.close-launcher.title',
defaultMessage: 'Choose how to close Starlight Launcher',
},
closeLauncherDirect: {
id: 'app.close-launcher.direct',
defaultMessage: 'Close directly',
},
closeLauncherTray: {
id: 'app.close-launcher.tray',
defaultMessage: 'Hide to tray',
},
closeLauncherRemember: {
id: 'app.close-launcher.remember',
defaultMessage: 'Remember my choice',
},
betaBuild: {
id: 'app.build.beta',
defaultMessage: 'Beta',
},
home: {
id: 'app.navigation.home',
defaultMessage: 'Home',
},
worlds: {
id: 'app.navigation.worlds',
defaultMessage: 'Worlds',
},
discoverContent: {
id: 'app.navigation.discover-content',
defaultMessage: 'Discover content',
},
skinSelector: {
id: 'app.navigation.skin-selector',
defaultMessage: 'Skin selector',
},
library: {
id: 'app.navigation.library',
defaultMessage: 'Library',
},
multiplayer: {
id: 'app.navigation.multiplayer',
defaultMessage: 'Multiplayer',
},
downloads: {
id: 'app.navigation.downloads',
defaultMessage: 'Downloads',
},
lab: {
id: 'app.navigation.lab',
defaultMessage: 'Lab',
},
createInstance: {
id: 'app.navigation.create-instance',
defaultMessage: 'Create new instance',
},
signedInAs: {
id: 'app.account.signed-in-as',
defaultMessage: 'Signed in as',
},
userInformation: {
id: 'app.minecraft.user-information',
defaultMessage: 'User information',
},
collapseSidebar: {
id: 'app.sidebar.collapse',
defaultMessage: 'Collapse sidebar',
},
expandSidebar: {
id: 'app.sidebar.expand',
defaultMessage: 'Expand sidebar',
},
warning: {
id: 'app.notification.warning',
defaultMessage: 'Warning',
},
exportErrorLogs: {
id: 'app.notification.export-error-logs',
defaultMessage: 'Export error logs',
},
// ── Drop / import notification messages ──
dropOverlayTitle: {
id: 'app.drop.overlay-title',
defaultMessage: 'Drop to import',
},
dropOverlaySubtitle: {
id: 'app.drop.overlay-subtitle',
defaultMessage: 'Release to analyze',
},
dropProcessing: {
id: 'app.drop.processing',
defaultMessage: 'Processing {name}...',
},
dropMultipleFilesTitle: {
id: 'app.drop.error.multiple-files-title',
defaultMessage: 'Cannot import multiple files',
},
dropMultipleFilesText: {
id: 'app.drop.error.multiple-files-text',
defaultMessage: 'Please drop one file at a time.',
},
dropShortcutFailedTitle: {
id: 'app.drop.error.shortcut-title',
defaultMessage: 'Shortcut resolution failed',
},
dropShortcutFailedText: {
id: 'app.drop.error.shortcut-text',
defaultMessage: 'Could not resolve the shortcut target.',
},
dropUnknownTitle: {
id: 'app.drop.error.unknown-title',
defaultMessage: 'Unknown file type',
},
dropUnknownText: {
id: 'app.drop.error.unknown-text',
defaultMessage: 'Could not determine what kind of file this is.',
},
dropUnknownDepthText: {
id: 'app.drop.error.unknown-depth-text',
defaultMessage:
'The archive is nested too deeply to analyze. Unpack it to a folder and try again.',
},
dropUnknownEncryptedText: {
id: 'app.drop.error.unknown-encrypted-text',
defaultMessage: 'The archive contains encrypted files and cannot be analyzed.',
},
dropNestedUnpackTitle: {
id: 'app.drop.nested-unpack-title',
defaultMessage: 'Nested archives detected',
},
dropNestedUnpackText: {
id: 'app.drop.nested-unpack-text',
defaultMessage:
'This archive contains nested archives ({size}) that must be unpacked to analyze. This may take some time. Continue?',
},
dropNestedUnpackButton: {
id: 'app.drop.nested-unpack-button',
defaultMessage: 'Continue analysis',
},
dropErrorTitle: {
id: 'app.drop.error.title',
defaultMessage: 'Drop error',
},
dropWorldImportedTitle: {
id: 'app.drop.world-imported-title',
defaultMessage: 'World imported',
},
dropWorldImportedText: {
id: 'app.drop.world-imported-text',
defaultMessage: 'World save has been imported successfully.',
},
dropContentInstalledTitle: {
id: 'app.drop.content-installed-title',
defaultMessage: 'Content installed',
},
dropContentInstalledText: {
id: 'app.drop.content-installed-text',
defaultMessage: 'File has been installed to the instance.',
},
dropInstallFailedTitle: {
id: 'app.drop.install-failed-title',
defaultMessage: 'Installation failed',
},
dropInstanceImportedTitle: {
id: 'app.drop.instance-imported-title',
defaultMessage: 'Instance imported',
},
dropInstanceImportedText: {
id: 'app.drop.instance-imported-text',
defaultMessage: '{name} imported successfully.',
},
dropImportFailedTitle: {
id: 'app.drop.import-failed-title',
defaultMessage: 'Import failed',
},
dropImportFailedText: {
id: 'app.drop.import-failed-text',
defaultMessage: 'Failed to import {name}: {error}',
},
dropNoInstances: {
id: 'app.drop.no-instances',
defaultMessage: 'No instances found',
},
dropScanning: {
id: 'app.drop.scanning',
defaultMessage: 'Scanning for instances',
},
dropScanFailed: {
id: 'app.drop.scan-failed',
defaultMessage: 'Failed to scan for instances',
},
dropExtractFailed: {
id: 'app.drop.extract-failed',
defaultMessage: 'Failed to extract archive',
},
dropProcessFailedTitle: {
id: 'app.drop.process-failed-title',
defaultMessage: 'Failed to process file',
},
dropTemporaryFileTitle: {
id: 'app.drop.temporary-file-title',
defaultMessage: 'Temporary file detected',
},
dropTemporaryFileText: {
id: 'app.drop.temporary-file-text',
defaultMessage:
'The file "{file}" appears to be a temporary copy. Try dragging the file from its original folder instead of from a browser, archive, or cloud storage.',
},
dropImportProgressTitle: {
id: 'app.drop.import-progress-title',
defaultMessage: 'Importing instances…',
},
dropImportProgressText: {
id: 'app.drop.import-progress-text',
defaultMessage: '{current} / {total} instances imported',
},
dropImportCompletedTitle: {
id: 'app.drop.import-completed-title',
defaultMessage: 'Import completed',
},
dropImportCompletedText: {
id: 'app.drop.import-completed-text',
defaultMessage: 'Successfully imported {count} instances',
},
dropImportCompletedPartialText: {
id: 'app.drop.import-completed-partial-text',
defaultMessage: 'Imported {completed} of {total} instances ({failed} failed)',
},
dropImportCancelledTitle: {
id: 'app.drop.batch.import-cancelled-title',
defaultMessage: 'Import cancelled',
},
dropImportCancelledText: {
id: 'app.drop.batch.import-cancelled-text',
defaultMessage: 'Nothing was imported.',
},
dropBatchNothingImportableTitle: {
id: 'app.drop.batch.nothing-importable-title',
defaultMessage: 'Nothing to import',
},
dropBatchNothingImportableText: {
id: 'app.drop.batch.nothing-importable-text',
defaultMessage: '{count, plural, one {# file} other {# files}} could not be recognized.',
},
dropBatchCompletedTitle: {
id: 'app.drop.batch.completed-title',
defaultMessage: 'Import finished',
},
dropBatchCompletedText: {
id: 'app.drop.batch.completed-text',
defaultMessage: 'Imported {succeeded} of {total} ({failed} failed, {skipped} skipped).',
},
dropBatchTargetLabel: {
id: 'app.drop.batch.target-label',
defaultMessage: 'Select target instance for this batch',
},
dropBatchGroupFileLabel: {
id: 'app.drop.batch.group-file-label',
defaultMessage: '{count, plural, one {# file} other {# files}}: {names}',
},
dropModpackInstallFailed: {
id: 'app.drop.modpack-install-failed',
defaultMessage: 'Failed to install modpack',
},
dropUnknownForceAnalysisTitle: {
id: 'app.drop.unknown-force-analysis-title',
defaultMessage: 'Unable to identify file type',
},
dropUnknownForceAnalysisText: {
id: 'app.drop.unknown-force-analysis-text',
defaultMessage:
'This archive needs to be extracted and deeply analyzed to determine its content type. This may take a while. Force analysis?',
},
dropUnknownForceAnalysisButton: {
id: 'app.drop.unknown-force-analysis-button',
defaultMessage: 'Force analysis',
},
dropUnknownForceAnalyzing: {
id: 'app.drop.unknown-force-analyzing',
defaultMessage: 'Force analyzing archive...',
},
dropUnknownForceAnalysisFailedTitle: {
id: 'app.drop.unknown-force-analysis-failed-title',
defaultMessage: 'Analysis failed',
},
dropUnknownForceAnalysisFailedText: {
id: 'app.drop.unknown-force-analysis-failed-text',
defaultMessage: 'Could not identify the file type even after deep analysis.',
},
dropInstallModTitle: {
id: 'app.drop.mod-compatibility-title',
defaultMessage: 'Version Mismatch',
},
dropInstallModWarning: {
id: 'app.drop.mod-compatibility-warning',
defaultMessage:
'{file} targets {modVersion} ({modLoader}), but the instance is {instVersion} ({instLoader}).',
},
// Compatible mode (pre-version-isolation) import
dropCompatibleModeTitle: {
id: 'app.drop.compatible-mode-title',
defaultMessage: 'This appears to be a pre-version-isolation instance',
},
dropCompatibleModeDesc: {
id: 'app.drop.compatible-mode-desc',
defaultMessage: 'Would you like to import this as a compatible mode instance?',
},
dropCompatibleModeImport: {
id: 'app.drop.compatible-mode-import',
defaultMessage: 'Compatible Import',
},
dropCompatibleModeOldWay: {
id: 'app.drop.compatible-mode-old-way',
defaultMessage: 'Import as Old Version',
},
dropCompatibleModeCancel: {
id: 'app.drop.compatible-mode-cancel',
defaultMessage: 'Cancel',
},
})
function getErrorNotificationDetails(notification) {
const details = [notification.title, notification.text, notification.errorCode].filter(Boolean)
if (notification.supportData) {
details.push(JSON.stringify(notification.supportData, null, 2))
}
return details.join('\n\n')
}
async function exportNotificationErrorLogs(notification) {
try {
await exportErrorLogs(getErrorNotificationDetails(notification))
} catch (error) {
handleError(error)
}
}
async function setupApp() {
const initialSettings = await getSettings()
await downloadManager.start()
const {
native_decorations,
theme,
accent_color,
locale,
collapsed_navigation,
hide_nametag_skins_page,
advanced_rendering,
onboarded,
default_page,
toggle_sidebar,
custom_background_path,
custom_background_blur,
custom_background_opacity,
transparent_background,
transparent_background_opacity,
transparent_background_blur,
sidebar_instance_count,
auto_hide_downloads_button,
home_layout,
minimal_home_instance_id,
close_behavior,
developer_mode,
feature_flags,
} = initialSettings
// Initialize locale from saved settings
if (locale) {
i18n.global.locale.value = locale
} else {
const resolvedLocale = resolveInitialLocale(navigator.languages)
i18n.global.locale.value = resolvedLocale
initialSettings.locale = resolvedLocale
await setSettings(initialSettings)
}
const defaultPageRoutes = {
Home: '/',
DiscoverContent: `/browse/${getLastBrowseContentProjectType()}`,
Library: '/library',
}
const defaultPageRoute = offline.value ? '/library' : defaultPageRoutes[default_page]
if (defaultPageRoute && defaultPageRoute !== '/') await router.push(defaultPageRoute)
os.value = await getOS()
const dev = await isDev()
isDevEnvironment.value = dev
if (!onboarded && route.path !== '/') await router.replace('/')
showOnboarding.value = false
onboardingSettings.value = initialSettings
nativeDecorations.value = native_decorations
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
themeStore.setThemeState(theme)
await initializeSystemAccentColor()
themeStore.setAccentColor(accent_color)
themeStore.collapsedNavigation = collapsed_navigation
themeStore.advancedRendering = advanced_rendering
themeStore.hideNametagSkinsPage = hide_nametag_skins_page
themeStore.toggleSidebar = toggle_sidebar
themeStore.customBackgroundPath = custom_background_path
themeStore.customBackgroundBlur = custom_background_blur
themeStore.customBackgroundOpacity = custom_background_opacity
themeStore.transparentBackground = transparent_background
themeStore.transparentBackgroundOpacity = transparent_background_opacity
themeStore.transparentBackgroundBlur = transparent_background_blur
themeStore.setTransparentBackgroundClass()
await applyWindowFrame()
await applyWindowEffects()
themeStore.sidebarInstanceCount = sidebar_instance_count
themeStore.autoHideDownloadsButton = auto_hide_downloads_button
themeStore.homeLayout = home_layout
themeStore.minimalHomeInstanceId = minimal_home_instance_id
themeStore.closeBehavior = close_behavior
themeStore.devMode = developer_mode
themeStore.featureFlags = feature_flags
stateInitialized.value = true
showOnboarding.value = !onboarded
void reconcileMojangAuthSourceAtStartup().catch(handleError)
isMaximized.value = await getCurrentWindow().isMaximized()
unlistenWindowResize = await getCurrentWindow().onResized(() => {
// Display mode/DPI changes can emit a burst of resize events. Coalesce
// them so WebView2 does not receive one IPC request per event.
if (maximizedStateTimer) clearTimeout(maximizedStateTimer)
maximizedStateTimer = setTimeout(async () => {
maximizedStateTimer = undefined
try {
isMaximized.value = await getCurrentWindow().isMaximized()
} catch (error) {
console.warn('Failed to refresh maximized state after resize', error)
}
}, 100)
})
if (!dev) document.addEventListener('contextmenu', (event) => event.preventDefault())
const osType = await getOsType()
if (osType === 'macos') {
document.getElementsByTagName('html')[0].classList.add('mac')
} else {
document.getElementsByTagName('html')[0].classList.add('windows')
}
await warning_listener(async (e) => {
if (e.kind === 'minecraft_crash') {
await minecraftCrashModal.value?.handleWarning(e)
return
}
addNotification({
title: formatMessage(messages.warning),
text: e.message,
type: 'warn',
})
})
await java_download_confirmation_listener((request) => {
javaDownloadConfirmationModal.value?.show(request)
})
await drop_classify_progress_listener((payload) => {
if (dropProcessingNotificationId.value === null) return
const name = payload.currentItem?.split(/[/\\]/).pop() || 'file'
notificationManager.removeNotification(dropProcessingNotificationId.value)
dropProcessingNotificationId.value = addNotification({
title: formatMessage(messages.dropProcessing, { name }),
type: 'info',
autoCloseMs: null,
}).id
})
get_opening_command().then(handleCommand)
fetchCredentials()
try {
const skins = (await get_available_skins()) ?? []
const capes = (await get_available_capes()) ?? []
const { generateSkinPreviews } = await import('./helpers/rendering/skin-preview-renderer')
generateSkinPreviews(skins, capes)
} catch (error) {
console.warn('Failed to generate skin previews in app setup.', error)
}
}
type SystemAccentColorPayload = {
hex: string
r: number
g: number
b: number
}
async function initializeSystemAccentColor() {
try {
unlistenSystemAccentColor = await listen<SystemAccentColorPayload>(
'system-accent-color-changed',
({ payload }) => themeStore.setSystemAccentColor(payload.hex),
)
} catch (error) {
console.warn('Failed to listen for system accent color changes', error)
}
try {
const color = await invoke<SystemAccentColorPayload>('plugin:system-accent|system_accent_color')
themeStore.setSystemAccentColor(color.hex)
} catch (error) {
themeStore.setSystemAccentUnavailable()
console.warn('Failed to read the system accent color', error)
}
}
function startOnboarding(mode = 'main') {
onboardingReplay.value = false
onboardingMode.value = mode
showOnboarding.value = true
}
async function replayOnboarding(mode) {
onboardingReplay.value = true
onboardingMode.value = mode
if (mode === 'main') await router.replace('/')
showOnboarding.value = true
}
async function finishOnboarding() {
const wasReplay = onboardingReplay.value
const settings = onboardingSettings.value ?? (await getSettings())
if (!onboardingReplay.value) {
if (onboardingMode.value === 'instance') {
settings.onboarding_instance_tour_completed = true
} else if (onboardingMode.value === 'main') {
settings.onboarded = true
settings.onboarding_version = 1
}
await setSettings(settings)
onboardingSettings.value = settings
}
showOnboarding.value = false
onboardingReplay.value = false
if (!wasReplay) await scheduleStartupDialogs()
}
async function skipOnboarding() {
await finishOnboarding()
}
async function closeOnboardingSettings() {
if (route.path === '/settings') await router.replace('/')
}
async function scheduleStartupDialogs() {
if (!stateInitialized.value || showOnboarding.value) return
}
provide('replayOnboarding', replayOnboarding)
provide(
minecraftLaunchErrorKey,
async (launchError, payload) =>
(await minecraftCrashModal.value?.handleLaunchError(launchError, payload)) ?? false,
)
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
provide('showLauncherPopup', (_request: unknown) => {})
const stateFailed = ref(false)
stateInitialization
.then(() => {
const scannerSettings = getMissingContentScannerSettings()
void configureCurseForgeManualDownloadWatcher(
scannerSettings.enabled,
scannerSettings.directory,
).catch((error) => {
console.warn('Failed to configure manual-download watcher', error)
})
setupApp().catch((err) => {
stateFailed.value = true
console.error(err)
error.showError(err, null, false, 'state_init')
})
})
.catch((err) => {
stateFailed.value = true
console.error('Failed to initialize app', err)
error.showError(err, null, false, 'state_init')
})
async function closeWindowImmediately() {
if (closeRequestInProgress.value) return
closeRequestInProgress.value = true
allowWindowClose = true
try {
await saveWindowState(StateFlags.ALL)
await invoke('exit_app')
} catch (error) {
allowWindowClose = false
closeRequestInProgress.value = false
handleError(error)
}
}
async function enterLightweightModeOnClose() {
closeRequestInProgress.value = true
try {
await saveWindowState(StateFlags.ALL)
await invoke('lightweight_mode_enter')
} catch (error) {
allowWindowClose = false
closeRequestInProgress.value = false
handleError(error)
}
}
async function applyCloseChoice(choice: 'close' | 'lightweight', remember: boolean) {
if (closeRequestInProgress.value) return
closeRequestInProgress.value = true
const previousCloseBehavior = themeStore.closeBehavior
let closeBehaviorPersisted = false
try {
if (remember) {
const settings = await getSettings()
settings.close_behavior = choice
await setSettings(settings)
themeStore.closeBehavior = choice
closeBehaviorPersisted = true
}
if (choice === 'close') {
allowWindowClose = true
await saveWindowState(StateFlags.ALL)
await invoke('exit_app')
} else {
await enterLightweightModeOnClose()
}
} catch (error) {
if (remember && !closeBehaviorPersisted) {
themeStore.closeBehavior = previousCloseBehavior
}
allowWindowClose = false
closeRequestInProgress.value = false
closeChoiceOpen.value = true
handleError(error)
}
}
async function handleCloseRequested(event: { preventDefault: () => void }) {
if (allowWindowClose) return
event.preventDefault()
if (closeRequestInProgress.value) return
if (closeChoiceOpen.value) return
const behavior = themeStore.closeBehavior
if (behavior === 'close') {
await closeWindowImmediately()
return
}
if (behavior === 'lightweight') {
await enterLightweightModeOnClose()
return
}
closeChoiceOpen.value = true
closeChoiceRemember.value = false
closeChoiceModal.value?.show()
}
const handleClose = closeWindowImmediately
const loading = setupLoadingStateProvider()
loading.setEnabled(false)
let initialLoadToken = loading.begin()
let routerToken = null
let suspenseToken = null
let suspensePending = false
const sidebarOverlayScrollbarsOptions = Object.freeze({
overflow: {
x: 'hidden',
y: 'scroll',
},
})
router.beforeEach(() => {
suspensePending = false
if (routerToken) loading.end(routerToken)
routerToken = loading.begin()
})
router.afterEach((to, from, failure) => {
hideAllPoppers()
if (!failure) void invoke('lightweight_mode_set_route', { route: to.fullPath })
if (!failure) {
void directLinkSync?.()
}
setTimeout(() => {
if (!suspensePending && stateInitialized.value) {
if (initialLoadToken) {
loading.end(initialLoadToken)
initialLoadToken = null
}
if (routerToken) {
loading.end(routerToken)
routerToken = null
}
}
}, 100)
})
function onSuspensePending() {
suspensePending = true
if (suspenseToken) loading.end(suspenseToken)
suspenseToken = loading.begin()
}
function onSuspenseResolve() {
if (suspenseToken) {
loading.end(suspenseToken)
suspenseToken = null
}
if (routerToken) {
loading.end(routerToken)
routerToken = null
}
}
watch(
stateInitialized,
(ready) => {
if (ready) {
if (initialLoadToken) {
loading.end(initialLoadToken)
initialLoadToken = null
}
if (routerToken) {
loading.end(routerToken)
routerToken = null
}
void scheduleStartupDialogs()
}
},
{ flush: 'post' },
)
watch(offline, (isOffline) => {
if (isOffline && (route.path.startsWith('/browse') || route.path.startsWith('/project'))) {
void router.push('/library')
}
})
watch(
() => route.path,
(path) => {
if (
path.startsWith('/instance/') &&
onboardingSettings.value?.onboarded &&
!onboardingSettings.value?.onboarding_instance_tour_completed &&
!showOnboarding.value
) {
startOnboarding('instance')
}
},
)
const error = useError()
error.setMinecraftLaunchErrorHandler((launchError, context) => {
if (!minecraftCrashModal.value?.isLaunchFailure(launchError) || !context?.instanceId) return false
void minecraftCrashModal.value.handleLaunchError(launchError, {
instance_id: context.instanceId,
instance_name: 'Minecraft',
})
return true
})
const errorModal = ref()
const minecraftAuthErrorModal = ref()
const contentInstall = createContentInstall({ router, handleError, addNotification })
provideContentInstall(contentInstall)
const {
instances: contentInstallInstances,
compatibleLoaders: contentInstallLoaders,
gameVersions: contentInstallGameVersions,
loading: contentInstallLoading,
defaultTab: contentInstallDefaultTab,
preferredLoader: contentInstallPreferredLoader,
preferredGameVersion: contentInstallPreferredGameVersion,
releaseGameVersions: contentInstallReleaseGameVersions,
projectInfo: contentInstallProjectInfo,
symlinkTarget: contentInstallSymlinkTarget,
handleInstallToInstance,
handleCreateAndInstall,
handleNavigate: handleContentInstallNavigate,
handleCancel: handleContentInstallCancel,
setContentInstallModal,
setContentInstallPreviewModal,
setModpackInstallModal: setContentInstallModpackInstallModal,
handleModpackInstall: handleContentInstallModpackInstall,
handleModpackInstallCancel: handleContentInstallModpackInstallCancel,
setCurseForgeManualDownloadsModal: setContentInstallCurseForgeManualDownloadsModal,
handleCurseForgeManualDownloadsImported: handleContentInstallCurseForgeManualDownloadsImported,
setIncompatibilityWarningModal: setContentIncompatibilityWarningModal,
incompatibilityWarningVersions: contentInstallIncompatibilityWarningVersions,
incompatibilityWarningCurrentGameVersion: contentInstallIncompatibilityWarningCurrentGameVersion,
incompatibilityWarningCurrentLoader: contentInstallIncompatibilityWarningCurrentLoader,
incompatibilityWarningProjectType: contentInstallIncompatibilityWarningProjectType,
incompatibilityWarningProjectIconUrl: contentInstallIncompatibilityWarningProjectIconUrl,
incompatibilityWarningProjectName: contentInstallIncompatibilityWarningProjectName,
incompatibilityWarningMessage: contentInstallIncompatibilityWarningMessage,
incompatibilityWarningInstalling: contentInstallIncompatibilityWarningInstalling,
} = contentInstall
const serverInstall = createServerInstall({ router, handleError, popupNotificationManager })
provideServerInstall(serverInstall)
const {
setInstallToPlayModal: setServerInstallToPlayModal,
setUpdateToPlayModal: setServerUpdateToPlayModal,
setAddServerToInstanceModal: setServerAddServerToInstanceModal,
playServerProject,
symlinkTarget: addServerSymlinkTarget,
} = serverInstall
const modInstallModal = ref()
const contentInstallPreviewModal = ref<InstanceType<typeof ContentInstallPreviewModal> | null>(null)
const modpackAlreadyInstalledModal = ref()
const contentInstallModpackInstallModal = ref<InstanceType<typeof ModpackInstallModal> | null>(null)
const handleContentInstallModpackDuplicateGoToInstance = (instanceId: string) =>
router.push(`/instance/${encodeURIComponent(instanceId)}`)
const contentInstallCurseForgeManualDownloadsModal = ref()
const addServerToInstanceModal = ref()
const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
const updateToPlayModal = ref()
const modrinthLoginFlowWaitModal = ref()
// ── Drop import system ──────────────────────────────────────────────────
const dropImport = useDropImport({
notificationManager,
popupNotificationManager,
installModpackFromPath,
contentInstall,
fileDrop,
onSkinsPage,
onSchematicWorkshopPage,
isSchematicFile,
router,
})
// Destructure what we need from dropImport
const {
// State
isDragging,
isProcessing,
batchActive,
batchPhase,
batchItems,
batchOriginalCount,
batchScanDone,
dropClassification,
dropFileName,
dropFilePath,
dropProcessingNotificationId,
scanningInstances,
batchGroupKey,
incompatWarningKey,
// Modal refs
confirmDropModal,
genericInstallModal,
launcherImportModal,
symlinkCardsModal,
dataPackWorldModal,
compatibleModeConfirmModal,
incompatibilityWarningModal: dropIncompatibilityWarningModal,
// Handlers
handleConfirmDropCancel,
handleConfirmDropConfirm,
handleConfirmDropHelp,
handleCompatibleModeConfirm,
handleGenericInstall,
handleGenericInstallCancel,
handleGenericInstallNavigateCreate,
handleBatchOrDatapackWorldSelect,
handleBatchWorldAfterHide,
onLauncherImportCancelled,
onImportSelected,
onSymlinkMethodCancelled,
onSymlinkMethodConfirmed,
chooseImportMethod,
cancelBatchScan,
handleIncompatibilityWarningUpdate,
handleIncompatibilityWarningCancel,
handleDropInstallSearchCompat,
// Utility
clearDropProcessingNotification,
showNestedUnpackPrompt,
showForceAnalysisPrompt,
unknownReasonMessage,
} = dropImport
provide('chooseImportMethod', chooseImportMethod)
watch(
dropIncompatibilityWarningModal,
(modal) => {
if (modal) {
setContentIncompatibilityWarningModal(modal)
}
},
{ flush: 'post' },
)
watch(
incompatibilityWarningModal,
(modal) => {
dropIncompatibilityWarningModal.value = modal
},
{ flush: 'post' },
)
setupAuthProvider(credentials, async (_redirectPath) => {
if (AxolotlBrandConfig.capabilities.privateModrinthServices) await signIn()
})
async function validateSession(sessionToken) {
try {
const response = await tauriFetch(`${getOfficialLabrinthBaseUrl()}/v2/user`, {
method: 'GET',
headers: { Authorization: sessionToken },
})
if (response.status === 401) return false
return true
} catch {
return true
}
}
async function fetchCredentials() {
if (!AxolotlBrandConfig.capabilities.privateModrinthServices) {
credentials.value = null
return
}
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
if (creds.session && !(await validateSession(creds.session))) {
await logout().catch(handleError)
credentials.value = null
return
}
creds.user = await get_user(creds.user_id, 'bypass').catch(handleError)
}
credentials.value = creds ?? null
}
async function signIn() {
modrinthLoginFlowWaitModal.value.show()
try {
await login()
await fetchCredentials()
} catch (error) {
if (
typeof error === 'object' &&
typeof error['message'] === 'string' &&
error.message.includes('Login canceled')
) {
// Not really an error due to being a result of user interaction, show nothing
} else {
handleError(error)
}
} finally {
modrinthLoginFlowWaitModal.value.hide()
}
}
async function logOut() {
await logout().catch(handleError)
await fetchCredentials()
}
onMounted(() => {
invoke('show_window')
error.setErrorModal(errorModal.value)
error.setMinecraftAuthErrorModal(minecraftAuthErrorModal.value)
setContentIncompatibilityWarningModal(incompatibilityWarningModal.value)
dropIncompatibilityWarningModal.value = incompatibilityWarningModal.value
setContentInstallModal(modInstallModal.value)
setContentInstallPreviewModal(contentInstallPreviewModal.value)
contentSelection.setPreviewModal(contentInstallPreviewModal.value)
setContentInstallModpackInstallModal(contentInstallModpackInstallModal.value!)
setContentInstallCurseForgeManualDownloadsModal(
contentInstallCurseForgeManualDownloadsModal.value,
)
setModpackAlreadyInstalledModal(modpackAlreadyInstalledModal.value)
setServerAddServerToInstanceModal(addServerToInstanceModal.value)
setServerInstallToPlayModal(installToPlayModal.value)
setServerUpdateToPlayModal(updateToPlayModal.value)
void (async () => {
try {
const ready = await invoke<{
pending_crashes: { instance_id: string; uuid: string }[]
pending_commands: Parameters<typeof handleCommand>[0][]
}>('lightweight_mode_frontend_ready', { route: route.fullPath })
for (const pendingCrash of ready.pending_crashes) {
const instance = await getInstance(pendingCrash.instance_id).catch(() => null)
await minecraftCrashModal.value?.handleWarning({
message: `Instance ${instance?.name || 'Minecraft'} has crashed`,
kind: 'minecraft_crash',
instance_id: pendingCrash.instance_id,
instance_name: instance?.name || 'Minecraft',
})
}
for (const command of ready.pending_commands) await handleCommand(command)
} catch (error) {
handleError(error)
}
})()
})
const accounts = ref(null)
provide('accountsCard', accounts)
command_listener(handleCommand)
async function handleCommand(e) {
if (!e) return
if (e.event === 'OpenSeedMap') {
const query = Object.fromEntries(new URLSearchParams(e.query ?? ''))
await router.push({ path: '/lab/seed-map', query })
return
}
if (offline.value && e.event !== 'LaunchInstance') {
await router.push('/library')
return
}
if (e.event === 'RunMRPack') {
// RunMRPack should directly install a local modpack file given a path;
// non-mrpack archives (CurseForge/MCBBS/HMCL/MultiMC zips) are format-sniffed by the backend
if (e.path.endsWith('.mrpack') || e.path.endsWith('.zip')) {
const location = { type: 'fromFile', path: e.path }
const preview = await install_get_modpack_preview(location).catch(handleError)
if (preview?.unknownFile) {
const splitPath = e.path.split(/[\\/]/)
const fileName = splitPath ? splitPath[splitPath.length - 1] : e.path
unknownPackWarningModal.value?.show(
() => install_create_modpack_instance(location).then(() => undefined),
fileName,
)
} else {
await install_create_modpack_instance(location).catch(handleError)
}
}
} else if (e.event === 'LaunchInstance') {
const instance = await getInstance(e.id).catch(() => null)
const handleLaunchCommandError = async (launchError) => {
const handled =
(await minecraftCrashModal.value?.handleLaunchError(launchError, {
instance_id: e.id,
instance_name: instance?.name || 'Minecraft',
})) ?? false
if (!handled) handleError(launchError)
}
if (e.server) {
await start_join_server(e.id, e.server).catch(handleLaunchCommandError)
} else if (e.singleplayer_world) {
await start_join_singleplayer_world(e.id, e.singleplayer_world).catch(
handleLaunchCommandError,
)
} else {
await run(e.id).catch(handleLaunchCommandError)
}
} else if (e.event === 'InstallServer') {
await router.push(`/project/${e.id}`)
await playServerProject(e.id).catch(handleError)
} else if (e.event === 'InstallVersion') {
const version = await get_version(e.id, 'must_revalidate').catch(handleError)
if (version) {
await contentInstall
.install(version.project_id, version.id, null, 'URLConfirmModal', undefined, undefined, {
showProjectInfo: true,
})
.catch(handleError)
}
} else {
await contentInstall
.install(e.id, null, null, 'URLConfirmModal', undefined, undefined, { showProjectInfo: true })
.catch(handleError)
}
}
const updatePopupMessages = defineMessages({
updateAvailable: {
id: 'app.update-popup.title',
defaultMessage: 'Update available',
},
downloadComplete: {
id: 'app.update-popup.download-complete',
defaultMessage: 'Download complete',
},
meteredBody: {
id: 'app.update-popup.body.metered',
defaultMessage: `Starlight Launcher v{version} is available now! Since you're on a metered network, we didn't automatically download it.`,
},
downloadedBody: {
id: 'app.update-popup.body.download-complete',
defaultMessage: `Starlight Launcher v{version} has finished downloading. Reload to update now, or automatically when you close Starlight Launcher.`,
},
reload: {
id: 'app.update-popup.reload',
defaultMessage: 'Reload to update',
},
download: {
id: 'app.update-popup.download',
defaultMessage: 'Download ({size})',
},
changelog: {
id: 'app.update-popup.changelog',
defaultMessage: 'Changelog',
},
})
function clearDelayedUpdatePopup() {
if (delayedUpdatePopupTimeout !== null) {
clearTimeout(delayedUpdatePopupTimeout)
delayedUpdatePopupTimeout = null
}
}
function getCurrentUpdatePromptStage() {
return finishedDownloading.value ? 'downloaded' : 'available'
}
function scheduleDelayedUpdatePopup() {
clearDelayedUpdatePopup()
const version = availableUpdate.value?.version
if (!version) {
return
}
const nextPopupTime = getNextAppUpdatePopupTime(version, getCurrentUpdatePromptStage())
if (nextPopupTime === null) {
return
}
const delay = nextPopupTime - Date.now()
if (delay <= 0) {
showDelayedUpdatePopup()
return
}
delayedUpdatePopupTimeout = setTimeout(showDelayedUpdatePopup, Math.min(delay, 2_147_483_647))
}
function showDelayedUpdatePopup() {
const update = availableUpdate.value
if (!update) {
return
}
const stage = getCurrentUpdatePromptStage()
const nextPopupTime = getNextAppUpdatePopupTime(update.version, stage)
if (nextPopupTime === null) {
return
}
if (Date.now() < nextPopupTime) {
scheduleDelayedUpdatePopup()
return
}
if (metered.value && !finishedDownloading.value) {
addPopupNotification({
title: formatMessage(updatePopupMessages.updateAvailable),
text: formatMessage(updatePopupMessages.meteredBody, { version: update.version }),
type: 'info',
autoCloseMs: null,
buttons: [
{
label: formatMessage(updatePopupMessages.download, {
size: formatBytes(updateSize.value ?? 0),
}),
action: () => downloadAvailableAppUpdate(),
color: 'brand',
},
{
label: formatMessage(updatePopupMessages.changelog),
action: () => openAppUpdateChangelog(),
keepOpen: true,
},
],
})
} else if (finishedDownloading.value) {
addPopupNotification({
title: formatMessage(updatePopupMessages.downloadComplete),
text: formatMessage(updatePopupMessages.downloadedBody, {
version: update.version,
}),
type: 'success',
autoCloseMs: null,
buttons: [
{
label: formatMessage(updatePopupMessages.reload),
action: () => installAvailableAppUpdate(),
color: 'brand',
},
{
label: formatMessage(updatePopupMessages.changelog),
action: () => openAppUpdateChangelog(),
keepOpen: true,
},
],
})
} else {
scheduleDelayedUpdatePopup()
return
}
markAppUpdatePopupShown(update.version, stage)
}
let lastUpdateChannel = 'release'
async function performUpdateCheck() {
const channel = await getUpdateChannel()
const preferences = await getUpdatePreferences()
updatesPaused.value = preferences.updatesPaused
if (updatesPaused.value) return 'paused'
if (channel !== lastUpdateChannel) {
availableUpdate.value = null
updateSize.value = null
appUpdateDownload.progress.value = 0
finishedDownloading.value = false
downloading.value = false
lastUpdateChannel = channel
}
const update = await checkAppUpdate(channel)
if (!update) {
console.log('No update available')
return 'up-to-date'
}
const publishedAt = Date.parse(update.publishedAt ?? '')
if (
channel === 'release' &&
!preferences.immediateUpdateFetch &&
!update.forceUpdate &&
(!Number.isFinite(publishedAt) || Date.now() < publishedAt + 24 * 60 * 60 * 1000)
) {
console.warn(
Number.isFinite(publishedAt)
? `Update ${update.version} is waiting for the release delay.`
: `Update ${update.version} has no valid published_at timestamp.`,
)
return 'up-to-date'
}
const isExistingUpdate = update.version === availableUpdate.value?.version
if (isExistingUpdate) {
console.log('Update is already known')
scheduleDelayedUpdatePopup()
return 'available'
}
appUpdateDownload.progress.value = 0
finishedDownloading.value = false
downloading.value = false
updateSize.value = null
availableUpdate.value = update
console.log(`Update ${update.version} is available.`)
metered.value = await isNetworkMetered()
if (!metered.value) {
console.log('Starting download of update')
downloadUpdate(update)
} else {
console.log(`Metered connection detected, not auto-downloading update.`)
markAppUpdateActionable(update.version)
scheduleDelayedUpdatePopup()
}
getUpdateSize(update.rid)
.then((size) => (updateSize.value = size))
.catch((error) => console.warn('Failed to fetch update size', error))
return 'available'
}
async function manualUpdateCheck() {
if (!(await areUpdatesEnabled())) {
updatesEnabled.value = false
return 'disabled'
}
updatesEnabled.value = true
updatesPaused.value = (await getUpdatePreferences()).updatesPaused
if (updatesPaused.value) return 'paused'
if (offline.value) {
return 'offline'
}
return await performUpdateCheck()
}
async function downloadAvailableUpdate() {
return downloadUpdate(availableUpdate.value)
}
async function downloadUpdate(versionToDownload) {
if (!versionToDownload) {
handleError(`Failed to download update: no version available`)
return
}
if (downloading.value || appUpdateDownload.progress.value !== 0) {
console.error(`Update ${versionToDownload.version} already downloading`)
return
}
console.log(`Downloading update ${versionToDownload.version} from Update Server`)
downloading.value = true
try {
enqueueUpdateForInstallation(versionToDownload.rid)
.then(() => {
downloading.value = false
finishedDownloading.value = true
unlistenUpdateDownload?.().then(() => {
unlistenUpdateDownload = null
})
console.log('Finished downloading!')
markAppUpdateActionable(versionToDownload.version, 'downloaded')
scheduleDelayedUpdatePopup()
})
.catch((error) => {
downloading.value = false
appUpdateDownload.progress.value = 0
unlistenUpdateDownload?.().then(() => {
unlistenUpdateDownload = null
})
handleError(error)
})
unlistenUpdateDownload = await subscribeToDownloadProgress(
appUpdateDownload,
versionToDownload.version,
)
} catch (error) {
downloading.value = false
appUpdateDownload.progress.value = 0
handleError(error)
}
}
async function installUpdate() {
restarting.value = true
try {
await backupAppDbForUpdate(availableUpdate.value?.version)
await setRestartAfterPendingUpdate(true)
} catch (e) {
restarting.value = false
handleError(e)
return
}
setTimeout(async () => {
await handleClose()
}, 250)
}
setAppUpdateActions({
check: manualUpdateCheck,
download: downloadAvailableUpdate,
install: installUpdate,
changelog: (version) => {
if (version && getAnnouncementByVersion(version)) {
openUrl(AxolotlBrandConfig.website)
} else {
openUrl(AxolotlBrandConfig.website)
}
},
})
async function openModrinthProjectLinkInApp(parsed) {
const { slug, pathSuffix, url } = parsed
const loadToken = loading.begin()
try {
const { id } = await tauriApiClient.labrinth.projects_v2.check(slug)
const query = mergeUrlQuery(route.query, url)
await router.push({
path: `/project/${id}${pathSuffix}`,
query,
hash: url.hash || undefined,
})
} catch (err) {
if (err instanceof ModrinthApiError && err.statusCode === 404) {
openUrl(url.href)
} else {
handleError(err)
}
} finally {
loading.end(loadToken)
}
}
function handleClick(e) {
let target = e.target
while (target != null) {
if (target.matches('a')) {
if (
target.href &&
['http://', 'https://', 'mailto:', 'tel:'].some((v) => target.href.startsWith(v)) &&
!target.classList.contains('router-link-active') &&
!target.href.startsWith('http://localhost') &&
!target.href.startsWith('https://tauri.localhost') &&
!target.href.startsWith('http://tauri.localhost')
) {
const parsed = parseModrinthLink(target.href)
if (target.target !== '_blank' && parsed) {
void openModrinthProjectLinkInApp(parsed)
} else {
openUrl(target.href)
}
}
e.preventDefault()
break
}
target = target.parentElement
}
}
function handleAuxClick(e) {
// disables middle click -> new tab
if (e.button === 1) {
e.preventDefault()
// instead do a left click
const event = new MouseEvent('click', {
view: window,
bubbles: true,
cancelable: true,
})
e.target.dispatchEvent(event)
}
}
provideAppUpdateDownloadProgress(appUpdateDownload)
</script>
<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"
class="launcher-background"
:style="customBackgroundStyle"
/>
<div
v-if="stateInitialized"
class="app-grid-layout relative"
:class="{
'disable-advanced-rendering': !themeStore.advancedRendering,
'has-custom-background': themeStore.customBackgroundPath && !themeStore.transparentBackground,
'has-transparent-background': themeStore.transparentBackground,
'is-maximized': isMaximized,
}"
>
<Transition name="fade">
<div
v-if="restarting"
data-tauri-drag-region
class="inset-0 fixed bg-black/80 backdrop-blur z-[200] flex items-center justify-center"
>
<span
data-tauri-drag-region
class="flex items-center gap-4 text-contrast font-semibold text-xl select-none cursor-default"
>
<RefreshCwIcon data-tauri-drag-region class="animate-spin w-6 h-6" />
{{ formatMessage(messages.restarting) }}
</span>
</div>
</Transition>
<Suspense>
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
</Suspense>
<InstanceIconPickerModal ref="instanceIconPickerModal" />
<CreationFlowModal
ref="installationModal"
type="instance"
:available-loaders="[...clientInstallableLoaders]"
show-snapshot-toggle
:fetch-existing-instance-names="fetchExistingInstanceNames"
:search-modpacks="searchModpacks"
:get-project-versions="getProjectVersions"
:has-compatible-opti-fabric="hasCompatibleOptiFabric"
:get-loader-manifest="getLoaderManifest"
:on-import-file-received="onImportFileReceived"
@create="handleCreate"
@browse-modpacks="handleBrowseModpacks"
/>
<UnknownPackWarningModal ref="unknownPackWarningModal" />
<div
class="app-grid-navbar bg-bg-raised flex flex-col p-[0.5rem] pt-0 gap-[0.5rem] w-[--left-bar-width] overflow-hidden"
>
<NavRail>
<NavButton v-tooltip.right="formatMessage(messages.home)" to="/">
<HomeIcon />
</NavButton>
<NavButton
v-if="themeStore.featureFlags.worlds_tab"
v-tooltip.right="formatMessage(messages.worlds)"
to="/worlds"
>
<WorldIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.discoverContent)"
data-onboarding-id="nav-discover"
:to="discoverContentPath"
:disabled="offline"
:is-primary="() => route.path.startsWith('/browse') && !route.query.i"
:is-subpage="(route) => route.path.startsWith('/project') && !route.query.i"
>
<CompassIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.skinSelector)"
data-onboarding-id="nav-skins"
to="/skins"
>
<ChangeSkinIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.library)"
data-onboarding-id="nav-library"
to="/library"
:is-primary="(r) => r.path === '/library' || r.path === '/library'"
:is-subpage="
() =>
route.path.startsWith('/instance') ||
((route.path.startsWith('/browse') || route.path.startsWith('/project')) &&
route.query.i)
"
>
<LibraryIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.lab)"
data-onboarding-id="nav-lab"
to="/lab"
:is-primary="(r) => r.path.startsWith('/lab')"
>
<FlaskConicalIcon />
</NavButton>
<NavButton
v-if="!themeStore.autoHideDownloadsButton || downloadManager.activeCount.value > 0"
v-tooltip.right="formatMessage(messages.downloads)"
data-onboarding-id="nav-downloads"
to="/downloads"
class="relative"
>
<DownloadIcon />
<span
v-if="downloadManager.activeCount.value > 0"
class="absolute right-0 top-0 min-w-4 rounded-full bg-brand px-1 text-center text-[10px] font-bold leading-4 text-white"
>
{{ Math.min(downloadManager.activeCount.value, 99) }}
</span>
</NavButton>
</NavRail>
<div class="h-px w-6 mx-auto my-2 bg-surface-5"></div>
<div class="quick-instance-scroll flex-1 min-h-0 overflow-x-hidden overflow-y-auto">
<suspense>
<QuickInstanceSwitcher />
</suspense>
</div>
<NavButton
v-tooltip.right="formatMessage(messages.createInstance)"
data-onboarding-id="create-instance"
to="/create"
:disabled="offline"
>
<PlusIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(commonMessages.settingsLabel)"
data-onboarding-id="nav-settings"
to="/settings"
>
<SettingsIcon />
</NavButton>
<OverflowMenu
v-if="AxolotlBrandConfig.capabilities.privateModrinthServices && credentials?.user"
v-tooltip.right="`Modrinth account`"
data-onboarding-id="account-entry"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast border-0 cursor-pointer"
:options="[
{
id: 'view-profile',
action: () => openUrl('https://modrinth.com/user/' + credentials.user.username),
},
{
id: 'sign-out',
action: () => logOut(),
color: 'danger',
},
]"
placement="right-end"
>
<Avatar :src="credentials?.user?.avatar_url" alt="" size="32px" circle />
<template #view-profile>
<UserIcon />
<span class="inline-flex items-center gap-1">
{{ formatMessage(messages.signedInAs) }}
<span class="inline-flex items-center gap-1 text-contrast font-semibold">
<Avatar :src="credentials?.user?.avatar_url" alt="" size="20px" circle />
{{ credentials?.user?.username }}
</span>
</span>
<ExternalIcon />
</template>
<template #sign-out> <LogOutIcon /> Sign out </template>
</OverflowMenu>
<NavButton
v-else-if="AxolotlBrandConfig.capabilities.privateModrinthServices"
v-tooltip.right="'Sign in to a Modrinth account'"
data-onboarding-id="account-entry"
:to="() => signIn()"
>
<LogInIcon class="text-brand" />
</NavButton>
</div>
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
<div data-tauri-drag-region class="flex min-w-0 flex-1 overflow-hidden p-3">
<div data-tauri-drag-region class="flex shrink-0 items-center gap-2">
<AxolotlLogo class="h-full w-auto shrink-0 pointer-events-none" />
<span
v-if="isBetaBuild"
class="inline-flex shrink-0 rounded-full bg-[#b6e9ff] px-2 py-0.5 text-xs font-semibold leading-none text-[#005bda]"
>
{{ formatMessage(messages.betaBuild) }}
</span>
</div>
<div data-tauri-drag-region class="flex shrink-0 items-center gap-1 ml-3">
<button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.back()"
>
<LeftArrowIcon />
</button>
<button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.forward()"
>
<RightArrowIcon />
</button>
</div>
<Breadcrumbs class="pt-[2px]" />
</div>
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
<div class="flex mr-3">
<Suspense>
<AppActionBar />
</Suspense>
</div>
<WindowControls />
</section>
</div>
</div>
<div
v-if="stateInitialized"
class="app-contents"
:class="{
'sidebar-enabled': sidebarVisible,
'studio-mode': route.name === 'FileStudio',
'disable-advanced-rendering': !themeStore.advancedRendering,
'has-custom-background': themeStore.customBackgroundPath && !themeStore.transparentBackground,
'has-transparent-background': themeStore.transparentBackground,
}"
>
<div class="app-viewport flex-grow router-view">
<div
class="loading-indicator-container h-8 fixed z-50 pointer-events-none"
:style="{
top: 'calc(var(--top-bar-height))',
left: 'calc(var(--left-bar-width))',
width: 'calc(100% - var(--left-bar-width) - var(--right-bar-width))',
}"
>
<LoadingBar position="absolute" />
</div>
<div
v-if="themeStore.featureFlags.page_path"
class="absolute bottom-0 left-0 m-2 bg-tooltip-bg text-tooltip-text font-semibold rounded-full px-2 py-1 text-xs z-50"
>
{{ route.fullPath }}
</div>
<div
id="background-teleport-target"
class="absolute h-full -z-10 rounded-tl-[--radius-xl] overflow-hidden"
:style="{
width: 'calc(100% - var(--right-bar-width))',
}"
></div>
<Admonition
v-if="authUnreachable"
type="warning"
:header="formatMessage(messages.authUnreachableHeader)"
class="m-6 mb-0"
>
{{ formatMessage(messages.authUnreachableBody) }}
</Admonition>
<div class="page-transition-grid grid min-h-full">
<RouterView v-slot="{ Component, route }">
<Transition name="page-slide" :css="themeStore.getFeatureFlag('page_transitions')">
<div v-if="Component" :key="getPageTransitionKey(route)" class="page-transition-layer">
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
<component :is="Component"></component>
</Suspense>
</div>
</Transition>
</RouterView>
</div>
</div>
<div
class="app-sidebar mt-px shrink-0 flex flex-col border-0 border-l-[1px] border-[--brand-gradient-border] border-solid"
>
<button
v-if="!forceSidebar && !forceSidebarHidden"
v-tooltip.left="
sidebarToggled
? formatMessage(messages.collapseSidebar)
: formatMessage(messages.expandSidebar)
"
class="sidebar-toggle-handle"
:aria-label="
sidebarToggled
? formatMessage(messages.collapseSidebar)
: formatMessage(messages.expandSidebar)
"
type="button"
@click="toggleSidebar"
>
<RightArrowIcon
class="w-2.5 h-2.5 -translate-x-[1px] transition-transform duration-300"
:class="{ 'rotate-180': !sidebarToggled }"
/>
</button>
<div
v-overlay-scrollbars="sidebarOverlayScrollbarsOptions"
class="app-sidebar-scrollable relative min-h-0 flex-1"
data-overlayscrollbars-initialize
>
<div id="sidebar-teleport-target" class="sidebar-teleport-content contents"></div>
<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.userInformation) }}
</h3>
<suspense>
<AccountsCard ref="accounts" />
</suspense>
</div>
<div id="sidebar-default-teleport-target"></div>
</div>
</div>
</div>
</div>
<I18nDebugPanel />
<NotificationPanel
:has-sidebar="sidebarVisible"
:on-error-action="exportNotificationErrorLogs"
:error-action-label="formatMessage(messages.exportErrorLogs)"
/>
<PopupNotificationPanel
:has-sidebar="sidebarVisible"
:on-error-action="exportNotificationErrorLogs"
:error-action-label="formatMessage(messages.exportErrorLogs)"
/>
<MinecraftCrashModal ref="minecraftCrashModal" @error="handleError" />
<JavaDownloadConfirmationModal ref="javaDownloadConfirmationModal" />
<NewModal
ref="closeChoiceModal"
:header="formatMessage(messages.closeLauncherTitle)"
:closable="false"
max-width="30rem"
>
<div class="grid grid-cols-2 gap-3">
<ButtonStyled color="brand">
<button
type="button"
:disabled="closeRequestInProgress"
@click="applyCloseChoice('close', closeChoiceRemember)"
>
{{ formatMessage(messages.closeLauncherDirect) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button
type="button"
:disabled="closeRequestInProgress"
@click="applyCloseChoice('lightweight', closeChoiceRemember)"
>
{{ formatMessage(messages.closeLauncherTray) }}
</button>
</ButtonStyled>
</div>
<div class="mt-4">
<Checkbox
v-model="closeChoiceRemember"
:disabled="closeRequestInProgress"
:label="formatMessage(messages.closeLauncherRemember)"
/>
</div>
</NewModal>
<ErrorModal ref="errorModal" />
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
<ContentInstallModal
ref="modInstallModal"
:instances="contentInstallInstances"
:compatible-loaders="contentInstallLoaders"
:game-versions="contentInstallGameVersions"
:loading="contentInstallLoading"
:default-tab="contentInstallDefaultTab"
:preferred-loader="contentInstallPreferredLoader"
:preferred-game-version="contentInstallPreferredGameVersion"
:release-game-versions="contentInstallReleaseGameVersions"
:project-info="contentInstallProjectInfo"
:symlink-target="contentInstallSymlinkTarget"
@install="handleInstallToInstance"
@create-and-install="handleCreateAndInstall"
@navigate="handleContentInstallNavigate"
@cancel="handleContentInstallCancel"
/>
<ContentInstallPreviewModal ref="contentInstallPreviewModal" />
<ModpackAlreadyInstalledModal
ref="modpackAlreadyInstalledModal"
@create-anyway="handleModpackDuplicateCreateAnyway"
@go-to-instance="handleModpackDuplicateGoToInstance"
/>
<AddServerToInstanceModal
ref="addServerToInstanceModal"
:symlink-target="addServerSymlinkTarget"
/>
<ContentUpdaterModal
ref="incompatibilityWarningModal"
:key="incompatWarningKey"
mode="incompatibility-warning"
:versions="contentInstallIncompatibilityWarningVersions"
:current-game-version="contentInstallIncompatibilityWarningCurrentGameVersion"
:current-loader="contentInstallIncompatibilityWarningCurrentLoader"
current-version-id=""
:is-app="true"
:project-type="contentInstallIncompatibilityWarningProjectType"
:project-icon-url="contentInstallIncompatibilityWarningProjectIconUrl"
:project-name="contentInstallIncompatibilityWarningProjectName"
:warning="contentInstallIncompatibilityWarningMessage"
:action-loading="contentInstallIncompatibilityWarningInstalling"
@update="handleIncompatibilityWarningUpdate"
@cancel="handleIncompatibilityWarningCancel"
@search-compat="handleDropInstallSearchCompat"
/>
<ModpackInstallModal
ref="contentInstallModpackInstallModal"
@install="handleContentInstallModpackInstall"
@cancel="handleContentInstallModpackInstallCancel"
/>
<TaggedModDownloadsModal />
<CurseForgeManualDownloadsModal
ref="contentInstallCurseForgeManualDownloadsModal"
@view-instance="handleContentInstallModpackDuplicateGoToInstance"
@imported="handleContentInstallCurseForgeManualDownloadsImported"
/>
<InstallToPlayModal ref="installToPlayModal" />
<UpdateToPlayModal ref="updateToPlayModal" />
<!-- Global drop overlay -->
<div
v-if="isDragging && !onSkinsPage"
class="fixed inset-0 z-[9999] bg-black/40 flex items-center justify-center pointer-events-none"
>
<div class="rounded-2xl border-2 border-dashed border-brand bg-surface-2/90 p-8 text-center">
<p class="text-lg text-contrast">{{ formatMessage(messages.dropOverlayTitle) }}</p>
<p class="text-sm text-secondary mt-2">{{ formatMessage(messages.dropOverlaySubtitle) }}</p>
</div>
</div>
<!-- Processing overlay -->
<div
v-if="(isProcessing || scanningInstances) && !isDragging && !onSkinsPage && !batchActive"
class="fixed inset-0 z-[9999] bg-black/20 flex items-center justify-center"
>
<div class="flex flex-col items-center gap-3">
<SpinnerIcon class="h-10 w-10 animate-spin text-contrast" />
<span v-if="scanningInstances" class="text-sm text-secondary"
>{{ formatMessage(messages.dropScanning) }}…</span
>
</div>
</div>
<!-- Drop type confirmation modal -->
<ConfirmDropTypeModal
ref="confirmDropModal"
:key="batchGroupKey"
:classification="dropClassification"
:file-name="dropFileName"
@confirm="handleConfirmDropConfirm"
@cancel="handleConfirmDropCancel"
@help="handleConfirmDropHelp"
/>
<!-- Generic content install modal (instance selection when not in an instance page) -->
<GenericContentInstallModal
ref="genericInstallModal"
@install="handleGenericInstall"
@cancel="handleGenericInstallCancel"
@navigate-create="handleGenericInstallNavigateCreate"
/>
<!-- Data pack world selection modal -->
<InstanceExportModal
ref="dataPackWorldModal"
:show-save-as="false"
@select="handleBatchOrDatapackWorldSelect"
@after-hide="handleBatchWorldAfterHide"
/>
<!-- Launcher import instance selection modal -->
<LauncherImportModal
ref="launcherImportModal"
@confirm="onImportSelected"
@cancel="onLauncherImportCancelled"
/>
<!-- Symlink method selection modal -->
<SymlinkMethodCards
ref="symlinkCardsModal"
@confirm="onSymlinkMethodConfirmed"
@cancel="onSymlinkMethodCancelled"
/>
<NewModal ref="compatibleModeConfirmModal" max-width="560px" :closable="true">
<template #title>
<span class="text-contrast">{{ formatMessage(messages.dropCompatibleModeTitle) }}</span>
</template>
<div class="flex flex-col gap-4">
<span class="text-secondary text-sm">{{
formatMessage(messages.dropCompatibleModeDesc)
}}</span>
<div class="grid grid-cols-2 gap-3">
<BigOptionButton
:icon="FolderOpenIcon"
:title="formatMessage(messages.dropCompatibleModeImport)"
:description="formatMessage(messages.dropCompatibleModeImport)"
no-icon-border
@click="handleCompatibleModeConfirm('compatible')"
/>
<BigOptionButton
:icon="RotateCounterClockwiseIcon"
:title="formatMessage(messages.dropCompatibleModeOldWay)"
:description="formatMessage(messages.dropCompatibleModeOldWay)"
no-icon-border
@click="handleCompatibleModeConfirm('old-way')"
/>
</div>
</div>
<template #actions>
<div class="flex w-full items-center justify-end">
<ButtonStyled>
<button class="flex items-center gap-2" @click="handleCompatibleModeConfirm('cancel')">
{{ formatMessage(messages.dropCompatibleModeCancel) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<!-- Batch drag-and-drop import UI -->
<BatchScanOverlay
v-if="batchPhase === 'scanning'"
:items="batchItems"
:done="batchScanDone"
:total="batchOriginalCount"
@cancel="cancelBatchScan"
/>
<OnboardingOverlay
:visible="showOnboarding"
:mode="onboardingMode"
@complete="finishOnboarding"
@skip="skipOnboarding"
@request-close-settings="closeOnboardingSettings"
/>
</template>
<style lang="scss" scoped>
@property --right-bar-width {
syntax: '<length>';
inherits: true;
initial-value: 0px;
}
.app-grid-layout,
.app-contents {
--top-bar-height: 3rem;
--left-bar-width: 4rem;
--right-bar-width: 300px;
}
.app-contents.studio-mode {
grid-template-columns: 1fr 0;
.app-sidebar {
display: none;
}
}
.app-contents.studio-mode {
top: var(--top-bar-height);
height: calc(100vh - var(--top-bar-height));
}
.app-grid-layout {
display: grid;
grid-template: 'status status' 'nav dummy';
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr;
position: relative;
//z-index: 0;
background-color: var(--color-raised-bg);
height: 100vh;
}
.quick-instance-scroll {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.launcher-background {
position: fixed;
inset: -3rem;
z-index: 0;
pointer-events: none;
background-position: center;
background-size: cover;
background-repeat: no-repeat;
transition:
filter 180ms ease,
opacity 180ms ease;
}
.app-grid-layout.has-custom-background,
.app-grid-layout.has-transparent-background {
&:not(.is-maximized) {
border-radius: 8px;
clip-path: inset(0 round 8px);
overflow: hidden;
}
background-color: transparent;
}
.app-grid-layout.has-custom-background {
.app-grid-navbar,
.app-grid-statusbar {
background-color: color-mix(in srgb, var(--color-raised-bg) 82%, transparent) !important;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
.app-grid-navbar {
grid-area: nav;
position: relative;
z-index: 2;
user-select: none;
}
.app-grid-statusbar {
grid-area: status;
padding-right: var(--window-controls-width, 0px);
position: relative;
z-index: 200;
}
[data-tauri-drag-region-exclude] {
-webkit-app-region: no-drag;
}
.app-contents {
position: absolute;
z-index: 1;
left: var(--left-bar-width);
top: var(--top-bar-height);
right: 0;
bottom: 0;
height: calc(100vh - var(--top-bar-height));
background-color: var(--color-bg);
border-top-left-radius: var(--radius-xl);
overflow: hidden;
--right-bar-width: 0px;
display: grid;
grid-template-columns: 1fr var(--right-bar-width);
// 显式行高:让 .app-viewport 的 height: 100% 有确定参照(隐式 auto 行会使百分比高度失效)
grid-template-rows: 1fr;
&.sidebar-enabled {
--right-bar-width: 300px;
}
&.has-custom-background,
&.has-transparent-background {
background-color: color-mix(in srgb, var(--color-bg) 76%, transparent);
border-top-left-radius: 0;
&::before {
border: none;
box-shadow: none;
}
}
&.has-custom-background {
.loading-indicator-container {
border-top-left-radius: 0;
}
}
}
@media (prefers-reduced-motion: no-preference) {
.app-contents {
transition: --right-bar-width 320ms cubic-bezier(0.22, 1, 0.36, 1);
}
}
.app-grid-layout.has-transparent-background {
.app-grid-navbar,
.app-grid-statusbar {
background-color: color-mix(
in srgb,
var(--surface-3-opaque) var(--window-alpha-chrome),
transparent
) !important;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
// Without native decorations or rounded corners the window edge dissolves
// into the desktop, so it needs drawing. Dark outside, light inside, to stay
// legible over any wallpaper.
&::after {
content: '';
position: fixed;
inset: 0;
border-radius: inherit;
z-index: 100;
pointer-events: none;
box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.5),
inset 0 0 0 2px rgba(255, 255, 255, 0.14);
}
}
.app-contents.has-transparent-background {
background-color: color-mix(
in srgb,
var(--surface-3-opaque) var(--window-alpha-chrome),
transparent
);
&::before {
position: absolute;
inset: 0;
z-index: -10;
border: none;
box-shadow: none;
border-top-left-radius: var(--radius-xl);
background-color: color-mix(
in srgb,
var(--surface-1-opaque) calc(var(--window-alpha) * 0.82),
transparent
);
}
:deep(.browse-install-header) {
background-color: color-mix(in srgb, var(--surface-1-opaque) 68%, transparent) !important;
backdrop-filter: blur(20px) saturate(115%);
-webkit-backdrop-filter: blur(20px) saturate(115%);
}
}
.loading-indicator-container {
border-top-left-radius: var(--radius-xl);
overflow: hidden;
}
.app-sidebar {
overflow: visible;
width: 300px;
position: relative;
z-index: 11;
height: calc(100vh - var(--top-bar-height));
background: var(--brand-gradient-bg);
--color-button-bg: var(--brand-gradient-button);
--color-button-bg-hover: var(--brand-gradient-border);
--color-divider: var(--brand-gradient-border);
--color-divider-dark: var(--brand-gradient-border);
}
.disable-advanced-rendering {
.app-sidebar::before {
box-shadow: none;
}
&.app-contents::before {
box-shadow: none;
}
*,
:deep(*) {
box-shadow: none !important;
--tw-drop-shadow: initial;
}
}
.app-sidebar::before {
content: '';
box-shadow: -15px 0 15px -15px rgba(0, 0, 0, 0.1) inset;
top: 0;
bottom: 0;
left: -2rem;
width: 2rem;
position: absolute;
pointer-events: none;
}
.sidebar-toggle-handle {
--handle-bg: color-mix(in srgb, var(--color-brand) 12%, var(--color-bg));
--handle-bg-hover: color-mix(in srgb, var(--color-brand) 20%, var(--color-bg));
--handle-border: var(--brand-gradient-border);
--handle-border-hover: color-mix(in srgb, var(--color-brand) 45%, transparent);
position: absolute;
top: 50%;
left: -15px;
transform: translateY(-50%);
z-index: 12;
display: flex;
align-items: center;
justify-content: center;
width: 15px;
height: 40px;
padding: 0;
// 只让外侧(左边)圆润,右边与 Sidebar 完全贴合,单一元素完成形状
border: 1px solid var(--handle-border);
border-right: none;
border-radius: 12px 0 0 12px;
background-color: var(--handle-bg);
color: var(--color-contrast);
cursor: pointer;
box-shadow: -4px 0 10px rgba(0, 0, 0, 0.08);
transition:
background-color 180ms ease,
border-color 180ms ease,
color 180ms ease;
&:hover {
color: var(--color-button-text-selected);
background-color: var(--handle-bg-hover);
border-color: var(--handle-border-hover);
}
&:hover svg {
transform: scale(1.12);
}
&:active svg {
transform: scale(0.9);
}
&:focus-visible {
outline: 2px solid var(--color-button-text-selected);
outline-offset: 1px;
}
svg {
transform-origin: center;
transition: transform 180ms ease;
}
}
.app-viewport {
flex-grow: 1;
height: 100%;
overflow: auto;
overflow-x: hidden;
scrollbar-gutter: stable;
padding-bottom: var(--floating-action-bar-clearance, 0px);
}
.app-contents::before {
z-index: 30;
content: '';
position: fixed;
left: var(--left-bar-width);
top: var(--top-bar-height);
right: calc(-1 * var(--left-bar-width));
bottom: calc(-1 * var(--left-bar-width));
border-radius: var(--radius-xl);
box-shadow: 1px 1px 15px rgba(0, 0, 0, 0.1) inset;
border-color: var(--surface-5);
border-width: 1px;
border-style: solid;
pointer-events: none;
}
.sidebar-teleport-content:empty + .sidebar-default-content.sidebar-enabled {
display: contents;
}
@media (prefers-reduced-motion: no-preference) {
.nav-button-animated-enter-active {
transition: all 0.5s cubic-bezier(0.15, 1.4, 0.64, 0.96);
}
.nav-button-animated-leave-active {
transition: all 0.25s ease;
}
.nav-button-animated-enter-active {
position: relative;
}
.nav-button-animated-enter-active::before {
content: '';
inset: 0;
border-radius: 100vw;
background-color: var(--color-brand-highlight);
position: absolute;
animation: pop 0.5s ease-in forwards;
opacity: 0;
}
@keyframes pop {
0% {
scale: 0.5;
}
50% {
opacity: 0.5;
}
100% {
scale: 1.5;
}
}
.nav-button-animated-enter-from {
scale: 0.5;
translate: -2rem 0;
opacity: 0;
}
.nav-button-animated-leave-to {
scale: 0.75;
opacity: 0;
}
.fade-enter-active {
transition: 0.25s ease-in-out;
}
.fade-enter-from {
opacity: 0;
}
}
</style>
<style>
.os-theme-dark,
.os-theme-light {
--os-handle-bg: var(--color-scrollbar) !important;
--os-handle-bg-hover: var(--color-scrollbar) !important;
--os-handle-bg-active: var(--color-scrollbar) !important;
}
.mac {
.app-grid-statusbar {
padding-left: 5rem;
}
}
.windows {
.fake-appbar {
height: 2.5rem !important;
}
.info-card {
right: 22rem;
}
.profile-card {
right: 8rem;
}
}
</style>