forked from AxTps/Starlight_Lancher
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.
3122 lines
90 KiB
Vue
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>
|