pages235/src/browserfs.ts

685 lines
20 KiB
TypeScript

import { join } from 'path'
import { promisify } from 'util'
import fs from 'fs'
import sanitizeFilename from 'sanitize-filename'
import { oneOf } from '@zardoy/utils'
import * as browserfs from 'browserfs'
import { options, resetOptions } from './optionsStorage'
import { fsState, loadSave } from './loadSave'
import { installResourcepackPack, installTexturePackFromHandle, updateTexturePackInstalledState } from './resourcePack'
import { miscUiState } from './globalState'
import { setLoadingScreenStatus } from './appStatus'
import { VALID_REPLAY_EXTENSIONS, openFile } from './packetsReplay/replayPackets'
import { getFixedFilesize } from './downloadAndOpenFile'
import { packetsReplayState } from './react/state/packetsReplayState'
import { createFullScreenProgressReporter } from './core/progressReporter'
import { showNotification } from './react/NotificationProvider'
import { resetAppStorage } from './react/appStorageProvider'
import { ConnectOptions } from './connect'
const { GoogleDriveFileSystem } = require('google-drive-browserfs/src/backends/GoogleDrive')
browserfs.install(window)
const defaultMountablePoints = {
'/data': { fs: 'IndexedDB' },
'/resourcepack': { fs: 'InMemory' }, // temporary storage for currently loaded resource pack
'/temp': { fs: 'InMemory' }
}
const fallbackMountablePoints = {
'/resourcepack': { fs: 'InMemory' }, // temporary storage for downloaded server resource pack
'/temp': { fs: 'InMemory' }
}
browserfs.configure({
fs: 'MountableFileSystem',
options: defaultMountablePoints,
}, async (e) => {
if (e) {
browserfs.configure({
fs: 'MountableFileSystem',
options: fallbackMountablePoints,
}, async (e2) => {
if (e2) {
showNotification('Unknown FS error, cannot continue', e2.message, true)
throw e2
}
showNotification('Failed to access device storage', `Check you have free space. ${e.message}`, true)
miscUiState.fsReady = true
miscUiState.singleplayerAvailable = false
})
return
}
await updateTexturePackInstalledState()
miscUiState.fsReady = true
miscUiState.singleplayerAvailable = true
})
export const forceCachedDataPaths = {}
export const forceRedirectPaths = {}
window.fs = fs
//@ts-expect-error
fs.promises = new Proxy(Object.fromEntries(['readFile', 'writeFile', 'stat', 'mkdir', 'rmdir', 'unlink', 'rename', /* 'copyFile', */'readdir'].map(key => [key, promisify(fs[key])])), {
get (target, p: string, receiver) {
if (!target[p]) throw new Error(`Not implemented fs.promises.${p}`)
return (...args) => {
// browser fs bug: if path doesn't start with / dirname will return . which would cause infinite loop, so we need to normalize paths
if (typeof args[0] === 'string' && !args[0].startsWith('/')) args[0] = '/' + args[0]
const toRemap = Object.entries(forceRedirectPaths).find(([from]) => args[0].startsWith(from))
if (toRemap) {
args[0] = args[0].replace(toRemap[0], toRemap[1])
}
// Write methods
// todo issue one-time warning (in chat I guess)
const readonly = fsState.isReadonly && !(args[0].startsWith('/data') && !fsState.inMemorySave) // allow copying worlds from external providers such as zip
if (readonly) {
if (oneOf(p, 'readFile', 'writeFile') && forceCachedDataPaths[args[0]]) {
if (p === 'readFile') {
return Promise.resolve(forceCachedDataPaths[args[0]])
} else if (p === 'writeFile') {
forceCachedDataPaths[args[0]] = args[1]
console.debug('Skipped writing to readonly fs', args[0])
return Promise.resolve()
}
}
if (oneOf(p, 'writeFile', 'mkdir', 'rename')) return
}
if (p === 'open' && fsState.isReadonly) {
args[1] = 'r' // read-only, zipfs throw otherwise
}
if (p === 'readFile') {
fsState.openReadOperations++
} else if (p === 'writeFile') {
fsState.openWriteOperations++
}
return target[p](...args).finally(() => {
if (p === 'readFile') {
fsState.openReadOperations--
} else if (p === 'writeFile') {
fsState.openWriteOperations--
}
})
}
}
})
//@ts-expect-error
fs.promises.open = async (...args) => {
//@ts-expect-error
const fd = await promisify(fs.open)(...args)
return {
...Object.fromEntries(['read', 'write', 'close'].map(x => [x, async (...args) => {
return new Promise(resolve => {
// todo it results in world corruption on interactions eg block placements
if (x === 'write' && fsState.isReadonly) {
resolve({ buffer: Buffer.from([]), bytesRead: 0 })
return
}
if (x === 'read') {
fsState.openReadOperations++
} else if (x === 'write' || x === 'close') {
fsState.openWriteOperations++
}
fs[x](fd, ...args, (err, bytesRead, buffer) => {
if (x === 'read') {
fsState.openReadOperations--
} else if (x === 'write' || x === 'close') {
// todo that's not correct
fsState.openWriteOperations--
}
if (err) throw err
// todo if readonly probably there is no need to open at all (return some mocked version - check reload)?
if (x === 'write' && !fsState.isReadonly) {
// flush data, though alternatively we can rely on close in unload
fs.fsync(fd, () => { })
}
resolve({ buffer, bytesRead })
})
})
}])),
// for debugging
fd,
filename: args[0],
async close () {
return new Promise<void>(resolve => {
fs.close(fd, (err) => {
if (err) {
throw err
} else {
resolve()
}
})
})
}
}
}
// for testing purposes, todo move it to core patch
const removeFileRecursiveSync = (path) => {
for (const file of fs.readdirSync(path)) {
const curPath = join(path, file)
if (fs.lstatSync(curPath).isDirectory()) {
// recurse
removeFileRecursiveSync(curPath)
fs.rmdirSync(curPath)
} else {
// delete file
fs.unlinkSync(curPath)
}
}
}
window.removeFileRecursiveSync = removeFileRecursiveSync
export const mkdirRecursive = async (path: string) => {
const parts = path.split('/')
let current = ''
for (const part of parts) {
current += part + '/'
try {
// eslint-disable-next-line no-await-in-loop
await fs.promises.mkdir(current)
} catch (err) {
}
}
}
export const uniqueFileNameFromWorldName = async (title: string, savePath: string) => {
const name = sanitizeFilename(title)
let resultPath!: string
// getUniqueFolderName
let i = 0
let free = false
while (!free) {
try {
resultPath = `${savePath.replace(/\$/, '')}/${name}${i === 0 ? '' : `-${i}`}`
// eslint-disable-next-line no-await-in-loop
await fs.promises.stat(resultPath)
i++
} catch (err) {
free = true
}
}
return resultPath
}
export const mountExportFolder = async () => {
let handle: FileSystemDirectoryHandle
try {
handle = await showDirectoryPicker({
id: 'world-export',
})
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
throw err
}
if (!handle) return false
await new Promise<void>(resolve => {
browserfs.configure({
fs: 'MountableFileSystem',
options: {
...defaultMountablePoints,
'/export': {
fs: 'FileSystemAccess',
options: {
handle
}
}
},
}, (e) => {
if (e) throw e
resolve()
})
})
return true
}
let googleDriveFileSystem
/** Only cached! */
export const googleDriveGetFileIdFromPath = (path: string) => {
return googleDriveFileSystem._getExistingFileId(path)
}
export const mountGoogleDriveFolder = async (readonly: boolean, rootId: string) => {
googleDriveFileSystem = new GoogleDriveFileSystem()
googleDriveFileSystem.rootDirId = rootId
googleDriveFileSystem.isReadonly = readonly
await new Promise<void>(resolve => {
browserfs.configure({
fs: 'MountableFileSystem',
options: {
...defaultMountablePoints,
'/google': googleDriveFileSystem
},
}, (e) => {
if (e) throw e
resolve()
})
})
fsState.isReadonly = readonly
fsState.syncFs = false
fsState.inMemorySave = false
fsState.remoteBackend = true
return true
}
export async function removeFileRecursiveAsync (path, removeDirectoryItself = true) {
const errors = [] as Array<[string, Error]>
try {
const files = await fs.promises.readdir(path)
// Use Promise.all to parallelize file/directory removal
await Promise.all(files.map(async (file) => {
const curPath = join(path, file)
const stats = await fs.promises.stat(curPath)
if (stats.isDirectory()) {
// Recurse
await removeFileRecursiveAsync(curPath)
} else {
// Delete file
await fs.promises.unlink(curPath)
}
}))
// After removing all files/directories, remove the current directory
if (removeDirectoryItself) {
await fs.promises.rmdir(path)
}
} catch (error) {
errors.push([path, error])
}
if (errors.length) {
setTimeout(() => {
console.error(errors)
throw new Error(`Error removing directories/files: ${errors.map(([path, err]) => `${path}: ${err.message}`).join(', ')}`)
})
}
}
const SUPPORT_WRITE = true
export const openWorldDirectory = async (dragndropHandle?: FileSystemDirectoryHandle) => {
let _directoryHandle: FileSystemDirectoryHandle
if (dragndropHandle) {
_directoryHandle = dragndropHandle
} else {
try {
_directoryHandle = await window.showDirectoryPicker({
id: 'select-world', // important: this is used to remember user choice (start directory)
})
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
throw err
}
}
const directoryHandle = _directoryHandle
const requestResult = SUPPORT_WRITE && !options.preferLoadReadonly ? await directoryHandle.requestPermission?.({ mode: 'readwrite' }) : undefined
const writeAccess = requestResult === 'granted'
const doContinue = writeAccess || !SUPPORT_WRITE || options.disableLoadPrompts || confirm('Continue in readonly mode?')
if (!doContinue) return
await new Promise<void>(resolve => {
browserfs.configure({
fs: 'MountableFileSystem',
options: {
...defaultMountablePoints,
'/world': {
fs: 'FileSystemAccess',
options: {
handle: directoryHandle
}
}
},
}, (e) => {
if (e) throw e
resolve()
})
})
fsState.isReadonly = !writeAccess
fsState.syncFs = false
fsState.inMemorySave = false
fsState.remoteBackend = false
await loadSave()
}
const tryToDetectResourcePack = async () => {
const askInstall = async () => {
// todo investigate browserfs read errors
return alert('ATM You can install texturepacks only via options menu.')
// if (confirm('Resource pack detected, do you want to install it?')) {
// await installTexturePackFromHandle()
// }
}
if (fs.existsSync('/world/pack.mcmeta')) {
await askInstall()
return true
}
// const jszip = new JSZip()
// let loaded = await jszip.loadAsync(file)
// if (loaded.file('pack.mcmeta')) {
// loaded = null
// askInstall()
// return true
// }
// loaded = null
}
export const possiblyCleanHandle = (callback = () => { }) => {
if (!fsState.saveLoaded) {
// todo clean handle
browserfs.configure({
fs: 'MountableFileSystem',
options: defaultMountablePoints,
}, (e) => {
callback()
if (e) throw e
})
}
}
const readdirSafe = async (path: string) => {
try {
return await fs.promises.readdir(path)
} catch (err) {
return null
}
}
export const collectFilesToCopy = async (basePath: string, safe = false): Promise<string[]> => {
const result: string[] = []
const countFiles = async (relPath: string) => {
const resolvedPath = join(basePath, relPath)
const files = relPath === '.' && !safe ? await fs.promises.readdir(resolvedPath) : await readdirSafe(resolvedPath)
if (!files) return null
await Promise.all(files.map(async file => {
const res = await countFiles(join(relPath, file))
if (res === null) {
// is file
result.push(join(relPath, file))
}
}))
}
await countFiles('.')
return result
}
export const copyFilesAsyncWithProgress = async (pathSrc: string, pathDest: string, throwRootNotExist = true, addMsg = '') => {
const stat = await existsViaStats(pathSrc)
if (!stat) {
if (throwRootNotExist) throw new Error(`Cannot copy. Source directory ${pathSrc} does not exist`)
console.debug('source directory does not exist', pathSrc)
return
}
if (!stat.isDirectory()) {
await fs.promises.writeFile(pathDest, await fs.promises.readFile(pathSrc) as any)
console.debug('copied single file', pathSrc, pathDest)
return
}
try {
setLoadingScreenStatus('Copying files')
let filesCount = 0
const countFiles = async (path: string) => {
const files = await fs.promises.readdir(path)
await Promise.all(files.map(async (file) => {
const curPath = join(path, file)
const stats = await fs.promises.stat(curPath)
if (stats.isDirectory()) {
// Recurse
await countFiles(curPath)
} else {
filesCount++
}
}))
}
console.debug('Counting files', pathSrc)
await countFiles(pathSrc)
console.debug('counted', filesCount)
let copied = 0
await copyFilesAsync(pathSrc, pathDest, (name) => {
copied++
setLoadingScreenStatus(`Copying files${addMsg} (${copied}/${filesCount}): ${name}`)
})
} finally {
setLoadingScreenStatus(undefined)
}
}
export const existsViaStats = async (path: string) => {
try {
return await fs.promises.stat(path)
} catch (e) {
return false
}
}
export const fileExistsAsyncOptimized = async (path: string) => {
try {
await fs.promises.readdir(path)
} catch (err) {
if (err.code === 'ENOTDIR') return true
// eslint-disable-next-line sonarjs/prefer-single-boolean-return
if (err.code === 'ENOENT') return false
// throw err
return false
}
return true
}
export const copyFilesAsync = async (pathSrc: string, pathDest: string, fileCopied?: (name) => void) => {
// query: can't use fs.copy! use fs.promises.writeFile and readFile
const files = await fs.promises.readdir(pathSrc)
if (!await existsViaStats(pathDest)) {
await fs.promises.mkdir(pathDest, { recursive: true })
}
// Use Promise.all to parallelize file/directory copying
await Promise.all(files.map(async (file) => {
const curPathSrc = join(pathSrc, file)
const curPathDest = join(pathDest, file)
const stats = await fs.promises.stat(curPathSrc)
if (stats.isDirectory()) {
// Recurse
await fs.promises.mkdir(curPathDest)
await copyFilesAsync(curPathSrc, curPathDest, fileCopied)
} else {
// Copy file
try {
await fs.promises.writeFile(curPathDest, await fs.promises.readFile(curPathSrc) as any)
console.debug('copied file', curPathSrc, curPathDest)
} catch (err) {
console.error('Error copying file', curPathSrc, curPathDest, err)
throw err
}
fileCopied?.(curPathDest)
}
}))
}
export const openWorldFromHttpDir = async (fileDescriptorUrls: string[]/* | undefined */, baseUrlParam) => {
// todo try go guess mode
let index
let baseUrl
for (const url of fileDescriptorUrls) {
let file
try {
setLoadingScreenStatus(`Trying to get world descriptor from ${new URL(url).host}`)
const controller = new AbortController()
setTimeout(() => {
controller.abort()
}, 3000)
// eslint-disable-next-line no-await-in-loop
const response = await fetch(url, { signal: controller.signal })
// eslint-disable-next-line no-await-in-loop
file = await response.json()
} catch (err) {
console.error('Error fetching file descriptor', url, err)
}
if (!file) continue
if (file.baseUrl) {
baseUrl = new URL(file.baseUrl, baseUrl).toString()
index = file.index
} else {
index = file
baseUrl = baseUrlParam ?? url.split('/').slice(0, -1).join('/')
}
break
}
if (!index) throw new Error(`The provided mapDir file is not valid descriptor file! ${fileDescriptorUrls.join(', ')}`)
await new Promise<void>(async resolve => {
browserfs.configure({
fs: 'MountableFileSystem',
options: {
...defaultMountablePoints,
'/world': {
fs: 'HTTPRequest',
options: {
index,
baseUrl
}
}
},
}, (e) => {
if (e) throw e
resolve()
})
})
fsState.saveLoaded = false
fsState.isReadonly = true
fsState.syncFs = false
fsState.inMemorySave = false
fsState.remoteBackend = true
await loadSave()
}
// todo rename method
const openWorldZipInner = async (file: File | ArrayBuffer, name = file['name'], connectOptions?: Partial<ConnectOptions>) => {
await new Promise<void>(async resolve => {
browserfs.configure({
// todo
fs: 'MountableFileSystem',
options: {
...defaultMountablePoints,
'/world': {
fs: 'ZipFS',
options: {
zipData: Buffer.from(file instanceof File ? (await file.arrayBuffer()) : file),
name
}
}
},
}, (e) => {
if (e) throw e
resolve()
})
})
fsState.saveLoaded = false
fsState.isReadonly = true
fsState.syncFs = true
fsState.inMemorySave = false
fsState.remoteBackend = false
if (fs.existsSync('/world/level.dat')) {
await loadSave()
} else {
const dirs = fs.readdirSync('/world')
const availableWorlds: string[] = []
for (const dir of dirs) {
if (fs.existsSync(`/world/${dir}/level.dat`)) {
availableWorlds.push(dir)
}
}
if (availableWorlds.length === 0) {
if (await tryToDetectResourcePack()) return
alert('No worlds found in the zip')
return
}
if (availableWorlds.length === 1) {
await loadSave(`/world/${availableWorlds[0]}`, connectOptions)
return
}
alert(`Many (${availableWorlds.length}) worlds found in the zip!`)
// todo prompt picker
// const selectWorld
}
}
export const openWorldZip = async (...args: Parameters<typeof openWorldZipInner>) => {
try {
return await openWorldZipInner(...args)
} finally {
possiblyCleanHandle()
}
}
export const resetLocalStorage = () => {
resetOptions()
resetAppStorage()
}
window.resetLocalStorage = resetLocalStorage
export const openFilePicker = (specificCase?: 'resourcepack') => {
// create and show input picker
let picker: HTMLInputElement = document.body.querySelector('input#file-zip-picker')!
if (!picker) {
picker = document.createElement('input')
picker.type = 'file'
picker.accept = specificCase ? '.zip' : [...VALID_REPLAY_EXTENSIONS, '.zip'].join(',')
picker.addEventListener('change', () => {
const file = picker.files?.[0]
picker.value = ''
if (!file) return
if (specificCase === 'resourcepack') {
if (!file.name.endsWith('.zip')) {
const doContinue = confirm(`Are you sure ${file.name.slice(-20)} is .zip file? ONLY .zip files are supported. Continue?`)
if (!doContinue) return
}
void installResourcepackPack(file, createFullScreenProgressReporter()).catch((err) => {
setLoadingScreenStatus(err.message, true)
})
} else {
// eslint-disable-next-line no-lonely-if
if (VALID_REPLAY_EXTENSIONS.some(ext => file.name.endsWith(ext)) || file.name.startsWith('packets-replay')) {
void file.text().then(contents => {
openFile({
contents,
filename: file.name,
filesize: file.size
})
})
} else {
void openWorldZip(file)
}
}
})
picker.hidden = true
document.body.appendChild(picker)
}
picker.click()
}
export const resetStateAfterDisconnect = () => {
miscUiState.gameLoaded = false
miscUiState.loadedDataVersion = null
miscUiState.singleplayer = false
miscUiState.flyingSquid = false
miscUiState.wanOpened = false
miscUiState.currentDisplayQr = null
fsState.saveLoaded = false
}