Files
Starlight_Lancher/apps/app-frontend/src/App.vue
Ap_Tx 4dae11919f
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
重构:移除多人服务器组件及相关逻辑
2026-09-10 22:32:32 +08:00

3209 lines
92 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,
UsersIcon,
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 { 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 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 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 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'
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,
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 {
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 privacyConsentModal = ref<InstanceType<typeof PrivacyConsentModal>>()
const privacyConsentPending = 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:
'Axolotl 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 Axolotl 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',
},
playingAs: {
id: 'app.minecraft.playing-as',
defaultMessage: 'Playing as',
},
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,
telemetry,
telemetry_consent_version,
discord_rpc,
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('/')
privacyConsentPending.value = telemetry_consent_version < 1
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
if (privacyConsentPending.value) {
await nextTick()
privacyConsentModal.value?.show({
telemetry,
discord_rpc,
consent_version: telemetry_consent_version,
})
} else {
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 || 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)
}
}
provide('replayOnboarding', replayOnboarding)
provide(
minecraftLaunchErrorKey,
async (launchError, payload) =>
(await minecraftCrashModal.value?.handleLaunchError(launchError, payload)) ?? false,
)
provide('previewMinecraftCrashModal', () => minecraftCrashModal.value?.showPreview())
provide('showLauncherPopup', (_request: unknown) => {})
provide('previewPrivacyConsentModal', previewPrivacyConsentModal)
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 lastDiscordActivity = null
let discordActivityUpdate = Promise.resolve()
let suspensePending = false
const sidebarOverlayScrollbarsOptions = Object.freeze({
overflow: {
x: 'hidden',
y: 'scroll',
},
})
router.beforeEach(() => {
suspensePending = false
if (routerToken) loading.end(routerToken)
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) => {
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) {
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) {
syncDiscordActivity(router.currentRoute.value)
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,
trackEvent,
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)
}
trackEvent('InstanceCreate', {
source: 'CreationModalFileDrop',
})
}
} 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: `Axolotl 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: `Axolotl Launcher v{version} has finished downloading. Reload to update now, or automatically when you close Axolotl 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 />
<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.multiplayer)"
to="/multiplayer"
:is-primary="(r) => r.path.startsWith('/multiplayer')"
>
<UsersIcon />
</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.playingAs) }}
</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" />
<PrivacyConsentModal ref="privacyConsentModal" @saved="handlePrivacyConsentSaved" />
<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"
/>
<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>