using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Linq; using System.Runtime.InteropServices; using System.Threading; using NitroxModel.Platforms.OS.MacOS; using NitroxModel.Platforms.OS.Unix; using NitroxModel.Platforms.OS.Windows; namespace NitroxModel.Platforms.OS.Shared; public abstract class FileSystem { private static readonly Lazy instance = new(() => { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { return new WinFileSystem(); } if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { return new UnixFileSystem(); } if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { return new MacFileSystem(); } throw new NotSupportedException("Current OS is not supported"); }, LazyThreadSafetyMode.ExecutionAndPublication); public virtual IEnumerable ExecutableFileExtensions => throw new NotSupportedException(); public static FileSystem Instance => instance.Value; public virtual string TextEditor => throw new NotSupportedException(); public virtual IEnumerable GetDefaultPrograms(string file) => throw new NotSupportedException(); /// /// Opens the file with the default associated program or the default editor of the OS. /// The returned should be disposed. /// /// File or program to open or execute. /// Instance of a running process. Should be disposed. public virtual Process OpenOrExecuteFile(string file) { if (string.IsNullOrWhiteSpace(file)) { throw new ArgumentException("File path must not be null or empty.", nameof(file)); } string editorProgram = GetDefaultPrograms(file).FirstOrDefault() ?? TextEditor; // Handle special arguments for popular editors. string arguments = Path.GetFileName(editorProgram)?.ToLowerInvariant() switch { "code.cmd" => "--wait", // Allow to wait on VS code _ => "" }; return Process.Start(new ProcessStartInfo { FileName = editorProgram, Arguments = $@"{(arguments.Length > 0 ? $"{arguments} " : "")}""{file}""", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, }); } /// /// Gets the full path to a file or program. Searches the PATH environment variables if file could not be found /// relatively. Returns null if not found. /// /// File or program name to get the full path from. /// public string GetFullPath(string fileName) { if (File.Exists(fileName)) { return Path.GetFullPath(fileName); } string values = Environment.GetEnvironmentVariable("PATH"); if (values == null) { return null; } fileName = Path.GetFileName(fileName); // Always test filename in system lib root first, then other paths. On UNIX systems the path is case-sensitive. IEnumerable pathsToTools = new[] { Environment.SystemDirectory }.Concat(values.Split(Path.PathSeparator)).Distinct(); foreach (string path in pathsToTools) { string fullPath = Path.Combine(path, fileName); if (File.Exists(fullPath)) { return fullPath; } } return null; } public string MakeRelativePath(string fromPath, string toPath) { if (string.IsNullOrEmpty(fromPath)) { throw new ArgumentNullException(nameof(fromPath)); } if (string.IsNullOrEmpty(toPath)) { throw new ArgumentNullException(nameof(toPath)); } // Ensure postfix so that result becomes relative to entire "from" path. fromPath = fromPath[fromPath.Length - 1] == Path.DirectorySeparatorChar ? fromPath : fromPath + Path.DirectorySeparatorChar; Uri fromUri = new(fromPath); Uri toUri = new(toPath); // Can path be made relative? if (fromUri.Scheme != toUri.Scheme) { return toPath; } Uri relativeUri = fromUri.MakeRelativeUri(toUri); string relativePath = Uri.UnescapeDataString(relativeUri.ToString()); if (toUri.Scheme.Equals("file", StringComparison.InvariantCultureIgnoreCase)) { relativePath = relativePath.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); } return relativePath; } /// /// Zips the files found in the given directory. /// /// Directory to search for files to zip. /// /// Name of output zip, optionally including full path. If null, uses the directory name given by /// . /// /// Search pattern used to filter against files to zip. /// If true, overwrites the file matching the output path. /// Full path to the newly created zip or null if no files are found to zip. /// If zip file already exists. public string ZipFilesInDirectory(string dir, string outputPath = null, string fileSearchPattern = "*", bool replaceFile = false) { if (string.IsNullOrWhiteSpace(dir)) { throw new ArgumentException("Directory must not be null or empty", nameof(dir)); } dir = Path.GetFullPath(dir); if (!Directory.Exists(dir)) { throw new ArgumentException("Path is not a directory", nameof(dir)); } // Figure out relative path of output OR use .zip of directory. outputPath = Path.GetFullPath(outputPath ?? dir); string outZipName = Path.GetFileName(outputPath); if (string.IsNullOrEmpty(Path.GetExtension(outZipName))) { outZipName = Path.ChangeExtension(outZipName, ".zip"); } string outZipDir = Path.GetDirectoryName(outputPath) ?? dir; string outZipFullName = Path.Combine(outZipDir, outZipName); if (!replaceFile && File.Exists(outZipFullName)) { throw new IOException($"The file '{outZipFullName}' already exists"); } string[] files = Directory.GetFiles(dir, fileSearchPattern, SearchOption.AllDirectories); if (files.Length < 1) { return null; } // Create the zip. Directory.CreateDirectory(outZipDir); using ZipArchive zip = new(File.Create(outZipFullName), ZipArchiveMode.Create); foreach (string file in files) { ZipArchiveEntry entry = zip.CreateEntry(MakeRelativePath(dir, file)); using Stream sourceStream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read); using Stream targetStream = entry.Open(); sourceStream.CopyTo(targetStream); } return outZipFullName; } /// /// Replaces target file with source file. If target file does not exist then it moves the file. /// This falls back to a copy if the target is on a different drive. /// The source file will always be deleted. /// /// Source file to replace with. /// Target file to replace. /// True if file was moved or replaced successfully. public bool ReplaceFile(string source, string target) { if (!File.Exists(source)) { return false; } source = Path.GetFullPath(source); if (!File.Exists(target)) { File.Move(source, target); return true; } try { File.Replace(source, target, null, false); } catch (IOException ex) { // TODO: Need to test on Linux because the ex.HResult will likely not work or be different number cross-platform. switch ((uint)ex.HResult) { case 0x80070498: // Tried to replace file between drives. This does not work, need to do so in steps. try { string originalExtension = Path.GetExtension(target); originalExtension = string.IsNullOrWhiteSpace(originalExtension) ? "" : $".{originalExtension}"; string backupFileName = $"{Path.GetFileNameWithoutExtension(target)}_{Path.GetFileNameWithoutExtension(Path.GetTempFileName())}{originalExtension}.bak"; Log.Debug($"Renaming file '{target}' to '{backupFileName}' as backup plan if file replace fails"); File.Move(target, backupFileName); File.Copy(source, target); // Cleanup redundant files, ignoring errors. try { File.Delete(source); File.Delete(backupFileName); } catch (Exception) { // ignored } } catch (Exception ex2) { Log.Error(ex2, $"Failed to replace file '{source}' with '{target}' which is on another drive"); return false; } break; default: // No special handling implemented for error, abort. Log.Warn($"Unhandled file replace of '{source}' with '{target}' with HRESULT: 0x{ex.HResult:X}"); return false; } } return true; } public bool IsWritable(string directory) { if (!Directory.Exists(directory)) { return false; } string randFileName = Path.GetRandomFileName(); try { File.Create(Path.Combine(directory, randFileName)).Close(); File.Delete(Path.Combine(directory, randFileName)); return true; } catch { return false; } } public abstract bool SetFullAccessToCurrentUser(string directory); public virtual bool IsTrustedFile(string file) => true; }