Files
launcher/src/app/layout.tsx

966 lines
39 KiB
TypeScript

'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Sidebar from './componets/Sidebar'
import './Globals.css'
import { DownloadProgress } from './types/DownloadProgress'
import { invoke } from '@tauri-apps/api/core'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {
faAdd,
faCheck,
faChevronLeft,
faCode,
faDownload,
faInfo,
faRemove,
faShieldHalved,
faWarning,
faXmark
} from '@fortawesome/free-solid-svg-icons'
import {
readNormalConfig,
readVersionsConfig,
writeVersionsConfig
} from './util/BazookaManager'
import { VersionsConfig } from './types/VersionsConfig'
import { NormalConfig } from './types/NormalConfig'
import { app } from '@tauri-apps/api'
import axios from 'axios'
import { openUrl } from '@tauri-apps/plugin-opener'
import { GlobalProvider } from './GlobalProvider'
import { Roboto } from 'next/font/google'
import { ServerVersionsResponse } from './types/ServerVersionsResponse'
import { GameVersion } from './types/GameVersion'
import { Game } from './types/Game'
import { listen } from '@tauri-apps/api/event'
import { usePathname } from 'next/navigation'
import { arch, platform } from '@tauri-apps/plugin-os'
import VersionInfo from './componets/VersionInfo'
import prettyBytes from 'pretty-bytes'
import ProgressBar from './componets/ProgressBar'
import { notifyUser } from './util/Notifications'
import {
isPermissionGranted,
requestPermission
} from '@tauri-apps/plugin-notification'
import VersionChangelog from './componets/VersionChangelog'
import { BaseDirectory, exists, remove } from '@tauri-apps/plugin-fs'
import VersionUpdateWarning from './componets/VersionUpdateWarning'
const roboto = Roboto({
subsets: ['latin']
})
export default function RootLayout ({
children
}: {
children: React.ReactNode
}) {
const [loading, setLoading] = useState(true)
const [loadingText, setLoadingText] = useState('Loading...')
const [outdated, setOutdated] = useState(false)
const [version, setVersion] = useState<string | null>(null)
const [serverVersionList, setServerVersionList] =
useState<null | ServerVersionsResponse>(null)
const [selectedVersionList, setSelectedVersionList] = useState<string[]>([])
const [downloadedVersionsConfig, setDownloadedVersionsConfig] =
useState<VersionsConfig | null>(null)
const [normalConfig, setNormalConfig] = useState<NormalConfig | null>(null)
const [showPopup, setShowPopup] = useState(false)
const [popupMode, setPopupMode] = useState<null | number>(null)
const [fadeOut, setFadeOut] = useState(false)
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress[]>(
[]
)
const [managingVersion, setManagingVersion] = useState<string | null>(null)
const [viewingInfoFromDownloads, setViewingInfoFromDownloads] =
useState<boolean>(false)
const [selectedGame, setSelectedGame] = useState<number | null>(null)
const pathname = usePathname()
const revisionCheck = useRef(false)
function getSpecialVersionsList (game?: number): GameVersion[] {
if (!normalConfig || !serverVersionList) return []
return serverVersionList.versions
.filter(
v => !Object.keys(downloadedVersionsConfig?.list ?? []).includes(v.id)
)
.filter(v => {
if (
platform() === 'linux' &&
v.wine &&
!normalConfig.settings.useWineOnUnixWhenNeeded
)
return false
if (game && v.game != game) return false
if (downloadProgress.length != 0) {
return !downloadProgress.some(d => d.version === v.id)
}
return true
})
.sort((a, b) => {
if (b.game !== a.game) return a.game - b.game
return 0
})
}
const getVersionInfo = useCallback(
(id: string | undefined): GameVersion | undefined => {
if (!id) return undefined
return serverVersionList?.versions.find(v => v.id === id)
},
[serverVersionList]
)
const getGameInfo = useCallback(
(game: number | undefined): Game | undefined => {
if (!game) return undefined
return serverVersionList?.games.find(g => g.id === game)
},
[serverVersionList]
)
function getListOfGames (): Game[] {
if (!downloadedVersionsConfig?.list) return []
const gamesMap = new Map<number, Game>()
Object.keys(downloadedVersionsConfig.list).forEach(i => {
const version = getVersionInfo(i)
if (!version) return
const game = getGameInfo(version.game)
if (!game) return
gamesMap.set(game.id, game)
})
return Array.from(gamesMap.values())
}
function getVersionsAmountData (gameId: number): {
installed: number
total: number
} | null {
if (!downloadedVersionsConfig || !serverVersionList) return null
const allowWine =
platform() !== 'linux' || normalConfig?.settings.useWineOnUnixWhenNeeded
const installed = Object.keys(downloadedVersionsConfig.list).filter(v => {
const info = getVersionInfo(v)
if (!info) return false
if (info.wine && !allowWine) return false
return getGameInfo(info.game)?.id === gameId
}).length
const total = serverVersionList.versions.filter(v => {
if (!v) return false
if (v.wine && !allowWine) return false
return getGameInfo(v.game)?.id === gameId
}).length
return { installed, total }
}
function formatEtaSmart (seconds: number) {
if (seconds < 60) return `${Math.floor(seconds)}s`
if (seconds < 3600)
return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`
if (seconds < 86400) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
return `${h}h ${m}m`
}
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
return `${d}d ${h}h`
}
function closePopup () {
if (popupMode == 0 && selectedGame && pathname === '/') {
setSelectedGame(null)
setSelectedVersionList([])
} else if (viewingInfoFromDownloads) {
setViewingInfoFromDownloads(false)
setPopupMode(0)
} else if (popupMode == 4) {
setPopupMode(3)
} else {
setFadeOut(true)
setTimeout(() => setShowPopup(false), 200)
}
}
useEffect(() => {
let unlistenProgress: (() => void) | null = null
listen<string>('download-progress', event => {
const [displayName, progStr, totalSizeStr, speedStr, etaSecsStr] =
event.payload.split(':')
const prog = Number(progStr)
const progBytes = Number(totalSizeStr)
const speed = Number(speedStr)
const etaSecs = Number(etaSecsStr)
setDownloadProgress(prev => {
const i = prev.findIndex(d => d.version === displayName)
if (i === -1) return prev
const copy = [...prev]
copy[i] = {
...copy[i],
progress: prog,
progressBytes: progBytes,
speed,
etaSecs
}
return copy
})
}).then(f => (unlistenProgress = f))
listen<string>('download-hash-checking', event => {
const displayName = event.payload
setDownloadProgress(prev => {
const i = prev.findIndex(d => d.version === displayName)
if (i === -1) return prev
const copy = [...prev]
copy[i] = { ...copy[i], hash_checking: true }
return copy
})
}).then(f => (unlistenProgress = f))
listen<string>('download-finishing', event => {
const displayName = event.payload
setDownloadProgress(prev => {
const i = prev.findIndex(d => d.version === displayName)
if (i === -1) return prev
const copy = [...prev]
copy[i] = { ...copy[i], hash_checking: false, finishing: true }
return copy
})
}).then(f => (unlistenProgress = f))
return () => {
unlistenProgress?.()
}
}, [])
useEffect(() => {
;(async () => {
const client = await app.getVersion()
setVersion(client)
if (process.env.NODE_ENV === 'production') {
setLoadingText('Checking latest version...')
try {
const response = await axios.get(
'https://games.lncvrt.xyz/api/launcher/latest'
)
if (response.data !== client) {
setOutdated(true)
return
}
} catch {
setLoadingText('Failed to check latest version.')
return
}
}
setLoadingText('Downloading version list...')
try {
const res = await axios.get(
`https://games.lncvrt.xyz/api/launcher/versions?platform=${platform()}&arch=${arch()}`
)
setServerVersionList(res.data)
} catch {
setLoadingText('Failed to download versions list.')
return
}
setLoadingText('Loading configs...')
const normalConfig = await readNormalConfig()
const versionsConfig = await readVersionsConfig()
setDownloadedVersionsConfig(versionsConfig)
setNormalConfig(normalConfig)
setLoading(false)
if (!(await isPermissionGranted())) {
await requestPermission()
}
})()
}, [])
useEffect(() => {
if (process.env.NODE_ENV !== 'production') return
const handler = (e: MouseEvent) => e.preventDefault()
document.addEventListener('contextmenu', handler)
return () => document.removeEventListener('contextmenu', handler)
}, [])
const downloadVersions = useCallback(
async (list: string[]): Promise<void> => {
if (list.length === 0) return
setSelectedVersionList([])
const newDownloads = list.map(
version =>
new DownloadProgress(version, 0, 0, false, true, false, false, 0, 0)
)
setDownloadProgress(newDownloads)
for (const download of newDownloads) {
const info = getVersionInfo(download.version)
if (!info) {
setDownloadProgress(prev =>
prev.filter(d => d.version !== download.version)
)
continue
}
const gameInfo = getGameInfo(info.game)
if (!gameInfo) {
setDownloadProgress(prev =>
prev.filter(d => d.version !== download.version)
)
continue
}
setDownloadProgress(prev =>
prev.map(d =>
d.version === download.version ? { ...d, queued: false } : d
)
)
try {
await axios.get(
'https://games.lncvrt.xyz/api/launcher/download?id=' + info.id
)
} catch {}
const res = await invoke<string>('download', {
url: info.downloadUrl,
name: info.id,
executable: info.executable,
hash: info.sha512sum
})
if (res === '1') {
setDownloadProgress(prev =>
prev.filter(d => d.version !== download.version)
)
setDownloadedVersionsConfig(prev => {
if (!prev) return prev
const updated = {
...prev,
list: {
...prev.list,
[download.version]: Date.now()
}
}
writeVersionsConfig(updated)
return updated
})
} else {
setDownloadProgress(prev =>
prev.map(d =>
d.version === download.version
? { ...d, queued: false, failed: true, progress: 0 }
: d
)
)
if (normalConfig?.settings.allowNotifications)
await notifyUser(
'Download Failed',
`The download for version ${info.displayName} has failed.`
)
}
}
if (normalConfig?.settings.allowNotifications)
await notifyUser('Downloads Finished', 'All downloads have finished.')
setFadeOut(true)
setTimeout(() => setShowPopup(false), 200)
},
[getGameInfo, getVersionInfo, normalConfig]
)
useEffect(() => {
if (revisionCheck.current) return
if (!serverVersionList || !downloadedVersionsConfig) return
revisionCheck.current = true
;(async () => {
for (const [key, value] of Object.entries(
downloadedVersionsConfig.list
)) {
const verInfo = serverVersionList.versions.find(item => item.id === key)
if (
!verInfo ||
(verInfo.lastRevision > 0 && value / 1000 <= verInfo.lastRevision)
) {
if (
await exists('game/' + key + '/' + verInfo?.executable, {
baseDir: BaseDirectory.AppLocalData
})
)
await remove('game/' + key + '/' + verInfo?.executable, {
baseDir: BaseDirectory.AppLocalData,
recursive: true
})
}
}
})()
}, [serverVersionList, downloadedVersionsConfig, downloadVersions])
return (
<>
<html lang='en' className={roboto.className}>
<body
className={
normalConfig?.settings.theme === 1
? 'red-theme'
: normalConfig?.settings.theme === 2
? 'blue-theme'
: normalConfig?.settings.theme === 3
? 'purple-theme'
: 'dark-theme'
}
>
{loading ? (
<div className='w-screen h-screen flex items-center justify-center'>
{outdated ? (
<div className='text-center'>
<p className='text-8xl mb-4'>Outdated Launcher!</p>
<p className='text-4xl mb-4'>
Please update to the latest version to continue.
</p>
<button
className='button'
onClick={() =>
openUrl('https://games.lncvrt.xyz/berrydash/download')
}
>
Download latest version
</button>
</div>
) : (
<p className='text-7xl text-center'>{loadingText}</p>
)}
</div>
) : (
<GlobalProvider
value={{
serverVersionList,
selectedVersionList,
setSelectedVersionList,
downloadProgress,
setDownloadProgress,
showPopup,
setShowPopup,
popupMode,
setPopupMode,
fadeOut,
setFadeOut,
downloadedVersionsConfig,
setDownloadedVersionsConfig,
normalConfig,
setNormalConfig,
managingVersion,
setManagingVersion,
getVersionInfo,
getGameInfo,
getListOfGames,
setSelectedGame,
getVersionsAmountData,
viewingInfoFromDownloads,
version,
downloadVersions
}}
>
<div
tabIndex={0}
onKeyDown={e => {
if (showPopup && e.key === 'Escape') closePopup()
}}
>
<Sidebar />
<div
className='relative z-2 ml-59.75 w-[calc(100vw-239px)] border-b border-(--col3) h-8.25 bg-(--col1)'
style={{
display: platform() === 'windows' ? 'block' : 'none'
}}
/>
<div className='relative z-0'>
<main className='ml-60'>{children}</main>
</div>
{showPopup && (
<div
className={`popup-overlay ${fadeOut ? 'fade-out' : ''}`}
onClick={(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
if (viewingInfoFromDownloads) {
setPopupMode(0)
setViewingInfoFromDownloads(false)
setManagingVersion(null)
setSelectedGame(null)
}
setFadeOut(true)
setTimeout(() => setShowPopup(false), 200)
}
}}
>
<div className='popup-box'>
<button
className='close-button btntheme1'
onClick={() => closePopup()}
>
<FontAwesomeIcon
icon={
(popupMode == 0 &&
selectedGame &&
pathname === '/') ||
viewingInfoFromDownloads ||
popupMode == 4
? faChevronLeft
: faXmark
}
/>
</button>
{popupMode === 0 && selectedGame ? (
<>
<p className='text-xl text-center'>
Select versions to download
</p>
<div className='popup-content'>
{getSpecialVersionsList(selectedGame).map(
(v, i) => (
<div key={i} className='popup-entry'>
<div className='flex items-center'>
<p
className={`text-2xl truncate ${
selectedVersionList.includes(v.id)
? 'max-w-84.5'
: 'max-w-91.5'
}`}
>
{v.displayName}
</p>
</div>
<button
className='button btntheme3 right-20.75 bottom-1.75'
onClick={() => {
setSelectedVersionList(prev =>
prev.includes(v.id)
? prev.filter(i => i !== v.id)
: [...prev, v.id]
)
}}
title={
selectedVersionList.includes(v.id)
? 'This version will be downloaded. Click to remove from the list of versions that will be downloaded.'
: 'This version will NOT be downloaded. Click to add from the list of versions that will be downloaded.'
}
>
{selectedVersionList.includes(v.id) ? (
<>
<FontAwesomeIcon icon={faRemove} />{' '}
Remove
</>
) : (
<>
<FontAwesomeIcon icon={faAdd} /> Add
</>
)}
</button>
<button
className='button btntheme3 right-1.5 bottom-1.75'
onClick={() => {
setManagingVersion(v.id)
setViewingInfoFromDownloads(true)
setPopupMode(3)
}}
title='Click to view version info'
>
<FontAwesomeIcon icon={faInfo} /> Info
</button>
</div>
)
)}
</div>
</>
) : popupMode === 0 && !selectedGame ? (
<>
<p className='text-xl text-center'>
Select a game to download
</p>
<div className='popup-content'>
{serverVersionList?.games
.filter(v => {
const data = getVersionsAmountData(v.id)
if (!data) return false
if (data.total > 0) return true
})
.map((v, i) => (
<div key={i} className='popup-entry'>
<p className='text-2xl'>{v.name}</p>
<div className='flex gap-2'>
<div
className='entry-info-item btntheme3'
title='The amount of versions installed of this game in installed/installable format.'
>
<p>
{(() => {
const data = getVersionsAmountData(
v.id
)
if (!data) return 'N/A'
return `${data.installed}/${data.total}`
})()}{' '}
versions installed
</p>
</div>
<div
className='entry-info-item btntheme3'
hidden={!v.official}
title='This game is official.'
>
<FontAwesomeIcon
icon={faCheck}
color='#19c84b'
/>
<p>Official</p>
</div>
<div
className='entry-info-item btntheme3'
hidden={v.official}
title={
v.verified
? 'This game is verified to be safe'
: 'This game is NOT verified to be save. Proceed with caution.'
}
>
<FontAwesomeIcon
icon={
v.verified
? faShieldHalved
: faWarning
}
color={
v.verified ? '#19c84b' : '#ffc800'
}
/>
<p>
{v.verified ? 'Verified' : 'Unverified'}
</p>
</div>
</div>
<div
className='entry-info-item btntheme3 mt-2'
hidden={v.developer == null}
title={`The developer of ${v.name} is ${v.developer}.`}
>
<FontAwesomeIcon
icon={faCode}
color='lightgray'
/>
<p>Developer: {v.developer}</p>
</div>
<button
className='button btntheme3 right-2 bottom-2'
onClick={() => setSelectedGame(v.id)}
title={`Click to download specific versions of the game. You have ${(() => {
const data = getVersionsAmountData(v.id)
if (!data) return 'N/A'
return `${data.installed} of ${data.total}`
})()} versions downloaded.`}
>
<>
<FontAwesomeIcon icon={faDownload} />{' '}
Download
</>
</button>
</div>
))}
</div>
</>
) : popupMode === 1 ? (
<>
<p className='text-xl text-center'>Downloads</p>
<div className='popup-content'>
{downloadProgress.map((v, i) => (
<div
key={i}
className='popup-entry flex flex-col justify-between'
>
<p className='text-2xl text-center'>
{getVersionInfo(v.version)?.displayName}
</p>
<div className='mt-6.25 flex items-center justify-between'>
{v.failed ? (
<>
<div className='flex items-center'>
<span className='text-red-500 inline-block w-full text-center'>
Download failed
</span>
<button
className='button btntheme3 ml-30 mb-2'
onClick={() => {
setDownloadProgress(prev =>
prev.filter(
d => d.version !== v.version
)
)
}}
title='Click to remove this version from this menu.'
>
Cancel
</button>
</div>
</>
) : v.queued ? (
<span className='text-yellow-500 inline-block w-full text-center'>
Queued
</span>
) : v.queued ? (
<span className='text-yellow-500 inline-block w-full text-center'>
Queued
</span>
) : v.hash_checking ? (
<span className='text-blue-500 inline-block w-full text-center'>
Checking hash...
</span>
) : v.finishing ? (
<span className='text-green-500 inline-block w-full text-center'>
Finishing...
</span>
) : (
<div className='flex flex-col gap-1 w-full'>
<span className='text-center'>
Downloaded{' '}
{prettyBytes(v.progressBytes, {
minimumFractionDigits: 1,
maximumFractionDigits: 1
})}{' '}
of{' '}
{prettyBytes(
getVersionInfo(v.version)?.size ?? 0,
{
minimumFractionDigits: 1,
maximumFractionDigits: 1
}
)}{' '}
(ETA: {formatEtaSmart(v.etaSecs)} &bull;
Speed:{' '}
{prettyBytes(v.speed, {
minimumFractionDigits: 1,
maximumFractionDigits: 1
})}
/s)
</span>
<ProgressBar
progress={v.progress}
className='w-full'
/>
</div>
)}
</div>
</div>
))}
</div>
</>
) : popupMode === 2 ? (
managingVersion ? (
<>
<p className='text-xl text-center'>
Manage{' '}
{getVersionInfo(managingVersion)?.displayName}
</p>
<div className='popup-content flex flex-col items-center justify-center gap-2 h-full'>
<button
className='button btntheme2'
disabled={downloadProgress.length != 0}
onClick={async () => {
closePopup()
setDownloadedVersionsConfig(prev => {
if (!prev) return prev
const updatedList = Object.fromEntries(
Object.entries(prev.list).filter(
([k]) => k !== managingVersion
)
)
const updatedConfig = {
...prev,
list: updatedList
}
writeVersionsConfig(updatedConfig)
return updatedConfig
})
if (
await exists('game/' + managingVersion, {
baseDir: BaseDirectory.AppLocalData
})
)
await remove('game/' + managingVersion, {
baseDir: BaseDirectory.AppLocalData,
recursive: true
})
}}
title='Click to uninstall this game. This will NOT remove any progress or any save files.'
>
Uninstall
</button>
<button
className='button btntheme2'
disabled={downloadProgress.length != 0}
onClick={async () => {
//change popup to downloads
setManagingVersion(null)
setPopupMode(1)
//uninstall
setDownloadedVersionsConfig(prev => {
if (!prev) return prev
const updatedList = Object.fromEntries(
Object.entries(prev.list).filter(
([k]) => k !== managingVersion
)
)
const updatedConfig = {
...prev,
list: updatedList
}
writeVersionsConfig(updatedConfig)
return updatedConfig
})
if (
await exists('game/' + managingVersion, {
baseDir: BaseDirectory.AppLocalData
})
)
await remove('game/' + managingVersion, {
baseDir: BaseDirectory.AppLocalData,
recursive: true
})
//reinstall
setSelectedVersionList([managingVersion])
downloadVersions([managingVersion])
}}
title="Click to reinstall this game. This will NOT remove any progress or any save files. This WILL uninstall any modifications to the game's executable files."
>
Reinstall
</button>
<button
className='button btntheme2'
onClick={async () =>
invoke('open_folder', {
name: managingVersion
})
}
title="Click to browse the game's files."
>
Open Folder
</button>
</div>
</>
) : (
<p className='text-xl text-center'>
No version selected
</p>
)
) : popupMode === 3 ? (
managingVersion && downloadedVersionsConfig ? (
<VersionInfo />
) : (
<p className='text-xl text-center'>
No version selected
</p>
)
) : popupMode === 4 ? (
managingVersion && downloadedVersionsConfig ? (
<VersionChangelog />
) : (
<p className='text-xl text-center'>
No version selected
</p>
)
) : popupMode === 5 ? (
managingVersion && downloadedVersionsConfig ? (
<VersionUpdateWarning />
) : (
<p className='text-xl text-center'>
No version selected
</p>
)
) : null}
{popupMode == 0 &&
selectedGame &&
serverVersionList != null && (
<div className='flex justify-center'>
<button
className='button btntheme1 w-fit mt-2 -mb-4'
onClick={() => {
setFadeOut(true)
setTimeout(() => setShowPopup(false), 200)
if (downloadedVersionsConfig)
downloadVersions(selectedVersionList)
}}
disabled={downloadProgress.length != 0}
title={
downloadProgress.length != 0
? "You cannot download the versions as you have another download that hasn't completed."
: `Click to download ${
selectedVersionList.length
} version${
selectedVersionList.length == 1 ? '' : 's'
} of ${getGameInfo(selectedGame)?.name}`
}
>
Download {selectedVersionList.length} version
{selectedVersionList.length == 1 ? '' : 's'}
</button>
<button
className='button btntheme1 w-fit mt-2 ml-2 -mb-4'
onClick={() => {
const allIds = getSpecialVersionsList(
selectedGame
).map(v => v.id)
setSelectedVersionList(prev =>
prev.length === allIds.length ? [] : allIds
)
}}
title={
selectedVersionList.length ===
getSpecialVersionsList(selectedGame).length
? 'Click to remove all selected versions for download.'
: 'Click to add all selected versions for download.'
}
>
{selectedVersionList.length ===
getSpecialVersionsList(selectedGame).length
? 'Deselect All'
: 'Select All'}
</button>
</div>
)}
</div>
</div>
)}
</div>
</GlobalProvider>
)}
</body>
</html>
</>
)
}