ConfigurationManager.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. global using static Terminal.Gui.ConfigurationManager;
  2. global using CM = Terminal.Gui.ConfigurationManager;
  3. using System;
  4. using System.Collections;
  5. using System.Collections.Generic;
  6. using System.Diagnostics;
  7. using System.IO;
  8. using System.Linq;
  9. using System.Reflection;
  10. using System.Text;
  11. using System.Text.Encodings.Web;
  12. using System.Text.Json;
  13. using System.Text.Json.Serialization;
  14. #nullable enable
  15. namespace Terminal.Gui;
  16. /// <summary>
  17. /// Provides settings and configuration management for Terminal.Gui applications.
  18. /// <para>
  19. /// Users can set Terminal.Gui settings on a global or per-application basis by providing JSON formatted configuration
  20. /// files.
  21. /// The configuration files can be placed in at <c>.tui</c> folder in the user's home directory (e.g.
  22. /// <c>C:/Users/username/.tui</c>,
  23. /// or <c>/usr/username/.tui</c>),
  24. /// the folder where the Terminal.Gui application was launched from (e.g. <c>./.tui</c>), or as a resource
  25. /// within the Terminal.Gui application's main assembly.
  26. /// </para>
  27. /// <para>
  28. /// Settings are defined in JSON format, according to this schema:
  29. /// https://gui-cs.github.io/Terminal.Gui/schemas/tui-config-schema.json
  30. /// </para>
  31. /// <para>
  32. /// Settings that will apply to all applications (global settings) reside in files named <c>config.json</c>. Settings
  33. /// that will apply to a specific Terminal.Gui application reside in files named <c>appname.config.json</c>,
  34. /// where <c>appname</c> is the assembly name of the application (e.g. <c>UICatalog.config.json</c>).
  35. /// </para>
  36. /// Settings are applied using the following precedence (higher precedence settings
  37. /// overwrite lower precedence settings):
  38. /// <para>
  39. /// 1. Application configuration found in the users's home directory (<c>~/.tui/appname.config.json</c>) -- Highest
  40. /// precedence
  41. /// </para>
  42. /// <para>
  43. /// 2. Application configuration found in the directory the app was launched from (<c>./.tui/appname.config.json</c>).
  44. /// </para>
  45. /// <para>
  46. /// 3. Application configuration found in the applications's resources (<c>Resources/config.json</c>).
  47. /// </para>
  48. /// <para>
  49. /// 4. Global configuration found in the user's home directory (<c>~/.tui/config.json</c>).
  50. /// </para>
  51. /// <para>
  52. /// 5. Global configuration found in the directory the app was launched from (<c>./.tui/config.json</c>).
  53. /// </para>
  54. /// <para>
  55. /// 6. Global configuration in <c>Terminal.Gui.dll</c>'s resources (<c>Terminal.Gui.Resources.config.json</c>) -- Lowest
  56. /// Precidence.
  57. /// </para>
  58. /// </summary>
  59. public static class ConfigurationManager {
  60. /// <summary>
  61. /// Describes the location of the configuration files. The constants can be
  62. /// combined (bitwise) to specify multiple locations.
  63. /// </summary>
  64. [Flags]
  65. public enum ConfigLocations {
  66. /// <summary>
  67. /// No configuration will be loaded.
  68. /// </summary>
  69. /// <remarks>
  70. /// Used for development and testing only. For Terminal,Gui to function properly, at least
  71. /// <see cref="DefaultOnly"/> should be set.
  72. /// </remarks>
  73. None = 0,
  74. /// <summary>
  75. /// Global configuration in <c>Terminal.Gui.dll</c>'s resources (<c>Terminal.Gui.Resources.config.json</c>) -- Lowest
  76. /// Precedence.
  77. /// </summary>
  78. DefaultOnly,
  79. /// <summary>
  80. /// This constant is a combination of all locations
  81. /// </summary>
  82. All = -1
  83. }
  84. static readonly string _configFilename = "config.json";
  85. internal static readonly JsonSerializerOptions _serializerOptions = new() {
  86. ReadCommentHandling = JsonCommentHandling.Skip,
  87. PropertyNameCaseInsensitive = true,
  88. DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
  89. WriteIndented = true,
  90. Converters = {
  91. // We override the standard Rune converter to support specifying Glyphs in
  92. // a flexible way
  93. new RuneJsonConverter (),
  94. // Override Key to support "Ctrl+Q" format.
  95. new KeyJsonConverter ()
  96. },
  97. // Enables Key to be "Ctrl+Q" vs "Ctrl\u002BQ"
  98. Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
  99. };
  100. /// <summary>
  101. /// A dictionary of all properties in the Terminal.Gui project that are decorated with the
  102. /// <see cref="SerializableConfigurationProperty"/> attribute.
  103. /// The keys are the property names pre-pended with the class that implements the property (e.g.
  104. /// <c>Application.UseSystemConsole</c>).
  105. /// The values are instances of <see cref="ConfigProperty"/> which hold the property's value and the
  106. /// <see cref="PropertyInfo"/> that allows <see cref="ConfigurationManager"/> to get and set the property's value.
  107. /// </summary>
  108. /// <remarks>
  109. /// Is <see langword="null"/> until <see cref="Initialize"/> is called.
  110. /// </remarks>
  111. internal static Dictionary<string, ConfigProperty>? _allConfigProperties;
  112. /// <summary>
  113. /// The backing property for <see cref="Settings"/>.
  114. /// </summary>
  115. /// <remarks>
  116. /// Is <see langword="null"/> until <see cref="Reset"/> is called. Gets set to a new instance by
  117. /// deserialization (see <see cref="Load"/>).
  118. /// </remarks>
  119. static SettingsScope? _settings;
  120. internal static StringBuilder jsonErrors = new ();
  121. /// <summary>
  122. /// The root object of Terminal.Gui configuration settings / JSON schema. Contains only properties with the
  123. /// <see cref="SettingsScope"/>
  124. /// attribute value.
  125. /// </summary>
  126. public static SettingsScope? Settings {
  127. get {
  128. if (_settings == null) {
  129. throw new InvalidOperationException ("ConfigurationManager has not been initialized. Call ConfigurationManager.Reset() before accessing the Settings property.");
  130. }
  131. return _settings;
  132. }
  133. set => _settings = value!;
  134. }
  135. /// <summary>
  136. /// The root object of Terminal.Gui themes manager. Contains only properties with the <see cref="ThemeScope"/>
  137. /// attribute value.
  138. /// </summary>
  139. public static ThemeManager? Themes => ThemeManager.Instance;
  140. /// <summary>
  141. /// Application-specific configuration settings scope.
  142. /// </summary>
  143. [SerializableConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true)] [JsonPropertyName ("AppSettings")]
  144. public static AppScope? AppSettings { get; set; }
  145. /// <summary>
  146. /// The set of glyphs used to draw checkboxes, lines, borders, etc...See also
  147. /// <seealso cref="Terminal.Gui.GlyphDefinitions"/>.
  148. /// </summary>
  149. [SerializableConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true)] [JsonPropertyName ("Glyphs")]
  150. public static GlyphDefinitions Glyphs { get; set; } = new ();
  151. /// <summary>
  152. /// Gets or sets whether the <see cref="ConfigurationManager"/> should throw an exception if it encounters
  153. /// an error on deserialization. If <see langword="false"/> (the default), the error is logged and printed to the
  154. /// console when <see cref="Application.Shutdown"/> is called.
  155. /// </summary>
  156. [SerializableConfigurationProperty (Scope = typeof (SettingsScope))]
  157. public static bool? ThrowOnJsonErrors { get; set; } = false;
  158. /// <summary>
  159. /// Name of the running application. By default this property is set to the application's assembly name.
  160. /// </summary>
  161. public static string AppName { get; set; } = Assembly.GetEntryAssembly ()?.FullName?.Split (',') [0]?.Trim ()!;
  162. /// <summary>
  163. /// Gets and sets the locations where <see cref="ConfigurationManager"/> will look for config files.
  164. /// The value is <see cref="ConfigLocations.All"/>.
  165. /// </summary>
  166. public static ConfigLocations Locations { get; set; } = ConfigLocations.All;
  167. /// <summary>
  168. /// Initializes the internal state of ConfigurationManager. Nominally called once as part of application
  169. /// startup to initialize global state. Also called from some Unit Tests to ensure correctness (e.g. Reset()).
  170. /// </summary>
  171. internal static void Initialize ()
  172. {
  173. _allConfigProperties = new Dictionary<string, ConfigProperty> ();
  174. _settings = null;
  175. var classesWithConfigProps = new Dictionary<string, Type> (StringComparer.InvariantCultureIgnoreCase);
  176. // Get Terminal.Gui.dll classes
  177. var types = from assembly in AppDomain.CurrentDomain.GetAssemblies ()
  178. from type in assembly.GetTypes ()
  179. where type.GetProperties ().Any (prop => prop.GetCustomAttribute (typeof (SerializableConfigurationProperty)) != null)
  180. select type;
  181. foreach (var classWithConfig in types) {
  182. classesWithConfigProps.Add (classWithConfig.Name, classWithConfig);
  183. }
  184. Debug.WriteLine ($"ConfigManager.getConfigProperties found {classesWithConfigProps.Count} classes:");
  185. classesWithConfigProps.ToList ().ForEach (x => Debug.WriteLine ($" Class: {x.Key}"));
  186. foreach (var p in from c in classesWithConfigProps
  187. let props = c.Value.GetProperties (BindingFlags.Instance | BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).Where (prop =>
  188. prop.GetCustomAttribute (typeof (SerializableConfigurationProperty)) is SerializableConfigurationProperty)
  189. let enumerable = props
  190. from p in enumerable
  191. select p) {
  192. if (p.GetCustomAttribute (typeof (SerializableConfigurationProperty)) is SerializableConfigurationProperty scp) {
  193. if (p.GetGetMethod (true)!.IsStatic) {
  194. // If the class name is omitted, JsonPropertyName is allowed.
  195. _allConfigProperties!.Add (scp.OmitClassName ? ConfigProperty.GetJsonPropertyName (p) : $"{p.DeclaringType?.Name}.{p.Name}", new ConfigProperty {
  196. PropertyInfo = p,
  197. PropertyValue = null
  198. });
  199. } else {
  200. throw new Exception ($"Property {p.Name} in class {p.DeclaringType?.Name} is not static. All SerializableConfigurationProperty properties must be static.");
  201. }
  202. }
  203. }
  204. _allConfigProperties = _allConfigProperties!.OrderBy (x => x.Key).ToDictionary (x => x.Key, x => x.Value, StringComparer.InvariantCultureIgnoreCase);
  205. Debug.WriteLine ($"ConfigManager.Initialize found {_allConfigProperties.Count} properties:");
  206. //_allConfigProperties.ToList ().ForEach (x => Debug.WriteLine ($" Property: {x.Key}"));
  207. AppSettings = new AppScope ();
  208. }
  209. /// <summary>
  210. /// Creates a JSON document with the configuration specified.
  211. /// </summary>
  212. /// <returns></returns>
  213. internal static string ToJson ()
  214. {
  215. Debug.WriteLine ("ConfigurationManager.ToJson()");
  216. return JsonSerializer.Serialize (Settings!, _serializerOptions);
  217. }
  218. internal static Stream ToStream ()
  219. {
  220. var json = JsonSerializer.Serialize (Settings!, _serializerOptions);
  221. // turn it into a stream
  222. var stream = new MemoryStream ();
  223. var writer = new StreamWriter (stream);
  224. writer.Write (json);
  225. writer.Flush ();
  226. stream.Position = 0;
  227. return stream;
  228. }
  229. internal static void AddJsonError (string error)
  230. {
  231. Debug.WriteLine ($"ConfigurationManager: {error}");
  232. jsonErrors.AppendLine (error);
  233. }
  234. /// <summary>
  235. /// Prints any Json deserialization errors that occurred during deserialization to the console.
  236. /// </summary>
  237. public static void PrintJsonErrors ()
  238. {
  239. if (jsonErrors.Length > 0) {
  240. Console.WriteLine (@"Terminal.Gui ConfigurationManager encountered the following errors while deserializing configuration files:");
  241. Console.WriteLine (jsonErrors.ToString ());
  242. }
  243. }
  244. static void ClearJsonErrors () => jsonErrors.Clear ();
  245. /// <summary>
  246. /// Called when the configuration has been updated from a configuration file. Invokes the <see cref="Updated"/>
  247. /// event.
  248. /// </summary>
  249. public static void OnUpdated ()
  250. {
  251. Debug.WriteLine (@"ConfigurationManager.OnApplied()");
  252. Updated?.Invoke (null, new ConfigurationManagerEventArgs ());
  253. }
  254. /// <summary>
  255. /// Event fired when the configuration has been updated from a configuration source.
  256. /// application.
  257. /// </summary>
  258. public static event EventHandler<ConfigurationManagerEventArgs>? Updated;
  259. /// <summary>
  260. /// Resets the state of <see cref="ConfigurationManager"/>. Should be called whenever a new app session
  261. /// (e.g. in <see cref="Application.Init"/> starts. Called by <see cref="Load"/>
  262. /// if the <c>reset</c> parameter is <see langword="true"/>.
  263. /// </summary>
  264. /// <remarks>
  265. ///
  266. /// </remarks>
  267. public static void Reset ()
  268. {
  269. Debug.WriteLine (@"ConfigurationManager.Reset()");
  270. if (_allConfigProperties == null) {
  271. Initialize ();
  272. }
  273. ClearJsonErrors ();
  274. Settings = new SettingsScope ();
  275. ThemeManager.Reset ();
  276. AppSettings = new AppScope ();
  277. // To enable some unit tests, we only load from resources if the flag is set
  278. if (Locations.HasFlag (ConfigLocations.DefaultOnly)) {
  279. Settings.UpdateFromResource (typeof (ConfigurationManager).Assembly, $"Terminal.Gui.Resources.{_configFilename}");
  280. }
  281. Apply ();
  282. ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply ();
  283. AppSettings?.Apply ();
  284. }
  285. /// <summary>
  286. /// Retrieves the hard coded default settings from the Terminal.Gui library implementation. Used in development of
  287. /// the library to generate the default configuration file. Before calling Application.Init, make sure
  288. /// <see cref="Locations"/> is set to <see cref="ConfigLocations.None"/>.
  289. /// </summary>
  290. /// <remarks>
  291. /// <para>
  292. /// This method is only really useful when using ConfigurationManagerTests
  293. /// to generate the JSON doc that is embedded into Terminal.Gui (during development).
  294. /// </para>
  295. /// <para>
  296. /// WARNING: The <c>Terminal.Gui.Resources.config.json</c> resource has setting definitions (Themes)
  297. /// that are NOT generated by this function. If you use this function to regenerate
  298. /// <c>Terminal.Gui.Resources.config.json</c>,
  299. /// make sure you copy the Theme definitions from the existing <c>Terminal.Gui.Resources.config.json</c> file.
  300. /// </para>
  301. /// </remarks>
  302. internal static void GetHardCodedDefaults ()
  303. {
  304. if (_allConfigProperties == null) {
  305. throw new InvalidOperationException ("Initialize must be called first.");
  306. }
  307. Settings = new SettingsScope ();
  308. ThemeManager.GetHardCodedDefaults ();
  309. AppSettings?.RetrieveValues ();
  310. foreach (var p in Settings!.Where (cp => cp.Value.PropertyInfo != null)) {
  311. Settings! [p.Key].PropertyValue = p.Value.PropertyInfo?.GetValue (null);
  312. }
  313. }
  314. /// <summary>
  315. /// Applies the configuration settings to the running <see cref="Application"/> instance.
  316. /// </summary>
  317. public static void Apply ()
  318. {
  319. var settings = false;
  320. var themes = false;
  321. var appSettings = false;
  322. try {
  323. settings = Settings?.Apply () ?? false;
  324. themes = !string.IsNullOrEmpty (ThemeManager.SelectedTheme) && (ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply () ?? false);
  325. appSettings = AppSettings?.Apply () ?? false;
  326. } catch (JsonException e) {
  327. if (ThrowOnJsonErrors ?? false) {
  328. throw;
  329. } else {
  330. AddJsonError ($"Error applying Configuration Change: {e.Message}");
  331. }
  332. } finally {
  333. if (settings || themes || appSettings) {
  334. OnApplied ();
  335. }
  336. }
  337. }
  338. /// <summary>
  339. /// Called when an updated configuration has been applied to the
  340. /// application. Fires the <see cref="Applied"/> event.
  341. /// </summary>
  342. public static void OnApplied ()
  343. {
  344. Debug.WriteLine ("ConfigurationManager.OnApplied()");
  345. Applied?.Invoke (null, new ConfigurationManagerEventArgs ());
  346. // TODO: Refactor ConfigurationManager to not use an event handler for this.
  347. // Instead, have it call a method on any class appropriately attributed
  348. // to update the cached values. See Issue #2871
  349. }
  350. /// <summary>
  351. /// Event fired when an updated configuration has been applied to the
  352. /// application.
  353. /// </summary>
  354. public static event EventHandler<ConfigurationManagerEventArgs>? Applied;
  355. /// <summary>
  356. /// Loads all settings found in the various configuration storage locations to
  357. /// the <see cref="ConfigurationManager"/>. Optionally,
  358. /// resets all settings attributed with <see cref="SerializableConfigurationProperty"/> to the defaults.
  359. /// </summary>
  360. /// <remarks>
  361. /// Use <see cref="Apply"/> to cause the loaded settings to be applied to the running application.
  362. /// </remarks>
  363. /// <param name="reset">
  364. /// If <see langword="true"/> the state of <see cref="ConfigurationManager"/> will
  365. /// be reset to the defaults.
  366. /// </param>
  367. public static void Load (bool reset = false)
  368. {
  369. Debug.WriteLine ("ConfigurationManager.Load()");
  370. if (reset) {
  371. Reset ();
  372. }
  373. // LibraryResources is always loaded by Reset
  374. if (Locations == ConfigLocations.All) {
  375. var embeddedStylesResourceName = Assembly.GetEntryAssembly ()?
  376. .GetManifestResourceNames ().FirstOrDefault (x => x.EndsWith (_configFilename));
  377. if (string.IsNullOrEmpty (embeddedStylesResourceName)) {
  378. embeddedStylesResourceName = _configFilename;
  379. }
  380. Settings = Settings?
  381. // Global current directory
  382. .Update ($"./.tui/{_configFilename}")?
  383. // Global home directory
  384. .Update ($"~/.tui/{_configFilename}")?
  385. // App resources
  386. .UpdateFromResource (Assembly.GetEntryAssembly ()!, embeddedStylesResourceName!)?
  387. // App current directory
  388. .Update ($"./.tui/{AppName}.{_configFilename}")?
  389. // App home directory
  390. .Update ($"~/.tui/{AppName}.{_configFilename}");
  391. }
  392. }
  393. /// <summary>
  394. /// Returns an empty Json document with just the $schema tag.
  395. /// </summary>
  396. /// <returns></returns>
  397. public static string GetEmptyJson ()
  398. {
  399. var emptyScope = new SettingsScope ();
  400. emptyScope.Clear ();
  401. return JsonSerializer.Serialize (emptyScope, _serializerOptions);
  402. }
  403. /// <summary>
  404. /// System.Text.Json does not support copying a deserialized object to an existing instance.
  405. /// To work around this, we implement a 'deep, memberwise copy' method.
  406. /// </summary>
  407. /// <remarks>
  408. /// TOOD: When System.Text.Json implements `PopulateObject` revisit
  409. /// https://github.com/dotnet/corefx/issues/37627
  410. /// </remarks>
  411. /// <param name="source"></param>
  412. /// <param name="destination"></param>
  413. /// <returns><paramref name="destination"/> updated from <paramref name="source"/></returns>
  414. internal static object? DeepMemberwiseCopy (object? source, object? destination)
  415. {
  416. if (destination == null) {
  417. throw new ArgumentNullException (nameof (destination));
  418. }
  419. if (source == null) {
  420. return null!;
  421. }
  422. if (source.GetType () == typeof (SettingsScope)) {
  423. return ((SettingsScope)destination).Update ((SettingsScope)source);
  424. }
  425. if (source.GetType () == typeof (ThemeScope)) {
  426. return ((ThemeScope)destination).Update ((ThemeScope)source);
  427. }
  428. if (source.GetType () == typeof (AppScope)) {
  429. return ((AppScope)destination).Update ((AppScope)source);
  430. }
  431. // If value type, just use copy constructor.
  432. if (source.GetType ().IsValueType || source.GetType () == typeof (string)) {
  433. return source;
  434. }
  435. // Dictionary
  436. if (source.GetType ().IsGenericType && source.GetType ().GetGenericTypeDefinition ().IsAssignableFrom (typeof (Dictionary<,>))) {
  437. foreach (var srcKey in ((IDictionary)source).Keys) {
  438. if (srcKey is string) { }
  439. if (((IDictionary)destination).Contains (srcKey)) {
  440. ((IDictionary)destination) [srcKey] = DeepMemberwiseCopy (((IDictionary)source) [srcKey], ((IDictionary)destination) [srcKey]);
  441. } else {
  442. ((IDictionary)destination).Add (srcKey, ((IDictionary)source) [srcKey]);
  443. }
  444. }
  445. return destination;
  446. }
  447. // ALl other object types
  448. var sourceProps = source?.GetType ().GetProperties ().Where (x => x.CanRead).ToList ();
  449. var destProps = destination?.GetType ().GetProperties ().Where (x => x.CanWrite).ToList ()!;
  450. foreach ((var sourceProp, var destProp) in
  451. from sourceProp in sourceProps
  452. where destProps.Any (x => x.Name == sourceProp.Name)
  453. let destProp = destProps.First (x => x.Name == sourceProp.Name)
  454. where destProp.CanWrite
  455. select (sourceProp, destProp)) {
  456. var sourceVal = sourceProp.GetValue (source);
  457. var destVal = destProp.GetValue (destination);
  458. if (sourceVal != null) {
  459. try {
  460. if (destVal != null) {
  461. // Recurse
  462. destProp.SetValue (destination, DeepMemberwiseCopy (sourceVal, destVal));
  463. } else {
  464. destProp.SetValue (destination, sourceVal);
  465. }
  466. } catch (ArgumentException e) {
  467. throw new JsonException ($"Error Applying Configuration Change: {e.Message}", e);
  468. }
  469. }
  470. }
  471. return destination!;
  472. }
  473. }