ConfigurationManager.cs 25 KB

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