diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx index ca42bb9bf..6ed966403 100644 --- a/source/funkin/modding/PolymodHandler.hx +++ b/source/funkin/modding/PolymodHandler.hx @@ -251,6 +251,9 @@ class PolymodHandler Polymod.addImportAlias('lime.utils.Assets', funkin.Assets); Polymod.addImportAlias('openfl.utils.Assets', funkin.Assets); + // `funkin.util.FileUtil` has unrestricted access to the file system. + Polymod.addImportAlias('funkin.util.FileUtil', funkin.util.FileUtilSandboxed); + // Add blacklisting for prohibited classes and packages. // `Sys` diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index 22845155f..7a4bfa87f 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -2361,7 +2361,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState } } - if (!FileUtil.doesFileExist(chartPath)) + if (!FileUtil.fileExists(chartPath)) { trace('Previously loaded chart file (${chartPath.toString()}) does not exist, disabling link...'); menuItemRecentChart.disabled = true; diff --git a/source/funkin/ui/debug/charting/dialogs/ChartEditorWelcomeDialog.hx b/source/funkin/ui/debug/charting/dialogs/ChartEditorWelcomeDialog.hx index 5de5fb6d2..8d5e8b620 100644 --- a/source/funkin/ui/debug/charting/dialogs/ChartEditorWelcomeDialog.hx +++ b/source/funkin/ui/debug/charting/dialogs/ChartEditorWelcomeDialog.hx @@ -106,7 +106,7 @@ class ChartEditorWelcomeDialog extends ChartEditorBaseDialog } } - if (!FileUtil.doesFileExist(chartPath)) + if (!FileUtil.fileExists(chartPath)) { trace('Previously loaded chart file (${chartPath}) does not exist, disabling link...'); linkRecentChart.disabled = true; diff --git a/source/funkin/ui/debug/stageeditor/StageEditorState.hx b/source/funkin/ui/debug/stageeditor/StageEditorState.hx index e66245b24..aaafc1a52 100644 --- a/source/funkin/ui/debug/stageeditor/StageEditorState.hx +++ b/source/funkin/ui/debug/stageeditor/StageEditorState.hx @@ -1073,7 +1073,7 @@ class StageEditorState extends UIState var filePath = new haxe.io.Path(file); var item = new MenuItem(); item.text = filePath.file + "." + filePath.ext; - item.disabled = !FileUtil.doesFileExist(file); + item.disabled = !FileUtil.fileExists(file); var load = function(file:String) { currentFile = file; diff --git a/source/funkin/ui/debug/stageeditor/components/BackupAvailableDialog.hx b/source/funkin/ui/debug/stageeditor/components/BackupAvailableDialog.hx index 6c3041e28..5ab4029ef 100644 --- a/source/funkin/ui/debug/stageeditor/components/BackupAvailableDialog.hx +++ b/source/funkin/ui/debug/stageeditor/components/BackupAvailableDialog.hx @@ -32,7 +32,7 @@ class BackupAvailableDialog extends Dialog { super(); - if (!FileUtil.doesFileExist(filePath)) return; + if (!FileUtil.fileExists(filePath)) return; // time text var fileDate = Path.withoutExtension(Path.withoutDirectory(filePath)); @@ -62,7 +62,7 @@ class BackupAvailableDialog extends Dialog } buttonOpenBackup.onClick = function(_) { - if (FileUtil.doesFileExist(filePath) && state.welcomeDialog != null) // doing a check in case a sleezy FUCK decides to delete the backup file AFTER dialog opens + if (FileUtil.fileExists(filePath) && state.welcomeDialog != null) // doing a check in case a sleezy FUCK decides to delete the backup file AFTER dialog opens { state.welcomeDialog.loadFromFilePath(filePath); } diff --git a/source/funkin/ui/debug/stageeditor/components/WelcomeDialog.hx b/source/funkin/ui/debug/stageeditor/components/WelcomeDialog.hx index 5cc07fbe9..f1ef14397 100644 --- a/source/funkin/ui/debug/stageeditor/components/WelcomeDialog.hx +++ b/source/funkin/ui/debug/stageeditor/components/WelcomeDialog.hx @@ -35,7 +35,7 @@ class WelcomeDialog extends Dialog { trace(file); - if (!FileUtil.doesFileExist(file)) continue; // whats the point of loading something that doesnt exist + if (!FileUtil.fileExists(file)) continue; // whats the point of loading something that doesnt exist var patj = new haxe.io.Path(file); diff --git a/source/funkin/util/FileUtil.hx b/source/funkin/util/FileUtil.hx index 5b053bb08..f1dcbb0a6 100644 --- a/source/funkin/util/FileUtil.hx +++ b/source/funkin/util/FileUtil.hx @@ -13,9 +13,12 @@ import haxe.ui.containers.dialogs.Dialogs; import haxe.ui.containers.dialogs.Dialogs.SelectedFileInfo; import haxe.ui.containers.dialogs.Dialogs.FileDialogExtensionInfo; +using StringTools; + /** * Utilities for reading and writing files on various platforms. */ +@:nullSafety class FileUtil { public static final FILE_FILTER_FNFC:FileFilter = new FileFilter("Friday Night Funkin' Chart (.fnfc)", "*.fnfc"); @@ -46,6 +49,43 @@ class FileUtil label: 'Friday Night Funkin\' Stage', }; + /** + * Paths which should not be deleted or modified by scripts. + */ + private static final PROTECTED_PATHS:Array = [ + '', + 'assets', + 'manifest', + 'manifest/*', + 'plugins', + 'plugins/*', + 'Funkin.exe', + 'Funkin', + 'libvlc.dll', + 'libvlccore.dll', + 'lime.ndll' + ]; + + /** + * Invalid filesystem characters. + */ + private static final INVALID_CHARS:Array = [':', '*', '?', '"', '<', '>', '|']; + + #if sys + private static var _gameDirectory:Null = null; + public static var gameDirectory(get, never):String; + + public static function get_gameDirectory():String + { + if (_gameDirectory != null) + { + return _gameDirectory; + } + + return _gameDirectory = Path.directory(Path.normalize(sys.FileSystem.fullPath(Sys.programPath()))); + } + #end + /** * Browses for a single file, then calls `onSelect(fileInfo)` when a file is selected. * Powered by HaxeUI, so it works on all platforms. @@ -55,15 +95,15 @@ class FileUtil * @param onSelect A callback that provides a `SelectedFileInfo` object when a file is selected. * @param onCancel A callback that is called when the user closes the dialog without selecting a file. */ - public static function browseForBinaryFile(dialogTitle:String, ?typeFilter:Array, ?onSelect:SelectedFileInfo->Void, - ?onCancel:Void->Void) + public static function browseForBinaryFile(dialogTitle:String, ?typeFilter:Array, onSelect:(SelectedFileInfo)->Void, + ?onCancel:()->Void) { var onComplete = function(button, selectedFiles) { if (button == DialogButton.OK && selectedFiles.length > 0) { onSelect(selectedFiles[0]); } - else + else if (onCancel != null) { onCancel(); } @@ -74,7 +114,7 @@ class FileUtil readContents: true, readAsBinary: true, // Binary multiple: false, - extensions: typeFilter ?? [], + extensions: typeFilter ?? new Array(), title: dialogTitle, }); } @@ -88,15 +128,15 @@ class FileUtil * @param onSelect A callback that provides a `SelectedFileInfo` object when a file is selected. * @param onCancel A callback that is called when the user closes the dialog without selecting a file. */ - public static function browseForTextFile(dialogTitle:String, ?typeFilter:Array, ?onSelect:SelectedFileInfo->Void, - ?onCancel:Void->Void) + public static function browseForTextFile(dialogTitle:String, ?typeFilter:Array, onSelect:(SelectedFileInfo)->Void, + ?onCancel:()->Void):Void { var onComplete = function(button, selectedFiles) { if (button == DialogButton.OK && selectedFiles.length > 0) { onSelect(selectedFiles[0]); } - else + else if (onCancel != null) { onCancel(); } @@ -107,7 +147,7 @@ class FileUtil readContents: true, readAsBinary: false, // Text multiple: false, - extensions: typeFilter ?? [], + extensions: typeFilter ?? new Array(), title: dialogTitle, }); } @@ -119,21 +159,29 @@ class FileUtil * @param typeFilter TODO What does this do? * @return Whether the file dialog was opened successfully. */ - public static function browseForDirectory(?typeFilter:Array, ?onSelect:String->Void, ?onCancel:Void->Void, ?defaultPath:String, + public static function browseForDirectory(?typeFilter:Array, onSelect:(String)->Void, ?onCancel:()->Void, ?defaultPath:String, ?dialogTitle:String):Bool { #if desktop - var filter:String = convertTypeFilter(typeFilter); + var filter:Null = convertTypeFilter(typeFilter); var fileDialog:FileDialog = new FileDialog(); - if (onSelect != null) fileDialog.onSelect.add(onSelect); - if (onCancel != null) fileDialog.onCancel.add(onCancel); + fileDialog.onSelect.add(onSelect); + if (onCancel != null) + { + fileDialog.onCancel.add(onCancel); + } + fileDialog.browse(OPEN_DIRECTORY, filter, defaultPath, dialogTitle); + return true; - #elseif html5 - onCancel(); - return false; #else - onCancel(); + trace('WARNING: browseForDirectory not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + return false; #end } @@ -144,21 +192,29 @@ class FileUtil * * @return Whether the file dialog was opened successfully. */ - public static function browseForMultipleFiles(?typeFilter:Array, ?onSelect:Array->Void, ?onCancel:Void->Void, ?defaultPath:String, + public static function browseForMultipleFiles(?typeFilter:Array, onSelect:(Array)->Void, ?onCancel:()->Void, ?defaultPath:String, ?dialogTitle:String):Bool { #if desktop - var filter:String = convertTypeFilter(typeFilter); + var filter:Null = convertTypeFilter(typeFilter); var fileDialog:FileDialog = new FileDialog(); - if (onSelect != null) fileDialog.onSelectMultiple.add(onSelect); - if (onCancel != null) fileDialog.onCancel.add(onCancel); + fileDialog.onSelectMultiple.add(onSelect); + if (onCancel != null) + { + fileDialog.onCancel.add(onCancel); + } + fileDialog.browse(OPEN_MULTIPLE, filter, defaultPath, dialogTitle); + return true; - #elseif html5 - onCancel(); - return false; #else - onCancel(); + trace('WARNING: browseForMultipleFiles not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + return false; #end } @@ -170,21 +226,29 @@ class FileUtil * @param typeFilter TODO What does this do? * @return Whether the file dialog was opened successfully. */ - public static function browseForSaveFile(?typeFilter:Array, ?onSelect:String->Void, ?onCancel:Void->Void, ?defaultPath:String, + public static function browseForSaveFile(?typeFilter:Array, onSelect:(String)->Void, ?onCancel:()->Void, ?defaultPath:String, ?dialogTitle:String):Bool { #if desktop - var filter:String = convertTypeFilter(typeFilter); + var filter:Null = convertTypeFilter(typeFilter); var fileDialog:FileDialog = new FileDialog(); - if (onSelect != null) fileDialog.onSelect.add(onSelect); - if (onCancel != null) fileDialog.onCancel.add(onCancel); + fileDialog.onSelect.add(onSelect); + if (onCancel != null) + { + fileDialog.onCancel.add(onCancel); + } + fileDialog.browse(SAVE, filter, defaultPath, dialogTitle); + return true; - #elseif html5 - onCancel(); - return false; #else - onCancel(); + trace('WARNING: browseForSaveFile not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + return false; #end } @@ -195,25 +259,49 @@ class FileUtil * * @return Whether the file dialog was opened successfully. */ - public static function saveFile(data:Bytes, ?typeFilter:Array, ?onSave:String->Void, ?onCancel:Void->Void, ?defaultFileName:String, + public static function saveFile(data:Bytes, ?typeFilter:Array, ?onSave:(String)->Void, ?onCancel:()->Void, ?defaultFileName:String, ?dialogTitle:String):Bool { #if desktop - var filter:String = convertTypeFilter(typeFilter); + var filter:Null = convertTypeFilter(typeFilter); var fileDialog:FileDialog = new FileDialog(); - if (onSave != null) fileDialog.onSave.add(onSave); - if (onCancel != null) fileDialog.onCancel.add(onCancel); + if (onSave != null) + { + fileDialog.onSave.add(onSave); + } + + if (onCancel != null) + { + fileDialog.onCancel.add(onCancel); + } + fileDialog.save(data, filter, defaultFileName, dialogTitle); + return true; #elseif html5 var filter:String = defaultFileName != null ? Path.extension(defaultFileName) : null; var fileDialog:FileDialog = new FileDialog(); - if (onSave != null) fileDialog.onSave.add(onSave); - if (onCancel != null) fileDialog.onCancel.add(onCancel); + if (onSave != null) + { + fileDialog.onSave.add(onSave); + } + + if (onCancel != null) + { + fileDialog.onCancel.add(onCancel); + } + fileDialog.save(data, filter, defaultFileName, dialogTitle); + return true; #else - onCancel(); + trace('WARNING: saveFile not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + return false; #end } @@ -226,16 +314,17 @@ class FileUtil * @param typeFilter TODO What does this do? * @return Whether the file dialog was opened successfully. */ - public static function saveMultipleFiles(resources:Array, ?onSaveAll:Array->Void, ?onCancel:Void->Void, ?defaultPath:String, + public static function saveMultipleFiles(resources:Array, ?onSaveAll:(Array)->Void, ?onCancel:()->Void, ?defaultPath:String, force:Bool = false):Bool { #if desktop // Prompt the user for a directory, then write all of the files to there. - var onSelectDir:String->Void = function(targetPath:String):Void { - var paths:Array = []; + var onSelectDir:(String)->Void = function(targetPath:String):Void { + var paths:Array = new Array(); for (resource in resources) { - var filePath = haxe.io.Path.join([targetPath, resource.fileName]); + /* + var filePath:String = Path.join([targetPath, resource.fileName]); try { if (resource.data == null) @@ -248,25 +337,50 @@ class FileUtil writeBytesToPath(filePath, resource.data, force ? Force : Skip); } } - catch (_) + catch (e:Dynamic) { throw 'Failed to write file (probably already exists): $filePath'; } + */ + if (resource.data == null) + { + trace('WARNING: File ${resource.fileName} has no data or content. Skipping.'); + continue; + } + + var filePath:String = Path.join([targetPath, resource.fileName]); + + paths.push(filePath); } - onSaveAll(paths); + + if (onSaveAll != null) + { + onSaveAll(paths); + } } + trace('Browsing for directory to save individual files to...'); + #if mac defaultPath = null; #end + browseForDirectory(null, onSelectDir, onCancel, defaultPath, 'Choose directory to save all files to...'); + return true; #elseif html5 saveFilesAsZIP(resources, onSaveAll, onCancel, defaultPath, force); + return true; #else - onCancel(); + trace('WARNING: saveMultipleFiles not implemented for this platform'); + + if (onCancel != null) + { + onCancel(); + } + return false; #end } @@ -274,14 +388,19 @@ class FileUtil /** * Takes an array of file entries and prompts the user to save them as a ZIP file. */ - public static function saveFilesAsZIP(resources:Array, ?onSave:Array->Void, ?onCancel:Void->Void, ?defaultPath:String, force:Bool = false):Bool + public static function saveFilesAsZIP(resources:Array, ?onSave:(Array)->Void, ?onCancel:()->Void, ?defaultPath:String, force:Bool = false):Bool { // Create a ZIP file. var zipBytes:Bytes = createZIPFromEntries(resources); - var onSave:String->Void = function(path:String) { - trace('Saved ${resources.length} files to ZIP at "$path".'); - if (onSave != null) onSave([path]); + var onSave:(String)->Void = function(path:String) { + trace('Saved ${resources.length} files to ZIP at "$path"'); + + if (onSave != null) + { + onSave([path]); + } }; + // Prompt the user to save the ZIP file. saveFile(zipBytes, [FILE_FILTER_ZIP], onSave, onCancel, defaultPath, 'Save files as ZIP...'); return true; @@ -290,14 +409,18 @@ class FileUtil /** * Takes an array of file entries and prompts the user to save them as a FNFC file. */ - public static function saveChartAsFNFC(resources:Array, ?onSave:Array->Void, ?onCancel:Void->Void, ?defaultPath:String, + public static function saveChartAsFNFC(resources:Array, ?onSave:(Array)->Void, ?onCancel:()->Void, ?defaultPath:String, force:Bool = false):Bool { // Create a ZIP file. var zipBytes:Bytes = createZIPFromEntries(resources); - var onSave:String->Void = function(path:String) { - trace('Saved FNF file to "$path"'); - if (onSave != null) onSave([path]); + var onSave:(String)->Void = function(path:String) { + trace('Saved FNFC file to "$path"'); + + if (onSave != null) + { + onSave([path]); + } }; // Prompt the user to save the ZIP file. saveFile(zipBytes, [FILE_FILTER_FNFC], onSave, onCancel, defaultPath, 'Save chart as FNFC...'); @@ -335,8 +458,7 @@ class FileUtil #if sys return sys.io.File.getContent(path); #else - trace('ERROR: readStringFromPath not implemented for this platform'); - return null; + throw 'Direct file reading by path is not supported on this platform.'; #end } @@ -350,21 +472,13 @@ class FileUtil public static function readBytesFromPath(path:String):Bytes { #if sys - if (!doesFileExist(path)) return null; return sys.io.File.getBytes(path); #else - return null; + throw 'Direct file reading by path is not supported on this platform.'; #end } - public static function doesFileExist(path:String):Bool - { - #if sys - return sys.FileSystem.exists(path); - #else - return false; - #end - } + /** * Browse for a file to read and execute a callback once we have a file reference. @@ -372,37 +486,45 @@ class FileUtil * * @param callback The function to call when the file is loaded. */ - public static function browseFileReference(callback:FileReference->Void) + public static function browseFileReference(callback:(FileReference)->Void):Void { var file = new FileReference(); file.addEventListener(Event.SELECT, function(e) { var selectedFileRef:FileReference = e.target; trace('Selected file: ' + selectedFileRef.name); + selectedFileRef.addEventListener(Event.COMPLETE, function(e) { var loadedFileRef:FileReference = e.target; trace('Loaded file: ' + loadedFileRef.name); + callback(loadedFileRef); }); + selectedFileRef.load(); }); + file.browse(); } /** * Prompts the user to save a file to their computer. */ - public static function writeFileReference(path:String, data:String) + public static function writeFileReference(path:String, data:String):Void { var file = new FileReference(); + file.addEventListener(Event.COMPLETE, function(e:Event) { - trace('Successfully wrote file.'); + trace('Successfully wrote file: "$path"'); }); + file.addEventListener(Event.CANCEL, function(e:Event) { - trace('Cancelled writing file.'); + trace('Cancelled writing file: "$path"'); }); + file.addEventListener(IOErrorEvent.IO_ERROR, function(e:IOErrorEvent) { - trace('IO error writing file.'); + trace('IO error writing file: "$path"'); }); + file.save(data, path); } @@ -416,16 +538,9 @@ class FileUtil public static function readJSONFromPath(path:String):Dynamic { #if sys - try - { - return SerializerUtil.fromJSON(sys.io.File.getContent(path)); - } - catch (ex) - { - return null; - } + return SerializerUtil.fromJSON(sys.io.File.getContent(path)); #else - return null; + throw 'Direct file reading by path is not supported on this platform.'; #end } @@ -440,26 +555,27 @@ class FileUtil public static function writeStringToPath(path:String, data:String, mode:FileWriteMode = Skip):Void { #if sys + if (directoryExists(path)) + { + throw 'Target path is a directory, not a file: "$path"'; + } + createDirIfNotExists(Path.directory(path)); + switch (mode) { case Force: sys.io.File.saveContent(path, data); case Skip: - if (!doesFileExist(path)) + if (!pathExists(path)) { sys.io.File.saveContent(path, data); } - else - { - // Do nothing. - // throw 'File already exists: $path'; - } case Ask: - if (doesFileExist(path)) + if (pathExists(path)) { // TODO: We don't have the technology to use native popups yet. - throw 'File already exists: $path'; + throw 'Entry at path already exists: $path'; } else { @@ -467,7 +583,7 @@ class FileUtil } } #else - throw 'Direct file writing by path not supported on this platform.'; + throw 'Direct file writing by path is not supported on this platform.'; #end } @@ -482,34 +598,42 @@ class FileUtil public static function writeBytesToPath(path:String, data:Bytes, mode:FileWriteMode = Skip):Void { #if sys + if (directoryExists(path)) + { + throw 'Target path is a directory, not a file: "$path"'; + } + createDirIfNotExists(Path.directory(path)); + + var shouldWrite:Bool = true; switch (mode) { case Force: - sys.io.File.saveBytes(path, data); + shouldWrite = true; case Skip: - if (!doesFileExist(path)) + if (!pathExists(path)) { - sys.io.File.saveBytes(path, data); - } - else - { - // Do nothing. - // throw 'File already exists: $path'; + shouldWrite = true; } case Ask: - if (doesFileExist(path)) + if (pathExists(path)) { // TODO: We don't have the technology to use native popups yet. - throw 'File already exists: $path'; + throw 'Entry at path already exists: "$path"'; } else { - sys.io.File.saveBytes(path, data); + shouldWrite = true; } } + + if (shouldWrite) + { + createDirIfNotExists(Path.directory(path)); + sys.io.File.saveBytes(path, data); + } #else - throw 'Direct file writing by path not supported on this platform.'; + throw 'Direct file writing by path is not supported on this platform.'; #end } @@ -523,9 +647,141 @@ class FileUtil public static function appendStringToPath(path:String, data:String):Void { #if sys - sys.io.File.append(path, false).writeString(data); + if (!pathExists(path)) + { + writeStringToPath(path, data, Force); + return; + } + else if (directoryExists(path)) + { + throw 'Target path is a directory, not a file: "$path"'; + } + + var output:Null = null; + try + { + output = sys.io.File.append(path, false); + output.writeString(data); + output.close(); + } + catch (e:Dynamic) + { + if (output != null) + { + output.close(); + } + + throw 'Failed to append to file: "$path"'; + } #else - throw 'Direct file writing by path not supported on this platform.'; + throw 'Direct file writing by path is not supported on this platform.'; + #end + } + + /** + * Moves a file from one location to another. + * Only works on desktop. + * + * @param path The path to the file. + * @param destination The path to move the file to. + */ + public static function moveFile(path:String, destination:String):Void + { + #if sys + if (Path.extension(destination) != '') + { + destination = Path.directory(destination); + } + + sys.FileSystem.rename(path, Path.join([destination, Path.withoutDirectory(path)])); + #else + throw 'File moving is not supported on this platform.'; + #end + } + + /** + * Delete a file at the given path. + * Only works on desktop. + * + * @param path The path to the file. + */ + public static function deleteFile(path:String):Void + { + #if sys + sys.FileSystem.deleteFile(path); + #else + throw 'File deletion is not supported on this platform.'; + #end + } + + /** + * Get a file's size in bytes. Max representable size is ~2.147 GB. + * Only works on desktop. + * + * @param path The path to the file. + * @return The size of the file in bytes. + */ + public static function getFileSize(path:String):Int + { + #if sys + return sys.FileSystem.stat(path).size; + #else + throw 'File size calculation is not supported on this platform.'; + #end + } + + /** + * Check if a path exists on the filesystem. + * Only works on desktop. + * + * @param path The path to the potential file or directory. + * @return Whether the path exists. + */ + public static function pathExists(path:String):Bool + { + #if sys + return sys.FileSystem.exists(path); + #else + return false; + #end + } + + /** + * Check if a path is a file on the filesystem. + * Only works on desktop. + * + * @param path The path to the potential file. + * @return Whether the path exists and is a file. + */ + public static function fileExists(path:String):Bool + { + #if sys + return pathExists(path) && !directoryExists(path); + #else + throw 'Filesystem check is not supported on this platform.'; + #end + } + + /** + * Check if a path is a directory on the filesystem. + * Only works on desktop. + * + * @param path The path to the potential directory. + * @return Whether the path exists and is a directory. + */ + public static function directoryExists(path:String):Bool + { + #if sys + try + { + return sys.FileSystem.isDirectory(path); + } + catch (e:Dynamic) + { + return false; + } + #else + throw 'Filesystem check is not supported on this platform.'; #end } @@ -538,14 +794,182 @@ class FileUtil public static function createDirIfNotExists(dir:String):Void { #if sys - if (!doesFileExist(dir)) + if (!directoryExists(dir)) { sys.FileSystem.createDirectory(dir); } + #else + throw 'Directory creation is not supported on this platform.'; #end } - static var tempDir:String = null; + /** + * List all entries in a directory. + * Only works on desktop. + * + * @param path The path to the directory. + * @return An array of entries in the directory. + */ + public static function readDir(path:String):Array + { + #if sys + return sys.FileSystem.readDirectory(path); + #else + throw 'Directory reading is not supported on this platform.'; + #end + } + + /** + * Move a directory from one location to another, optionally ignoring some paths. + * Only works on desktop. + * + * @param path The path to the directory. + * @param destination The path to move the directory to. + * @param ignore A list of paths to ignore. + * @param strict Fails if the destination directory is not empty. + */ + public static function moveDir(path:String, destination:String, ?ignore:Array, strict:Bool = true):Void + { + #if sys + if (!directoryExists(path)) + { + throw 'Path is not a directory: "$path"'; + } + + createDirIfNotExists(destination); + if (strict) + { + // Ensure the destination is empty if strict mode is enabled. + var entries:Array = readDir(destination); + if (entries.length > 0) + { + throw 'Destination directory "$destination" is not empty.'; + } + } + + var stack:Array = [path]; + while (stack.length > 0) + { + var currentPath:Null = stack.pop(); + if (currentPath == null) continue; + + var entries:Array = readDir(currentPath); + for (entry in entries) + { + var entryPath:String = Path.join([currentPath, entry]); + if (ignore != null && ignore.contains(entryPath)) continue; + if (directoryExists(entryPath)) + { + stack.push(entryPath); + } + else + { + moveFile(entryPath, Path.join([destination, entry])); + } + } + } + + if (readDir(path)?.length == 0) + { + deleteDir(path); + } + #else + throw 'Directory moving is not supported on this platform.'; + #end + } + + /** + * Delete a directory, optionally including its contents, and optionally ignoring some paths. + * Only works on desktop. + * + * @param path The path to the directory. + * @param recursive Whether to delete all contents of the directory. + * @param ignore A list of paths to ignore. + */ + public static function deleteDir(path:String, recursive:Bool = false, ?ignore:Array):Void + { + #if sys + if (!directoryExists(path)) + { + throw 'Path is not a valid directory: "$path"'; + } + + if (recursive) + { + var stack:Array = [path]; + while (stack.length > 0) + { + var currentPath:Null = stack.pop(); + if (currentPath == null) continue; + + var entries:Array = readDir(currentPath); + for (entry in entries) + { + var entryPath:String = Path.join([currentPath, entry]); + if (ignore != null && ignore.contains(entryPath)) continue; + if (directoryExists(entryPath)) + { + stack.push(entryPath); + } + else + { + deleteFile(entryPath); + } + } + } + } + else + { + sys.FileSystem.deleteDirectory(path); + } + #else + throw 'Directory deletion is not supported on this platform.'; + #end + } + + /** + * Get a directory's total size in bytes. Max representable size is ~2.147 GB. + * Only works on desktop. + * + * @param path The path to the directory. + * @return The total size of the directory in bytes. + */ + public static function getDirSize(path:String):Int + { + #if sys + if (!directoryExists(path)) + { + throw 'Path is not a valid directory path: $path'; + } + + var stack:Array = [path]; + var total:Int = 0; + while (stack.length > 0) + { + var currentPath:Null = stack.pop(); + if (currentPath == null) continue; + + for (entry in readDir(currentPath)) + { + var entryPath:String = Path.join([currentPath, entry]); + if (directoryExists(entryPath)) + { + stack.push(entryPath); + } + else + { + total += getFileSize(entryPath); + } + } + } + + return total; + #else + throw 'Directory size calculation not supported on this platform.'; + #end + } + + static var tempDir:Null = null; static final TEMP_ENV_VARS:Array = ['TEMP', 'TMPDIR', 'TEMPDIR', 'TMP']; /** @@ -554,19 +978,19 @@ class FileUtil * * @return The path to the temporary directory. */ - public static function getTempDir():String + public static function getTempDir():Null { if (tempDir != null) return tempDir; #if sys #if windows - var path:String = null; + var path:Null = null; for (envName in TEMP_ENV_VARS) { path = Sys.getEnv(envName); if (path == '') path = null; if (path != null) break; } - tempDir = Path.join([path, 'funkin/']); + tempDir = Path.join([path ?? '', 'funkin/']); return tempDir; #else tempDir = '/tmp/funkin/'; @@ -577,6 +1001,51 @@ class FileUtil #end } + /** + * Rename a file or directory. + * Only works on desktop. + * + * @param path The path to the file or directory. + * @param newName The new name of the file or directory. + * @param keepExtension Whether to keep the extension the same, if applicable. + */ + public static function rename(path:String, newName:String, keepExtension:Bool = true):Void + { + #if sys + if (!pathExists(path)) + { + throw 'Path does not exist: "$path"'; + } + + final isDirectory:Bool = directoryExists(path); + newName = Path.withoutDirectory(newName); + if (isDirectory) + { + newName = Path.withoutExtension(newName); + } + else if (keepExtension) + { + newName = Path.withExtension(newName, Path.extension(path)); + } + + newName = Path.join([Path.directory(path), newName]); + if (newName == path) + { + return; + } + + if (pathExists(newName)) + { + // Prevent overwriting something. + throw 'Destination path already exists: "$newName"'; + } + + sys.FileSystem.rename(path, newName); + #else + throw 'File renaming by path is not supported on this platform.'; + #end + } + /** * Create a Bytes object containing a ZIP file, containing the provided entries. * @@ -593,29 +1062,30 @@ class FileUtil public static function readZIPFromBytes(input:Bytes):Array { - trace('TEST: ' + input.length); - trace(input.sub(0, 30).toHex()); var bytesInput = new haxe.io.BytesInput(input); var zippedEntries = haxe.zip.Reader.readZip(bytesInput); - var results:Array = []; + var results:Array = new Array(); for (entry in zippedEntries) { if (entry.compressed) { entry.data = haxe.zip.Reader.unzip(entry); } + results.push(entry); } + return results; } public static function mapZIPEntriesByName(input:Array):Map { - var results:Map = []; + var results:Map = new Map(); for (entry in input) { results.set(entry.fileName, entry); } + return results; } @@ -653,7 +1123,12 @@ class FileUtil }; } - public static function openFolder(pathFolder:String) + /** + * Open a folder in the file explorer. + * + * @param pathFolder The path to the folder. + */ + public static function openFolder(pathFolder:String):Void { #if windows Sys.command('explorer', [pathFolder]); @@ -664,28 +1139,334 @@ class FileUtil // FileUtil.hx note: this was originally used to open the logs specifically! // thats why the above comment is there! Sys.command('open', [pathFolder]); - #end - + #elseif linux // TODO: implement linux // some shit with xdg-open :thinking: emoji... + #else + throw 'External folder open is not supported on this platform.'); + #end } - static function convertTypeFilter(typeFilter:Array):String + private static function convertTypeFilter(?typeFilter:Array):Null { - var filter:String = null; + var filter:Null = null; if (typeFilter != null) { - var filters:Array = []; + var filters:Array = new Array(); for (type in typeFilter) { - filters.push(StringTools.replace(StringTools.replace(type.extension, '*.', ''), ';', ',')); + filters.push(type.extension.replace('*.', '').replace(';', ',')); } + filter = filters.join(';'); } + return filter; } } +/** + * Utilities for reading and writing files on various platforms. + * Wrapper for `FileUtil` that sanitizes paths for script safety. + */ +@:nullSafety +class FileUtilSandboxed +{ + /** + * Prevent paths from exiting the root. + * + * @param path The path to sanitize. + * @return The sanitized path. + */ + public static function sanitizePath(path:String):String + { + if (path == null) + { + #if sys + return FileUtil.gameDirectory; + #else + return ''; + #end + } + + if (path.contains(':')) + { + path = path.substring(path.lastIndexOf(':') + 1); + } + + path = path.trim().replace('\\', '/'); + while (path.contains('//')) + { + path = path.replace('//', '/'); + } + + @:privateAccess for (char in FileUtil.INVALID_CHARS) + { + path = path.replace(char, ''); + } + + var parts:Array = path.split('/'); + var sanitized:Array = new Array(); + for (part in parts) + { + switch (part) + { + case '.' | '': continue; + case '..': sanitized.pop(); + default: sanitized.push(part); + } + } + + var ret:String = sanitized.join('/'); + + #if sys + var realPath:String = Path.normalize(sys.FileSystem.fullPath(Path.join([FileUtil.gameDirectory, ret]))); + // ^ TODO: figure out how to get "real" path of symlinked paths + + if (!realPath.startsWith(FileUtil.gameDirectory.toString())) + { + return FileUtil.gameDirectory; + } + + ret = realPath; + #end + + return ret; + } + + /** + * Check against protected paths. + * @param path The path to check. + * @return Whether the path is protected. + */ + public static function isProtected(path:String):Bool + { + path = sanitizePath(path); + @:privateAccess for (protected in FileUtil.PROTECTED_PATHS) + { + if (path == protected || (protected.contains('*') && path.startsWith(protected.substring(0, protected.indexOf('*'))))) + { + return true; + } + } + + return false; + } + + public static final FILE_FILTER_FNFC:FileFilter = FileUtil.FILE_FILTER_FNFC; + public static final FILE_FILTER_JSON:FileFilter = FileUtil.FILE_FILTER_JSON; + public static final FILE_FILTER_ZIP:FileFilter = FileUtil.FILE_FILTER_ZIP; + public static final FILE_FILTER_PNG:FileFilter = FileUtil.FILE_FILTER_PNG; + + public static final FILE_EXTENSION_INFO_FNFC:FileDialogExtensionInfo = FileUtil.FILE_EXTENSION_INFO_FNFC; + public static final FILE_EXTENSION_INFO_ZIP:FileDialogExtensionInfo = FileUtil.FILE_EXTENSION_INFO_ZIP; + public static final FILE_EXTENSION_INFO_PNG:FileDialogExtensionInfo = FileUtil.FILE_EXTENSION_INFO_PNG; + + public static function browseForBinaryFile(dialogTitle:String, ?typeFilter:Array, onSelect:(SelectedFileInfo)->Void, + ?onCancel:()->Void) + { + FileUtil.browseForBinaryFile(dialogTitle, typeFilter, onSelect, onCancel); + } + + public static function browseForTextFile(dialogTitle:String, ?typeFilter:Array, onSelect:(SelectedFileInfo)->Void, + ?onCancel:()->Void):Void + { + FileUtil.browseForTextFile(dialogTitle, typeFilter, onSelect, onCancel); + } + + public static function browseForDirectory(?typeFilter:Array, onSelect:(String)->Void, ?onCancel:()->Void, ?defaultPath:String, + ?dialogTitle:String):Bool + { + return FileUtil.browseForDirectory(typeFilter, onSelect, onCancel, defaultPath, dialogTitle); + } + + public static function browseForMultipleFiles(?typeFilter:Array, onSelect:(Array)->Void, ?onCancel:()->Void, ?defaultPath:String, + ?dialogTitle:String):Bool + { + return FileUtil.browseForMultipleFiles(typeFilter, onSelect, onCancel, defaultPath, dialogTitle); + } + + public static function browseForSaveFile(?typeFilter:Array, onSelect:(String)->Void, ?onCancel:()->Void, ?defaultPath:String, + ?dialogTitle:String):Bool + { + return FileUtil.browseForSaveFile(typeFilter, onSelect, onCancel, defaultPath, dialogTitle); + } + + public static function saveFile(data:Bytes, ?typeFilter:Array, ?onSave:(String)->Void, ?onCancel:()->Void, ?defaultFileName:String, + ?dialogTitle:String):Bool + { + return FileUtil.saveFile(data, typeFilter, onSave, onCancel, defaultFileName, dialogTitle); + } + + public static function saveMultipleFiles(resources:Array, ?onSaveAll:(Array)->Void, ?onCancel:()->Void, ?defaultPath:String, + force:Bool = false):Bool + { + return FileUtil.saveMultipleFiles(resources, onSaveAll, onCancel, defaultPath, force); + } + + public static function saveFilesAsZIP(resources:Array, ?onSave:(Array)->Void, ?onCancel:()->Void, ?defaultPath:String, force:Bool = false):Bool + { + return FileUtil.saveFilesAsZIP(resources, onSave, onCancel, defaultPath, force); + } + + public static function saveChartAsFNFC(resources:Array, ?onSave:(Array)->Void, ?onCancel:()->Void, ?defaultPath:String, + force:Bool = false):Bool + { + return FileUtil.saveChartAsFNFC(resources, onSave, onCancel, defaultPath, force); + } + + public static function saveFilesAsZIPToPath(resources:Array, path:String, mode:FileWriteMode = Skip):Bool + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot write to protected path: $path'; + return FileUtil.saveFilesAsZIPToPath(resources, path, mode); + } + + public static function readStringFromPath(path:String):String + { + return FileUtil.readStringFromPath(sanitizePath(path)); + } + + public static function readBytesFromPath(path:String):Bytes + { + return FileUtil.readBytesFromPath(sanitizePath(path)); + } + + public static function browseFileReference(callback:(FileReference)->Void):Void + { + FileUtil.browseFileReference(callback); + } + + public static function writeFileReference(path:String, data:String):Void + { + FileUtil.writeFileReference(path, data); + } + + public static function readJSONFromPath(path:String):Dynamic + { + return FileUtil.readJSONFromPath(sanitizePath(path)); + } + + public static function writeStringToPath(path:String, data:String, mode:FileWriteMode = Skip):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot write to protected path: $path'; + FileUtil.writeStringToPath(path, data, mode); + } + + public static function writeBytesToPath(path:String, data:Bytes, mode:FileWriteMode = Skip):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot write to protected path: $path'; + FileUtil.writeBytesToPath(path, data, mode); + } + + public static function appendStringToPath(path:String, data:String):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot write to protected path: $path'; + FileUtil.appendStringToPath(path, data); + } + + public static function moveFile(path:String, destination:String):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot move protected path: $path'; + if (isProtected(destination = sanitizePath(destination))) throw 'Cannot move to protected path: $destination'; + FileUtil.moveFile(path, destination); + } + + public static function deleteFile(path:String):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot delete protected path: $path'; + FileUtil.deleteFile(path); + } + + public static function getFileSize(path:String):Int + { + return FileUtil.getFileSize(sanitizePath(path)); + } + + public static function pathExists(path:String):Bool + { + return FileUtil.pathExists(sanitizePath(path)); + } + + public static function fileExists(path:String):Bool + { + return FileUtil.fileExists(sanitizePath(path)); + } + + public static function directoryExists(path:String):Bool + { + return FileUtil.directoryExists(sanitizePath(path)); + } + + public static function createDirIfNotExists(dir:String):Void + { + FileUtil.createDirIfNotExists(sanitizePath(dir)); + } + + public static function readDir(path:String):Array + { + return FileUtil.readDir(sanitizePath(path)); + } + + public static function moveDir(path:String, destination:String, ?ignore:Array, strict:Bool = true):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot move protected path: "$path"'; + if (isProtected(destination = sanitizePath(destination))) throw 'Cannot move to protected path: "$destination"'; + FileUtil.moveDir(path, destination, ignore, strict); + } + + public static function deleteDir(path:String, recursive:Bool = false, ?ignore:Array):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot delete protected path: "$path"'; + FileUtil.deleteDir(path, recursive, ignore); + } + + public static function getDirSize(path:String):Int + { + return FileUtil.getDirSize(sanitizePath(path)); + } + + public static function getTempDir():Null + { + return FileUtil.getTempDir(); + } + + public static function rename(path:String, newName:String, keepExtension:Bool = true):Void + { + if (isProtected(path = sanitizePath(path))) throw 'Cannot rename protected path: "$path"'; + FileUtil.rename(path, sanitizePath(newName), keepExtension); + } + + public static function createZIPFromEntries(entries:Array):Bytes + { + return FileUtil.createZIPFromEntries(entries); + } + + public static function readZIPFromBytes(input:Bytes):Array + { + return FileUtil.readZIPFromBytes(input); + } + + public static function mapZIPEntriesByName(input:Array):Map + { + return FileUtil.mapZIPEntriesByName(input); + } + + public static function makeZIPEntry(name:String, content:String):Entry + { + return FileUtil.makeZIPEntry(name, content); + } + + public static function makeZIPEntryFromBytes(name:String, data:haxe.io.Bytes):Entry + { + return FileUtil.makeZIPEntryFromBytes(name, data); + } + + public static function openFolder(pathFolder:String) + { + FileUtil.openFolder(pathFolder); + } +} + enum FileWriteMode { /**