diff --git a/App/App.config b/App/App.config new file mode 100644 index 0000000..5f548ab --- /dev/null +++ b/App/App.config @@ -0,0 +1,18 @@ + + + + +
+ + + + + + lib;locale + + + 0.0.8 + + + + \ No newline at end of file diff --git a/App/App.xaml b/App/App.xaml new file mode 100644 index 0000000..65c582c --- /dev/null +++ b/App/App.xaml @@ -0,0 +1,7 @@ + + + + + diff --git a/App/App.xaml.cs b/App/App.xaml.cs new file mode 100644 index 0000000..4f5c28c --- /dev/null +++ b/App/App.xaml.cs @@ -0,0 +1,91 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Runtime.Loader; +using System.Windows; + +namespace DevModManager +{ + /// + /// Interaction logic for App.xaml + /// + public partial class App : Application + { + public App() + { + AssemblyLoadContext.Default.Resolving += OnAssemblyResolve; + } + + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + Debug.WriteLine("Application_Startup called"); + + try + { + // Set the probing paths before any other DLLs are loaded + string probingPaths = DevModManager.Properties.Settings.Default.ProbingPaths; + AppDomain.CurrentDomain.SetData("PROBING_DIRECTORIES", probingPaths); + + // Initialize the database + Debug.WriteLine("Initializing database..."); + DbManager.Instance.Initialize(); + Debug.WriteLine("Database initialized."); + + // Initialize the configuration + Debug.WriteLine("Initializing configuration..."); + Config.Initialize(); + Debug.WriteLine("Configuration initialized."); + + // Check if the database is correctly initialized + if (DbManager.Instance.IsDatabaseInitialized()) + { + // Open MainWindow if the database is initialized + Debug.WriteLine("Database initialized. Opening MainWindow."); + var mainWindow = new MainWindow(); + mainWindow.Show(); + } + else + { + // The DbManager should handle showing the SettingsWindow if needed + Debug.WriteLine("Database not initialized. DbManager will handle SettingsWindow."); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Exception during startup: {ex.Message}"); + _ = MessageBox.Show($"An error occurred during startup: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + Shutdown(); + } + } + + private void RestartApplication() + { + var exePath = Process.GetCurrentProcess().MainModule?.FileName; + if (exePath != null) + { + _ = Process.Start(exePath); + Shutdown(); + } + } + + private static Assembly? OnAssemblyResolve(AssemblyLoadContext context, AssemblyName assemblyName) + { + string probingPaths = DevModManager.Properties.Settings.Default.ProbingPaths; + string[] paths = probingPaths.Split(';'); + + foreach (string path in paths) + { + string assemblyPath = Path.Combine(AppContext.BaseDirectory, path, $"{assemblyName.Name}.dll"); + + if (File.Exists(assemblyPath)) + { + return context.LoadFromAssemblyPath(assemblyPath); + } + } + + return null; + } + } +} diff --git a/App/AssemblyInfo.cs b/App/AssemblyInfo.cs new file mode 100644 index 0000000..b0ec827 --- /dev/null +++ b/App/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/App/Config.cs b/App/Config.cs new file mode 100644 index 0000000..b6e6397 --- /dev/null +++ b/App/Config.cs @@ -0,0 +1,362 @@ +using System.Data.SQLite; +using System.IO; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace DevModManager +{ + + /// + /// Configuration class for DevModManager. + /// + public class Config + { + private static Config? _instance; + private static readonly object _lock = new object(); + + /// + /// Gets the singleton instance of the Config class. + /// + public static Config Instance + { + get + { + if (_instance == null) + { + lock (_lock) + { + if (_instance == null) + { + _instance = new Config(); + } + } + } + return _instance; + } + } + + public string? RepoFolder { get; set; } + public bool UseGit { get; set; } + public string? GitHubRepo { get; set; } + public bool UseModManager { get; set; } + public string? ModStagingFolder { get; set; } + public string? GameFolder { get; set; } + public string? ModManagerExecutable { get; set; } + public string? ModManagerParameters { get; set; } + public string? IdeExecutable { get; set; } + public string[]? ModStages { get; set; } + public bool LimitFiletypes { get; set; } + public string[]? PromoteIncludeFiletypes { get; set; } + public string[]? PackageExcludeFiletypes { get; set; } + public string? TimestampFormat { get; set; } + public string? ArchiveFormat { get; set; } + public string? MyNameSpace { get; set; } + public string? MyResourcePrefix { get; set; } + public bool ShowSaveMessage { get; set; } + public bool ShowOverwriteMessage { get; set; } + public string? NexusAPIKey { get; set; } + + public static readonly string configFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config", "config.yaml"); + public static readonly string dbFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "data", "DevModManager.db"); + + /// + /// Initializes the configuration. + /// + public static void Initialize() + { + if (_instance == null) + { + lock (_lock) + { + if (_instance == null) + { + if (File.Exists(dbFilePath)) + { + _ = LoadFromDatabase(); + } + else + { + _ = LoadFromYaml(); + } + } + } + } + } + + /// + /// Loads the configuration from a YAML file. + /// + /// The loaded configuration. + public static Config LoadFromYaml() + { + return LoadFromYaml(configFilePath); + } + + /// + /// Saves the configuration to a YAML file. + /// + public static void SaveToYaml() + { + SaveToYaml(configFilePath); + } + + /// + /// Loads the configuration from a specified YAML file. + /// + /// The path to the YAML file. + /// The loaded configuration. + public static Config LoadFromYaml(string filePath) + { + if (!File.Exists(filePath)) + { + throw new FileNotFoundException("Configuration file not found", filePath); + } + + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + using var reader = new StreamReader(filePath); + var config = deserializer.Deserialize(reader); + + lock (_lock) + { + _instance = config; + } + + return _instance; + } + + /// + /// Saves the configuration to a specified YAML file. + /// + /// The path to the YAML file. + public static void SaveToYaml(string filePath) + { + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + var yaml = serializer.Serialize(Instance); + + File.WriteAllText(filePath, yaml); + } + + /// + /// Loads the configuration from the database. + /// + /// The loaded configuration. + public static Config? LoadFromDatabase() + { + using (var connection = DbManager.Instance.GetConnection()) + { + connection.Open(); + using var command = new SQLiteCommand("SELECT * FROM vwConfig", connection); + using var reader = command.ExecuteReader(); + if (reader.Read()) + { + _instance = new Config + { + RepoFolder = reader["RepoFolder"]?.ToString(), + UseGit = Convert.ToBoolean(reader["UseGit"]), + GitHubRepo = reader["GitHubRepo"]?.ToString(), + UseModManager = Convert.ToBoolean(reader["UseModManager"]), + GameFolder = reader["GameFolder"]?.ToString(), + ModStagingFolder = reader["ModStagingFolder"]?.ToString(), + ModManagerExecutable = reader["ModManagerExecutable"]?.ToString(), + ModManagerParameters = reader["ModManagerParameters"]?.ToString(), + IdeExecutable = reader["IDEExecutable"]?.ToString(), + LimitFiletypes = Convert.ToBoolean(reader["LimitFileTypes"]), + PromoteIncludeFiletypes = reader["PromoteIncludeFiletypes"]?.ToString()?.Split(',') ?? Array.Empty(), + PackageExcludeFiletypes = reader["PackageExcludeFiletypes"]?.ToString()?.Split(',') ?? Array.Empty(), + TimestampFormat = reader["TimestampFormat"]?.ToString(), + MyNameSpace = reader["MyNameSpace"]?.ToString(), + MyResourcePrefix = reader["MyResourcePrefix"]?.ToString(), + ShowSaveMessage = Convert.ToBoolean(reader["ShowSaveMessage"]), + ShowOverwriteMessage = Convert.ToBoolean(reader["ShowOverwriteMessage"]), + NexusAPIKey = reader["NexusAPIKey"]?.ToString(), + ModStages = reader["ModStages"]?.ToString()?.Split(',') ?? Array.Empty(), + ArchiveFormat = reader["ArchiveFormat"]?.ToString() + }; + } + } + return _instance; + } + + /// + /// Asynchronously initializes the configuration. + /// + public static async Task InitializeAsync() + { + if (_instance == null) + { + if (File.Exists(dbFilePath)) + { + _instance = await LoadFromDatabaseAsync(); + } + else + { + _instance = await LoadFromYamlAsync(configFilePath); + } + } + } + + /// + /// Asynchronously loads the configuration from a specified YAML file. + /// + /// The path to the YAML file. + /// The loaded configuration. + public static async Task LoadFromYamlAsync(string filePath) + { + if (!File.Exists(filePath)) + { + throw new FileNotFoundException("Configuration file not found", filePath); + } + + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + using var reader = new StreamReader(filePath); + var config = deserializer.Deserialize(await reader.ReadToEndAsync()); + + lock (_lock) + { + _instance = config; + } + + return _instance; + } + + /// + /// Asynchronously loads the configuration from the database. + /// + /// The loaded configuration. + public static async Task LoadFromDatabaseAsync() + { + using (var connection = DbManager.Instance.GetConnection()) + { + await connection.OpenAsync(); + using var command = new SQLiteCommand("SELECT * FROM vwConfig", connection); + using var reader = await command.ExecuteReaderAsync(); + if (await reader.ReadAsync()) + { + _instance = new Config + { + // Populate properties from reader + }; + } + } + return _instance; + } + + /// + /// Saves the configuration to the database. + /// + public static void SaveToDatabase() + { + var config = Instance; + + using var connection = DbManager.Instance.GetConnection(); + connection.Open(); + using var transaction = connection.BeginTransaction(); + using (var command = new SQLiteCommand(connection)) + { + command.CommandText = "DELETE FROM Config"; + _ = command.ExecuteNonQuery(); + + command.CommandText = @" + INSERT INTO Config ( + RepoFolder, + UseGit, + GitHubRepo, + UseModManager, + GameFolder, + ModStagingFolder, + ModManagerExecutable, + ModManagerParameters, + IDEExecutable, + LimitFileTypes, + PromoteIncludeFiletypes, + PackageExcludeFiletypes, + TimestampFormat, + MyNameSpace, + MyResourcePrefix, + ShowSaveMessage, + ShowOverwriteMessage, + NexusAPIKey, + ArchiveFormatID + ) VALUES ( + @RepoFolder, + @UseGit, + @GitHubRepo, + @UseModManager, + @GameFolder, + @ModStagingFolder, + @ModManagerExecutable, + @ModManagerParameters, + @IdeExecutable, + @LimitFiletypes, + @PromoteIncludeFiletypes, + @PackageExcludeFiletypes, + @TimestampFormat, + @MyNameSpace, + @MyResourcePrefix, + @ShowSaveMessage, + @ShowOverwriteMessage, + @NexusAPIKey, + (SELECT ArchiveFormatID FROM ArchiveFormats WHERE FormatName = @ArchiveFormat) + )"; + + command.Parameters.AddWithValue("@RepoFolder", config.RepoFolder ?? (object)DBNull.Value); + _ = command.Parameters.AddWithValue("@UseGit", config.UseGit); + command.Parameters.AddWithValue("@GitHubRepo", config.GitHubRepo ?? (object)DBNull.Value); + _ = command.Parameters.AddWithValue("@UseModManager", config.UseModManager); + command.Parameters.AddWithValue("@GameFolder", config.GameFolder ?? (object)DBNull.Value); + command.Parameters.AddWithValue("@ModStagingFolder", config.ModStagingFolder ?? (object)DBNull.Value); + command.Parameters.AddWithValue("@ModManagerExecutable", config.ModManagerExecutable ?? (object)DBNull.Value); + command.Parameters.AddWithValue("@ModManagerParameters", config.ModManagerParameters ?? (object)DBNull.Value); + command.Parameters.AddWithValue("@IdeExecutable", config.IdeExecutable ?? (object)DBNull.Value); + _ = command.Parameters.AddWithValue("@LimitFiletypes", config.LimitFiletypes); + _ = command.Parameters.AddWithValue("@PromoteIncludeFiletypes", string.Join(",", config.PromoteIncludeFiletypes ?? Array.Empty())); + _ = command.Parameters.AddWithValue("@PackageExcludeFiletypes", string.Join(",", config.PackageExcludeFiletypes ?? Array.Empty())); + command.Parameters.AddWithValue("@TimestampFormat", config.TimestampFormat ?? (object)DBNull.Value); + command.Parameters.AddWithValue("@MyNameSpace", config.MyNameSpace ?? (object)DBNull.Value); + command.Parameters.AddWithValue("@MyResourcePrefix", config.MyResourcePrefix ?? (object)DBNull.Value); + _ = command.Parameters.AddWithValue("@ShowSaveMessage", config.ShowSaveMessage); + _ = command.Parameters.AddWithValue("@ShowOverwriteMessage", config.ShowOverwriteMessage); + command.Parameters.AddWithValue("@NexusAPIKey", config.NexusAPIKey ?? (object)DBNull.Value); + command.Parameters.AddWithValue("@ArchiveFormat", config.ArchiveFormat ?? (object)DBNull.Value); + + _ = command.ExecuteNonQuery(); + } + + if (config.ModStages != null && config.ModStages.Length > 0) + { + using (var deleteCommand = new SQLiteCommand("DELETE FROM Stages WHERE IsReserved = 0", connection)) + { + _ = deleteCommand.ExecuteNonQuery(); + } + + foreach (var stage in config.ModStages) + { + var stageName = stage.TrimStart('*', '#'); + var isSource = stage.StartsWith('*'); + var isReserved = stage.StartsWith('#'); + + using var stageCommand = new SQLiteCommand(connection); + stageCommand.CommandText = @" + INSERT OR REPLACE INTO Stages (StageName, IsSource, IsReserved) + VALUES (@StageName, @IsSource, @IsReserved)"; + _ = stageCommand.Parameters.AddWithValue("@StageName", stageName); + _ = stageCommand.Parameters.AddWithValue("@IsSource", isSource); + _ = stageCommand.Parameters.AddWithValue("@IsReserved", isReserved); + + _ = stageCommand.ExecuteNonQuery(); + } + } + + transaction.Commit(); + } + } +} \ No newline at end of file diff --git a/App/DbManager.cs b/App/DbManager.cs new file mode 100644 index 0000000..e16acef --- /dev/null +++ b/App/DbManager.cs @@ -0,0 +1,165 @@ +using System.Data.SQLite; +using System.Diagnostics; +using System.IO; +using System.Windows; + +namespace DevModManager +{ + public class DbManager + { + private static readonly Lazy _instance = new Lazy(() => new DbManager()); + private const string ConnectionString = "Data Source=data/DevModManager.db;Version=3;"; + private const string dbFilePath = "data/DevModManager.db"; + + private DbManager() { } + + public static DbManager Instance => _instance.Value; + + public void Initialize() + { + bool dbExists = File.Exists(dbFilePath); + Debug.WriteLine($"Database file path: {dbFilePath}"); + Debug.WriteLine($"Database exists: {dbExists}"); + + if (!dbExists) + { + ReloadDatabaseFromInstaller(); + return; + } + + using var connection = GetConnection(); + connection.Open(); + + if (IsConfigTableEmpty() || !IsDatabaseInitialized()) + { + var config = Config.LoadFromYaml(); + if (IsSampleOrInvalidData(config)) + { + Debug.WriteLine($"Loaded sample data from YAML: {config}"); + bool settingsSaved = LaunchSettingsWindow(SettingsLaunchSource.DatabaseInitialization); + + config = Config.LoadFromYaml(); + if (IsSampleOrInvalidData(config)) + { + Debug.WriteLine("Configuration data is still invalid after settings window."); + var resultRetry = MessageBox.Show("Configuration data is invalid. Would you like to retry?", "Error", MessageBoxButton.YesNo, MessageBoxImage.Error); + if (resultRetry == MessageBoxResult.Yes) + { + settingsSaved = LaunchSettingsWindow(SettingsLaunchSource.DatabaseInitialization); + } + else + { + Debug.WriteLine("User chose not to retry. Shutting down application."); + Application.Current.Shutdown(); + return; + } + } + + if (!settingsSaved) + { + Debug.WriteLine("Settings were not saved. Shutting down application."); + Application.Current.Shutdown(); + return; + } + } + SetInitializationStatus(true); + } + else + { + Debug.WriteLine("Loading configuration from database."); + _ = Config.LoadFromDatabase(); + } + } + + private void ReloadDatabaseFromInstaller() + { + _ = MessageBox.Show("The database file is missing. Please reinstall the application and try again.", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + Application.Current.Shutdown(); + } + + private bool IsSampleOrInvalidData(Config config) + { + return config.RepoFolder == "<>" || + config.GitHubRepo == "<>" || + config.ModStagingFolder == "<>" || + config.GameFolder == "<>" || + !Directory.Exists(config.RepoFolder) || + !Directory.Exists(config.ModStagingFolder) || + !Directory.Exists(config.GameFolder); + } + + public bool LaunchSettingsWindow(SettingsLaunchSource source) + { + var settingsWindow = new SettingsWindow(source); + bool? result = settingsWindow.ShowDialog(); + Debug.WriteLine($"SettingsWindow result: {result}"); + return result == true; + } + + public SQLiteConnection GetConnection() + { + return new SQLiteConnection(ConnectionString); + } + + private bool IsConfigTableEmpty() + { + using var connection = GetConnection(); + connection.Open(); + using var command = new SQLiteCommand("SELECT COUNT(*) FROM Config", connection); + return Convert.ToInt32(command.ExecuteScalar()) == 0; + } + + public bool IsDatabaseInitialized() + { + using var connection = GetConnection(); + connection.Open(); + using var command = new SQLiteCommand("SELECT IsInitialized FROM InitializationStatus", connection); + var result = command.ExecuteScalar(); + return result != null && Convert.ToBoolean(result); + } + + public void SetInitializationStatus(bool status) + { + using var connection = GetConnection(); + connection.Open(); + using (var command = new SQLiteCommand("INSERT OR REPLACE INTO InitializationStatus (Id, IsInitialized, InitializationTime) VALUES (1, @IsInitialized, @InitializationTime)", connection)) + { + _ = command.Parameters.AddWithValue("@IsInitialized", status); + command.Parameters.AddWithValue("@InitializationTime", DateTime.UtcNow); + _ = command.ExecuteNonQuery(); + } + Debug.WriteLine($"Database marked as initialized: {status}"); + } + + public static void FlushDB() + { + using var connection = Instance.GetConnection(); + connection.Open(); + + using var transaction = connection.BeginTransaction(); + try + { + transaction.Commit(); + + using (var vacuumCommand = new SQLiteCommand("VACUUM;", connection)) + { + _ = vacuumCommand.ExecuteNonQuery(); + } + + using var reindexCommand = new SQLiteCommand("REINDEX;", connection); + _ = reindexCommand.ExecuteNonQuery(); + } + catch (Exception ex) + { + Debug.WriteLine($"Error during FlushDB: {ex.Message}"); + transaction.Rollback(); + } + finally + { + connection.Close(); + } + + SQLiteConnection.ClearAllPools(); + } + } +} diff --git a/App/DevModManager.csproj b/App/DevModManager.csproj new file mode 100644 index 0000000..8971d5d --- /dev/null +++ b/App/DevModManager.csproj @@ -0,0 +1,177 @@ + + + + + WinExe + + net8.0-windows + + enable + + enable + + true + + True + + False + + False + + ZeeOgre.DevModManager + A mod lifecycle manager designed to leverage Vortex or MO2 to efficiently handle release cycles of Bethesda Game mods + Copyright 2024 ZeeOgre + docs\img\ZeeOgre_NoBackground.jpg + https://github.com/ZeeOgre/DevModManager + docs\README.md + https://github.com/ZeeOgre/DevModManager + git + AGPL-3.0-or-later + 0.0.8 + Debug;Release;GitRelease;GitPublish + ZeeOgre + docs\img\ZeeOgre.ico + DevModManager.App + en-US + + + + + powershell -ExecutionPolicy Bypass -Command "& { + if ('$(Configuration)' -eq 'GitRelease') { + . '$(SolutionDir)scripts\IncrementVersion.ps1' -SettingsFile '$(SolutionDir)Properties\Settings.settings' -AipFilePath '$(SolutionDir)..\DevModManager_AIP\DevModManager_AIP.aip' -CsprojFilePath '$(SolutionDir)DevModManager.csproj' -AppConfigFilePath '$(SolutionDir)App.config' -VersionTxtFilePath '$(SolutionDir)Properties\version.txt' + } + }" + + + + + + + powershell -Command "& { + if ('$(Configuration)' -eq 'GitRelease') { + Set-Location '$(SolutionDir)'; + $newVersion = Get-Content '$(SolutionDir)Properties\version.txt'; + git add .; + git commit -m \"Increment version to $newVersion\"; + git push; + Write-Output 'Post-build event completed.'; + } + }" + + + + + + True + + + + True + bin\GitPublish\ + + + + + + True + \ + + + + + + True + \ + + + True + \ + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + + + Always + + + + + + Always + + + + + + SettingsSingleFileGenerator + Settings.Designer.cs + + + True + True + Settings.settings + + + + + + + + + $(OutputPath) + + + + + + + + $(PublishDir) + + + + + + + + $(OutputPath) + + + + + + + + + + + + $(OutputPath) + + + + + + + + \ No newline at end of file diff --git a/App/FormatConverters.cs b/App/FormatConverters.cs new file mode 100644 index 0000000..c39fc0d --- /dev/null +++ b/App/FormatConverters.cs @@ -0,0 +1,190 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace DevModManager +{ + public class ArchiveFormatConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string format) + { + format = format.ToLower(); + if (format.Contains("7z")) + { + return "7z"; + } + else if (format.Contains("zip")) + { + return "zip"; + } + } + return "zip"; // Default to zip if no match + } + + + public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string format) + { + return format.ToLower(); // Simply return the selected format + } + return null; // or return a default if needed + } + + } + + public class BethesdaUrlConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string url && !string.IsNullOrEmpty(url)) + { + if (url.StartsWith("https://")) + { + return ModItem.DB.ExtractID(url); + } + else if (Guid.TryParse(url, out _)) + { + return $"https://creations.bethesda.net/en/starfield/details/{url}"; + } + } + return "Bethesda"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public class NexusUrlConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is string url && !string.IsNullOrEmpty(url)) + { + if (url.StartsWith("https://")) + { + return ModItem.DB.ExtractID(url); + } + else if (int.TryParse(url, out _)) + { + return $"https://www.nexusmods.com/starfield/mods/{url}"; + } + } + return "Nexus"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public class NullToBooleanConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value != null; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public class NullToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return value == null ? Visibility.Collapsed : Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public class PathToFolderConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return System.IO.Path.GetDirectoryName(value?.ToString() ?? string.Empty) ?? string.Empty; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public class PathToFileNameConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return System.IO.Path.GetFileName(value?.ToString() ?? string.Empty) ?? string.Empty; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public class BackupStatusToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return string.IsNullOrEmpty(value as string) ? Visibility.Collapsed : Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + + public class BooleanToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is bool boolValue) + { + return boolValue ? Visibility.Visible : Visibility.Collapsed; + } + if (value is string stringValue) + { + return string.IsNullOrEmpty(stringValue) ? Visibility.Collapsed : Visibility.Visible; + } + return Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is Visibility visibility) + { + return visibility == Visibility.Visible; + } + return false; + } + } + + + public class StringNullOrEmptyToBooleanConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return !string.IsNullOrEmpty(value as string); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/App/GetJunctionTarget.cs b/App/GetJunctionTarget.cs new file mode 100644 index 0000000..720a7dd --- /dev/null +++ b/App/GetJunctionTarget.cs @@ -0,0 +1,129 @@ +using Microsoft.Win32.SafeHandles; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +namespace DevModManager +{ + public partial class ModItem + { + public partial class Files + { + public static string GetJunctionTarget(string junctionPoint) + { + var directoryInfo = new DirectoryInfo(junctionPoint); + if (!directoryInfo.Attributes.HasFlag(FileAttributes.ReparsePoint)) + { + return string.Empty; + } + + using var handle = CreateFile( + junctionPoint, + FileAccess.Read, + FileShare.ReadWrite, + IntPtr.Zero, + FileMode.Open, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + IntPtr.Zero); + if (handle.IsInvalid) + { + int errorCode = Marshal.GetLastWin32Error(); + throw new IOException($"Unable to open junction point. Error code: {errorCode}"); + } + + var reparseDataBuffer = new byte[REPARSE_DATA_BUFFER_HEADER_SIZE + MAXIMUM_REPARSE_DATA_BUFFER_SIZE]; + if (!DeviceIoControl( + handle, + FSCTL_GET_REPARSE_POINT, + IntPtr.Zero, + 0, + reparseDataBuffer, + reparseDataBuffer.Length, + out _, + IntPtr.Zero)) + { + int errorCode = Marshal.GetLastWin32Error(); + throw new IOException($"Unable to get information about junction point. Error code: {errorCode}"); + } + + var target = ParseReparsePoint(reparseDataBuffer); + return target; + } + + private static string ParseReparsePoint(byte[] reparseDataBuffer) + { + + GCHandle handle = GCHandle.Alloc(reparseDataBuffer, GCHandleType.Pinned); + try + { + var reparseData = Marshal.PtrToStructure(handle.AddrOfPinnedObject()); + if (reparseData.ReparseTag != IO_REPARSE_TAG_MOUNT_POINT) + { + throw new IOException("The reparse point is not a junction point."); + } + + if (reparseData.PathBuffer == null) + { + throw new IOException("The reparse point buffer is null."); + } + + var target = Encoding.Unicode.GetString(reparseData.PathBuffer, reparseData.SubstituteNameOffset, reparseData.SubstituteNameLength); + if (target.StartsWith("\\??\\")) + { + target = target.Substring(4); + } + + return target; + } + finally + { + handle.Free(); + } + + + } + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern SafeFileHandle CreateFile( + string lpFileName, + [MarshalAs(UnmanagedType.U4)] FileAccess dwDesiredAccess, + [MarshalAs(UnmanagedType.U4)] FileShare dwShareMode, + IntPtr lpSecurityAttributes, + [MarshalAs(UnmanagedType.U4)] FileMode dwCreationDisposition, + [MarshalAs(UnmanagedType.U4)] uint dwFlagsAndAttributes, + IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl( + SafeFileHandle hDevice, + uint dwIoControlCode, + IntPtr lpInBuffer, + uint nInBufferSize, + [Out] byte[] lpOutBuffer, + int nOutBufferSize, + out uint lpBytesReturned, + IntPtr lpOverlapped); + + private const uint FSCTL_GET_REPARSE_POINT = 0x000900A8; + private const uint IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003; + private const int REPARSE_DATA_BUFFER_HEADER_SIZE = 8; + private const int MAXIMUM_REPARSE_DATA_BUFFER_SIZE = 16384; + private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + + [StructLayout(LayoutKind.Sequential)] + private struct REPARSE_DATA_BUFFER + { + public uint ReparseTag; + public ushort ReparseDataLength; + public ushort Reserved; + public ushort SubstituteNameOffset; + public ushort SubstituteNameLength; + public ushort PrintNameOffset; + public ushort PrintNameLength; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = MAXIMUM_REPARSE_DATA_BUFFER_SIZE)] + public byte[] PathBuffer; + } + } + } +} diff --git a/App/LoadOrderWindow.xaml b/App/LoadOrderWindow.xaml new file mode 100644 index 0000000..db45855 --- /dev/null +++ b/App/LoadOrderWindow.xaml @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +