SettingsManager.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. using System.Globalization;
  2. using System.Runtime.InteropServices;
  3. using System.Text.Json;
  4. using System.Text.Json.Serialization;
  5. using PicView.Core.Config.ConfigFileManagement;
  6. using PicView.Core.DebugTools;
  7. using PicView.Core.FileHandling;
  8. namespace PicView.Core.Config;
  9. [JsonSourceGenerationOptions(AllowTrailingCommas = true, WriteIndented = true)]
  10. [JsonSerializable(typeof(AppSettings))]
  11. internal partial class SettingsGenerationContext : JsonSerializerContext;
  12. /// <summary>
  13. /// Provides functionality to manage loading, saving, and modifying application settings.
  14. /// </summary>
  15. public static class SettingsManager
  16. {
  17. /// <summary>
  18. /// Gets the file path to the currently loaded settings file, if available.
  19. /// </summary>
  20. /// <remarks>
  21. /// This property reflects the full path to the settings file currently in use by the application.
  22. /// It is updated when settings are loaded via <see cref="SettingsManager.LoadSettingsAsync"/>
  23. /// or saved using <see cref="SettingsManager.SaveSettingsAsync"/>. If no settings file is loaded,
  24. /// the value will be null. This path can be used to reference the specific configuration file
  25. /// being utilized or modified.
  26. /// </remarks>
  27. public static string? CurrentSettingsPath => Configuration.CorrectPath;
  28. /// <summary>
  29. /// Gets or sets the current application settings instance, which stores all configurable
  30. /// values for the application's behavior, appearance, and functionality.
  31. /// </summary>
  32. /// <remarks>
  33. /// This property can be used to retrieve or assign settings related to application preferences,
  34. /// such as UI, theme, sorting, scaling, and startup configurations. The associated settings
  35. /// are loaded via <see cref="SettingsManager.LoadSettingsAsync"/> or set to defaults using
  36. /// <see cref="SettingsManager.SetDefaults"/>. Changes to the settings can be saved using
  37. /// <see cref="SettingsManager.SaveSettingsAsync"/>.
  38. /// </remarks>
  39. public static AppSettings? Settings { get; private set; }
  40. public static SettingsConfiguration? Configuration { get; private set; }
  41. /// <summary>
  42. /// Global Configuration Support
  43. /// </summary>
  44. /// <remarks>
  45. /// Overrides any UserSettings with GlobalSettings if they are set
  46. /// </remarks>
  47. public static GlobalSettingsConfiguration? GlobalConfig { get; private set; }
  48. public static AppSettings? GlobalSettings { get; private set; }
  49. /// <summary>
  50. /// Loads application settings asynchronously from a file or initializes them to default if loading fails.
  51. /// </summary>
  52. /// <returns>
  53. /// A boolean indicating whether the settings were successfully loaded.
  54. /// </returns>
  55. /// <exception cref="JsonException">Thrown if deserialization of the settings file fails.</exception>
  56. public static async ValueTask<bool> LoadSettingsAsync()
  57. {
  58. try
  59. {
  60. // Load global config (read-only, Program Path)
  61. GlobalConfig ??= new GlobalSettingsConfiguration();
  62. string globalPath = GlobalConfig.LocalConfigPath;
  63. if (File.Exists(globalPath))
  64. {
  65. await using var globalStream = File.OpenRead(globalPath);
  66. if (globalStream.Length > 0)
  67. {
  68. GlobalSettings = await JsonSerializer.DeserializeAsync<AppSettings>(
  69. globalStream, SettingsGenerationContext.Default.AppSettings).ConfigureAwait(false);
  70. }
  71. }
  72. // Load user config (User Profile or Program Path)
  73. Configuration ??= new SettingsConfiguration();
  74. var userPath = ConfigFileManager.ResolveDefaultConfigPath(Configuration);
  75. Configuration.CorrectPath = userPath;
  76. if (File.Exists(userPath))
  77. {
  78. await using var userStream = File.OpenRead(userPath);
  79. if (userStream.Length > 0)
  80. {
  81. Settings = await JsonSerializer.DeserializeAsync<AppSettings>(
  82. userStream, SettingsGenerationContext.Default.AppSettings).ConfigureAwait(false);
  83. }
  84. }
  85. // Fallback to defaults if no user config found
  86. Settings ??= GetDefaults();
  87. // Apply Global Overrides
  88. if (GlobalSettings != null)
  89. ApplyOverrides(Settings, GlobalSettings);
  90. return true;
  91. }
  92. catch (Exception ex)
  93. {
  94. DebugHelper.LogDebug(nameof(SettingsManager), nameof(LoadSettingsAsync), ex);
  95. SetDefaults();
  96. return false;
  97. }
  98. }
  99. /// <summary>
  100. /// Asynchronously saves the current application settings to the appropriate file location.
  101. /// </summary>
  102. /// <returns>
  103. /// Whether the settings were successfully saved.
  104. /// </returns>
  105. public static async ValueTask<bool> SaveSettingsAsync()
  106. {
  107. if (Settings == null)
  108. {
  109. return false;
  110. }
  111. if (string.IsNullOrEmpty(Configuration.CorrectPath))
  112. {
  113. Configuration.CorrectPath = ConfigFileManager.ResolveDefaultConfigPath(Configuration);
  114. }
  115. if (!FileHelper.IsPathWritable(CurrentSettingsPath))
  116. {
  117. Configuration.CorrectPath = Configuration.RoamingConfigPath;
  118. }
  119. var saveLocation = await ConfigFileManager.SaveConfigFileAndReturnPathAsync(Configuration,
  120. CurrentSettingsPath,
  121. Settings, typeof(AppSettings), SettingsGenerationContext.Default).ConfigureAwait(false);
  122. if (string.IsNullOrWhiteSpace(saveLocation))
  123. {
  124. DebugHelper.LogDebug(nameof(SettingsManager), nameof(SaveSettingsAsync), "Empty save location");
  125. return false;
  126. }
  127. Configuration.CorrectPath = saveLocation;
  128. return true;
  129. }
  130. private static AppSettings EnsureSettingsIfNeeded(AppSettings settings)
  131. {
  132. if (settings?.WindowProperties is null)
  133. {
  134. return GetDefaults();
  135. }
  136. // ReSharper disable once CompareOfFloatsByEqualityOperator
  137. if (settings.Version != SettingsConfiguration.CurrentSettingsVersion)
  138. {
  139. return EnsureSettings(settings);
  140. }
  141. // If navigation settings is null, it is an upgrade from an old version or the config is otherwise invalid
  142. if (settings.Navigation is null)
  143. {
  144. return EnsureSettings(settings);
  145. }
  146. settings.Version = SettingsConfiguration.CurrentSettingsVersion;
  147. return settings;
  148. }
  149. private static AppSettings EnsureSettings(AppSettings existingSettings)
  150. {
  151. var newSettings = GetDefaults();
  152. existingSettings.UIProperties ??= newSettings.UIProperties;
  153. existingSettings.Gallery ??= newSettings.Gallery;
  154. existingSettings.Theme ??= newSettings.Theme;
  155. existingSettings.Sorting ??= newSettings.Sorting;
  156. existingSettings.ImageScaling ??= newSettings.ImageScaling;
  157. existingSettings.WindowProperties ??= newSettings.WindowProperties;
  158. existingSettings.Zoom ??= newSettings.Zoom;
  159. existingSettings.StartUp ??= newSettings.StartUp;
  160. existingSettings.Navigation ??= newSettings.Navigation;
  161. existingSettings.Version = SettingsConfiguration.CurrentSettingsVersion;
  162. return existingSettings;
  163. }
  164. /// <summary>
  165. /// Sets the application's settings to their default values.
  166. /// </summary>
  167. public static void SetDefaults() => Settings = GetDefaults();
  168. /// <summary>
  169. /// Initializes and returns an instance of the default application settings.
  170. /// </summary>
  171. /// <returns>
  172. /// An <see cref="AppSettings"/> object populated with default values.
  173. /// </returns>
  174. public static AppSettings GetDefaults()
  175. {
  176. UIProperties uiProperties;
  177. if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
  178. {
  179. uiProperties = new UIProperties
  180. {
  181. IsTaskbarProgressEnabled = false,
  182. OpenInSameWindow = true
  183. };
  184. }
  185. else
  186. {
  187. uiProperties = new UIProperties();
  188. }
  189. var settings = new AppSettings
  190. {
  191. UIProperties = uiProperties,
  192. Gallery = new Gallery(),
  193. ImageScaling = new ImageScaling(),
  194. Sorting = new Sorting(),
  195. Theme = new Theme(),
  196. WindowProperties = new WindowProperties(),
  197. Zoom = new Zoom(),
  198. StartUp = new StartUp(),
  199. Navigation = new Navigation(),
  200. Version = SettingsConfiguration.CurrentSettingsVersion
  201. };
  202. // Get the default culture from the OS
  203. settings.UIProperties.UserLanguage = CultureInfo.CurrentCulture.Name;
  204. return settings;
  205. }
  206. /// <summary>
  207. /// Resets the application's settings to their default values and removes any existing settings file
  208. /// to start with a clean configuration.
  209. /// </summary>
  210. /// <exception cref="IOException">Thrown if an error occurs while attempting to delete the existing settings file.</exception>
  211. /// <exception cref="UnauthorizedAccessException">Thrown if access to the settings file is denied during deletion.</exception>
  212. /// <remarks>
  213. /// This method ensures that the default settings are applied and deletes the user settings file if it exists,
  214. /// allowing the application configuration to be completely reset.
  215. /// </remarks>
  216. public static void ResetDefaults()
  217. {
  218. try
  219. {
  220. DeleteFileIfExists(Configuration.TryGetCurrentUserConfigPath);
  221. }
  222. catch (Exception ex)
  223. {
  224. DebugHelper.LogDebug(nameof(SettingsManager), nameof(ResetDefaults), ex);
  225. }
  226. finally
  227. {
  228. SetDefaults();
  229. }
  230. return;
  231. void DeleteFileIfExists(string path)
  232. {
  233. if (File.Exists(path))
  234. {
  235. File.Delete(path);
  236. }
  237. }
  238. }
  239. private static void ApplyOverrides(AppSettings target, AppSettings global)
  240. {
  241. MergeObjects(target, global);
  242. }
  243. /// <summary>
  244. /// Recursively merges all non-null properties from source into target.
  245. /// Complex nested types (like UIProperties, Theme, etc.) are merged recursively.
  246. /// Value types and simple properties are directly overwritten.
  247. /// </summary>
  248. private static void MergeObjects(object? target, object? source)
  249. {
  250. if (target == null || source == null)
  251. return;
  252. var targetType = target.GetType();
  253. var sourceType = source.GetType();
  254. foreach (var prop in sourceType.GetProperties())
  255. {
  256. var sourceValue = prop.GetValue(source);
  257. if (sourceValue == null)
  258. continue;
  259. var targetProp = targetType.GetProperty(prop.Name);
  260. if (targetProp == null || !targetProp.CanWrite)
  261. continue;
  262. var targetValue = targetProp.GetValue(target);
  263. // If this is a nested object (class) and not a string, merge recursively
  264. if (prop.PropertyType.IsClass && prop.PropertyType != typeof(string))
  265. {
  266. if (targetValue == null)
  267. {
  268. // If user doesn't have that object at all, copy it fully
  269. targetProp.SetValue(target, sourceValue);
  270. }
  271. else
  272. {
  273. // Recursively merge individual properties
  274. MergeObjects(targetValue, sourceValue);
  275. }
  276. }
  277. else
  278. {
  279. // Simple value type or string – overwrite directly
  280. targetProp.SetValue(target, sourceValue);
  281. }
  282. }
  283. }
  284. }