import fs from 'fs' import { proxy, subscribe, useSnapshot } from 'valtio' import { useEffect, useRef, useState } from 'react' import { loadScript } from 'renderer/viewer/lib/utils' import { fsState, loadSave, longArrayToNumber, readLevelDat } from '../loadSave' import { googleDriveGetFileIdFromPath, mountExportFolder, mountGoogleDriveFolder, removeFileRecursiveAsync } from '../browserfs' import { hideCurrentModal, showModal } from '../globalState' import { haveDirectoryPicker } from '../utils' import { setLoadingScreenStatus } from '../appStatus' import { exportWorld } from '../builtinCommands' import { googleProviderState, useGoogleLogIn, GoogleDriveProvider, isGoogleDriveAvailable, APP_ID } from '../googledrive' import Singleplayer, { WorldProps } from './Singleplayer' import { useIsModalActive } from './utilsApp' import { showOptionsModal } from './SelectOption' import Input from './Input' import GoogleButton from './GoogleButton' const worldsProxy = proxy({ value: null as null | WorldProps[], brokenWorlds: [] as string[], selectedProvider: 'local' as 'local' | 'google', selectedGoogleId: '', error: '', }) export const getWorldsPath = () => { return worldsProxy.selectedProvider === 'local' ? `/data/worlds` : worldsProxy.selectedProvider === 'google' ? `/google/${'/'.replace(/\/$/, '')}` : '' } const providersEnableFeatures = { local: { calculateSize: true, delete: true, export: true, icon: true }, google: { calculateSize: false, // TODO delete: false, export: false, icon: true } } export const readWorlds = (abortController: AbortController) => { if (abortController.signal.aborted) return worldsProxy.error = '' worldsProxy.brokenWorlds = []; (async () => { const brokenWorlds = [] as string[] try { const loggedIn = !!googleProviderState.accessToken worldsProxy.value = null if (worldsProxy.selectedProvider === 'google' && !loggedIn) { worldsProxy.value = [] return } const worldsPath = getWorldsPath() const provider = worldsProxy.selectedProvider const worlds = await fs.promises.readdir(worldsPath) const newMappedWorlds = (await Promise.allSettled(worlds.map(async (folder) => { const { levelDat } = (await readLevelDat(`${worldsPath}/${folder}`))! const levelDatStat = await fs.promises.stat(`${worldsPath}/${folder}/level.dat`) let size = 0 if (providersEnableFeatures[provider].calculateSize) { // todo use whole dir size for (const region of await fs.promises.readdir(`${worldsPath}/${folder}/region`)) { // eslint-disable-next-line no-await-in-loop -- it's still fast enough, nobody complains about it const stat = await fs.promises.stat(`${worldsPath}/${folder}/region/${region}`) size += stat.size } } let iconBase64 = '' if (providersEnableFeatures[provider].icon) { const iconPath = `${worldsPath}/${folder}/icon.png` try { iconBase64 = await fs.promises.readFile(iconPath, 'base64') } catch { // ignore } } const levelName = levelDat.LevelName as string | undefined return { name: folder, title: levelName ?? folder, lastPlayed: levelDat.LastPlayed && longArrayToNumber(levelDat.LastPlayed), detail: `${levelDat.Version?.Name ?? 'unknown version'}, ${folder}`, iconSrc: iconBase64 ? `data:image/png;base64,${iconBase64}` : undefined, size, lastModified: levelDatStat.mtimeMs, group: 'IndexedDB Memory Worlds' } satisfies WorldProps & { lastModified?: number } }))).filter((x, i) => { if (x.status === 'rejected') { console.warn(x.reason) brokenWorlds.push(worlds[i]) return false } return true }).map(x => (x as Extract).value).sort((a, b) => { const getScore = (x: typeof a) => { return x.lastModified ?? 0 } return getScore(b) - getScore(a) }) if (abortController.signal.aborted) return worldsProxy.value = newMappedWorlds } catch (err) { if (err.name === 'AbortError') return console.warn(err) worldsProxy.value = null worldsProxy.error = err.message } worldsProxy.brokenWorlds = brokenWorlds })().catch((err) => { // todo it still doesn't work for some reason! worldsProxy.error = err.message }) } export const loadInMemorySave = async (worldPath: string) => { fsState.saveLoaded = false fsState.isReadonly = false fsState.syncFs = false fsState.inMemorySave = true await loadSave(worldPath) } export default () => { const active = useIsModalActive('singleplayer') if (!active) return null return } export const loadGoogleDriveApi = async () => { const scriptEl = await loadScript('https://apis.google.com/js/api.js') if (!scriptEl) return // already loaded return new Promise((resolve) => { gapi.load('client', () => { gapi.load('client:picker', () => { void gapi.client.load('https://www.googleapis.com/discovery/v1/apis/drive/v3/rest').then(() => { googleProviderState.isReady = true resolve() }) }) }) }) } const Inner = () => { const worlds = useSnapshot(worldsProxy).value as WorldProps[] | null const { selectedProvider, error, brokenWorlds, selectedGoogleId } = useSnapshot(worldsProxy) const readWorldsAbortController = useRef(new AbortController()) // 3rd party providers useEffect(() => { if (selectedProvider !== 'google') return void loadGoogleDriveApi() }, [selectedProvider]) const loggedIn = !!useSnapshot(googleProviderState).accessToken const googleDriveReadonly = useSnapshot(googleProviderState).readonlyMode useEffect(() => { (async () => { if (selectedProvider === 'google') { if (!selectedGoogleId) { worldsProxy.value = [] return } await mountGoogleDriveFolder(googleProviderState.readonlyMode, selectedGoogleId) const exists = async (path) => { try { await fs.promises.stat(path) return true } catch { return false } } if (await exists(`${getWorldsPath()}/level.dat`)) { await loadInMemorySave(getWorldsPath()) return } } if (selectedProvider === 'local' && !(await fs.promises.stat('/data/worlds').catch(() => false))) { await fs.promises.mkdir('/data/worlds') } readWorlds(readWorldsAbortController.current) })() return () => { readWorldsAbortController.current.abort() readWorldsAbortController.current = new AbortController() } }, [selectedProvider, loggedIn, googleDriveReadonly, selectedGoogleId]) const googleLogIn = useGoogleLogIn() const googlePicker = useRef/* */(null as any) useEffect(() => { return () => { googlePicker.current?.dispose() } }) const selectGoogleFolder = async () => { if (googleProviderState.lastSelectedFolder) { // ask to use saved previous fodler const choice = await showOptionsModal(`Use previously selected folder "${googleProviderState.lastSelectedFolder.name}"?`, ['Yes', 'No']) if (!choice) return if (choice === 'Yes') { worldsProxy.selectedGoogleId = googleProviderState.lastSelectedFolder.id return } } const { google } = window const view = new google.picker.DocsView(google.picker.ViewId.FOLDERS) .setIncludeFolders(true) .setMimeTypes('application/vnd.google-apps.folder') .setSelectFolderEnabled(true) .setParent('root') googlePicker.current = new google.picker.PickerBuilder() .enableFeature(google.picker.Feature.NAV_HIDDEN) .enableFeature(google.picker.Feature.MULTISELECT_ENABLED) .setDeveloperKey('AIzaSyBTiHpEqaLL7mEcrsnSS4M-z8cpRH5UwY0') .setAppId(APP_ID) .setOAuthToken(googleProviderState.accessToken) .addView(view) .addView(new google.picker.DocsUploadView()) .setTitle('Select a folder with your worlds') .setCallback((data) => { if (data.action === google.picker.Action.PICKED) { googleProviderState.lastSelectedFolder = { id: data.docs[0].id, name: data.docs[0].name, } worldsProxy.selectedGoogleId = data.docs[0].id } }) .build() googlePicker.current.setVisible(true) } const isGoogleProviderReady = useSnapshot(googleProviderState).isReady const providerActions = loggedIn && selectedProvider === 'google' && isGoogleProviderReady && !selectedGoogleId ? { 'Select Folder': selectGoogleFolder } : selectedProvider === 'google' ? isGoogleProviderReady ? loggedIn ? { 'Log Out' () { googleProviderState.hasEverLoggedIn = false googleProviderState.accessToken = null googleProviderState.lastSelectedFolder = null window.google.accounts.oauth2.revoke(googleProviderState.accessToken) }, async [`Read Only: ${googleDriveReadonly ? 'ON' : 'OFF'}`] () { if (googleProviderState.readonlyMode) { const choice = await showOptionsModal('[Unstable Feature] Enabling world save might corrupt your worlds, eg remove entities (note: you can always restore previous version of files in Drive)', ['Continue']) if (choice !== 'Continue') return } googleProviderState.readonlyMode = !googleProviderState.readonlyMode }, 'Select Folder': selectGoogleFolder, // 'Worlds Path': { // googleProviderData.worldsPath = e.target.value // }} /> } : { 'Log In': } : { 'Loading...' () { } } : undefined // end return { worldsProxy.selectedProvider = provider as any }} warning={brokenWorlds.length ? `Some worlds are broken: ${brokenWorlds.join(', ')}` : undefined} warningAction={async () => { for (const brokenWorld of worldsProxy.brokenWorlds) { setLoadingScreenStatus(`Removing broken world ${brokenWorld}`) // eslint-disable-next-line no-await-in-loop await removeFileRecursiveAsync(`${getWorldsPath()}/${brokenWorld}`) } setLoadingScreenStatus(undefined) }} warningActionLabel='Remove broken worlds' onWorldAction={async (action, worldName) => { const worldPath = `${getWorldsPath()}/${worldName}` const openInGoogleDrive = () => { const fileId = googleDriveGetFileIdFromPath(worldPath.replace('/google/', '')) if (!fileId) return alert('File not found') window.open(`https://drive.google.com/drive/folders/${fileId}`) } if (action === 'load') { setLoadingScreenStatus(`Starting loading world ${worldName}`) await loadInMemorySave(worldPath) return } if (action === 'delete') { if (selectedProvider === 'google') { openInGoogleDrive() return } if (!confirm('Are you sure you want to delete current world')) return setLoadingScreenStatus(`Removing world ${worldName}`) await removeFileRecursiveAsync(worldPath) setLoadingScreenStatus(undefined) readWorlds(readWorldsAbortController.current) } if (action === 'export') { if (selectedProvider === 'google') { openInGoogleDrive() return } const selectedVariant = haveDirectoryPicker() ? await showOptionsModal('Select export type', ['Select folder (recommended)', 'Download ZIP file']) : await showOptionsModal('Select export type', ['Download ZIP file']) if (!selectedVariant) return if (selectedVariant === 'Select folder (recommended)') { const success = await mountExportFolder() if (!success) return } await exportWorld(worldPath, selectedVariant === 'Select folder (recommended)' ? 'folder' : 'zip', worldName) } }} onGeneralAction={(action) => { if (action === 'cancel') { hideCurrentModal() } if (action === 'create') { showModal({ reactType: 'create-world' }) } }} /> }