280 lines
12 KiB
C#
280 lines
12 KiB
C#
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<FileSystem> 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<string> ExecutableFileExtensions => throw new NotSupportedException();
|
|
public static FileSystem Instance => instance.Value;
|
|
public virtual string TextEditor => throw new NotSupportedException();
|
|
|
|
public virtual IEnumerable<string> GetDefaultPrograms(string file) => throw new NotSupportedException();
|
|
|
|
/// <summary>
|
|
/// Opens the file with the default associated program or the default editor of the OS.
|
|
/// The returned <see cref="Process" /> should be disposed.
|
|
/// </summary>
|
|
/// <param name="file">File or program to open or execute.</param>
|
|
/// <returns>Instance of a running process. Should be disposed.</returns>
|
|
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,
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="fileName">File or program name to get the full path from.</param>
|
|
/// <returns></returns>
|
|
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<string> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Zips the files found in the given directory.
|
|
/// </summary>
|
|
/// <param name="dir">Directory to search for files to zip.</param>
|
|
/// <param name="outputPath">
|
|
/// Name of output zip, optionally including full path. If null, uses the directory name given by
|
|
/// <see cref="dir" />.
|
|
/// </param>
|
|
/// <param name="fileSearchPattern">Search pattern used to filter against files to zip.</param>
|
|
/// <param name="replaceFile">If true, overwrites the file matching the output path.</param>
|
|
/// <returns>Full path to the newly created zip or null if no files are found to zip.</returns>
|
|
/// <exception cref="IOException">If zip file already exists.</exception>
|
|
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 <basename>.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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="source">Source file to replace with.</param>
|
|
/// <param name="target">Target file to replace.</param>
|
|
/// <returns>True if file was moved or replaced successfully.</returns>
|
|
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;
|
|
}
|