package funkin.util; import haxe.zip.Entry; import lime.utils.Bytes; import lime.ui.FileDialog; import openfl.net.FileFilter; import haxe.io.Path; import openfl.net.FileReference; import openfl.events.Event; import openfl.events.IOErrorEvent; import haxe.ui.containers.dialogs.Dialog.DialogButton; 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"); public static final FILE_FILTER_JSON:FileFilter = new FileFilter("JSON Data File (.json)", "*.json"); public static final FILE_FILTER_ZIP:FileFilter = new FileFilter("ZIP Archive (.zip)", "*.zip"); public static final FILE_FILTER_PNG:FileFilter = new FileFilter("PNG Image (.png)", "*.png"); public static final FILE_FILTER_FNFS:FileFilter = new FileFilter("Friday Night Funkin' Stage (.fnfs)", "*.fnfs"); public static final FILE_EXTENSION_INFO_FNFC:FileDialogExtensionInfo = { extension: 'fnfc', label: 'Friday Night Funkin\' Chart', }; public static final FILE_EXTENSION_INFO_ZIP:FileDialogExtensionInfo = { extension: 'zip', label: 'ZIP Archive', }; public static final FILE_EXTENSION_INFO_PNG:FileDialogExtensionInfo = { extension: 'png', label: 'PNG Image', }; public static final FILE_EXTENSION_INFO_FNFS:FileDialogExtensionInfo = { extension: 'fnfs', label: 'Friday Night Funkin\' Stage', }; /** * Paths which should not be deleted or modified by scripts. */ public static var PROTECTED_PATHS(get, never):Array; public static function get_PROTECTED_PATHS():Array { final protected:Array = [ '', '.', 'assets', 'assets/*', 'backups', 'backups/*', 'manifest', 'manifest/*', 'plugins', 'plugins/*', 'Funkin.exe', 'Funkin', 'icon.ico', 'libvlc.dll', 'libvlccore.dll', 'lime.ndll', 'scores.json' ]; #if sys for (i in 0...protected.length) { protected[i] = sys.FileSystem.fullPath(Path.join([gameDirectory, protected[i]])); } #end return protected; } /** * Regex for invalid filesystem characters. */ public static final INVALID_CHARS:EReg = ~/[:*?"<>|\n\r\t]/g; #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 = sys.FileSystem.fullPath(Path.directory(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. * File contents will be binary, not String. * * @param typeFilter * @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) { var onComplete = function(button, selectedFiles) { if (button == DialogButton.OK && selectedFiles.length > 0) { onSelect(selectedFiles[0]); } else if (onCancel != null) { onCancel(); } }; Dialogs.openFile(onComplete, { readContents: true, readAsBinary: true, // Binary multiple: false, extensions: typeFilter ?? new Array(), title: dialogTitle, }); } /** * Browses for a single file, then calls `onSelect(fileInfo)` when a file is selected. * Powered by HaxeUI, so it works on all platforms. * File contents will be a String, not binary. * * @param typeFilter * @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 { var onComplete = function(button, selectedFiles) { if (button == DialogButton.OK && selectedFiles.length > 0) { onSelect(selectedFiles[0]); } else if (onCancel != null) { onCancel(); } }; Dialogs.openFile(onComplete, { readContents: true, readAsBinary: false, // Text multiple: false, extensions: typeFilter ?? new Array(), title: dialogTitle, }); } /** * Browses for a directory, then calls `onSelect(path)` when a path chosen. * Note that on HTML5 this will immediately fail. * * @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, ?defaultPath:String, ?dialogTitle:String):Bool { #if desktop var filter:Null = convertTypeFilter(typeFilter); var fileDialog:FileDialog = new FileDialog(); fileDialog.onSelect.add(onSelect); if (onCancel != null) { fileDialog.onCancel.add(onCancel); } fileDialog.browse(OPEN_DIRECTORY, filter, defaultPath, dialogTitle); return true; #else trace('WARNING: browseForDirectory not implemented for this platform'); if (onCancel != null) { onCancel(); } return false; #end } /** * Browses for multiple file, then calls `onSelect(paths)` when a path chosen. * Note that on HTML5 this will immediately fail. * * @return Whether the file dialog was opened successfully. */ public static function browseForMultipleFiles(?typeFilter:Array, onSelect:(Array) -> Void, ?onCancel:() -> Void, ?defaultPath:String, ?dialogTitle:String):Bool { #if desktop var filter:Null = convertTypeFilter(typeFilter); var fileDialog:FileDialog = new FileDialog(); fileDialog.onSelectMultiple.add(onSelect); if (onCancel != null) { fileDialog.onCancel.add(onCancel); } fileDialog.browse(OPEN_MULTIPLE, filter, defaultPath, dialogTitle); return true; #else trace('WARNING: browseForMultipleFiles not implemented for this platform'); if (onCancel != null) { onCancel(); } return false; #end } /** * Browses for a file location to save to, then calls `onSave(path)` when a path chosen. * Note that on HTML5 you can't do much with this, you should call `saveFile(resource:haxe.io.Bytes)` instead. * * @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, ?defaultPath:String, ?dialogTitle:String):Bool { #if desktop var filter:Null = convertTypeFilter(typeFilter); var fileDialog:FileDialog = new FileDialog(); fileDialog.onSelect.add(onSelect); if (onCancel != null) { fileDialog.onCancel.add(onCancel); } fileDialog.browse(SAVE, filter, defaultPath, dialogTitle); return true; #else trace('WARNING: browseForSaveFile not implemented for this platform'); if (onCancel != null) { onCancel(); } return false; #end } /** * Browses for a single file location, then writes the provided `haxe.io.Bytes` data and calls `onSave(path)` when done. * Works great on desktop and HTML5. * * @return Whether the file dialog was opened successfully. */ public static function saveFile(data:Bytes, ?typeFilter:Array, ?onSave:(String) -> Void, ?onCancel:() -> Void, ?defaultFileName:String, ?dialogTitle:String):Bool { #if desktop var filter:Null = convertTypeFilter(typeFilter); var fileDialog:FileDialog = new FileDialog(); 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:Null = 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); } fileDialog.save(data, filter, defaultFileName, dialogTitle); return true; #else trace('WARNING: saveFile not implemented for this platform'); if (onCancel != null) { onCancel(); } return false; #end } /** * Prompts the user to save multiple files. * On desktop, this will prompt the user for a directory, then write all of the files to there. * On HTML5, this will zip the files up and prompt the user to save that. * * @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, ?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 = new Array(); for (resource in resources) { /* var filePath:String = Path.join([targetPath, resource.fileName]); try { if (resource.data == null) { trace('WARNING: File $filePath has no data or content. Skipping.'); continue; } else { writeBytesToPath(filePath, resource.data, force ? Force : Skip); } } 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); } 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 trace('WARNING: saveMultipleFiles not implemented for this platform'); if (onCancel != null) { onCancel(); } return false; #end } /** * 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, ?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]); } }; // Prompt the user to save the ZIP file. saveFile(zipBytes, [FILE_FILTER_ZIP], onSave, onCancel, defaultPath, 'Save files as ZIP...'); return true; } /** * 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, ?defaultPath:String, force:Bool = false):Bool { // Create a ZIP file. var zipBytes:Bytes = createZIPFromEntries(resources); 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...'); return true; } /** * Takes an array of file entries and forcibly writes a ZIP to the given path. * Only works on native, because HTML5 doesn't allow you to write files to arbitrary paths. * Use `saveFilesAsZIP` instead. * @param force Whether to force overwrite an existing file. */ public static function saveFilesAsZIPToPath(resources:Array, path:String, mode:FileWriteMode = Skip):Bool { #if sys // Create a ZIP file. var zipBytes:Bytes = createZIPFromEntries(resources); // Write the ZIP. writeBytesToPath(path, zipBytes, mode); return true; #else return false; #end } /** * Read string file contents directly from a given path. * Only works on native. * * @param path The path to the file. * @return The file contents. */ public static function readStringFromPath(path:String):String { #if sys return sys.io.File.getContent(path); #else throw 'Direct file reading by path is not supported on this platform.'; #end } /** * Read bytes file contents directly from a given path. * Only works on native. * * @param path The path to the file. * @return The file contents. */ public static function readBytesFromPath(path:String):Bytes { #if sys return sys.io.File.getBytes(path); #else throw 'Direct file reading by path is not supported on this platform.'; #end } /** * Browse for a file to read and execute a callback once we have a file reference. * Works great on HTML5 or desktop. * * @param callback The function to call when the file is loaded. */ 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, callback:String->Void) { var file = new FileReference(); file.addEventListener(Event.COMPLETE, function(e:Event) { trace('Successfully wrote file: "$path"'); callback("success"); }); file.addEventListener(Event.CANCEL, function(e:Event) { trace('Cancelled writing file: "$path"'); callback("info"); }); file.addEventListener(IOErrorEvent.IO_ERROR, function(e:IOErrorEvent) { trace('IO error writing file: "$path"'); callback("error"); }); file.save(data, path); } /** * Read JSON file contents directly from a given path. * Only works on native. * * @param path The path to the file. * @return The JSON data. */ public static function readJSONFromPath(path:String):Dynamic { #if sys return SerializerUtil.fromJSON(sys.io.File.getContent(path)); #else throw 'Direct file reading by path is not supported on this platform.'; #end } /** * Write string file contents directly to a given path. * Only works on native. * * @param path The path to the file. * @param data The string to write. * @param mode Whether to Force, Skip, or Ask to overwrite an existing file. */ 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 (!pathExists(path)) { sys.io.File.saveContent(path, data); } case Ask: if (pathExists(path)) { // TODO: We don't have the technology to use native popups yet. throw 'Entry at path already exists: $path'; } else { sys.io.File.saveContent(path, data); } } #else throw 'Direct file writing by path is not supported on this platform.'; #end } /** * Write byte file contents directly to a given path. * Only works on native. * * @param path The path to the file. * @param data The bytes to write. * @param mode Whether to Force, Skip, or Ask to overwrite an existing file. */ 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"'; } var shouldWrite:Bool = true; switch (mode) { case Force: shouldWrite = true; case Skip: if (!pathExists(path)) { shouldWrite = true; } case Ask: if (pathExists(path)) { // TODO: We don't have the technology to use native popups yet. throw 'Entry at path already exists: "$path"'; } else { shouldWrite = true; } } if (shouldWrite) { createDirIfNotExists(Path.directory(path)); sys.io.File.saveBytes(path, data); } #else throw 'Direct file writing by path is not supported on this platform.'; #end } /** * Write string file contents directly to the end of a file at the given path. * Only works on native. * * @param path The path to the file. * @param data The string to append. */ public static function appendStringToPath(path:String, data:String):Void { #if sys 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 is not supported on this platform.'; #end } /** * Moves a file from one location to another. * Only works on native. * * @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 native. * * @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 native. * * @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 native. * * @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 native. * * @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 native. * * @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 } /** * Create a directory if it doesn't already exist. * Only works on native. * * @param dir The path to the directory. */ public static function createDirIfNotExists(dir:String):Void { if (!directoryExists(dir)) { #if sys sys.FileSystem.createDirectory(dir); #else throw 'Directory creation is not supported on this platform.'; #end } } /** * List all entries in a directory. * Only works on native. * * @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 native. * * @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 native. * * @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 native. * * @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']; /** * Get the path to a temporary directory we can use for writing files. * Only works on native. * * @return The path to the temporary directory. */ public static function getTempDir():Null { if (tempDir != null) return tempDir; #if sys #if windows 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/']); return tempDir; #elseif android tempDir = Path.addTrailingSlash(extension.androidtools.content.Context.getCacheDir()); return tempDir; #else tempDir = '/tmp/funkin/'; return tempDir; #end #else return null; #end } /** * Rename a file or directory. * Only works on native. * * @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. * * @param entries The entries to add to the ZIP file. * @return The ZIP file as a Bytes object. */ public static function createZIPFromEntries(entries:Array):Bytes { var o:haxe.io.BytesOutput = new haxe.io.BytesOutput(); var zipWriter:haxe.zip.Writer = new haxe.zip.Writer(o); zipWriter.write(entries.list()); return o.getBytes(); } public static function readZIPFromBytes(input:Bytes):Array { var bytesInput = new haxe.io.BytesInput(input); var zippedEntries = haxe.zip.Reader.readZip(bytesInput); 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 = new Map(); for (entry in input) { results.set(entry.fileName, entry); } return results; } /** * Create a ZIP file entry from a file name and its string contents. * * @param name The name of the file. You can use slashes to create subdirectories. * @param content The string contents of the file. * @return The resulting entry. */ public static function makeZIPEntry(name:String, content:String):Entry { var data:Bytes = haxe.io.Bytes.ofString(content, UTF8); return makeZIPEntryFromBytes(name, data); } /** * Create a ZIP file entry from a file name and its string contents. * * @param name The name of the file. You can use slashes to create subdirectories. * @param data The byte data of the file. * @return The resulting entry. */ public static function makeZIPEntryFromBytes(name:String, data:haxe.io.Bytes):Entry { return { fileName: name, fileSize: data.length, data: data, dataSize: data.length, compressed: false, fileTime: Date.now(), crc32: null, extraFields: null, }; } /** * Runs platform-specific code to open a path in the file explorer. * * @param pathFolder The path of the folder to open. * @param createIfNotExists If `true`, creates the folder if missing; otherwise, throws an error. */ public static function openFolder(pathFolder:String, createIfNotExists:Bool = true):Void { #if sys pathFolder = pathFolder.trim(); if (createIfNotExists) { createDirIfNotExists(pathFolder); } else if (!directoryExists(pathFolder)) { throw 'Path is not a directory: "$pathFolder"'; } #if windows Sys.command('explorer', [pathFolder.replace('/', '\\')]); #elseif mac // mac could be fuckie with where the log folder is relative to the game file... // if this comment is still here... it means it has NOT been verified on mac yet! // // FileUtil.hx note: this was originally used to open the logs specifically! // thats why the above comment is there! Sys.command('open', [pathFolder]); #elseif linux // TODO: implement linux // some shit with xdg-open :thinking: emoji... #end #else throw 'External folder open is not supported on this platform.'; #end } /** * Runs platform-specific code to open a file explorer and select a specific file. * * @param path The path of the file to select. */ public static function openSelectFile(path:String):Void { #if sys path = path.trim(); if (!pathExists(path)) { throw 'Path does not exist: "$path"'; } #if windows Sys.command('explorer', ['/select,', path.replace('/', '\\')]); #elseif mac Sys.command('open', ['-R', path]); #elseif linux // TODO: unsure of the linux equivalent to opening a folder and then "selecting" a file. Sys.command('open', [path]); #end #else throw 'External file selection is not supported on this platform.'; #end } private static function convertTypeFilter(?typeFilter:Array):Null { var filter:Null = null; if (typeFilter != null) { var filters:Array = new Array(); for (type in typeFilter) { 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 { path = (path ?? '').trim(); if (path == '') { #if sys return FileUtil.gameDirectory; #else return ''; #end } if (path.contains(':')) { path = path.substring(path.lastIndexOf(':') + 1); } path = path.replace('\\', '/'); while (path.contains('//')) { path = path.replace('//', '/'); } final parts:Array = FileUtil.INVALID_CHARS.replace(path, '').split('/'); final sanitized:Array = new Array(); for (part in parts) { switch (part) { case '.' | '': continue; case '..': sanitized.pop(); default: sanitized.push(part.trim()); } } if (sanitized.length == 0) { #if sys return FileUtil.gameDirectory; #else return ''; #end } #if sys // TODO: figure out how to get "real" path of symlinked paths final realPath:String = sys.FileSystem.fullPath(Path.join([FileUtil.gameDirectory, sanitized.join('/')])); if (!realPath.startsWith(FileUtil.gameDirectory)) { return FileUtil.gameDirectory; } return realPath; #else return sanitized.join('/'); #end } /** * Check against protected paths. * @param path The path to check. * @return Whether the path is protected. */ public static function isProtected(path:String, sanitizeFirst:Bool = true):Bool { if (sanitizeFirst) 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), false)) 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, callback:String->Void):Void { FileUtil.writeFileReference(path, data, callback); } 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), false)) 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), false)) 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), false)) 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), false)) throw 'Cannot move protected path: $path'; if (isProtected(destination = sanitizePath(destination), false)) throw 'Cannot move to protected path: $destination'; FileUtil.moveFile(path, destination); } public static function deleteFile(path:String):Void { if (isProtected(path = sanitizePath(path), false)) 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), false)) throw 'Cannot move protected path: "$path"'; if (isProtected(destination = sanitizePath(destination), false)) 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), false)) 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), false)) 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, createIfNotExists:Bool = true):Void { FileUtil.openFolder(sanitizePath(pathFolder), createIfNotExists); } public static function openSelectFile(path:String):Void { FileUtil.openSelectFile(sanitizePath(path)); } } enum FileWriteMode { /** * Forcibly overwrite the file if it already exists. */ Force; /** * Ask the user if they want to overwrite the file if it already exists. */ Ask; /** * Skip the file if it already exists. */ Skip; }