ConfigurationManager.cs 25 KB

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